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
50 changes: 46 additions & 4 deletions packages/tui/src/component/prompt/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,11 @@ export type PromptProps = {
}
}

// Replace CRLF first, then any remaining CR.
function normalizeLineEndings(text: string) {
return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n")
}

function pastedFilepath(value: string, platform: string) {
const raw = value.replace(/^['"]+|['"]+$/g, "")
if (raw.startsWith("file://")) {
Expand Down Expand Up @@ -389,6 +394,23 @@ export function Prompt(props: PromptProps) {
}
},
},
{
title: "Paste without a placeholder",
name: "prompt.paste.raw",
category: "Prompt",
run: async () => {
const content = await clipboard.read?.()
if (content?.mime.startsWith("image/")) {
await pasteAttachment({
filename: "clipboard",
mime: content.mime,
content: content.data,
})
return
}
if (content?.mime === "text/plain") insertPastedText(normalizeLineEndings(content.data))
},
},
{
title: "Interrupt session",
name: "session.interrupt",
Expand Down Expand Up @@ -805,6 +827,14 @@ export function Prompt(props: PromptProps) {
}
})

useBindings(() => {
return {
target: inputTarget,
enabled: inputTarget() !== undefined && !props.disabled,
bindings: tuiConfig.keybinds.get("prompt.paste.raw"),
}
})

useBindings(() => {
return {
target: inputTarget,
Expand Down Expand Up @@ -1181,7 +1211,7 @@ export function Prompt(props: PromptProps) {
}

async function pasteInputText(text: string) {
const normalizedText = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n")
const normalizedText = normalizeLineEndings(text)
const pastedContent = normalizedText.trim()
const filepath = pastedFilepath(pastedContent, terminalEnvironment.platform)
const isUrl = /^(https?):\/\//.test(filepath)
Expand Down Expand Up @@ -1212,7 +1242,11 @@ export function Prompt(props: PromptProps) {
return
}

input.insertText(normalizedText)
insertPastedText(normalizedText)
}

function insertPastedText(text: string) {
input.insertText(text)

setTimeout(() => {
if (!input || input.isDestroyed) return
Expand Down Expand Up @@ -1401,9 +1435,13 @@ export function Prompt(props: PromptProps) {

// Normalize line endings at the boundary
// Windows ConPTY/Terminal often sends CR-only newlines in bracketed paste
// Replace CRLF first, then any remaining CR
const normalizedText = decodePasteBytes(event.bytes).replace(/\r\n/g, "\n").replace(/\r/g, "\n")
const normalizedText = normalizeLineEndings(decodePasteBytes(event.bytes))
const pastedContent = normalizedText.trim()
// A terminal paste right after the leader key goes in as typed, the way the raw paste key
// does with the clipboard: over ssh or tmux, the terminal's paste is the one that holds the
// clipboard of the machine the user is sitting at.
const raw = leader()
if (raw) keymap.clearPendingSequence()

// Windows Terminal <1.25 can surface image-only clipboard as an
// empty bracketed paste. Windows Terminal 1.25+ does not.
Expand All @@ -1416,6 +1454,10 @@ export function Prompt(props: PromptProps) {
// default paste unless we suppress it first and handle insertion ourselves.
event.preventDefault()

if (raw) {
insertPastedText(normalizedText)
return
}
await pasteInputText(normalizedText)
}}
ref={(r: TextareaRenderable) => {
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 @@ -160,6 +160,7 @@ export const Definitions = {

input_clear: keybind("ctrl+c", "Clear input field"),
input_paste: keybind({ key: "ctrl+v", preventDefault: false }, "Paste from clipboard"),
input_paste_raw: keybind("<leader>v", "Paste from clipboard as typed, without a placeholder"),
input_submit: keybind("return", "Submit input"),
input_newline: keybind("shift+return,ctrl+return,alt+return,ctrl+j", "Insert newline in input"),
input_move_left: keybind("left,ctrl+b", "Move cursor left in input"),
Expand Down Expand Up @@ -363,6 +364,7 @@ export const CommandMap = {
workspace_set: "workspace.set",
input_clear: "prompt.clear",
input_paste: "prompt.paste",
input_paste_raw: "prompt.paste.raw",
input_submit: "input.submit",
input_newline: "input.newline",
input_move_left: "input.move.left",
Expand Down
55 changes: 55 additions & 0 deletions packages/tui/test/keymap.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -139,3 +139,58 @@ test("mode-less bindings stay active when opencode mode changes", async () => {
app.renderer.destroy()
}
})

test("leader then v runs the raw paste, and a terminal paste after the leader finds it still pending", async () => {
const ran: string[] = []
const pasted: { text: string; leader: boolean }[] = []
let leaderActive = () => false

function Harness() {
const renderer = useRenderer()
const keymap = createDefaultOpenTuiKeymap(renderer)
const config = createResolvedKeymapConfig()
const offKeymap = registerOpencodeKeymap(keymap, renderer, config)
const offLayer = keymap.registerLayer({
commands: [{ name: "prompt.paste.raw", run: () => void ran.push("prompt.paste.raw") }],
bindings: config.keybinds.get("prompt.paste.raw"),
})
leaderActive = () => keymap.getPendingSequence()[0]?.tokenName === "leader"
onCleanup(() => {
offLayer()
offKeymap()
})

return (
<OpencodeKeymapProvider keymap={keymap}>
<textarea
focused
onPaste={(event: { bytes: Uint8Array; preventDefault(): void }) => {
event.preventDefault()
pasted.push({ text: Buffer.from(event.bytes).toString(), leader: leaderActive() })
}}
/>
</OpencodeKeymapProvider>
)
}

const app = await testRender(() => <Harness />)
const press = async (bytes: string) => {
app.renderer.stdin.emit("data", Buffer.from(bytes))
await Bun.sleep(50)
}
try {
await press("\x18") // ctrl+x, the default leader
await press("v")
expect(ran).toEqual(["prompt.paste.raw"])

await press("\x1b[200~one\ntwo\nthree\x1b[201~")
await press("\x18")
await press("\x1b[200~one\ntwo\nthree\x1b[201~")
expect(pasted).toEqual([
{ text: "one\ntwo\nthree", leader: false },
{ text: "one\ntwo\nthree", leader: true },
])
} finally {
app.renderer.destroy()
}
})
12 changes: 12 additions & 0 deletions packages/web/src/content/docs/keybinds.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ OpenCode has a list of keybinds that you can customize through `tui.json`.
"key": "ctrl+v",
"preventDefault": false
},
"input_paste_raw": "<leader>v",
"input_submit": "return",
"input_newline": "shift+return,ctrl+return,alt+return,ctrl+j",
"input_move_left": "left,ctrl+b",
Expand Down Expand Up @@ -201,6 +202,17 @@ Some navigation keybinds intentionally do not use the leader key by default. For

---

## Paste Without a Placeholder

A long paste goes into the prompt as a placeholder such as `[Pasted ~12 lines]`. To paste text as typed instead:

- `input_paste_raw` (default `<leader>v`) pastes the clipboard as typed. Like `input_paste`, it reads the clipboard of the machine OpenCode runs on.
- Pressing the leader key and then pasting with your terminal's own paste shortcut also pastes as typed. Use this when OpenCode runs on another machine, over ssh or in tmux: the terminal's paste carries the clipboard of the machine you are sitting at.

`input_paste_raw` still attaches an image on the clipboard, the way `input_paste` does.

---

## Binding Values

A string can contain one shortcut or multiple comma-separated shortcuts. You can also use an array for multiple shortcuts.
Expand Down
Loading