From 320527a3e4c9c064d3a3e7ce28a138ad8976e830 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 30 Apr 2026 14:15:50 -0400 Subject: [PATCH] Support multiple Zed selections in TUI context (#25140) --- .../cli/cmd/tui/component/prompt/index.tsx | 111 ++++++++++------ .../src/cli/cmd/tui/context/editor-zed.ts | 66 +++++++-- .../src/cli/cmd/tui/context/editor.ts | 51 +++++-- .../test/cli/tui/editor-context-zed.test.ts | 125 ++++++++++++++---- .../test/cli/tui/editor-context.test.tsx | 14 +- 5 files changed, 278 insertions(+), 89 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index cd47e91708..1f93a43947 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -12,7 +12,7 @@ import { useRoute } from "@tui/context/route" import { useProject } from "@tui/context/project" import { useSync } from "@tui/context/sync" import { useEvent } from "@tui/context/event" -import { useEditorContext, type EditorSelection } from "@tui/context/editor" +import { editorSelectionKey, useEditorContext, type EditorSelection } from "@tui/context/editor" import { MessageID, PartID } from "@/session/schema" import { createStore, produce, unwrap } from "solid-js/store" import { useKeybind } from "@tui/context/keybind" @@ -84,16 +84,30 @@ function fadeColor(color: RGBA, alpha: number) { return RGBA.fromValues(color.r, color.g, color.b, color.a * alpha) } -function getEditorSelectionKey(selection: EditorSelection) { - return [ - selection.filePath, - selection.text, - selection.source ?? "", - selection.selection.start.line, - selection.selection.start.character, - selection.selection.end.line, - selection.selection.end.character, - ].join("-") +function hasEditorRangeSelection(selection: EditorSelection["ranges"][number]) { + return ( + selection.selection.start.line !== selection.selection.end.line || + selection.selection.start.character !== selection.selection.end.character + ) +} + +function getEditorRangeLabel(selection: EditorSelection["ranges"][number]) { + if (!hasEditorRangeSelection(selection)) return + if (selection.selection.start.line === selection.selection.end.line) return `#${selection.selection.start.line}` + return `#${selection.selection.start.line}-${selection.selection.end.line}` +} + +function formatEditorContext(selection: EditorSelection) { + const selected = selection.ranges.filter(hasEditorRangeSelection) + if (selected.length === 0) + return `Note: The user opened the file "${selection.filePath}". This may or may not be relevant to the current task.\n` + + const ranges = selected.map((range, index) => { + const prefix = selected.length > 1 ? `Selection ${index + 1}: ` : "" + return `Note: The user selected ${prefix}${getEditorRangeLabel(range)} from "${selection.filePath}". \`\`\`${range.text}\`\`\`\n\n` + }) + + return `${ranges.join("\n")} This may or may not be relevant to the current task.\n` } let stashed: { prompt: PromptInfo; cursor: number } | undefined @@ -125,13 +139,21 @@ export function Prompt(props: PromptProps) { const list = createMemo(() => props.placeholders?.normal ?? []) const shell = createMemo(() => props.placeholders?.shell ?? []) const fileContextEnabled = createMemo(() => kv.get("file_context_enabled", true)) - const editorPath = createMemo(() => (fileContextEnabled() ? editor.selection()?.filePath : undefined)) - const editorSelectionLabel = createMemo(() => { - const selection = fileContextEnabled() ? editor.selection()?.selection : undefined + const [dismissedEditorSelectionKey, setDismissedEditorSelectionKey] = createSignal() + const editorContext = createMemo(() => { + const selection = fileContextEnabled() ? editor.selection() : undefined if (!selection) return - if (selection.start.line === selection.end.line && selection.start.character === selection.end.character) return - if (selection.start.line === selection.end.line) return `#${selection.start.line}` - return `#${selection.start.line}-${selection.end.line}` + return editorSelectionKey(selection) === dismissedEditorSelectionKey() ? undefined : selection + }) + const editorPath = createMemo(() => editorContext()?.filePath) + const editorSelectionLabel = createMemo(() => { + const ranges = editorContext()?.ranges + if (!ranges) return + const first = ranges.find(hasEditorRangeSelection) ?? ranges[0] + if (!first) return + return [getEditorRangeLabel(first), ranges.length > 1 ? `+${ranges.length - 1}` : undefined] + .filter(Boolean) + .join(" ") }) const editorFileLabel = createMemo(() => { const value = editorPath() @@ -147,6 +169,7 @@ export function Prompt(props: PromptProps) { if (!file) return return Locale.truncateMiddle(file, Math.max(12, Math.min(48, Math.floor(dimensions().width / 3)))) }) + const [editorContextHover, setEditorContextHover] = createSignal(false) let lastSubmittedEditorSelectionKey: string | undefined const [auto, setAuto] = createSignal() const currentProviderLabel = createMemo(() => local.model.parsed().provider) @@ -163,6 +186,11 @@ export function Prompt(props: PromptProps) { } } + function dismissEditorContext() { + setDismissedEditorSelectionKey(editorSelectionKey(editorContext())) + editor.clearSelection() + } + const textareaKeybindings = useTextareaKeybindings() const fileStyleId = syntax().getStyleId("extmark.file")! @@ -292,6 +320,16 @@ export function Prompt(props: PromptProps) { dialog.clear() }, }, + { + title: "Remove editor context", + value: "prompt.editor_context.clear", + category: "Prompt", + enabled: Boolean(editorContext()), + onSelect: (dialog) => { + dismissEditorContext() + dialog.clear() + }, + }, { title: "Paste", value: "prompt.paste", @@ -760,35 +798,21 @@ export function Prompt(props: PromptProps) { // Capture mode before it gets reset const currentMode = store.mode const variant = local.model.variant.current() - const editorSelection = fileContextEnabled() ? editor.selection() : undefined - const editorSelectionKey = editorSelection ? getEditorSelectionKey(editorSelection) : undefined + const editorSelection = editorContext() + const currentEditorSelectionKey = editorSelectionKey(editorSelection) const editorParts = - editorSelection && editorSelectionKey !== lastSubmittedEditorSelectionKey + editorSelection && currentEditorSelectionKey !== lastSubmittedEditorSelectionKey ? [ { id: PartID.ascending(), type: "text" as const, - text: (() => { - const start = editorSelection.selection.start - const end = editorSelection.selection.end - - let text = "" - if (start.line === end.line && start.character === end.character) { - text = `Note: The user opened the file "${editorSelection.filePath}".` - } else if (start.line === end.line) { - text = `Note: The user selected line ${start.line + 1} from "${editorSelection.filePath}". \`\`\`${editorSelection.text}\`\`\`\n\n` - } else { - text = `Note: The user selected lines ${start.line + 1} to ${end.line + 1} from "${editorSelection.filePath}". \`\`\`${editorSelection.text}\`\`\`\n\n` - } - - return `${text} This may or may not be relevant to the current task.\n` - })(), + text: formatEditorContext(editorSelection), synthetic: true, metadata: { kind: "editor_context", source: editorSelection.source ?? "editor", filePath: editorSelection.filePath, - selection: editorSelection.selection, + ranges: editorSelection.ranges, }, }, ] @@ -855,7 +879,7 @@ export function Prompt(props: PromptProps) { ], }) .catch(() => {}) - lastSubmittedEditorSelectionKey = editorSelectionKey + lastSubmittedEditorSelectionKey = currentEditorSelectionKey } history.append({ ...store.prompt, @@ -1406,7 +1430,18 @@ export function Prompt(props: PromptProps) { - {(file) => {file()}} + + {(file) => ( + setEditorContextHover(true)} + onMouseOut={() => setEditorContextHover(false)} + onMouseUp={dismissEditorContext} + > + {editorContextHover() ? `x ${file()}` : file()} + + )} + diff --git a/packages/opencode/src/cli/cmd/tui/context/editor-zed.ts b/packages/opencode/src/cli/cmd/tui/context/editor-zed.ts index 00f90857c0..5b7bf1cf4a 100644 --- a/packages/opencode/src/cli/cmd/tui/context/editor-zed.ts +++ b/packages/opencode/src/cli/cmd/tui/context/editor-zed.ts @@ -12,6 +12,9 @@ const ZedEditorRowSchema = z.object({ workspace_paths: z.string().nullable(), timestamp: z.string(), buffer_path: z.string().nullable(), +}) + +const ZedSelectionRowSchema = z.object({ selection_start: z.number().nullable(), selection_end: z.number().nullable(), }) @@ -24,6 +27,7 @@ const utf8 = new TextEncoder() type ZedEditorRow = z.infer type ZedActiveEditorRow = ZedEditorRow & { item_kind: "Editor"; editor_id: number } +type ZedSelectionRow = z.infer export type ZedSelectionResult = | { type: "selection"; selection: EditorSelection } @@ -36,7 +40,21 @@ export async function resolveZedSelection(dbPath: string, cwd = process.cwd()): const row = active.row if (!row.buffer_path) return { type: "empty" } - if (row.selection_start == null || row.selection_end == null) return { type: "unavailable" } + + const selections = queryZedEditorSelections(dbPath, row) + if (selections.type !== "selections") return selections + const byteRanges = selections.selections + .flatMap((selection) => { + if (selection.selection_start == null || selection.selection_end == null) return [] + return [ + { + start: Math.min(selection.selection_start, selection.selection_end), + end: Math.max(selection.selection_start, selection.selection_end), + }, + ] + }) + .sort((left, right) => left.start - right.start || left.end - right.end) + if (byteRanges.length === 0) return { type: "unavailable" } const contents = queryZedEditorContents(dbPath, row) const text = @@ -47,16 +65,21 @@ export async function resolveZedSelection(dbPath: string, cwd = process.cwd()): .catch(() => undefined) if (text == null) return { type: "unavailable" } - const startOffset = utf8ByteOffsetToStringIndex(text, Math.min(row.selection_start, row.selection_end)) - const endOffset = utf8ByteOffsetToStringIndex(text, Math.max(row.selection_start, row.selection_end)) + const ranges = byteRanges.map((range) => { + const startOffset = utf8ByteOffsetToStringIndex(text, range.start) + const endOffset = utf8ByteOffsetToStringIndex(text, range.end) + return { + text: text.slice(startOffset, endOffset), + selection: offsetsToSelection(text, startOffset, endOffset), + } + }) return { type: "selection", selection: { - text: text.slice(startOffset, endOffset), filePath: row.buffer_path, source: "zed", - selection: offsetsToSelection(text, startOffset, endOffset), + ranges, }, } } @@ -73,14 +96,11 @@ function queryZedActiveEditor(dbPath: string, cwd: string) { i.workspace_id as workspace_id, w.paths as workspace_paths, w.timestamp as timestamp, - e.buffer_path as buffer_path, - s.start as selection_start, - s.end as selection_end + e.buffer_path as buffer_path from items i join panes p on p.pane_id = i.pane_id and p.workspace_id = i.workspace_id join workspaces w on w.workspace_id = i.workspace_id left join editors e on e.item_id = i.item_id and e.workspace_id = i.workspace_id - left join editor_selections s on s.editor_id = e.item_id and s.workspace_id = e.workspace_id where i.active = 1 and p.active = 1 order by w.timestamp desc`, ) @@ -108,6 +128,34 @@ function queryZedActiveEditor(dbPath: string, cwd: string) { } } +function queryZedEditorSelections(dbPath: string, row: ZedActiveEditorRow) { + let db: Database | undefined + try { + db = new Database(dbPath, { readonly: true }) + const raw = db + .query( + `select + start as selection_start, + end as selection_end + from editor_selections + where editor_id = $editorID and workspace_id = $workspaceID`, + ) + .all({ $editorID: row.editor_id, $workspaceID: row.workspace_id }) + + const selections = raw.flatMap((selection) => { + const parsed = ZedSelectionRowSchema.safeParse(selection) + return parsed.success ? [parsed.data] : [] + }) + + if (raw.length > 0 && selections.length === 0) return { type: "unavailable" as const } + return { type: "selections" as const, selections } + } catch { + return { type: "unavailable" as const } + } finally { + db?.close() + } +} + function queryZedEditorContents(dbPath: string, row: ZedActiveEditorRow) { let db: Database | undefined try { diff --git a/packages/opencode/src/cli/cmd/tui/context/editor.ts b/packages/opencode/src/cli/cmd/tui/context/editor.ts index 4ebc1c2c06..06dd6fd042 100644 --- a/packages/opencode/src/cli/cmd/tui/context/editor.ts +++ b/packages/opencode/src/cli/cmd/tui/context/editor.ts @@ -28,16 +28,46 @@ const PositionSchema = z.object({ character: z.number(), }) -const EditorSelectionSchema = z.object({ +const EditorSelectionRangeSchema = z.object({ text: z.string(), - filePath: z.string(), - source: z.enum(["websocket", "zed"]).optional(), selection: z.object({ start: PositionSchema, end: PositionSchema, }), }) +const EditorSelectionSchema = z + .union([ + z.object({ + filePath: z.string(), + source: z.enum(["websocket", "zed"]).optional(), + ranges: z.array(EditorSelectionRangeSchema).min(1), + }), + z.object({ + text: z.string(), + filePath: z.string(), + source: z.enum(["websocket", "zed"]).optional(), + selection: z.object({ + start: PositionSchema, + end: PositionSchema, + }), + }), + ]) + .transform((value) => + "ranges" in value + ? value + : { + filePath: value.filePath, + source: value.source, + ranges: [ + { + text: value.text, + selection: value.selection, + }, + ], + }, + ) + const EditorMentionSchema = z.object({ filePath: z.string(), lineStart: z.number(), @@ -262,6 +292,7 @@ export const { use: useEditorContext, provider: EditorContextProvider } = create return store.selection }, clearSelection() { + lastZedSelectionKey = undefined setStore("selection", undefined) }, onMention(listener: (mention: EditorMention) => void) { @@ -352,15 +383,17 @@ function readEditorLockFile(filePath: string): EditorLockFile | undefined { } } -function editorSelectionKey(selection: EditorSelection | undefined) { +export function editorSelectionKey(selection: EditorSelection | undefined) { if (!selection) return "" return [ selection.filePath, - selection.selection.start.line, - selection.selection.start.character, - selection.selection.end.line, - selection.selection.end.character, - selection.text, + ...selection.ranges.flatMap((range) => [ + range.selection.start.line, + range.selection.start.character, + range.selection.end.line, + range.selection.end.character, + range.text, + ]), ].join("\0") } diff --git a/packages/opencode/test/cli/tui/editor-context-zed.test.ts b/packages/opencode/test/cli/tui/editor-context-zed.test.ts index 4c5491461e..9a9bca8c5e 100644 --- a/packages/opencode/test/cli/tui/editor-context-zed.test.ts +++ b/packages/opencode/test/cli/tui/editor-context-zed.test.ts @@ -10,6 +10,7 @@ type ZedFixtureOptions = { editor?: boolean selectionStart?: number | null selectionEnd?: number | null + selections?: Array<{ start: number | null; end: number | null }> contents?: string } @@ -30,10 +31,16 @@ async function writeZedFixture(dir: string, options: ZedFixtureOptions = {}) { db.run("insert into items values (1, 1, 1, 1, ?)", [options.itemKind ?? "Editor"]) if (options.editor !== false) { db.run("insert into editors values (1, 1, ?, ?)", [filePath, contents]) - db.run("insert into editor_selections values (1, 1, ?, ?)", [ - options.selectionStart === undefined ? 4 : options.selectionStart, - options.selectionEnd === undefined ? 7 : options.selectionEnd, - ]) + ;( + options.selections ?? [ + { + start: options.selectionStart === undefined ? 4 : options.selectionStart, + end: options.selectionEnd === undefined ? 7 : options.selectionEnd, + }, + ] + ).forEach((selection) => + db.run("insert into editor_selections values (1, 1, ?, ?)", [selection.start, selection.end]), + ) } db.close() @@ -66,13 +73,59 @@ test("resolveZedSelection returns active editor selection", async () => { expect(await resolveZedSelection(fixture.dbPath, tmp.path)).toEqual({ type: "selection", selection: { - text: "two", filePath: fixture.filePath, source: "zed", - selection: { - start: { line: 2, character: 1 }, - end: { line: 2, character: 4 }, + ranges: [ + { + text: "two", + selection: { + start: { line: 2, character: 1 }, + end: { line: 2, character: 4 }, + }, + }, + ], + }, + }) +}) + +test("resolveZedSelection returns all active editor selections sorted by offset", async () => { + await using tmp = await tmpdir() + const contents = "one\ntwo\nthree\nfour" + const fixture = await writeZedFixture(tmp.path, { + contents, + selections: [ + { + start: utf8ByteOffset(contents, contents.indexOf("four")), + end: utf8ByteOffset(contents, contents.indexOf("four") + 4), }, + { + start: utf8ByteOffset(contents, contents.indexOf("two")), + end: utf8ByteOffset(contents, contents.indexOf("two") + 3), + }, + ], + }) + + expect(await resolveZedSelection(fixture.dbPath, tmp.path)).toEqual({ + type: "selection", + selection: { + filePath: fixture.filePath, + source: "zed", + ranges: [ + { + text: "two", + selection: { + start: { line: 2, character: 1 }, + end: { line: 2, character: 4 }, + }, + }, + { + text: "four", + selection: { + start: { line: 4, character: 1 }, + end: { line: 4, character: 5 }, + }, + }, + ], }, }) }) @@ -90,13 +143,17 @@ test("resolveZedSelection converts Zed UTF-8 byte offsets to string offsets", as expect(await resolveZedSelection(fixture.dbPath, tmp.path)).toEqual({ type: "selection", selection: { - text: "TARGET", filePath: fixture.filePath, source: "zed", - selection: { - start: { line: 4, character: 1 }, - end: { line: 4, character: 7 }, - }, + ranges: [ + { + text: "TARGET", + selection: { + start: { line: 4, character: 1 }, + end: { line: 4, character: 7 }, + }, + }, + ], }, }) }) @@ -114,13 +171,17 @@ test("resolveZedSelection handles non-ASCII text inside the selected range", asy expect(await resolveZedSelection(fixture.dbPath, tmp.path)).toEqual({ type: "selection", selection: { - text: "выбор", filePath: fixture.filePath, source: "zed", - selection: { - start: { line: 3, character: 1 }, - end: { line: 3, character: 6 }, - }, + ranges: [ + { + text: "выбор", + selection: { + start: { line: 3, character: 1 }, + end: { line: 3, character: 6 }, + }, + }, + ], }, }) }) @@ -138,13 +199,17 @@ test("resolveZedSelection handles emoji before the selected range", async () => expect(await resolveZedSelection(fixture.dbPath, tmp.path)).toEqual({ type: "selection", selection: { - text: "TARGET", filePath: fixture.filePath, source: "zed", - selection: { - start: { line: 2, character: 1 }, - end: { line: 2, character: 7 }, - }, + ranges: [ + { + text: "TARGET", + selection: { + start: { line: 2, character: 1 }, + end: { line: 2, character: 7 }, + }, + }, + ], }, }) }) @@ -162,13 +227,17 @@ test("resolveZedSelection handles reversed Zed byte offsets", async () => { expect(await resolveZedSelection(fixture.dbPath, tmp.path)).toEqual({ type: "selection", selection: { - text: "TARGET", filePath: fixture.filePath, source: "zed", - selection: { - start: { line: 3, character: 1 }, - end: { line: 3, character: 7 }, - }, + ranges: [ + { + text: "TARGET", + selection: { + start: { line: 3, character: 1 }, + end: { line: 3, character: 7 }, + }, + }, + ], }, }) }) diff --git a/packages/opencode/test/cli/tui/editor-context.test.tsx b/packages/opencode/test/cli/tui/editor-context.test.tsx index e896c29fb5..14dead86ac 100644 --- a/packages/opencode/test/cli/tui/editor-context.test.tsx +++ b/packages/opencode/test/cli/tui/editor-context.test.tsx @@ -190,13 +190,17 @@ test("useEditorContext resets selection when reconnecting", async () => { serverInfo: { name: "test", version: "0.0.0" }, }) expect(mounted.editor.selection()).toEqual({ - text: "foo", filePath: path.join(startupDirectory, "file.ts"), source: "websocket", - selection: { - start: { line: 1, character: 1 }, - end: { line: 1, character: 4 }, - }, + ranges: [ + { + text: "foo", + selection: { + start: { line: 1, character: 1 }, + end: { line: 1, character: 4 }, + }, + }, + ], }) mounted.editor.reconnect(startupDirectory)