diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/copy-mode.ts b/packages/opencode/src/cli/cmd/tui/routes/session/copy-mode.ts index 3b5c9470911c..976d66d3a03c 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/copy-mode.ts +++ b/packages/opencode/src/cli/cmd/tui/routes/session/copy-mode.ts @@ -50,6 +50,7 @@ const empty: CopyState = { } const segmenter = new Intl.Segmenter() +type RenderableEntry = { node: any; y: number; x: number; gutter: number } type Endpoint = { idx: number; col: number } function orderEndpoints(a: Endpoint, b: Endpoint): { start: Endpoint; end: Endpoint } { @@ -146,13 +147,13 @@ export function createCopyMode(input: { // --- renderable tree helpers --- - function findRenderables(node: any, y = 0, gutter = 0): { node: any; y: number; gutter: number }[] { - if (node.lineInfo && node.plainText !== undefined) return [{ node, y, gutter }] + function findRenderables(node: any, y = 0, x = 0, gutter = 0): RenderableEntry[] { + if (node.lineInfo && node.plainText !== undefined) return [{ node, y, x, gutter }] const width = gutter || ("gutter" in node && node.gutter ? node.gutter.calculateWidth() : 0) - const result: { node: any; y: number; gutter: number }[] = [] + const result: RenderableEntry[] = [] for (const child of node.getChildren?.() ?? []) { if (child._positionType === "absolute") continue - result.push(...findRenderables(child, y + Math.floor(child._y ?? 0), width)) + result.push(...findRenderables(child, y + Math.floor(child._y ?? 0), x + Math.floor(child._x ?? 0), width)) } return result } @@ -175,6 +176,59 @@ export function createCopyMode(input: { return text.slice(begin, end) } + function sourceLine(node: any, src: number): string | undefined { + let current = node + while (current) { + const content = + typeof current.content === "string" + ? current.content + : typeof current._content === "string" + ? current._content + : undefined + const line = content?.split("\n")[src] + if (line !== undefined) return line + current = current.parent + } + } + + function markdownListPrefix(raw?: string): string { + const match = raw?.match(/^(\s{0,3}(?:[-+*]|\d{1,9}[.)])\s+)(\[[ xX]\]\s+)?/) + return match ? `${match[1] ?? ""}${match[2] ?? ""}` : "" + } + + function stripPrefix(text: string, length: number) { + return { text: text.slice(length), width: Bun.stringWidth(text.slice(0, length)) } + } + + function stripMatchedPrefix(text: string, pattern: RegExp) { + const match = text.match(pattern) + if (!match?.[0]) return { text, width: 0 } + return stripPrefix(text, match[0].length) + } + + function stripRenderedListPrefix(text: string, prefix: string) { + const task = prefix.match(/\[[ xX]\]\s+$/)?.[0] + if (task && text.toLowerCase().startsWith(task.toLowerCase())) return stripPrefix(text, task.length) + if (task) return stripMatchedPrefix(text, /^\[[^\]]+\]\s+/) + return stripMatchedPrefix(text, /^(?:[-+*•◦‣]\s+|\d{1,9}[.)]\s+)/) + } + + function prefixedText(raw: string | undefined, visiblePrefix: string, text: string) { + const prefix = markdownListPrefix(raw) + if (!prefix) return { text: visiblePrefix + text, colOffset: 0 } + if (text.startsWith(prefix)) return { text, colOffset: 0 } + const stripped = stripRenderedListPrefix(text, prefix) + return { + text: prefix + stripped.text, + colOffset: -Math.max(0, Bun.stringWidth(prefix) - stripped.width), + } + } + + function copyResult(raw: string | undefined, visiblePrefix: string, text: string, col: number) { + const result = prefixedText(raw, visiblePrefix, text) + return { text: result.text, col: Math.max(0, col + result.colOffset) } + } + function childById(id: string, cache?: Map) { if (cache) return cache.get(id) return input @@ -183,6 +237,25 @@ export function createCopyMode(input: { .find((c) => c.id === id) } + function entryLine(entry: { node: any; y: number }, rowLine: number): string { + if (typeof entry.node.plainText !== "string") return "" + const local = rowLine - entry.y + const lines = entry.node.plainText.split("\n") + const info = entry.node.lineInfo + if (info?.lineSources && local >= 0 && local < info.lineSources.length) { + return lines[info.lineSources[local]] ?? "" + } + return lines[local] ?? "" + } + + function rowPrefix(entries: RenderableEntry[], match: RenderableEntry, row: CopyRow): string { + return entries + .filter((entry) => entry !== match && entry.y === row.line && entry.x < match.x) + .toSorted((a, b) => a.x - b.x) + .map((entry) => entryLine(entry, row.line)) + .join("") + } + function copyLine(row: CopyRow, child: any): { text: string; col: number } { const entries = findRenderables(child) if (!entries.length) return { text: "", col: 0 } @@ -193,14 +266,15 @@ export function createCopyMode(input: { } if (typeof match.node.plainText !== "string") return { text: "", col: 0 } const local = row.line - match.y + const prefix = rowPrefix(entries, match, row) const lines = match.node.plainText.split("\n") const info = match.node.lineInfo if (info?.lineSources && local < info.lineSources.length) { const src = info.lineSources[local] - const text = lines[src] ?? "" + const source = lines[src] ?? "" const wrapped = info.lineWraps?.[local] === 1 || info.lineSources[local - 1] === src || info.lineSources[local + 1] === src - if (!wrapped) return { text, col: match.gutter } + if (!wrapped) return copyResult(sourceLine(match.node, src), prefix, source, match.gutter) const lineStart = info.lineStartCols?.[local] ?? 0 let base = lineStart for (let i = local - 1; i >= 0; i--) { @@ -208,11 +282,16 @@ export function createCopyMode(input: { else break } const offset = lineStart - base - const width = info.lineWidthCols?.[local] ?? Bun.stringWidth(text) - return { text: sliceCols(text, offset, width), col: match.gutter } + const width = info.lineWidthCols?.[local] ?? Bun.stringWidth(source) + return copyResult( + offset === 0 ? sourceLine(match.node, src) : undefined, + prefix, + sliceCols(source, offset, width), + match.gutter, + ) } if (local >= lines.length) return { text: "", col: match.gutter } - return { text: lines[local] ?? "", col: match.gutter } + return copyResult(sourceLine(match.node, local), prefix, lines[local] ?? "", match.gutter) } function shift(row?: CopyRow, gutter?: number) { diff --git a/packages/opencode/test/cli/tui/vim-motions.test.ts b/packages/opencode/test/cli/tui/vim-motions.test.ts index fda38e3ad20f..9b39007813e2 100644 --- a/packages/opencode/test/cli/tui/vim-motions.test.ts +++ b/packages/opencode/test/cli/tui/vim-motions.test.ts @@ -6794,7 +6794,7 @@ describe("vim scroll mapping", () => { }) describe("copy mode", () => { - function createRenderedCopyMode(lines: string[], gutter = 4) { + function createRenderedCopyMode(lines: string[], gutter = 4, options?: { content?: string }) { const child = { id: "text-part", y: 0, @@ -6804,6 +6804,8 @@ describe("copy mode", () => { { _y: 0, plainText: lines.join("\n"), + content: options?.content, + _content: options?.content, lineInfo: { lineSources: lines.map((_, i) => i), lineStartCols: lines.map(() => 0), @@ -6824,7 +6826,7 @@ describe("copy mode", () => { const cm = createCopyMode({ scroll: () => scroll, messages: () => [{ id: "message", role: "assistant" }], - parts: () => [{ id: "part", type: "text", text: lines.join("\n") }] as Part[], + parts: () => [{ id: "part", type: "text", text: options?.content ?? lines.join("\n") }] as Part[], thinking: () => false, details: () => false, session: () => "session", @@ -6835,6 +6837,118 @@ describe("copy mode", () => { return cm } + test("yank line preserves markdown list markers from source", () => { + const cm = createRenderedCopyMode(["• Inspect current branch", "[x] Push visual-fix"], 4, { + content: "- Inspect current branch\n- [x] Push visual-fix", + }) + + expect(cm.prompt.yankLine()).toEqual({ text: "- Inspect current branch", linewise: false }) + cm.prompt.move("down") + expect(cm.prompt.yankLine()).toEqual({ text: "- [x] Push visual-fix", linewise: false }) + }) + + test("copy mode cursor starts on restored markdown list marker", () => { + const line = "If you launched OpenCode" + const leaf = { + _y: 0, + plainText: line, + parent: undefined as unknown, + lineInfo: { + lineSources: [0], + lineStartCols: [0], + lineWidthCols: [Bun.stringWidth(line)], + lineWraps: [0], + }, + } + const child = { + id: "text-part", + y: 0, + height: 1, + content: `- ${line}`, + _content: `- ${line}`, + gutter: { calculateWidth: () => 4 }, + getChildren: () => [leaf], + } + leaf.parent = child + const scroll = { + y: 0, + height: 10, + width: 120, + scrollHeight: 1, + getChildren: () => [child], + scrollBy() {}, + } as unknown as ScrollBoxRenderable + const cm = createCopyMode({ + scroll: () => scroll, + messages: () => [{ id: "message", role: "assistant" }], + parts: () => [{ id: "part", type: "text", text: `- ${line}` }] as Part[], + thinking: () => false, + details: () => false, + session: () => "session", + toBottom() {}, + }) + + cm.prompt.enter() + + expect(cm.prompt.col()).toBe(3 + 4 - Bun.stringWidth("- ")) + expect(cm.cursorText()).toBe("-") + expect(cm.prompt.yankLine()).toEqual({ text: `- ${line}`, linewise: false }) + }) + + test("yank line includes visible same-row prefixes", () => { + const line = "Inspect current branch" + const child = { + id: "text-part", + y: 0, + height: 1, + getChildren: () => [ + { + _x: 0, + _y: 0, + plainText: "[✓] ", + lineInfo: { + lineSources: [0], + lineStartCols: [0], + lineWidthCols: [Bun.stringWidth("[✓] ")], + lineWraps: [0], + }, + }, + { + _x: 4, + _y: 0, + plainText: line, + lineInfo: { + lineSources: [0], + lineStartCols: [0], + lineWidthCols: [Bun.stringWidth(line)], + lineWraps: [0], + }, + }, + ], + } + const scroll = { + y: 0, + height: 10, + width: 120, + scrollHeight: 1, + getChildren: () => [child], + scrollBy() {}, + } as unknown as ScrollBoxRenderable + const cm = createCopyMode({ + scroll: () => scroll, + messages: () => [{ id: "message", role: "assistant" }], + parts: () => [{ id: "part", type: "text", text: line }] as Part[], + thinking: () => false, + details: () => false, + session: () => "session", + toBottom() {}, + }) + + cm.prompt.enter() + + expect(cm.prompt.yankLine()).toEqual({ text: "[✓] Inspect current branch", linewise: false }) + }) + test("entering copy mode keeps visible row when unified layout changes", async () => { let offset = 20 let cm: ReturnType | undefined