From 19a81859a3b91d85c72558ac50833c82ac7afe0c Mon Sep 17 00:00:00 2001 From: Aditya Teltia Date: Sun, 23 Aug 2026 20:34:25 +0530 Subject: [PATCH 1/2] feat(tui): add selection to prompt as quoted context Selecting text in the transcript copies it, but there was no way to carry it into the next message. Add a prompt.add_selection command, bound to p, that inserts the selection at the cursor as a blockquote chip that expands on submit. Copy-on-select previously cleared the highlight on mouse release, so any command running afterwards saw nothing selected. It is now retained and dismissed by the next click, or by the first key no binding consumed. Leader sequences and open dialogs are exempt so the keybind and the command palette can both read it. --- packages/tui/src/app.tsx | 24 ++++++- packages/tui/src/component/prompt/index.tsx | 24 +++++++ packages/tui/src/config/keybind.ts | 2 + .../src/feature-plugins/home/tips-view.tsx | 3 + packages/tui/src/ui/dialog.tsx | 2 +- packages/tui/src/util/selection.ts | 33 ++++++--- packages/tui/test/keymap.test.tsx | 8 +++ packages/tui/test/selection-retention.test.ts | 69 +++++++++++++++++++ packages/tui/test/selection.test.ts | 65 +++++++++++++++++ packages/web/src/content/docs/keybinds.mdx | 1 + 10 files changed, 220 insertions(+), 11 deletions(-) create mode 100644 packages/tui/test/selection-retention.test.ts create mode 100644 packages/tui/test/selection.test.ts diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index 57f372ef709a..58497319d482 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -428,8 +428,23 @@ function App(props: { onSnapshot?: () => Promise; 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() }) @@ -1091,7 +1106,12 @@ function App(props: { onSnapshot?: () => Promise; 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 @@ -1100,7 +1120,7 @@ function App(props: { onSnapshot?: () => Promise; pluginHost: TuiPlugi }} onMouseUp={ !Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT - ? () => Selection.copy(renderer, toast, clipboard) + ? () => Selection.copy(renderer, toast, clipboard, { retain: true }) : undefined } > diff --git a/packages/tui/src/component/prompt/index.tsx b/packages/tui/src/component/prompt/index.tsx index fe7f4a22f75f..59d5b1889b28 100644 --- a/packages/tui/src/component/prompt/index.tsx +++ b/packages/tui/src/component/prompt/index.tsx @@ -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() @@ -367,6 +368,28 @@ 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.take(renderer) + if (!selected) { + toast.show({ message: "Select text in the conversation first", variant: "warning" }) + dialog.clear() + return + } + + const lines = selected.split("\n") + pasteText( + lines.map((line) => `> ${line}`).join("\n") + "\n", + `[Quoted ${lines.length} ${lines.length === 1 ? "line" : "lines"}]`, + ) + input.focus() + dialog.clear() + }, + }, { title: "Paste", name: "prompt.paste", @@ -569,6 +592,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", diff --git a/packages/tui/src/config/keybind.ts b/packages/tui/src/config/keybind.ts index 5dd7e4b5aafe..e6d5bb9272b7 100644 --- a/packages/tui/src/config/keybind.ts +++ b/packages/tui/src/config/keybind.ts @@ -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("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"), @@ -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", diff --git a/packages/tui/src/feature-plugins/home/tips-view.tsx b/packages/tui/src/feature-plugins/home/tips-view.tsx index d9ef81e40cca..313373514791 100644 --- a/packages/tui/src/feature-plugins/home/tips-view.tsx +++ b/packages/tui/src/feature-plugins/home/tips-view.tsx @@ -8,6 +8,7 @@ const themeCount = Object.keys(DEFAULT_THEMES).length type TipPart = { text: string; highlight: boolean } type TipShortcut = Accessor type Shortcuts = { + addSelection: TipShortcut agentCycle: TipShortcut childFirst: TipShortcut childNext: TipShortcut @@ -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"), @@ -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", diff --git a/packages/tui/src/ui/dialog.tsx b/packages/tui/src/ui/dialog.tsx index 50281630bc5b..3eb437828bd6 100644 --- a/packages/tui/src/ui/dialog.tsx +++ b/packages/tui/src/ui/dialog.tsx @@ -103,7 +103,7 @@ function init() { } useBindings(() => ({ - enabled: store.stack.length > 0 && !renderer.getSelection()?.getSelectedText(), + enabled: store.stack.length > 0, bindings: [ { key: "escape", diff --git a/packages/tui/src/util/selection.ts b/packages/tui/src/util/selection.ts index d9158ba40760..280692441a74 100644 --- a/packages/tui/src/util/selection.ts +++ b/packages/tui/src/util/selection.ts @@ -23,23 +23,40 @@ 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 +// Consumes what is currently highlighted, so what the user sees is what gets added. +export function take(renderer: Renderer) { + const selected = text(renderer) + renderer.clearSelection() + return selected?.trim() || undefined +} - 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 } diff --git a/packages/tui/test/keymap.test.tsx b/packages/tui/test/keymap.test.tsx index b0eed3081248..981ded174b18 100644 --- a/packages/tui/test/keymap.test.tsx +++ b/packages/tui/test/keymap.test.tsx @@ -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(["p"]) + expect(config.keybinds.get("prompt.add_selection")).toEqual( + config.keybinds.gather("prompt.palette", ["prompt.add_selection"]), + ) +}) diff --git a/packages/tui/test/selection-retention.test.ts b/packages/tui/test/selection-retention.test.ts new file mode 100644 index 000000000000..0f1b9967b3a1 --- /dev/null +++ b/packages/tui/test/selection-retention.test.ts @@ -0,0 +1,69 @@ +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() + } +}) diff --git a/packages/tui/test/selection.test.ts b/packages/tui/test/selection.test.ts new file mode 100644 index 000000000000..6019b1126c0d --- /dev/null +++ b/packages/tui/test/selection.test.ts @@ -0,0 +1,65 @@ +import { expect, test } from "bun:test" +import { Selection } from "../src/util/selection" + +function createRenderer( + selected?: string, + focus?: { hasSelection: () => boolean; getClipboardText: (text: string) => string }, +) { + let current = selected + return { + cleared: 0, + currentFocusedRenderable: focus ?? null, + getSelection() { + if (current === undefined) return null + return { + getSelectedText: () => current!, + selectedRenderables: focus ? [focus] : [], + } + }, + clearSelection() { + current = undefined + this.cleared++ + }, + } +} + +const toast = { show: () => {}, error: () => {} } +const clipboard = { write: async () => {} } + +test("take returns the active selection and clears it", () => { + const renderer = createRenderer("hello\nworld") + expect(Selection.take(renderer)).toBe("hello\nworld") + expect(renderer.cleared).toBe(1) + expect(Selection.take(renderer)).toBeUndefined() +}) + +test("copy-on-select retains the highlight so it can still be added to the prompt", () => { + const renderer = createRenderer("from the response") + expect(Selection.copy(renderer, toast, clipboard, { retain: true })).toBe(true) + expect(renderer.cleared).toBe(0) + + expect(Selection.take(renderer)).toBe("from the response") + expect(renderer.cleared).toBe(1) + // nothing is highlighted anymore, so no stale text can be added + expect(Selection.take(renderer)).toBeUndefined() +}) + +test("an explicit copy clears the highlight", () => { + const renderer = createRenderer("from the response") + expect(Selection.copy(renderer, toast, clipboard)).toBe(true) + expect(renderer.getSelection()).toBeNull() + expect(Selection.take(renderer)).toBeUndefined() +}) + +test("take trims and ignores whitespace only selections", () => { + expect(Selection.take(createRenderer(" spaced "))).toBe("spaced") + expect(Selection.take(createRenderer(" \n "))).toBeUndefined() +}) + +test("take expands placeholders when the selection is inside the focused input", () => { + const focus = { + hasSelection: () => true, + getClipboardText: (text: string) => text.replace("[Pasted ~3 lines]", "a\nb\nc"), + } + expect(Selection.take(createRenderer("look at [Pasted ~3 lines]", focus))).toBe("look at a\nb\nc") +}) diff --git a/packages/web/src/content/docs/keybinds.mdx b/packages/web/src/content/docs/keybinds.mdx index 86f67dfd73ca..be582be57c00 100644 --- a/packages/web/src/content/docs/keybinds.mdx +++ b/packages/web/src/content/docs/keybinds.mdx @@ -89,6 +89,7 @@ OpenCode has a list of keybinds that you can customize through `tui.json`. "prompt_submit": "none", "prompt_editor_context_clear": "none", + "prompt_add_selection": "p", "prompt_skills": "none", "prompt_stash": "none", "prompt_stash_pop": "none", From 495972c028bb34e520f507ed5da258daff6a0329 Mon Sep 17 00:00:00 2001 From: Aditya Teltia Date: Sun, 23 Aug 2026 22:14:58 +0530 Subject: [PATCH 2/2] fix(tui): address review on quoted selection formatting and dialog keys Blank and padded lines no longer carry trailing whitespace into the quote. The highlight is cleared only after the quote reaches the prompt. Dialog escape and ctrl+c dismiss a retained highlight before closing, restoring the old two-step behaviour for every dialog rather than only the palette. --- packages/tui/src/component/prompt/index.tsx | 11 +++--- packages/tui/src/ui/dialog.tsx | 32 ++++++++--------- packages/tui/src/util/selection.ts | 14 +++++--- packages/tui/test/selection-retention.test.ts | 27 ++++++++++++++ packages/tui/test/selection.test.ts | 35 ++++++++++--------- 5 files changed, 73 insertions(+), 46 deletions(-) diff --git a/packages/tui/src/component/prompt/index.tsx b/packages/tui/src/component/prompt/index.tsx index 59d5b1889b28..809b386673a1 100644 --- a/packages/tui/src/component/prompt/index.tsx +++ b/packages/tui/src/component/prompt/index.tsx @@ -374,18 +374,17 @@ export function Prompt(props: PromptProps) { name: "prompt.add_selection", category: "Prompt", run: () => { - const selected = Selection.take(renderer) + 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") - pasteText( - lines.map((line) => `> ${line}`).join("\n") + "\n", - `[Quoted ${lines.length} ${lines.length === 1 ? "line" : "lines"}]`, - ) + 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() }, diff --git a/packages/tui/src/ui/dialog.tsx b/packages/tui/src/ui/dialog.tsx index 3eb437828bd6..179069e05d84 100644 --- a/packages/tui/src/ui/dialog.tsx +++ b/packages/tui/src/ui/dialog.tsx @@ -102,6 +102,18 @@ 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, bindings: [ @@ -109,29 +121,13 @@ function init() { 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, }, ], })) diff --git a/packages/tui/src/util/selection.ts b/packages/tui/src/util/selection.ts index 280692441a74..e825cb96fc1a 100644 --- a/packages/tui/src/util/selection.ts +++ b/packages/tui/src/util/selection.ts @@ -33,11 +33,15 @@ export function text(renderer: Renderer) { return selected } -// Consumes what is currently highlighted, so what the user sees is what gets added. -export function take(renderer: Renderer) { - const selected = text(renderer) - renderer.clearSelection() - return selected?.trim() || undefined +// 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" + ) } // `retain` keeps the highlight up after copy-on-select so it can still be acted on, for diff --git a/packages/tui/test/selection-retention.test.ts b/packages/tui/test/selection-retention.test.ts index 0f1b9967b3a1..451b4dcd4ca2 100644 --- a/packages/tui/test/selection-retention.test.ts +++ b/packages/tui/test/selection-retention.test.ts @@ -1,3 +1,4 @@ +import { ScrollBoxRenderable, TextRenderable } from "@opentui/core" import { createTestRenderer } from "@opentui/core/testing" import { createBindingLookup } from "@opentui/keymap/extras" import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui" @@ -67,3 +68,29 @@ test("a retained selection survives the leader sequence but not an unbound key", 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) +}) diff --git a/packages/tui/test/selection.test.ts b/packages/tui/test/selection.test.ts index 6019b1126c0d..c0a3e2b0a69c 100644 --- a/packages/tui/test/selection.test.ts +++ b/packages/tui/test/selection.test.ts @@ -26,40 +26,41 @@ function createRenderer( const toast = { show: () => {}, error: () => {} } const clipboard = { write: async () => {} } -test("take returns the active selection and clears it", () => { +test("text reads the highlight without clearing it", () => { const renderer = createRenderer("hello\nworld") - expect(Selection.take(renderer)).toBe("hello\nworld") - expect(renderer.cleared).toBe(1) - expect(Selection.take(renderer)).toBeUndefined() + expect(Selection.text(renderer)).toBe("hello\nworld") + // the command clears only once the quote is in the prompt, so a failure keeps the highlight + expect(renderer.cleared).toBe(0) + expect(Selection.text(createRenderer())).toBeUndefined() }) test("copy-on-select retains the highlight so it can still be added to the prompt", () => { const renderer = createRenderer("from the response") expect(Selection.copy(renderer, toast, clipboard, { retain: true })).toBe(true) expect(renderer.cleared).toBe(0) - - expect(Selection.take(renderer)).toBe("from the response") - expect(renderer.cleared).toBe(1) - // nothing is highlighted anymore, so no stale text can be added - expect(Selection.take(renderer)).toBeUndefined() + expect(Selection.text(renderer)).toBe("from the response") }) test("an explicit copy clears the highlight", () => { const renderer = createRenderer("from the response") expect(Selection.copy(renderer, toast, clipboard)).toBe(true) expect(renderer.getSelection()).toBeNull() - expect(Selection.take(renderer)).toBeUndefined() -}) - -test("take trims and ignores whitespace only selections", () => { - expect(Selection.take(createRenderer(" spaced "))).toBe("spaced") - expect(Selection.take(createRenderer(" \n "))).toBeUndefined() + expect(Selection.text(renderer)).toBeUndefined() }) -test("take expands placeholders when the selection is inside the focused input", () => { +test("text expands placeholders when the selection is inside the focused input", () => { const focus = { hasSelection: () => true, getClipboardText: (text: string) => text.replace("[Pasted ~3 lines]", "a\nb\nc"), } - expect(Selection.take(createRenderer("look at [Pasted ~3 lines]", focus))).toBe("look at a\nb\nc") + expect(Selection.text(createRenderer("look at [Pasted ~3 lines]", focus))).toBe("look at a\nb\nc") +}) + +test("quote leaves no trailing whitespace on blank or padded lines", () => { + expect(Selection.quote("first\n\nsecond ")).toBe("> first\n>\n> second\n") + expect(Selection.quote(" \nonly")).toBe(">\n> only\n") +}) + +test("quote nests an already quoted region rather than flattening it", () => { + expect(Selection.quote("> cited\nreply")).toBe("> > cited\n> reply\n") })