Merge pull request #2248 from chan1103/feat/smart-paste-text-fallback

feat(coding-agent): fall back to text paste when clipboard holds no image
This commit is contained in:
Can Bölük
2026-06-12 04:24:29 +02:00
committed by GitHub
7 changed files with 225 additions and 13 deletions
@@ -137,7 +137,7 @@ export const KEYBINDINGS = {
},
"app.clipboard.pasteImage": {
defaultKeys: getDefaultPasteImageKeys(),
description: "Paste image from clipboard",
description: "Paste image or text from clipboard",
},
"app.clipboard.pasteTextRaw": {
defaultKeys: ["ctrl+shift+v", "alt+shift+v"],
@@ -49,7 +49,14 @@ const TINY_TITLE_PROGRESS_DONE_TTL_MS = 3_000;
const TINY_TITLE_PROGRESS_REVEAL_DELAY_MS = 1_000;
export class InputController {
constructor(private ctx: InteractiveModeContext) {}
constructor(
private ctx: InteractiveModeContext,
/** Injectable clipboard reads so tests can drive paste flows without a real clipboard. */
private clipboard: {
readImage: typeof readImageFromClipboard;
readText: typeof readTextFromClipboard;
} = { readImage: readImageFromClipboard, readText: readTextFromClipboard },
) {}
#enhancedPaste?: EnhancedPasteController;
@@ -876,10 +883,25 @@ export class InputController {
async handleImagePaste(): Promise<boolean> {
try {
const image = await readImageFromClipboard();
const image = await this.clipboard.readImage();
if (!image) {
this.ctx.showStatus("No image in clipboard (use terminal paste for text)");
return false;
// Smart paste (#1628): no image on the clipboard — fall back to
// pasting its text so the same chord covers both payload kinds.
// Hosts that pre-empt the terminal's own paste (VS Code's
// integrated terminal, Win+V clipboard history) deliver only
// this keypress, so a miss here must not dead-end.
const text = await this.clipboard.readText();
if (!text) {
this.ctx.showStatus("Clipboard is empty");
return false;
}
// Route to the focused component when it accepts pastes (modal
// Input prompts), matching the enhanced-paste text path (#2127).
const focused = this.ctx.ui.getFocused();
const target = focused && focused !== this.ctx.editor && hasPasteText(focused) ? focused : this.ctx.editor;
target.pasteText(text);
this.ctx.ui.requestRender();
return true;
}
return await this.#normalizeAndInsertPastedImage(
{
@@ -897,10 +919,11 @@ export class InputController {
async handleClipboardTextRawPaste(): Promise<void> {
try {
const text = await readTextFromClipboard();
const text = await this.clipboard.readText();
if (text) {
this.ctx.editor.insertText(text);
this.ctx.ui.requestRender();
} else {
this.ctx.showStatus("No text in clipboard to paste raw");
}
} catch {
@@ -48,7 +48,7 @@ export function buildHotkeysMarkdown(bindings: HotkeysMarkdownBindings): string
`| \`${appKey(bindings, "app.tools.expand")}\` | Toggle tool output expansion |`,
`| \`${appKey(bindings, "app.thinking.toggle")}\` | Toggle thinking block visibility |`,
`| \`${appKey(bindings, "app.editor.external")}\` | Edit message in external editor |`,
`| \`${appKey(bindings, "app.clipboard.pasteImage")}\` | Paste image from clipboard |`,
`| \`${appKey(bindings, "app.clipboard.pasteImage")}\` | Paste image or text from clipboard |`,
`| \`${appKey(bindings, "app.stt.toggle")}\` | Toggle speech-to-text recording |`,
`| \`${appKey(bindings, "app.agents.hub")}\` / \`${appKey(bindings, "app.session.observe")}\` / double-tap \`←\` (empty editor) | Open the agent hub |`,
"| `#` | Open prompt actions |",
+56 -4
View File
@@ -125,6 +125,56 @@ async function readImageViaPowerShell(): Promise<ClipboardImage | null> {
}
}
// PowerShell one-liner that emits the clipboard text verbatim on stdout, or
// nothing when the clipboard holds no text. `[Console]::Out.Write` avoids the
// trailing newline Write-Output would add; output encoding is forced to UTF-8
// so non-ASCII text survives the interop boundary regardless of console
// codepage.
const POWERSHELL_TEXT_SCRIPT = `
$ErrorActionPreference = 'Stop'
[Console]::OutputEncoding = [Text.Encoding]::UTF8
[Console]::Out.Write([string](Get-Clipboard -Raw))
`;
/**
* Read clipboard text through Windows PowerShell — native win32 or the WSL
* host over interop.
*
* Same rationale as `readImageViaPowerShell`: under WSL, the WSLg Wayland
* clipboard only works when `wl-clipboard` happens to be installed in the
* distro, while `powershell.exe` is always reachable. Forcing UTF-8 output
* encoding keeps non-ASCII text intact regardless of the console codepage
* (the legacy win32 `Get-Clipboard` shell-out mangled it), and `Bun.spawn`
* keeps a cold PowerShell start off the TUI event loop.
*
* Returns null when the bridge fails (WSL callers fall through to
* wl-paste/xclip); an empty string is a successful "no text" read.
*/
async function readTextViaPowerShell(): Promise<string | null> {
try {
const proc = Bun.spawn(["powershell.exe", "-NoProfile", "-NonInteractive", "-Command", POWERSHELL_TEXT_SCRIPT], {
stdout: "pipe",
stderr: "ignore",
stdin: "ignore",
});
const timer = setTimeout(() => proc.kill(), POWERSHELL_TIMEOUT_MS);
let stdout = "";
try {
stdout = await new Response(proc.stdout).text();
await proc.exited;
} catch (err) {
logger.warn("clipboard: powershell text read failed", { error: String(err) });
return null;
} finally {
clearTimeout(timer);
}
if (proc.exitCode !== 0) return null;
return stdout.replaceAll("\r\n", "\n");
} catch {
return null;
}
}
/**
* Read an image from the system clipboard.
*
@@ -165,14 +215,16 @@ export async function readTextFromClipboard(): Promise<string> {
return execSync("pbpaste", { encoding: "utf8", timeout: 2000 }).toString();
}
if (p === "win32") {
return execSync('powershell.exe -NoProfile -Command "Get-Clipboard"', {
encoding: "utf8",
timeout: 2000,
}).toString();
return (await readTextViaPowerShell()) ?? "";
}
if (process.env.TERMUX_VERSION) {
return execSync("termux-clipboard-get", { encoding: "utf8", timeout: 2000 }).toString();
}
if (isWsl()) {
const text = await readTextViaPowerShell();
if (text !== null) return text;
// Bridge failed — fall through to the wl-paste/xclip paths below.
}
const hasWaylandDisplay = Boolean(process.env.WAYLAND_DISPLAY);
const hasX11Display = Boolean(process.env.DISPLAY);
if (hasWaylandDisplay) {