Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
"dev": "bun run --cwd packages/opencode --conditions=browser src/index.ts",
"typecheck": "bun turbo typecheck",
"prepare": "husky",
"generate": "bun run --cwd packages/sdk/js build",
"random": "echo 'Random script'",
"hello": "echo 'Hello World!'"
},
Expand Down
9 changes: 9 additions & 0 deletions packages/opencode/src/cli/cmd/tui/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { SyncProvider, useSync } from "@tui/context/sync"
import { LocalProvider, useLocal } from "@tui/context/local"
import { DialogModel, useConnected } from "@tui/component/dialog-model"
import { DialogMcp } from "@tui/component/dialog-mcp"
import { DialogIde } from "@tui/component/dialog-ide"
import { DialogStatus } from "@tui/component/dialog-status"
import { DialogThemeList } from "@tui/component/dialog-theme-list"
import { DialogHelp } from "./ui/dialog-help"
Expand Down Expand Up @@ -312,6 +313,14 @@ function App() {
dialog.replace(() => <DialogMcp />)
},
},
{
title: "Toggle IDEs",
value: "ide.list",
category: "Agent",
onSelect: () => {
dialog.replace(() => <DialogIde />)
},
},
{
title: "Agent cycle",
value: "agent.cycle",
Expand Down
76 changes: 76 additions & 0 deletions packages/opencode/src/cli/cmd/tui/component/dialog-ide.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { createMemo, createSignal } from "solid-js"
import { useLocal } from "@tui/context/local"
import { useSync } from "@tui/context/sync"
import { map, pipe, entries, sortBy } from "remeda"
import { DialogSelect, type DialogSelectRef, type DialogSelectOption } from "@tui/ui/dialog-select"
import { useTheme } from "../context/theme"
import { Keybind } from "@/util/keybind"
import { TextAttributes } from "@opentui/core"

function Status(props: { connected: boolean; loading: boolean }) {
const { theme } = useTheme()
if (props.loading) {
return <span style={{ fg: theme.textMuted }}>⋯ Loading</span>
}
if (props.connected) {
return <span style={{ fg: theme.success, attributes: TextAttributes.BOLD }}>✓ Connected</span>
}
return <span style={{ fg: theme.textMuted }}>○ Disconnected</span>
}

export function DialogIde() {
const local = useLocal()
const sync = useSync()
const [, setRef] = createSignal<DialogSelectRef<unknown>>()
const [loading, setLoading] = createSignal<string | null>(null)

const options = createMemo(() => {
const ideData = sync.data.ide
const loadingIde = loading()
const projectDir = process.cwd()

return pipe(
ideData ?? {},
entries(),
sortBy(
([key]) => {
const folders = local.ide.getWorkspaceFolders(key)
// Exact match - highest priority
if (folders.some((folder: string) => folder === projectDir)) return 0
// IDE workspace contains current directory (we're in a subdirectory of IDE workspace)
if (folders.some((folder: string) => projectDir.startsWith(folder + "/"))) return 1
return 2
},
([, status]) => status.name,
),
map(([key, status]) => {
return {
value: key,
title: status.name,
description: local.ide.getWorkspaceFolders(key)[0],
footer: <Status connected={local.ide.isConnected(key)} loading={loadingIde === key} />,
category: undefined,
}
}),
)
})

const keybinds = createMemo(() => [
{
keybind: Keybind.parse("space")[0],
title: "toggle",
onTrigger: async (option: DialogSelectOption<string>) => {
if (loading() !== null) return

setLoading(option.value)
try {
await local.ide.toggle(option.value)
} finally {
setLoading(null)
}
},
},
])

return <DialogSelect ref={setRef} title="IDEs" options={options()} keybind={keybinds()} onSelect={() => {}} />
}
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,11 @@ export function Autocomplete(props: {
description: "toggle MCPs",
onSelect: () => command.trigger("mcp.list"),
},
{
display: "/ide",
description: "toggle IDEs",
onSelect: () => command.trigger("ide.list"),
},
{
display: "/theme",
description: "toggle theme",
Expand Down
95 changes: 95 additions & 0 deletions packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { useExit } from "../../context/exit"
import { Clipboard } from "../../util/clipboard"
import type { FilePart } from "@opencode-ai/sdk/v2"
import { TuiEvent } from "../../event"
import { Ide } from "@/ide"
import { iife } from "@/util/iife"
import { Locale } from "@/util/locale"
import { createColors, createFrames } from "../../ui/spinner.ts"
Expand Down Expand Up @@ -311,6 +312,10 @@ export function Prompt(props: PromptProps) {
input.insertText(evt.properties.text)
})

sdk.event.on(Ide.Event.SelectionChanged.type, (evt) => {
updateIdeSelection(evt.properties.selection)
})

createEffect(() => {
if (props.disabled) input.cursorColor = theme.backgroundElement
if (!props.disabled) input.cursorColor = theme.text
Expand Down Expand Up @@ -341,6 +346,95 @@ export function Prompt(props: PromptProps) {
promptPartTypeId = input.extmarks.registerType("prompt-part")
})

// Track IDE selection extmark so we can update/remove it
let ideSelectionExtmarkId: number | null = null

function removeExtmark(extmarkId: number) {
const allExtmarks = input.extmarks.getAllForTypeId(promptPartTypeId)
const extmark = allExtmarks.find((e) => e.id === extmarkId)
const partIndex = store.extmarkToPartIndex.get(extmarkId)

if (partIndex !== undefined) {
setStore(
produce((draft) => {
draft.prompt.parts.splice(partIndex, 1)
draft.extmarkToPartIndex.delete(extmarkId)
const newMap = new Map<number, number>()
for (const [id, idx] of draft.extmarkToPartIndex) {
newMap.set(id, idx > partIndex ? idx - 1 : idx)
}
draft.extmarkToPartIndex = newMap
}),
)
}

if (extmark) {
const savedOffset = input.cursorOffset
input.cursorOffset = extmark.start
const start = { ...input.logicalCursor }
input.cursorOffset = extmark.end + 1
input.deleteRange(start.row, start.col, input.logicalCursor.row, input.logicalCursor.col)
input.cursorOffset =
savedOffset > extmark.start
? Math.max(extmark.start, savedOffset - (extmark.end + 1 - extmark.start))
: savedOffset
}

input.extmarks.delete(extmarkId)
}

function updateIdeSelection(selection: Ide.Selection | null) {
if (!input || promptPartTypeId === undefined) return

if (ideSelectionExtmarkId !== null) {
removeExtmark(ideSelectionExtmarkId)
ideSelectionExtmarkId = null
}

// Ignore empty selections (just a cursor position)
if (!selection || !selection.text) return

const { filePath, text } = selection
const filename = filePath.split("/").pop() || filePath
const start = selection.selection.start.line + 1
const end = selection.selection.end.line + 1
const lines = text.split("\n").length

const previewText = `[${filename}:${start}-${end} ~${lines} lines]`
const contextText = `\`\`\`\n# ${filePath}:${start}-${end}\n${text}\n\`\`\`\n\n`

const extmarkStart = input.visualCursor.offset
const extmarkEnd = extmarkStart + previewText.length

input.insertText(previewText + " ")

ideSelectionExtmarkId = input.extmarks.create({
start: extmarkStart,
end: extmarkEnd,
virtual: true,
styleId: pasteStyleId,
typeId: promptPartTypeId,
})

setStore(
produce((draft) => {
const partIndex = draft.prompt.parts.length
draft.prompt.parts.push({
type: "text" as const,
text: contextText,
source: {
text: {
start: extmarkStart,
end: extmarkEnd,
value: previewText,
},
},
})
draft.extmarkToPartIndex.set(ideSelectionExtmarkId!, partIndex)
}),
)
}

function restoreExtmarksFromParts(parts: PromptInfo["parts"]) {
input.extmarks.clear()
setStore("extmarkToPartIndex", new Map())
Expand Down Expand Up @@ -546,6 +640,7 @@ export function Prompt(props: PromptProps) {
parts: [],
})
setStore("extmarkToPartIndex", new Map())
ideSelectionExtmarkId = null
props.onSubmit?.()

// temporary hack to make sure the message is sent
Expand Down
25 changes: 25 additions & 0 deletions packages/opencode/src/cli/cmd/tui/context/local.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -329,10 +329,35 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
},
}

const ide = {
isConnected(name: string) {
const status = sync.data.ide[name]
return status?.status === "connected"
},
getWorkspaceFolders(name: string) {
const status = sync.data.ide[name]
if (status && "workspaceFolders" in status && status.workspaceFolders) {
return status.workspaceFolders
}
return []
},
async toggle(name: string) {
const current = sync.data.ide[name]
if (current?.status === "connected") {
await sdk.client.ide.disconnect({ name })
} else {
await sdk.client.ide.connect({ name })
}
const status = await sdk.client.ide.status()
if (status.data) sync.set("ide", status.data)
},
}

const result = {
model,
agent,
mcp,
ide,
}
return result
},
Expand Down
4 changes: 4 additions & 0 deletions packages/opencode/src/cli/cmd/tui/context/sync.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import type {
Permission,
LspStatus,
McpStatus,
IdeStatus,
FormatterStatus,
SessionStatus,
ProviderListResponse,
Expand Down Expand Up @@ -61,6 +62,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
mcp: {
[key: string]: McpStatus
}
ide: { [key: string]: IdeStatus }
formatter: FormatterStatus[]
vcs: VcsInfo | undefined
path: Path
Expand All @@ -86,6 +88,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
part: {},
lsp: [],
mcp: {},
ide: {},
formatter: [],
vcs: undefined,
path: { state: "", config: "", worktree: "", directory: "" },
Expand Down Expand Up @@ -285,6 +288,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
sdk.client.command.list().then((x) => setStore("command", x.data ?? [])),
sdk.client.lsp.status().then((x) => setStore("lsp", x.data!)),
sdk.client.mcp.status().then((x) => setStore("mcp", x.data!)),
sdk.client.ide.status().then((x) => setStore("ide", x.data!)),
sdk.client.formatter.status().then((x) => setStore("formatter", x.data!)),
sdk.client.session.status().then((x) => setStore("session_status", x.data!)),
sdk.client.provider.auth().then((x) => setStore("provider_auth", x.data ?? {})),
Expand Down
7 changes: 7 additions & 0 deletions packages/opencode/src/cli/cmd/tui/routes/session/footer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export function Footer() {
const mcp = createMemo(() => Object.values(sync.data.mcp).filter((x) => x.status === "connected").length)
const mcpError = createMemo(() => Object.values(sync.data.mcp).some((x) => x.status === "failed"))
const lsp = createMemo(() => Object.keys(sync.data.lsp))
const ide = createMemo(() => Object.values(sync.data.ide).find((x) => x.status === "connected"))
const permissions = createMemo(() => {
if (route.data.type !== "session") return []
return sync.data.permission[route.data.sessionID] ?? []
Expand Down Expand Up @@ -79,6 +80,12 @@ export function Footer() {
{mcp()} MCP
</text>
</Show>
<Show when={ide()}>
<text fg={theme.text}>
<span style={{ fg: theme.success }}>◆ </span>
{ide()!.name}
</text>
</Show>
<text fg={theme.textMuted}>/status</text>
</Match>
</Switch>
Expand Down
7 changes: 7 additions & 0 deletions packages/opencode/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -748,6 +748,13 @@ export namespace Config {
url: z.string().optional().describe("Enterprise URL"),
})
.optional(),
ide: z
.object({
lockfile_dir: z.string().optional().describe("Directory containing IDE lock files for WebSocket connections"),
auth_header_name: z.string().optional().describe("HTTP header name for IDE WebSocket authentication"),
})
.optional()
.describe("IDE integration settings"),
experimental: z
.object({
hook: z
Expand Down
Loading