Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 22 additions & 2 deletions packages/tui/src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -428,8 +428,23 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
},
{ priority: 1 },
)
// Copy-on-select keeps the highlight so it can still be added to the prompt. Dismiss it on
// the first key that no binding consumed, leaving leader sequences mid-flight untouched.
// Dialogs are exempt so typing in the command palette cannot drop the pending selection.
const offRetainedSelection = keymap.intercept(
"key:after",
(ctx) => {
if (Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT) return
if (ctx.handled || ctx.pendingSequence.length > 0) return
if (dialog.stack.length > 0) return
renderer.clearSelection()
},
{ priority: 1 },
)

onCleanup(() => {
offSelectionKeys()
offRetainedSelection()
attention.dispose()
})

Expand Down Expand Up @@ -1091,7 +1106,12 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
flexDirection="column"
backgroundColor={theme.background}
onMouseDown={(evt) => {
if (!Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT) return
if (!Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT) {
// A click that did not start a new selection dismisses the retained highlight,
// so the click still reaches handlers that ignore selection releases.
if (renderer.getSelection()?.getSelectedText()) renderer.clearSelection()
return
}
if (evt.button !== MouseButton.RIGHT) return

if (!Selection.copy(renderer, toast, clipboard)) return
Expand All @@ -1100,7 +1120,7 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
}}
onMouseUp={
!Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT
? () => Selection.copy(renderer, toast, clipboard)
? () => Selection.copy(renderer, toast, clipboard, { retain: true })
: undefined
}
>
Expand Down
23 changes: 23 additions & 0 deletions packages/tui/src/component/prompt/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ import { useTuiConfig } from "../../config"
import { usePromptWorkspace } from "./workspace"
import { usePromptMove } from "./move"
import { readLocalAttachment } from "./local-attachment"
import { Selection } from "../../util/selection"
import { useLocation } from "../../context/location"

registerOpencodeSpinner()
Expand Down Expand Up @@ -367,6 +368,27 @@ export function Prompt(props: PromptProps) {
dialog.clear()
},
},
{
title: "Add selection to chat",
desc: "Quote the selected text into the prompt",
name: "prompt.add_selection",
category: "Prompt",
run: () => {
const selected = Selection.text(renderer)?.trim()
if (!selected) {
toast.show({ message: "Select text in the conversation first", variant: "warning" })
dialog.clear()
return
}

const lines = selected.split("\n").length
pasteText(Selection.quote(selected), `[Quoted ${lines} ${lines === 1 ? "line" : "lines"}]`)
// Only drop the highlight once the quote is actually in the prompt.
renderer.clearSelection()
input.focus()
dialog.clear()
},
},
{
title: "Paste",
name: "prompt.paste",
Expand Down Expand Up @@ -569,6 +591,7 @@ export function Prompt(props: PromptProps) {
"prompt.submit",
"prompt.editor",
"prompt.editor_context.clear",
"prompt.add_selection",
"prompt.stash",
"prompt.stash.pop",
"prompt.stash.list",
Expand Down
2 changes: 2 additions & 0 deletions packages/tui/src/config/keybind.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,7 @@ export const Definitions = {

prompt_submit: keybind("none", "Submit prompt"),
prompt_editor_context_clear: keybind("none", "Clear editor context"),
prompt_add_selection: keybind("<leader>p", "Add the selected text to the prompt as a quote"),
prompt_skills: keybind("none", "Open skill selector"),
prompt_stash: keybind("none", "Stash prompt"),
prompt_stash_pop: keybind("none", "Pop stashed prompt"),
Expand Down Expand Up @@ -356,6 +357,7 @@ export const CommandMap = {
display_thinking: "session.toggle.thinking",
prompt_submit: "prompt.submit",
prompt_editor_context_clear: "prompt.editor_context.clear",
prompt_add_selection: "prompt.add_selection",
prompt_skills: "prompt.skills",
prompt_stash: "prompt.stash",
prompt_stash_pop: "prompt.stash.pop",
Expand Down
3 changes: 3 additions & 0 deletions packages/tui/src/feature-plugins/home/tips-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ const themeCount = Object.keys(DEFAULT_THEMES).length
type TipPart = { text: string; highlight: boolean }
type TipShortcut = Accessor<string>
type Shortcuts = {
addSelection: TipShortcut
agentCycle: TipShortcut
childFirst: TipShortcut
childNext: TipShortcut
Expand Down Expand Up @@ -98,6 +99,7 @@ export function Tips(props: { api: TuiPluginApi; connected?: boolean }) {
const theme = useTheme().theme
const tipOffset = Math.random()
const shortcuts: Shortcuts = {
addSelection: configShortcut(props.api, "prompt.add_selection"),
agentCycle: useCommandShortcut("agent.cycle"),
childFirst: configShortcut(props.api, "session.child.first"),
childNext: configShortcut(props.api, "session.child.next"),
Expand Down Expand Up @@ -165,6 +167,7 @@ const TIPS: Tip[] = [
"Type {highlight}@{/highlight} followed by a filename to fuzzy search and attach files",
"Start a message with {highlight}!{/highlight} to run shell commands (e.g., {highlight}!ls -la{/highlight})",
(shortcuts) => press(shortcuts.agentCycle(), "to cycle between Build and Plan agents"),
(shortcuts) => press(shortcuts.addSelection(), "to quote selected text into the prompt as context"),
"Use {highlight}/undo{/highlight} to revert the last message and file changes",
"Use {highlight}/redo{/highlight} to restore previously undone messages and file changes",
"Run {highlight}/share{/highlight} to create a public opencode.ai link",
Expand Down
34 changes: 15 additions & 19 deletions packages/tui/src/ui/dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -102,36 +102,32 @@ function init() {
}, 1)
}

// A retained copy-on-select highlight is dismissed first so it is never closed out from
// behind the dialog; the next press closes. Escape stays live either way.
function dismiss() {
const selected = renderer.getSelection()?.getSelectedText()
renderer.clearSelection()
if (selected) return
const current = store.stack.at(-1)
current?.onClose?.()
setStore("stack", store.stack.slice(0, -1))
refocus()
}

useBindings(() => ({
enabled: store.stack.length > 0 && !renderer.getSelection()?.getSelectedText(),
enabled: store.stack.length > 0,
bindings: [
{
key: "escape",
desc: "Close dialog",
group: "Dialog",
cmd: () => {
if (renderer.getSelection()) {
renderer.clearSelection()
}
const current = store.stack.at(-1)
current?.onClose?.()
setStore("stack", store.stack.slice(0, -1))
refocus()
},
cmd: dismiss,
},
{
key: "ctrl+c",
desc: "Close dialog",
group: "Dialog",
cmd: () => {
if (renderer.getSelection()) {
renderer.clearSelection()
}
const current = store.stack.at(-1)
current?.onClose?.()
setStore("stack", store.stack.slice(0, -1))
refocus()
},
cmd: dismiss,
},
],
}))
Expand Down
37 changes: 29 additions & 8 deletions packages/tui/src/util/selection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,23 +23,44 @@ type SelectionKeyEvent = {
stopPropagation: () => void
}

export function copy(renderer: Renderer, toast: Toast, clipboard: ClipboardService): boolean {
export function text(renderer: Renderer) {
const selection = renderer.getSelection()
if (!selection) return false
if (!selection) return undefined
const selected = selection.getSelectedText()
if (!selected) return undefined
const focus = renderer.currentFocusedRenderable
if (focus?.getClipboardText && selection.selectedRenderables.includes(focus)) return focus.getClipboardText(selected)
return selected
}

const text = selection.getSelectedText()
if (!text) return false
// Blank lines carry a bare ">" and content lines drop trailing padding, so a quoted block
// never introduces trailing whitespace. Already-quoted lines nest, which is what they mean.
export function quote(value: string) {
return (
value
.split("\n")
.map((line) => (line.trim() ? `> ${line.trimEnd()}` : ">"))
.join("\n") + "\n"
)
}

const focus = renderer.currentFocusedRenderable
const clipboardText =
focus?.getClipboardText && selection.selectedRenderables.includes(focus) ? focus.getClipboardText(text) : text
// `retain` keeps the highlight up after copy-on-select so it can still be acted on, for
// example added to the prompt. The highlight is dismissed by the next click or key press.
export function copy(
renderer: Renderer,
toast: Toast,
clipboard: ClipboardService,
options?: { retain?: boolean },
): boolean {
const clipboardText = text(renderer)
if (!clipboardText) return false

clipboard
?.write?.(clipboardText)
.then(() => toast.show({ message: "Copied to clipboard", variant: "info" }))
.catch(toast.error)

renderer.clearSelection()
if (!options?.retain) renderer.clearSelection()
return true
}

Expand Down
8 changes: 8 additions & 0 deletions packages/tui/test/keymap.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -139,3 +139,11 @@ test("mode-less bindings stay active when opencode mode changes", async () => {
app.renderer.destroy()
}
})

test("add selection to chat resolves through the keybind command map", () => {
const config = createResolvedKeymapConfig()
expect(config.keybinds.get("prompt.add_selection").map((binding) => binding.key)).toEqual(["<leader>p"])
expect(config.keybinds.get("prompt.add_selection")).toEqual(
config.keybinds.gather("prompt.palette", ["prompt.add_selection"]),
)
})
96 changes: 96 additions & 0 deletions packages/tui/test/selection-retention.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { ScrollBoxRenderable, TextRenderable } from "@opentui/core"
import { createTestRenderer } from "@opentui/core/testing"
import { createBindingLookup } from "@opentui/keymap/extras"
import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui"
import { expect, test } from "bun:test"
import { TuiKeybind } from "../src/config/keybind"
import { OPENCODE_BASE_MODE, registerOpencodeKeymap } from "../src/keymap"

// Mirrors the intercept in app.tsx that dismisses a retained copy-on-select highlight.
test("a retained selection survives the leader sequence but not an unbound key", async () => {
const setup = await createTestRenderer({ width: 80, height: 24, useThread: false })
const keymap = createDefaultOpenTuiKeymap(setup.renderer)
const config = {
keybinds: createBindingLookup(TuiKeybind.toBindingConfig(TuiKeybind.parse({})), {
commandMap: TuiKeybind.CommandMap,
bindingDefaults: TuiKeybind.bindingDefaults(),
}),
leader_timeout: 2000,
}
const offKeymap = registerOpencodeKeymap(keymap, setup.renderer, config)

const ran: string[] = []
const offLayer = keymap.registerLayer({
mode: OPENCODE_BASE_MODE,
commands: [
{
name: "prompt.add_selection",
run: () => {
ran.push("add_selection")
},
},
],
bindings: config.keybinds.gather("prompt.palette", ["prompt.add_selection"]),
})

let dialogOpen = false
const dismissed: string[] = []
const offIntercept = keymap.intercept(
"key:after",
(ctx) => {
if (ctx.handled || ctx.pendingSequence.length > 0) return
if (dialogOpen) return
dismissed.push(ctx.event.name ?? "?")
},
{ priority: 1 },
)

try {
setup.mockInput.pressKey("x", { ctrl: true })
expect(dismissed).toEqual([])

setup.mockInput.pressKey("p")
expect(ran).toEqual(["add_selection"])
expect(dismissed).toEqual([])

setup.mockInput.pressKey("a")
expect(dismissed).toEqual(["a"])

// Filtering the command palette must not drop the selection the command is about to read.
dialogOpen = true
setup.mockInput.pressKey("d")
setup.mockInput.pressKey("d")
expect(dismissed).toEqual(["a"])
} finally {
offIntercept()
offLayer()
offKeymap()
setup.renderer.destroy()
}
})

// A retained highlight is anchored to each renderable's text buffer once the drag finishes,
// so scrolling moves it with its content instead of re-reading whatever is at those coordinates.
test("scroll does not re-target a retained selection", async () => {
const setup = await createTestRenderer({ width: 40, height: 6, useThread: false })
const scroll = new ScrollBoxRenderable(setup.renderer, { id: "scroll", width: 40, height: 6 })
setup.renderer.root.add(scroll)
for (let i = 0; i < 20; i++) {
scroll.add(
new TextRenderable(setup.renderer, { id: `l${i}`, content: `line-${i}`, selectable: true, width: 40, height: 1 }),
)
}
await setup.renderOnce()

await setup.mockMouse.drag(0, 0, 7, 0)
const before = setup.renderer.getSelection()?.getSelectedText()

await setup.mockMouse.scroll(10, 3, "down")
await setup.renderOnce()
await setup.mockMouse.scroll(10, 3, "down")
await setup.renderOnce()
const after = setup.renderer.getSelection()?.getSelectedText()

setup.renderer.destroy()
expect(after).toBe(before)
})
Loading
Loading