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 35c9335cbf02..845b214b1c2a 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 @@ -182,15 +182,17 @@ export function createCopyMode(input: { if (info?.lineSources && local < info.lineSources.length) { const src = info.lineSources[local] const text = lines[src] ?? "" - const wrapped = info.lineWraps?.[local] === 1 || info.lineSources[local + 1] === src + const wrapped = + info.lineWraps?.[local] === 1 || info.lineSources[local - 1] === src || info.lineSources[local + 1] === src if (!wrapped) return { text, col: match.gutter } - let base = info.lineStartCols[local] + const lineStart = info.lineStartCols?.[local] ?? 0 + let base = lineStart for (let i = local - 1; i >= 0; i--) { - if (info.lineSources[i] === src) base = info.lineStartCols[i] + if (info.lineSources[i] === src) base = info.lineStartCols?.[i] ?? base else break } - const offset = info.lineStartCols[local] - base - const width = info.lineWidthCols[local] + const offset = lineStart - base + const width = info.lineWidthCols?.[local] ?? Bun.stringWidth(text) return { text: sliceCols(text, offset, width), col: match.gutter } } if (local >= lines.length) return { text: "", col: match.gutter } diff --git a/packages/opencode/test/cli/tui/vim-motions.test.ts b/packages/opencode/test/cli/tui/vim-motions.test.ts index 4c2225f60ac5..7b46a57ff56d 100644 --- a/packages/opencode/test/cli/tui/vim-motions.test.ts +++ b/packages/opencode/test/cli/tui/vim-motions.test.ts @@ -2,11 +2,11 @@ import { describe, expect, test } from "bun:test" import type { ScrollBoxRenderable, TextareaRenderable } from "@opentui/core" import type { Part } from "@opencode-ai/sdk/v2" import { createRoot, createSignal } from "solid-js" -import { createCopyMode } from "../../../src/cli/cmd/tui/routes/session/copy-mode" import { createVimHandler } from "../../../src/cli/cmd/tui/component/vim/vim-handler" import { createVimState } from "../../../src/cli/cmd/tui/component/vim/vim-state" import type { VimScroll } from "../../../src/cli/cmd/tui/component/vim/vim-scroll" import { vimScroll } from "../../../src/cli/cmd/tui/component/vim/vim-scroll" +import { createCopyMode } from "../../../src/cli/cmd/tui/routes/session/copy-mode" import type { VimJump } from "../../../src/cli/cmd/tui/component/vim/vim-motion-jump" import { copyNextParagraph, @@ -4250,6 +4250,45 @@ describe("vim scroll mapping", () => { }) describe("copy mode", () => { + test("highlights final wrapped row using its visual slice", () => { + const child = { + id: "text-part", + y: 0, + height: 3, + plainText: "abcdefghijklmnopqrstuvwxyz", + lineInfo: { + lineSources: [0, 0, 0], + lineStartCols: [0, 10, 20], + lineWidthCols: [10, 10, 6], + lineWraps: [1, 1, 0], + }, + } + const scroll = { + y: 0, + height: 10, + width: 80, + scrollHeight: 3, + getChildren: () => [child], + scrollBy() {}, + } as unknown as ScrollBoxRenderable + const cm = createCopyMode({ + scroll: () => scroll, + messages: () => [{ id: "message", role: "assistant" }], + parts: () => [{ id: "part", type: "text", text: child.plainText }] as Part[], + thinking: () => false, + details: () => false, + session: () => "session", + toBottom() {}, + }) + + cm.prompt.enter() + cm.prompt.visual("line") + cm.prompt.jump("top") + + expect(cm.highlights().get("text-part")?.at(-1)).toMatchObject({ line: 2, text: "uvwxyz" }) + expect(cm.prompt.yank()).toEqual({ text: "abcdefghij\nklmnopqrst\nuvwxyz", linewise: false }) + }) + test("copyWordNext advances to next row when next word is on following line", () => { const next = copyWordNext([{ col: 0 }, { col: 0 }], (idx) => ["alpha", "beta gamma"][idx]!, 0, 4, false) expect(next).toEqual({ idx: 1, col: 5 })