From 8381153418faa32396af98ec173228d7eb16ea5f Mon Sep 17 00:00:00 2001 From: Simon Klee Date: Thu, 27 Aug 2026 23:53:07 +0200 Subject: [PATCH] fix(tui): respect selection copy modes (#45758) --- packages/tui/src/app.tsx | 14 +- packages/tui/src/util/selection.ts | 31 ++-- packages/tui/test/app-lifecycle.test.tsx | 143 ++++++++++++++- .../util/selection-copy-on-select.test.tsx | 20 +- packages/tui/test/util/selection-keys.test.ts | 173 ++++++++++++++++++ packages/tui/test/util/selection.test.ts | 8 +- 6 files changed, 352 insertions(+), 37 deletions(-) create mode 100644 packages/tui/test/util/selection-keys.test.ts diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index 550787b8d16..d6207054c4a 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -8,7 +8,7 @@ import { ClipboardProvider, useClipboard } from "./context/clipboard" import { LogProvider, useLog, type LogSink } from "./context/log" import { ExitProvider, useExit } from "./context/exit" import { EpilogueProvider } from "./context/epilogue" -import * as Selection from "./util/selection" +import { Selection } from "./util/selection" import { CliRenderEvents, createCliRenderer, @@ -558,14 +558,16 @@ function App(props: { pair?: DialogPairCredentials }) { } }) - // Let selection copy/dismiss win ahead of normal bindings when explicit copy is required. + const copyOnSelectEnabled = () => + (config.data.terminal?.copy ?? (process.platform === "win32" ? "manual" : "select")) === "select" + + // Selection copy/dismiss must precede both app bindings and the terminal pane's raw key forwarding. const offSelectionKeys = keymap.intercept( "key", ({ event }) => { - if ((config.data.terminal?.copy ?? (process.platform === "win32" ? "manual" : "select")) === "select") return - Selection.handleSelectionKey(renderer, toast, event, clipboard) + Selection.handleSelectionKey(renderer, toast, event, clipboard, copyOnSelectEnabled()) }, - { priority: 1 }, + { priority: 101 }, ) onCleanup(() => { offSelectionKeys() @@ -583,8 +585,6 @@ function App(props: { pair?: DialogPairCredentials }) { renderer.clearSelection() } const terminalTitleEnabled = () => config.data.terminal?.title ?? true - const copyOnSelectEnabled = () => - (config.data.terminal?.copy ?? (process.platform === "win32" ? "manual" : "select")) === "select" const pasteSummaryEnabled = () => config.data.prompt?.paste !== "full" const tabsVertical = () => config.data.tabs.layout === "vertical" && sessionTabsFitVertically(dimensions().width, preferredTabsWidth()) diff --git a/packages/tui/src/util/selection.ts b/packages/tui/src/util/selection.ts index a3d218db3ff..c812e968787 100644 --- a/packages/tui/src/util/selection.ts +++ b/packages/tui/src/util/selection.ts @@ -20,10 +20,12 @@ type Renderer = { } | null clearSelection: () => void currentFocusedRenderable?: FocusableSelectionTarget | null + currentFocusedEditor?: FocusableSelectionTarget | null } type SelectionKeyEvent = { ctrl?: boolean + baseCode?: number name: string preventDefault: () => void stopPropagation: () => void @@ -36,25 +38,16 @@ export function copyOnSelectRelease( clipboard: ClipboardService, ): boolean { if (!event.isDragging) return false - const selection = renderer.getSelection() - // Preserve the first click so OpenTUI can recognize the following double/triple click. - if (selection?.isStart && selection.behavior === "cell") return false return copy(renderer, toast, clipboard) } export function copy(renderer: Renderer, toast: Toast, clipboard: ClipboardService): boolean { const selection = renderer.getSelection() if (!selection) return false - if (selection.isStart && selection.behavior === "cell") { - renderer.clearSelection() - return false - } + if (selection.isStart && selection.behavior === "cell") return false const text = selection.getSelectedText() - if (!text) { - renderer.clearSelection() - return false - } + if (!text) return false const focus = renderer.currentFocusedRenderable const clipboardText = @@ -65,8 +58,7 @@ export function copy(renderer: Renderer, toast: Toast, clipboard: ClipboardServi .then(() => toast.show({ message: "Copied to clipboard", variant: "info" })) .catch(toast.error) - // Keep the highlight. clearSelection() also resets OpenTUI's click - // counter, so clearing here would turn a triple-click into a new single-click. + // Copy never clears selection, including empty releases: clearing also resets multi-click history. return true } @@ -75,12 +67,16 @@ export function handleSelectionKey( toast: Toast, event: SelectionKeyEvent, clipboard: ClipboardService, + copyOnSelect: boolean, ) { const selection = renderer.getSelection() if (!selection) return + const focus = renderer.currentFocusedEditor + const editing = focus?.hasSelection() && selection.selectedRenderables.includes(focus) - if (event.ctrl && event.name === "c") { - if (!copy(renderer, toast, clipboard)) { + // Kitty can report a non-Latin key name with a Latin base-layout C. + if (event.ctrl && (event.name === "c" || event.baseCode === 99 || event.baseCode === 67)) { + if ((copyOnSelect && !editing) || !copy(renderer, toast, clipboard)) { renderer.clearSelection() return } @@ -91,14 +87,15 @@ export function handleSelectionKey( } if (event.name === "escape") { + const text = selection.isStart && selection.behavior === "cell" ? "" : selection.getSelectedText() renderer.clearSelection() + if (!text) return event.preventDefault() event.stopPropagation() return } - const focus = renderer.currentFocusedRenderable - if (focus?.hasSelection() && selection.selectedRenderables.includes(focus)) return + if (editing) return renderer.clearSelection() } diff --git a/packages/tui/test/app-lifecycle.test.tsx b/packages/tui/test/app-lifecycle.test.tsx index a1ba3f2dd4d..74fa2207220 100644 --- a/packages/tui/test/app-lifecycle.test.tsx +++ b/packages/tui/test/app-lifecycle.test.tsx @@ -1,5 +1,5 @@ import { expect, test } from "bun:test" -import { type Renderable, ScrollBoxRenderable } from "@opentui/core" +import { EmbeddedTerminalRenderable, type Renderable, ScrollBoxRenderable } from "@opentui/core" import { createTestRenderer } from "@opentui/core/testing" import { Effect, FileSystem } from "effect" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" @@ -851,3 +851,144 @@ test("ctrl+c dismisses autocomplete and shell mode before exiting", async () => await server.stop() } }) + +test.each(["manual", "select"] as const)( + "selection copy and dismissal respect %s mode in the prompt and terminal pane", + async (copy) => { + const setup = await createTestRenderer({ width: 100, height: 30, useThread: false, kittyKeyboard: true }) + setup.renderer.start() + const ready = Promise.withResolvers() + const session = { + id: "dummy", + title: "Selection fixture", + projectID: "project", + location: { directory }, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: 0, updated: 0 }, + } + const pty = { + id: "pty_fixture", + sessionID: session.id, + title: "Terminal", + command: "/bin/sh", + args: [], + cwd: directory, + status: "running", + pid: 1, + foregroundProcess: null, + size: { cols: 48, rows: 24 }, + output: { head: 0, tail: 0 }, + } + const input: string[] = [] + const calls = createFetch((url, request) => { + if (url.pathname === "/api/session") return json({ data: [session], cursor: {} }) + if (url.pathname === "/api/session/dummy") return json({ data: session }) + if (url.pathname === "/api/session/dummy/message") return json({ data: [], cursor: {} }) + if (url.pathname === "/api/session/dummy/inbox") return json({ data: [] }) + if (url.pathname === "/api/session/dummy/permission") return json({ data: [] }) + if (url.pathname === "/api/experimental/session/dummy/terminal") + return json({ data: request.method === "POST" ? pty : [pty] }) + if (url.pathname === "/api/experimental/persistent-pty/pty_fixture/snapshot") + return json({ + data: { + info: pty, + text: "alpha beta gamma", + checkpoint: Buffer.from("alpha beta gamma").toString("base64"), + cursor: { x: 16, y: 0 }, + }, + }) + if (url.pathname === "/api/experimental/persistent-pty/pty_fixture/connect-token") + return json({ data: { ticket: "fixture" } }) + return undefined + }, createEventStream()) + const server = Bun.serve({ + port: 0, + fetch(request, server) { + if (new URL(request.url).pathname.endsWith("/connect") && server.upgrade(request)) return undefined + return calls.fetch(request) + }, + websocket: { + open(socket) { + socket.send(JSON.stringify({ type: "attached", inputProtocol: 1, role: "controller", info: pty })) + socket.send(JSON.stringify({ type: "replay_complete" })) + }, + message(_socket, message) { + const data = Buffer.from(message) + if (data[0] === 1) input.push(data.subarray(5).toString()) + }, + }, + }) + + try { + const { run } = await import("../src/app") + const task = Effect.runPromise( + run({ + app: { name: "test", version: "test", channel: "test" }, + server: { endpoint: { url: server.url.toString() } }, + config: { + get: async () => ({ + animations: false, + terminal: { copy }, + session: { terminal: true }, + }), + update: async () => ({}), + }, + packages: { resolve: async () => undefined }, + args: { sessionID: session.id }, + terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: ready.resolve }), + log: () => {}, + }).pipe(Effect.provide(AppNodeBuilder.build(Global.node)), Effect.provide(FileSystem.layerNoop({}))), + ) + + await ready.promise + await setup.waitForFrame((frame) => frame.includes("commands")) + await setup.mockInput.typeText("selection audit draft") + setup.mockInput.pressKey("a", { ctrl: true, shift: true }) + expect(setup.renderer.getSelection()?.getSelectedText()).toBe("selection audit draft") + + setup.mockInput.pressEscape() + expect(setup.renderer.hasSelection).toBeFalse() + expect(setup.renderer.currentFocusedEditor?.plainText).toBe("selection audit draft") + + setup.mockInput.pressKey("c", { ctrl: true }) + await setup.waitForFrame((frame) => !frame.includes("selection audit draft")) + expect(setup.renderer.currentFocusedEditor?.plainText).toBe("") + expect(setup.renderer.hasSelection).toBeFalse() + expect(setup.renderer.isDestroyed).toBeFalse() + + await setup.mockInput.typeText("/terminal") + await setup.waitForFrame((frame) => frame.includes("New terminal")) + setup.mockInput.pressEnter() + await setup.waitForFrame((frame) => frame.includes("alpha beta gamma")) + setup.mockInput.pressKey("x", { ctrl: true }) + setup.mockInput.pressArrow("right") + const terminal = setup.renderer.currentFocusedRenderable + if (!(terminal instanceof EmbeddedTerminalRenderable)) throw new Error("Terminal was not focused") + setup.renderer.startSelection(terminal, terminal.x + 6, terminal.y) + setup.renderer.updateSelection(terminal, terminal.x + 9, terminal.y, { finishDragging: true }) + expect(setup.renderer.getSelection()?.getSelectedText()).toBe("beta") + + setup.mockInput.pressEscape() + expect(setup.renderer.hasSelection).toBeFalse() + expect(terminal.hasSelection()).toBeFalse() + + await setup.mockMouse.click(terminal.x + 6, terminal.y) + if (copy === "select") { + setup.renderer.updateSelection(terminal, terminal.x + 9, terminal.y, { finishDragging: true }) + expect(setup.renderer.getSelection()?.getSelectedText()).toBe("beta") + } + setup.mockInput.pressKey("c", { ctrl: true }) + await setup.waitFor(() => input.length > 0) + expect(input).toEqual(["\x03"]) + expect(setup.renderer.hasSelection).toBeFalse() + expect(setup.renderer.isDestroyed).toBeFalse() + + setup.renderer.destroy() + await task + } finally { + if (!setup.renderer.isDestroyed) setup.renderer.destroy() + await server.stop() + } + }, +) diff --git a/packages/tui/test/util/selection-copy-on-select.test.tsx b/packages/tui/test/util/selection-copy-on-select.test.tsx index 7b9547c50b1..8151e9a60cd 100644 --- a/packages/tui/test/util/selection-copy-on-select.test.tsx +++ b/packages/tui/test/util/selection-copy-on-select.test.tsx @@ -1,5 +1,6 @@ /** @jsxImportSource @opentui/solid */ import { expect, test } from "bun:test" +import { ManualClock } from "@opentui/core/testing" import { testRender, useRenderer } from "@opentui/solid" import { useClipboard } from "../../src/context/clipboard" import { copyOnSelectRelease } from "../../src/util/selection" @@ -19,7 +20,10 @@ function CopyOnSelectText() { ) } -test("copy-on-select keeps a word highlight so a third click can select the line", async () => { +test.each([ + { column: 6, word: "beta" }, + { column: 17, word: "" }, +])("copy-on-select preserves multi-clicks at column $column", async (input) => { const writes: string[] = [] const app = await testRender( () => ( @@ -36,24 +40,24 @@ test("copy-on-select keeps a word highlight so a third click can select the line ), - { width: 20, height: 2 }, + { width: 20, height: 2, clock: new ManualClock() }, ) try { app.renderer.start() await app.waitForFrame((frame) => frame.includes("beta")) - await app.mockMouse.click(6, 0) + await app.mockMouse.click(input.column, 0) expect(app.renderer.getSelection()?.getSelectedText() ?? "").toBe("") expect(writes).toEqual([]) - await app.mockMouse.click(6, 0) - expect(app.renderer.getSelection()?.getSelectedText()).toBe("beta") - expect(writes).toEqual(["beta"]) + await app.mockMouse.click(input.column, 0) + expect(app.renderer.getSelection()?.getSelectedText() ?? "").toBe(input.word) + expect(writes).toEqual(input.word ? [input.word] : []) - await app.mockMouse.click(6, 0) + await app.mockMouse.click(input.column, 0) expect(app.renderer.getSelection()?.getSelectedText()).toBe("alpha beta gamma") - expect(writes).toEqual(["beta", "alpha beta gamma"]) + expect(writes).toEqual([...(input.word ? [input.word] : []), "alpha beta gamma"]) } finally { app.renderer.destroy() } diff --git a/packages/tui/test/util/selection-keys.test.ts b/packages/tui/test/util/selection-keys.test.ts new file mode 100644 index 00000000000..bc4ac21a29d --- /dev/null +++ b/packages/tui/test/util/selection-keys.test.ts @@ -0,0 +1,173 @@ +import { expect, test } from "bun:test" +import { EmbeddedTerminalRenderable, InputRenderable, TextareaRenderable, TextRenderable } from "@opentui/core" +import { createTestRenderer, ManualClock } from "@opentui/core/testing" +import type { ClipboardService } from "../../src/context/clipboard" +import { Selection } from "../../src/util/selection" + +async function setup(copyOnSelect = false) { + const clock = new ManualClock() + const app = await createTestRenderer({ + width: 24, + height: 3, + useThread: false, + exitOnCtrlC: false, + useKittyKeyboard: {}, + clock, + }) + const writes: string[] = [] + const clipboard: ClipboardService = { + read: async () => undefined, + write: async (text) => { + writes.push(text) + }, + } + app.renderer.keyInput.on("keypress", (event) => + Selection.handleSelectionKey(app.renderer, { show() {}, error() {} }, event, clipboard, copyOnSelect), + ) + return { ...app, clock, writes } +} + +async function terminal(copyOnSelect = false) { + const app = await setup(copyOnSelect) + const input: string[] = [] + const terminal = new EmbeddedTerminalRenderable(app.renderer, { + width: 24, + height: 3, + onData(data, source) { + if (source === "input") input.push(Buffer.from(data).toString()) + }, + }) + app.renderer.root.add(terminal) + terminal.write("alpha beta gamma") + terminal.focus() + await app.renderOnce() + return { ...app, terminal, input } +} + +test("terminal selections retain repeated copies until Escape or typing dismisses them", async () => { + const app = await terminal() + try { + await app.mockMouse.drag(6, 0, 9, 0) + + app.mockInput.pressCtrlC() + app.mockInput.pressCtrlC() + expect(app.writes).toEqual(["beta", "beta"]) + expect(app.renderer.getSelection()?.getSelectedText()).toBe("beta") + expect(app.input).toEqual([]) + + app.mockInput.pressEscape() + app.clock.advance(20) + expect(app.renderer.hasSelection).toBeFalse() + expect(app.terminal.hasSelection()).toBeFalse() + expect(app.input).toEqual([]) + + app.mockInput.pressCtrlC() + expect(app.input).toEqual(["\x03"]) + + await app.mockMouse.drag(6, 0, 9, 0) + app.mockInput.pressKey("x") + expect(app.renderer.hasSelection).toBeFalse() + expect(app.terminal.hasSelection()).toBeFalse() + expect(app.input).toEqual(["\x03", "x"]) + + app.mockInput.pressCtrlC() + expect(app.input).toEqual(["\x03", "x", "\x03"]) + expect(app.writes).toEqual(["beta", "beta"]) + } finally { + app.renderer.destroy() + } +}) + +test("copy-on-select forwards Ctrl+C without recopying the selection", async () => { + const app = await terminal(true) + try { + await app.mockMouse.drag(6, 0, 9, 0) + expect(app.renderer.getSelection()?.getSelectedText()).toBe("beta") + + app.mockInput.pressCtrlC() + expect(app.writes).toEqual([]) + expect(app.input).toEqual(["\x03"]) + expect(app.renderer.hasSelection).toBeFalse() + expect(app.terminal.hasSelection()).toBeFalse() + } finally { + app.renderer.destroy() + } +}) + +test.each(["click", "empty drag"])("a terminal %s does not consume Ctrl+C or Escape", async (gesture) => { + const app = await terminal() + const select = () => (gesture === "click" ? app.mockMouse.click(6, 0) : app.mockMouse.drag(18, 0, 21, 0)) + try { + await select() + app.mockInput.pressCtrlC() + expect(app.renderer.hasSelection).toBeFalse() + expect(app.input).toEqual(["\x03"]) + + await select() + app.mockInput.pressEscape() + app.clock.advance(20) + expect(app.renderer.hasSelection).toBeFalse() + expect(app.input).toEqual(["\x03", "\x1b"]) + expect(app.writes).toEqual([]) + } finally { + app.renderer.destroy() + } +}) + +test.each(["manual", "select"].flatMap((mode) => ["textarea", "input"].map((kind) => ({ mode, kind }))))( + "$kind selections can be copied and edited in $mode mode", + async (input) => { + const app = await setup(input.mode === "select") + const editor = + input.kind === "input" + ? new InputRenderable(app.renderer, { width: 24, value: "draft" }) + : new TextareaRenderable(app.renderer, { width: 24, height: 3, initialValue: "draft" }) + app.renderer.root.add(editor) + editor.focus() + try { + await app.renderOnce() + app.mockInput.pressKey("END") + app.mockInput.pressArrow("left", { shift: true }) + + app.mockInput.pressCtrlC() + app.mockInput.pressKey("\x1b[1089::99;5u") + expect(app.writes).toEqual(["t", "t"]) + expect(app.renderer.getSelection()?.getSelectedText()).toBe("t") + expect(editor.plainText).toBe("draft") + + app.mockInput.pressArrow("left", { shift: true }) + expect(app.renderer.getSelection()?.getSelectedText()).toBe("ft") + app.mockInput.pressKey("x") + expect(editor.plainText).toBe("drax") + expect(app.renderer.hasSelection).toBeFalse() + expect(app.writes).toEqual(["t", "t"]) + } finally { + app.renderer.destroy() + } + }, +) + +test("copy-on-select does not treat output selection as editor selection when an editor is focused", async () => { + const app = await setup(true) + const editor = new TextareaRenderable(app.renderer, { width: 24, height: 1, initialValue: "draft" }) + app.renderer.root.add(new TextRenderable(app.renderer, { width: 24, height: 1, content: "alpha beta gamma" })) + app.renderer.root.add(editor) + editor.focus() + const forwarded: string[] = [] + app.renderer.keyInput.on("keypress", (event) => { + if (!event.defaultPrevented) forwarded.push(event.name) + }) + try { + await app.renderOnce() + await app.mockMouse.drag(6, 0, 9, 0) + expect(app.renderer.currentFocusedEditor === editor).toBeTrue() + expect(app.renderer.getSelection()?.getSelectedText()).toBe("beta") + + app.mockInput.pressCtrlC() + expect(app.writes).toEqual([]) + expect(forwarded).toEqual(["c"]) + expect(app.renderer.hasSelection).toBeFalse() + } finally { + app.renderer.destroy() + } +}) diff --git a/packages/tui/test/util/selection.test.ts b/packages/tui/test/util/selection.test.ts index 14ee4dac8a3..7ac1135f99f 100644 --- a/packages/tui/test/util/selection.test.ts +++ b/packages/tui/test/util/selection.test.ts @@ -92,17 +92,17 @@ test.each(["word", "line"] as const)("copy-on-select copies a %s selection witho expect(value.writes).toEqual(["selected"]) }) -test("clears a click-only selection without copying", () => { +test("ignores a click-only selection without copying or clearing", () => { const value = setup("x", true) expect(Selection.copy(value.renderer, value.toast, value.clipboard)).toBeFalse() - expect(value.clears()).toBe(1) + expect(value.clears()).toBe(0) expect(value.writes).toEqual([]) }) -test("clears an empty dragged selection without copying", () => { +test("ignores an empty dragged selection without copying or clearing", () => { const value = setup("", false) expect(Selection.copy(value.renderer, value.toast, value.clipboard)).toBeFalse() - expect(value.clears()).toBe(1) + expect(value.clears()).toBe(0) expect(value.writes).toEqual([]) })