Skip to content

Commit 3287349

Browse files
author
Numerilab
committed
feat(ide): IDE integration with Cursor/VSCode and improved UX
- Add IDE connection via WebSocket with JSON-RPC - Live text selection from editor displayed in footer - Selection sent as synthetic part (invisible but included in context) - IDE status visible in home screen footer - Fix reactivity with reconcile for IDE status updates Based on initial work from anomalyco#5447, with additional UX improvements.
1 parent c81506b commit 3287349

8 files changed

Lines changed: 536 additions & 70 deletions

File tree

packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx

Lines changed: 68 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import { useExit } from "../../context/exit"
2020
import { Clipboard } from "../../util/clipboard"
2121
import type { FilePart } from "@opencode-ai/sdk/v2"
2222
import { TuiEvent } from "../../event"
23+
import { Ide } from "@/ide"
2324
import { iife } from "@/util/iife"
2425
import { Locale } from "@/util/locale"
2526
import { createColors, createFrames } from "../../ui/spinner.ts"
@@ -44,7 +45,6 @@ export type PromptRef = {
4445
reset(): void
4546
blur(): void
4647
focus(): void
47-
submit(): void
4848
}
4949

5050
const PLACEHOLDERS = ["Fix a TODO in the codebase", "What is the tech stack of this project?", "Fix broken tests"]
@@ -116,7 +116,7 @@ export function Prompt(props: PromptProps) {
116116
const sync = useSync()
117117
const dialog = useDialog()
118118
const toast = useToast()
119-
const status = createMemo(() => sync.data.session_status?.[props.sessionID ?? ""] ?? { type: "idle" })
119+
const status = createMemo(() => sync.data.session_status[props.sessionID ?? ""] ?? { type: "idle" })
120120
const history = usePromptHistory()
121121
const command = useCommandDialog()
122122
const renderer = useRenderer()
@@ -312,6 +312,10 @@ export function Prompt(props: PromptProps) {
312312
input.insertText(evt.properties.text)
313313
})
314314

315+
sdk.event.on(Ide.Event.SelectionChanged.type, (evt) => {
316+
updateIdeSelection(evt.properties.selection)
317+
})
318+
315319
createEffect(() => {
316320
if (props.disabled) input.cursorColor = theme.backgroundElement
317321
if (!props.disabled) input.cursorColor = theme.text
@@ -342,6 +346,49 @@ export function Prompt(props: PromptProps) {
342346
promptPartTypeId = input.extmarks.registerType("prompt-part")
343347
})
344348

349+
// Track IDE selection extmark so we can update/remove it
350+
let ideSelectionExtmarkId: number | null = null
351+
352+
function removeExtmark(extmarkId: number) {
353+
const allExtmarks = input.extmarks.getAllForTypeId(promptPartTypeId)
354+
const extmark = allExtmarks.find((e) => e.id === extmarkId)
355+
const partIndex = store.extmarkToPartIndex.get(extmarkId)
356+
357+
if (partIndex !== undefined) {
358+
setStore(
359+
produce((draft) => {
360+
draft.prompt.parts.splice(partIndex, 1)
361+
draft.extmarkToPartIndex.delete(extmarkId)
362+
const newMap = new Map<number, number>()
363+
for (const [id, idx] of draft.extmarkToPartIndex) {
364+
newMap.set(id, idx > partIndex ? idx - 1 : idx)
365+
}
366+
draft.extmarkToPartIndex = newMap
367+
}),
368+
)
369+
}
370+
371+
if (extmark) {
372+
const savedOffset = input.cursorOffset
373+
input.cursorOffset = extmark.start
374+
const start = { ...input.logicalCursor }
375+
input.cursorOffset = extmark.end + 1
376+
input.deleteRange(start.row, start.col, input.logicalCursor.row, input.logicalCursor.col)
377+
input.cursorOffset =
378+
savedOffset > extmark.start
379+
? Math.max(extmark.start, savedOffset - (extmark.end + 1 - extmark.start))
380+
: savedOffset
381+
}
382+
383+
input.extmarks.delete(extmarkId)
384+
}
385+
386+
function updateIdeSelection(selection: Ide.Selection | null) {
387+
// Selection is now displayed in footer via local.selection
388+
// No visual insertion in the input needed
389+
// Content will be included at submit time from local.selection
390+
}
391+
345392
function restoreExtmarksFromParts(parts: PromptInfo["parts"]) {
346393
input.extmarks.clear()
347394
setStore("extmarkToPartIndex", new Map())
@@ -448,14 +495,11 @@ export function Prompt(props: PromptProps) {
448495
})
449496
setStore("extmarkToPartIndex", new Map())
450497
},
451-
submit() {
452-
submit()
453-
},
454498
})
455499

456500
async function submit() {
457501
if (props.disabled) return
458-
if (autocomplete?.visible) return
502+
if (autocomplete.visible) return
459503
if (!store.prompt.input) return
460504
const trimmed = store.prompt.input.trim()
461505
if (trimmed === "exit" || trimmed === "quit" || trimmed === ":q") {
@@ -476,6 +520,8 @@ export function Prompt(props: PromptProps) {
476520
const messageID = Identifier.ascending("message")
477521
let inputText = store.prompt.input
478522

523+
// IDE selection is displayed in footer only - not injected into message
524+
479525
// Expand pasted text inline before submitting
480526
const allExtmarks = input.extmarks.getAllForTypeId(promptPartTypeId)
481527
const sortedExtmarks = allExtmarks.sort((a: { start: number }, b: { start: number }) => b.start - a.start)
@@ -495,9 +541,6 @@ export function Prompt(props: PromptProps) {
495541
// Filter out text parts (pasted content) since they're now expanded inline
496542
const nonTextParts = store.prompt.parts.filter((part) => part.type !== "text")
497543

498-
// Capture mode before it gets reset
499-
const currentMode = store.mode
500-
501544
if (store.mode === "shell") {
502545
sdk.client.session.shell({
503546
sessionID,
@@ -539,23 +582,27 @@ export function Prompt(props: PromptProps) {
539582
type: "text",
540583
text: inputText,
541584
},
585+
...(local.selection.current()?.text ? [{
586+
id: Identifier.ascending("part"),
587+
type: "text" as const,
588+
text: `\n\n[IDE Selection: ${local.selection.current()!.filePath.split("/").pop() || local.selection.current()!.filePath}:${local.selection.current()!.selection.start.line + 1}-${local.selection.current()!.selection.end.line + 1}]\n\`\`\`\n${local.selection.current()!.text}\n\`\`\``,
589+
synthetic: true,
590+
}] : []),
542591
...nonTextParts.map((x) => ({
543592
id: Identifier.ascending("part"),
544593
...x,
545594
})),
546595
],
547596
})
548597
}
549-
history.append({
550-
...store.prompt,
551-
mode: currentMode,
552-
})
598+
history.append(store.prompt)
553599
input.extmarks.clear()
554600
setStore("prompt", {
555601
input: "",
556602
parts: [],
557603
})
558604
setStore("extmarkToPartIndex", new Map())
605+
ideSelectionExtmarkId = null
559606
props.onSubmit?.()
560607

561608
// temporary hack to make sure the message is sent
@@ -715,8 +762,8 @@ export function Prompt(props: PromptProps) {
715762
>
716763
<textarea
717764
placeholder={props.sessionID ? undefined : `Ask anything... "${PLACEHOLDERS[store.placeholder]}"`}
718-
textColor={keybind.leader ? theme.textMuted : theme.text}
719-
focusedTextColor={keybind.leader ? theme.textMuted : theme.text}
765+
textColor={theme.text}
766+
focusedTextColor={theme.text}
720767
minHeight={1}
721768
maxHeight={6}
722769
onContentChange={() => {
@@ -742,12 +789,8 @@ export function Prompt(props: PromptProps) {
742789
return
743790
}
744791
if (keybind.match("app_exit", e)) {
745-
if (store.prompt.input === "") {
746-
await exit()
747-
// Don't preventDefault - let textarea potentially handle the event
748-
e.preventDefault()
749-
return
750-
}
792+
await exit()
793+
return
751794
}
752795
if (e.name === "!" && input.visualCursor.offset === 0) {
753796
setStore("mode", "shell")
@@ -773,7 +816,6 @@ export function Prompt(props: PromptProps) {
773816
if (item) {
774817
input.setText(item.input)
775818
setStore("prompt", item)
776-
setStore("mode", item.mode ?? "normal")
777819
restoreExtmarksFromParts(item.parts)
778820
e.preventDefault()
779821
if (direction === -1) input.cursorOffset = 0
@@ -865,7 +907,7 @@ export function Prompt(props: PromptProps) {
865907
</text>
866908
<Show when={store.mode === "normal"}>
867909
<box flexDirection="row" gap={1}>
868-
<text flexShrink={0} fg={keybind.leader ? theme.textMuted : theme.text}>
910+
<text flexShrink={0} fg={theme.text}>
869911
{local.model.parsed().model}
870912
</text>
871913
<text fg={theme.textMuted}>{local.model.parsed().provider}</text>
@@ -880,15 +922,16 @@ export function Prompt(props: PromptProps) {
880922
borderColor={highlight()}
881923
customBorderChars={{
882924
...EmptyBorder,
883-
vertical: theme.backgroundElement.a !== 0 ? "╹" : " ",
925+
// when the background is transparent, don't draw the vertical line
926+
vertical: theme.background.a != 0 ? "╹" : " ",
884927
}}
885928
>
886929
<box
887930
height={1}
888931
border={["bottom"]}
889932
borderColor={theme.backgroundElement}
890933
customBorderChars={
891-
theme.backgroundElement.a !== 0
934+
theme.background.a != 0
892935
? {
893936
...EmptyBorder,
894937
horizontal: "▀",

packages/opencode/src/cli/cmd/tui/context/local.tsx

Lines changed: 55 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { createStore } from "solid-js/store"
1+
import { createStore, reconcile } from "solid-js/store"
22
import { batch, createEffect, createMemo } from "solid-js"
33
import { useSync } from "@tui/context/sync"
44
import { useTheme } from "@tui/context/theme"
@@ -12,6 +12,7 @@ import { Provider } from "@/provider/provider"
1212
import { useArgs } from "./args"
1313
import { useSDK } from "./sdk"
1414
import { RGBA } from "@opentui/core"
15+
import { Ide } from "@/ide"
1516

1617
export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
1718
name: "Local",
@@ -52,11 +53,11 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
5253
})
5354

5455
const agent = iife(() => {
55-
const agents = createMemo(() => sync.data.agent.filter((x) => x.mode !== "subagent" && !x.hidden))
56+
const agents = createMemo(() => sync.data.agent.filter((x) => x.mode !== "subagent"))
5657
const [agentStore, setAgentStore] = createStore<{
5758
current: string
5859
}>({
59-
current: agents().find((x) => x.default)?.name ?? agents()[0].name,
60+
current: agents()[0].name,
6061
})
6162
const { theme } = useTheme()
6263
const colors = createMemo(() => [
@@ -329,10 +330,61 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
329330
},
330331
}
331332

333+
const ide = {
334+
isConnected(name: string) {
335+
const status = sync.data.ide[name]
336+
return status?.status === "connected"
337+
},
338+
getWorkspaceFolders(name: string) {
339+
const status = sync.data.ide[name]
340+
if (status && "workspaceFolders" in status && status.workspaceFolders) {
341+
return status.workspaceFolders
342+
}
343+
return []
344+
},
345+
async toggle(name: string) {
346+
const current = sync.data.ide[name]
347+
if (current?.status === "connected") {
348+
await sdk.client.ide.disconnect({ name })
349+
} else {
350+
await sdk.client.ide.connect({ name })
351+
}
352+
const status = await sdk.client.ide.status()
353+
if (status.data) sync.set("ide", reconcile(status.data))
354+
},
355+
}
356+
357+
358+
const selection = iife(() => {
359+
const [selStore, setSelStore] = createStore<{
360+
current: Ide.Selection | null
361+
}>({ current: null })
362+
363+
sdk.event.on(Ide.Event.SelectionChanged.type, async (evt) => {
364+
setSelStore("current", evt.properties.selection)
365+
// Refresh IDE status when we receive a selection
366+
const status = await sdk.client.ide.status()
367+
if (status.data) sync.set("ide", reconcile(status.data))
368+
})
369+
370+
return {
371+
current: () => selStore.current,
372+
clear: () => setSelStore("current", null),
373+
formatted: () => {
374+
const sel = selStore.current
375+
if (!sel || !sel.text) return null
376+
const lines = sel.text.split("\n").length
377+
return `${lines} lines`
378+
},
379+
}
380+
})
381+
332382
const result = {
333383
model,
334384
agent,
335385
mcp,
386+
ide,
387+
selection,
336388
}
337389
return result
338390
},

packages/opencode/src/cli/cmd/tui/routes/home.tsx

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { useSync } from "../context/sync"
77
import { Toast } from "../ui/toast"
88
import { useArgs } from "../context/args"
99
import { useDirectory } from "../context/directory"
10+
import { useLocal } from "../context/local"
1011
import { useRoute, useRouteData } from "@tui/context/route"
1112
import { usePromptRef } from "../context/prompt"
1213
import { Installation } from "@/installation"
@@ -57,10 +58,11 @@ export function Home() {
5758
} else if (args.prompt) {
5859
prompt.set({ input: args.prompt, parts: [] })
5960
once = true
60-
prompt.submit()
6161
}
6262
})
6363
const directory = useDirectory()
64+
const local = useLocal()
65+
const ide = createMemo(() => Object.values(sync.data.ide).find((x) => x.status === "connected"))
6466

6567
return (
6668
<>
@@ -92,8 +94,20 @@ export function Home() {
9294
</Switch>
9395
{connectedMcpCount()} MCP
9496
</text>
95-
<text fg={theme.textMuted}>/status</text>
9697
</Show>
98+
<Show when={ide()}>
99+
<text fg={theme.text}>
100+
<span style={{ fg: theme.success }}></span>
101+
{ide()!.name}
102+
</text>
103+
</Show>
104+
<Show when={local.selection.formatted()}>
105+
<text fg={theme.text}>
106+
<span style={{ fg: theme.accent }}>[] </span>
107+
{local.selection.formatted()}
108+
</text>
109+
</Show>
110+
<text fg={theme.textMuted}>/status</text>
97111
</box>
98112
<box flexGrow={1} />
99113
<box flexShrink={0}>

packages/opencode/src/cli/cmd/tui/routes/session/footer.tsx

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { useDirectory } from "../../context/directory"
55
import { useConnected } from "../../component/dialog-model"
66
import { createStore } from "solid-js/store"
77
import { useRoute } from "../../context/route"
8+
import { useLocal } from "../../context/local"
89

910
export function Footer() {
1011
const { theme } = useTheme()
@@ -13,12 +14,14 @@ export function Footer() {
1314
const mcp = createMemo(() => Object.values(sync.data.mcp).filter((x) => x.status === "connected").length)
1415
const mcpError = createMemo(() => Object.values(sync.data.mcp).some((x) => x.status === "failed"))
1516
const lsp = createMemo(() => Object.keys(sync.data.lsp))
17+
const ide = createMemo(() => Object.values(sync.data.ide).find((x) => x.status === "connected"))
1618
const permissions = createMemo(() => {
1719
if (route.data.type !== "session") return []
1820
return sync.data.permission[route.data.sessionID] ?? []
1921
})
2022
const directory = useDirectory()
2123
const connected = useConnected()
24+
const local = useLocal()
2225

2326
const [store, setStore] = createStore({
2427
welcome: false,
@@ -79,6 +82,18 @@ export function Footer() {
7982
{mcp()} MCP
8083
</text>
8184
</Show>
85+
<Show when={ide()}>
86+
<text fg={theme.text}>
87+
<span style={{ fg: theme.success }}></span>
88+
{ide()!.name}
89+
</text>
90+
</Show>
91+
<Show when={local.selection.formatted()}>
92+
<text fg={theme.text}>
93+
<span style={{ fg: theme.accent }}>[] </span>
94+
{local.selection.formatted()}
95+
</text>
96+
</Show>
8297
<text fg={theme.textMuted}>/status</text>
8398
</Match>
8499
</Switch>

0 commit comments

Comments
 (0)