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:
@@ -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 |",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user