From 00e34135a2e8351e87b90bc0e24f261932a314c1 Mon Sep 17 00:00:00 2001 From: Robin Gagnon Date: Fri, 1 May 2026 18:58:00 -0500 Subject: [PATCH] fix(tui): preserve copy-mode visual anchor --- .../cli/cmd/tui/routes/session/copy-mode.ts | 2 +- .../opencode/test/cli/tui/vim-motions.test.ts | 44 ++++++++++++++++++- 2 files changed, 43 insertions(+), 3 deletions(-) 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 da074879b93e..ff103f69c111 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 @@ -437,7 +437,7 @@ export function createCopyMode(input: { setState((prev) => ({ ...prev, visual: mode, - anchor: { idx: prev.idx, col: prev.col }, + anchor: prev.anchor ?? { idx: prev.idx, col: prev.col }, })) } diff --git a/packages/opencode/test/cli/tui/vim-motions.test.ts b/packages/opencode/test/cli/tui/vim-motions.test.ts index 1498eb3fdc9a..5babd0d8052a 100644 --- a/packages/opencode/test/cli/tui/vim-motions.test.ts +++ b/packages/opencode/test/cli/tui/vim-motions.test.ts @@ -1,6 +1,8 @@ import { describe, expect, test } from "bun:test" -import type { TextareaRenderable } from "@opentui/core" -import { createSignal } from "solid-js" +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" @@ -4507,6 +4509,44 @@ describe("copy mode", () => { expect(ctx.copyVisualCalls).not.toContain("char") }) + test("V after characterwise visual preserves copy anchor", () => { + createRoot((dispose) => { + const children = [ + { id: "text-part", y: 0, height: 1 }, + { id: "text-part", y: 1, height: 1 }, + { id: "text-part", y: 2, height: 1 }, + ] + const scroll = { + y: 0, + height: 3, + width: 80, + getChildren: () => children, + scrollBy(delta: number) { + scroll.y += delta + }, + } as unknown as ScrollBoxRenderable + const cm = createCopyMode({ + scroll: () => scroll, + messages: () => [{ id: "msg", role: "assistant" }], + parts: () => [{ type: "text", id: "part" } as Part], + thinking: () => false, + details: () => false, + session: () => "session", + toBottom() {}, + }) + + cm.prompt.enter() + cm.prompt.visual("char") + cm.prompt.move("up") + cm.prompt.visual("line") + + expect(cm.state().visual).toBe("line") + expect(cm.state().anchor).toEqual({ idx: 2, col: 3 }) + expect(cm.state().idx).toBe(1) + dispose() + }) + }) + test("y yanks copy selection and exits copy mode", () => { const ctx = createHandler("abc", { mode: "copy", copy: { text: "picked text", isVisual: true } })