fix(tui): respect selection copy modes (#45758)

This commit is contained in:
Simon Klee 2026-08-27 23:53:07 +02:00 committed by GitHub
parent 96d84626f8
commit 8381153418
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 352 additions and 37 deletions

View file

@ -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())

View file

@ -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()
}

View file

@ -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<void>()
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()
}
},
)

View file

@ -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
<CopyOnSelectText />
</TestTuiContexts>
),
{ 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()
}

View file

@ -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()
}
})

View file

@ -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([])
})