Skip to content

Commit 56d78d5

Browse files
committed
test: merge upstream PR anomalyco#5873 (IDE UX improvements) on top
Applies UX improvements from anomalyco#5873: - Selection displayed in footer instead of cluttering input - Synthetic parts so selection doesn't pollute chat history - Better error handling for malformed lock files - Home screen shows IDE connection status - Windows path compatibility fixes Preserved fork features: - Double Ctrl+C to exit - Session search keybind
2 parents 2152449 + af006cf commit 56d78d5

File tree

6 files changed

+97
-144
lines changed

6 files changed

+97
-144
lines changed

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

Lines changed: 30 additions & 107 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,6 @@ export type PromptRef = {
4747
reset(): void
4848
blur(): void
4949
focus(): void
50-
submit(): void
5150
}
5251

5352
const PLACEHOLDERS = ["Fix a TODO in the codebase", "What is the tech stack of this project?", "Fix broken tests"]
@@ -119,7 +118,7 @@ export function Prompt(props: PromptProps) {
119118
const sync = useSync()
120119
const dialog = useDialog()
121120
const toast = useToast()
122-
const status = createMemo(() => sync.data.session_status?.[props.sessionID ?? ""] ?? { type: "idle" })
121+
const status = createMemo(() => sync.data.session_status[props.sessionID ?? ""] ?? { type: "idle" })
123122
const history = usePromptHistory()
124123
const command = useCommandDialog()
125124
const renderer = useRenderer()
@@ -350,93 +349,10 @@ export function Prompt(props: PromptProps) {
350349
promptPartTypeId = input.extmarks.registerType("prompt-part")
351350
})
352351

353-
// Track IDE selection extmark so we can update/remove it
354-
let ideSelectionExtmarkId: number | null = null
355-
356-
function removeExtmark(extmarkId: number) {
357-
const allExtmarks = input.extmarks.getAllForTypeId(promptPartTypeId)
358-
const extmark = allExtmarks.find((e) => e.id === extmarkId)
359-
const partIndex = store.extmarkToPartIndex.get(extmarkId)
360-
361-
if (partIndex !== undefined) {
362-
setStore(
363-
produce((draft) => {
364-
draft.prompt.parts.splice(partIndex, 1)
365-
draft.extmarkToPartIndex.delete(extmarkId)
366-
const newMap = new Map<number, number>()
367-
for (const [id, idx] of draft.extmarkToPartIndex) {
368-
newMap.set(id, idx > partIndex ? idx - 1 : idx)
369-
}
370-
draft.extmarkToPartIndex = newMap
371-
}),
372-
)
373-
}
374-
375-
if (extmark) {
376-
const savedOffset = input.cursorOffset
377-
input.cursorOffset = extmark.start
378-
const start = { ...input.logicalCursor }
379-
input.cursorOffset = extmark.end + 1
380-
input.deleteRange(start.row, start.col, input.logicalCursor.row, input.logicalCursor.col)
381-
input.cursorOffset =
382-
savedOffset > extmark.start
383-
? Math.max(extmark.start, savedOffset - (extmark.end + 1 - extmark.start))
384-
: savedOffset
385-
}
386-
387-
input.extmarks.delete(extmarkId)
388-
}
389-
390-
function updateIdeSelection(selection: Ide.Selection | null) {
391-
if (!input || promptPartTypeId === undefined) return
392-
393-
if (ideSelectionExtmarkId !== null) {
394-
removeExtmark(ideSelectionExtmarkId)
395-
ideSelectionExtmarkId = null
396-
}
397-
398-
// Ignore empty selections (just a cursor position)
399-
if (!selection || !selection.text) return
400-
401-
const { filePath, text } = selection
402-
const filename = filePath.split("/").pop() || filePath
403-
const start = selection.selection.start.line + 1
404-
const end = selection.selection.end.line + 1
405-
const lines = text.split("\n").length
406-
407-
const previewText = `[${filename}:${start}-${end} ~${lines} lines]`
408-
const contextText = `\`\`\`\n# ${filePath}:${start}-${end}\n${text}\n\`\`\`\n\n`
409-
410-
const extmarkStart = input.visualCursor.offset
411-
const extmarkEnd = extmarkStart + previewText.length
412-
413-
input.insertText(previewText + " ")
414-
415-
ideSelectionExtmarkId = input.extmarks.create({
416-
start: extmarkStart,
417-
end: extmarkEnd,
418-
virtual: true,
419-
styleId: pasteStyleId,
420-
typeId: promptPartTypeId,
421-
})
422-
423-
setStore(
424-
produce((draft) => {
425-
const partIndex = draft.prompt.parts.length
426-
draft.prompt.parts.push({
427-
type: "text" as const,
428-
text: contextText,
429-
source: {
430-
text: {
431-
start: extmarkStart,
432-
end: extmarkEnd,
433-
value: previewText,
434-
},
435-
},
436-
})
437-
draft.extmarkToPartIndex.set(ideSelectionExtmarkId!, partIndex)
438-
}),
439-
)
352+
function updateIdeSelection(_selection: Ide.Selection | null) {
353+
// Selection is now displayed in footer via local.selection
354+
// No visual insertion in the input needed
355+
// Content will be included at submit time from local.selection
440356
}
441357

442358
function restoreExtmarksFromParts(parts: PromptInfo["parts"]) {
@@ -545,14 +461,11 @@ export function Prompt(props: PromptProps) {
545461
})
546462
setStore("extmarkToPartIndex", new Map())
547463
},
548-
submit() {
549-
submit()
550-
},
551464
})
552465

553466
async function submit() {
554467
if (props.disabled) return
555-
if (autocomplete?.visible) return
468+
if (autocomplete.visible) return
556469
if (!store.prompt.input) return
557470
const trimmed = store.prompt.input.trim()
558471
if (trimmed === "exit" || trimmed === "quit" || trimmed === ":q") {
@@ -573,6 +486,8 @@ export function Prompt(props: PromptProps) {
573486
const messageID = Identifier.ascending("message")
574487
let inputText = store.prompt.input
575488

489+
// IDE selection is displayed in footer only - not injected into message
490+
576491
// Expand pasted text inline before submitting
577492
const allExtmarks = input.extmarks.getAllForTypeId(promptPartTypeId)
578493
const sortedExtmarks = allExtmarks.sort((a: { start: number }, b: { start: number }) => b.start - a.start)
@@ -592,9 +507,6 @@ export function Prompt(props: PromptProps) {
592507
// Filter out text parts (pasted content) since they're now expanded inline
593508
const nonTextParts = store.prompt.parts.filter((part) => part.type !== "text")
594509

595-
// Capture mode before it gets reset
596-
const currentMode = store.mode
597-
598510
if (store.mode === "shell") {
599511
sdk.client.session.shell({
600512
sessionID,
@@ -637,24 +549,35 @@ export function Prompt(props: PromptProps) {
637549
type: "text",
638550
text: inputText,
639551
},
552+
...(local.selection.current()?.text
553+
? [
554+
{
555+
id: Identifier.ascending("part"),
556+
type: "text" as const,
557+
text: `\n\n[IDE Selection: ${
558+
local.selection
559+
.current()!
560+
.filePath.split(/[\/\\]/)
561+
.pop() || local.selection.current()!.filePath
562+
}:${local.selection.current()!.selection.start.line + 1}-${local.selection.current()!.selection.end.line + 1}]\n\`\`\`\n${local.selection.current()!.text}\n\`\`\``,
563+
synthetic: true,
564+
},
565+
]
566+
: []),
640567
...nonTextParts.map((x) => ({
641568
id: Identifier.ascending("part"),
642569
...x,
643570
})),
644571
],
645572
})
646573
}
647-
history.append({
648-
...store.prompt,
649-
mode: currentMode,
650-
})
574+
history.append(store.prompt)
651575
input.extmarks.clear()
652576
setStore("prompt", {
653577
input: "",
654578
parts: [],
655579
})
656580
setStore("extmarkToPartIndex", new Map())
657-
ideSelectionExtmarkId = null
658581
props.onSubmit?.()
659582

660583
// temporary hack to make sure the message is sent
@@ -830,8 +753,8 @@ export function Prompt(props: PromptProps) {
830753
>
831754
<textarea
832755
placeholder={props.sessionID ? undefined : `Ask anything... "${PLACEHOLDERS[store.placeholder]}"`}
833-
textColor={keybind.leader ? theme.textMuted : theme.text}
834-
focusedTextColor={keybind.leader ? theme.textMuted : theme.text}
756+
textColor={theme.text}
757+
focusedTextColor={theme.text}
835758
minHeight={1}
836759
maxHeight={6}
837760
onContentChange={() => {
@@ -889,7 +812,6 @@ export function Prompt(props: PromptProps) {
889812
if (item) {
890813
input.setText(item.input)
891814
setStore("prompt", item)
892-
setStore("mode", item.mode ?? "normal")
893815
restoreExtmarksFromParts(item.parts)
894816
e.preventDefault()
895817
if (direction === -1) input.cursorOffset = 0
@@ -981,7 +903,7 @@ export function Prompt(props: PromptProps) {
981903
</text>
982904
<Show when={store.mode === "normal"}>
983905
<box flexDirection="row" gap={1}>
984-
<text flexShrink={0} fg={keybind.leader ? theme.textMuted : theme.text}>
906+
<text flexShrink={0} fg={theme.text}>
985907
{local.model.parsed().model}
986908
</text>
987909
<text fg={theme.textMuted}>{local.model.parsed().provider}</text>
@@ -996,15 +918,16 @@ export function Prompt(props: PromptProps) {
996918
borderColor={highlight()}
997919
customBorderChars={{
998920
...EmptyBorder,
999-
vertical: theme.backgroundElement.a !== 0 ? "╹" : " ",
921+
// when the background is transparent, don't draw the vertical line
922+
vertical: theme.background.a != 0 ? "╹" : " ",
1000923
}}
1001924
>
1002925
<box
1003926
height={1}
1004927
border={["bottom"]}
1005928
borderColor={theme.backgroundElement}
1006929
customBorderChars={
1007-
theme.backgroundElement.a !== 0
930+
theme.background.a != 0
1008931
? {
1009932
...EmptyBorder,
1010933
horizontal: "▀",

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

Lines changed: 30 additions & 4 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(() => [
@@ -349,15 +350,40 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
349350
await sdk.client.ide.connect({ name })
350351
}
351352
const status = await sdk.client.ide.status()
352-
if (status.data) sync.set("ide", status.data)
353+
if (status.data) sync.set("ide", reconcile(status.data))
353354
},
354355
}
355356

357+
const selection = iife(() => {
358+
const [selStore, setSelStore] = createStore<{
359+
current: Ide.Selection | null
360+
}>({ current: null })
361+
362+
sdk.event.on(Ide.Event.SelectionChanged.type, async (evt) => {
363+
setSelStore("current", evt.properties.selection)
364+
// Refresh IDE status when we receive a selection
365+
const status = await sdk.client.ide.status()
366+
if (status.data) sync.set("ide", reconcile(status.data))
367+
})
368+
369+
return {
370+
current: () => selStore.current,
371+
clear: () => setSelStore("current", null),
372+
formatted: () => {
373+
const sel = selStore.current
374+
if (!sel || !sel.text) return null
375+
const lines = sel.text.split("\n").length
376+
return `${lines} lines`
377+
},
378+
}
379+
})
380+
356381
const result = {
357382
model,
358383
agent,
359384
mcp,
360385
ide,
386+
selection,
361387
}
362388
return result
363389
},

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"
@@ -59,10 +60,11 @@ export function Home() {
5960
} else if (args.prompt) {
6061
prompt.set({ input: args.prompt, parts: [] })
6162
once = true
62-
prompt.submit()
6363
}
6464
})
6565
const directory = useDirectory()
66+
const local = useLocal()
67+
const ide = createMemo(() => Object.values(sync.data.ide).find((x) => x.status === "connected"))
6668

6769
return (
6870
<>
@@ -102,8 +104,20 @@ export function Home() {
102104
</Switch>
103105
{connectedMcpCount()} MCP
104106
</text>
105-
<text fg={theme.textMuted}>/status</text>
106107
</Show>
108+
<Show when={ide()}>
109+
<text fg={theme.text}>
110+
<span style={{ fg: theme.success }}></span>
111+
{ide()!.name}
112+
</text>
113+
</Show>
114+
<Show when={local.selection.formatted()}>
115+
<text fg={theme.text}>
116+
<span style={{ fg: theme.accent }}>[] </span>
117+
{local.selection.formatted()}
118+
</text>
119+
</Show>
120+
<text fg={theme.textMuted}>/status</text>
107121
</box>
108122
<box flexGrow={1} />
109123
<box flexShrink={0}>

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

Lines changed: 8 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()
@@ -20,6 +21,7 @@ export function Footer() {
2021
})
2122
const directory = useDirectory()
2223
const connected = useConnected()
24+
const local = useLocal()
2325

2426
const [store, setStore] = createStore({
2527
welcome: false,
@@ -86,6 +88,12 @@ export function Footer() {
8688
{ide()!.name}
8789
</text>
8890
</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>
8997
<text fg={theme.textMuted}>/status</text>
9098
</Match>
9199
</Switch>

0 commit comments

Comments
 (0)