Skip to content
Closed
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
10 changes: 10 additions & 0 deletions packages/opencode/src/cli/cmd/tui/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -302,6 +302,16 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
const sel = renderer.getSelection()
if (!sel) return

if (keybind.match("input_quote_selection", evt)) {
if (route.data.type !== "session") return
if (!Selection.text(renderer)) return
evt.preventDefault()
evt.stopPropagation()
promptRef.current?.focus()
command.trigger("prompt.quote_selection")
return
}

// Windows Terminal-like behavior:
// - Ctrl+C copies and dismisses selection
// - Esc dismisses selection
Expand Down
18 changes: 18 additions & 0 deletions packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { useKeyboard, useRenderer, type JSX } from "@opentui/solid"
import { Editor } from "@tui/util/editor"
import { useExit } from "../../context/exit"
import { Clipboard } from "../../util/clipboard"
import { Selection } from "../../util/selection"
import type { AssistantMessage, FilePart } from "@opencode-ai/sdk/v2"
import { TuiEvent } from "../../event"
import { iife } from "@/util/iife"
Expand Down Expand Up @@ -246,6 +247,19 @@ export function Prompt(props: PromptProps) {
}
},
},
{
title: "Quote selection",
value: "prompt.quote_selection",
keybind: "input_quote_selection",
category: "Prompt",
hidden: true,
onSelect: () => {
const text = Selection.text(renderer)
if (!text) return
insert(Selection.quote(text))
renderer.clearSelection()
},
},
{
title: "Interrupt session",
value: "session.interrupt",
Expand Down Expand Up @@ -775,6 +789,10 @@ export function Prompt(props: PromptProps) {
)
}

function insert(text: string) {
input.insertText(text)
}

async function pasteImage(file: { filename?: string; content: string; mime: string }) {
const currentOffset = input.visualCursor.offset
const extmarkStart = currentOffset
Expand Down
15 changes: 14 additions & 1 deletion packages/opencode/src/cli/cmd/tui/util/selection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,21 @@ type Renderer = {
}

export namespace Selection {
export function text(renderer: Renderer) {
return renderer.getSelection()?.getSelectedText()
}

export function quote(text: string) {
return text
.replace(/\r\n/g, "\n")
.replace(/\r/g, "\n")
.split("\n")
.map((line) => `> ${line}`)
.join("\n") + "\n\n"
}

export function copy(renderer: Renderer, toast: Toast): boolean {
const text = renderer.getSelection()?.getSelectedText()
const text = Selection.text(renderer)
if (!text) return false

Clipboard.copy(text)
Expand Down
1 change: 1 addition & 0 deletions packages/opencode/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -670,6 +670,7 @@ export namespace Config {
variant_cycle: z.string().optional().default("ctrl+t").describe("Cycle model variants"),
input_clear: z.string().optional().default("ctrl+c").describe("Clear input field"),
input_paste: z.string().optional().default("ctrl+v").describe("Paste from clipboard"),
input_quote_selection: z.string().optional().default("ctrl+q").describe("Quote selected text into input"),
input_submit: z.string().optional().default("return").describe("Submit input"),
input_newline: z
.string()
Expand Down
16 changes: 16 additions & 0 deletions packages/opencode/test/cli/tui/selection.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { describe, expect, test } from "bun:test"
import { Selection } from "../../../src/cli/cmd/tui/util/selection"

describe("Selection.quote", () => {
test("quotes each line and appends spacing", () => {
expect(Selection.quote("original\nfew\nlines")).toBe("> original\n> few\n> lines\n\n")
})

test("normalizes windows line endings", () => {
expect(Selection.quote("first\r\nsecond\rthird")).toBe("> first\n> second\n> third\n\n")
})

test("preserves blank lines inside selection", () => {
expect(Selection.quote("first\n\nthird")).toBe("> first\n> \n> third\n\n")
})
})
16 changes: 16 additions & 0 deletions packages/opencode/test/config/tui.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,22 @@ test("migrates tui-specific keys from opencode.json when tui.json does not exist
})
})

test("accepts input_quote_selection keybind", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, "tui.json"), JSON.stringify({ keybinds: { input_quote_selection: "ctrl+q" } }, null, 2))
},
})

await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await TuiConfig.get()
expect(config.keybinds?.input_quote_selection).toBe("ctrl+q")
},
})
})

test("migrates project legacy tui keys even when global tui.json already exists", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
Expand Down
Loading