diff --git a/ui-tui/packages/hermes-ink/src/ink/ink.tsx b/ui-tui/packages/hermes-ink/src/ink/ink.tsx index 7422cf4637bab..40e3762800321 100644 --- a/ui-tui/packages/hermes-ink/src/ink/ink.tsx +++ b/ui-tui/packages/hermes-ink/src/ink/ink.tsx @@ -1301,7 +1301,13 @@ export default class Ink { * highlight. Matches iTerm2's copy-on-select behavior where the selected * region stays visible after the automatic copy. */ - copySelectionNoClear(): string { + /** + * Copy the current text selection to the system clipboard without clearing the + * selection. Returns the copied text on success (empty if no selection or + * clipboard operation failed). Success is determined by whether an OSC 52 + * sequence was emitted (native/tmux paths do not produce a sequence). + */ + async copySelectionNoClear(): Promise { if (!hasSelection(this.selection)) { return '' } @@ -1309,28 +1315,36 @@ export default class Ink { const text = getSelectedText(this.selection, this.frontFrame.screen) if (text) { - // Raw OSC 52, or DCS-passthrough-wrapped OSC 52 inside tmux (tmux - // drops it silently unless allow-passthrough is on — no regression). - void setClipboard(text).then(raw => { + try { + const raw = await setClipboard(text) if (raw) { this.options.stdout.write(raw) + return text } - }) + if (process.env.HERMES_TUI_DEBUG_CLIPBOARD) { + console.error('[clipboard] [osc52] no sequence emitted — native clipboard or tmux buffer path in use') + } + } catch (err) { + if (process.env.HERMES_TUI_DEBUG_CLIPBOARD) { + console.error('[clipboard] [osc52] error:', err) + } + } } - return text + return '' } /** * Copy the current text selection to the system clipboard via OSC 52 - * and clear the selection. Returns the copied text (empty if no selection). + * and clear the selection. Returns the copied text (empty if no selection + * or clipboard operation failed). */ - copySelection(): string { + async copySelection(): Promise { if (!hasSelection(this.selection)) { return '' } - const text = this.copySelectionNoClear() + const text = await this.copySelectionNoClear() clearSelection(this.selection) this.notifySelectionChange() diff --git a/ui-tui/packages/hermes-ink/src/ink/termio/osc.ts b/ui-tui/packages/hermes-ink/src/ink/termio/osc.ts index 3230767e7e25f..a7e232c96e776 100644 --- a/ui-tui/packages/hermes-ink/src/ink/termio/osc.ts +++ b/ui-tui/packages/hermes-ink/src/ink/termio/osc.ts @@ -84,7 +84,11 @@ export function getClipboardPath(): ClipboardPath { } export function shouldEmitClipboardSequence(env: NodeJS.ProcessEnv = process.env): boolean { - const override = (env.HERMES_TUI_CLIPBOARD_OSC52 ?? env.HERMES_TUI_COPY_OSC52 ?? '').trim() + const override = ( + env.HERMES_TUI_FORCE_OSC52 ?? + env.HERMES_TUI_CLIPBOARD_OSC52 ?? + env.HERMES_TUI_COPY_OSC52 ?? '' + ).trim() if (ENV_ON_RE.test(override)) { return true @@ -198,11 +202,33 @@ export async function setClipboard(text: string): Promise { // Cached after first attempt so repeated mouse-ups skip the probe chain. let linuxCopy: 'wl-copy' | 'xclip' | 'xsel' | null | undefined +/** Internal: probe once and cache — wl-copy first, then xclip, then xsel. */ +async function probeLinuxCopy(): Promise<'wl-copy' | 'xclip' | 'xsel' | null> { + const opts = { useCwd: false, timeout: 500 } + + const r = await execFileNoThrow('wl-copy', [], opts) + if (r.code === 0) { + return 'wl-copy' + } + + const r2 = await execFileNoThrow('xclip', ['-selection', 'clipboard'], opts) + if (r2.code === 0) { + return 'xclip' + } + + const r3 = await execFileNoThrow('xsel', ['--clipboard', '--input'], opts) + return r3.code === 0 ? 'xsel' : null +} + /** * Shell out to a native clipboard utility as a safety net for OSC 52. * Only called when not in an SSH session (over SSH, these would write to * the remote machine's clipboard — OSC 52 is the right path there). * Fire-and-forget: failures are silent since OSC 52 may have succeeded. + * + * Linux behaviour: if DISPLAY and WAYLAND_DISPLAY are both unset, native + * clipboard tools cannot work (they need a display server). In that case + * we skip probing entirely and treat linuxCopy as permanently null. */ function copyNative(text: string): void { const opts = { input: text, useCwd: false, timeout: 2000 } @@ -210,51 +236,44 @@ function copyNative(text: string): void { switch (process.platform) { case 'darwin': void execFileNoThrow('pbcopy', [], opts) - return - case 'linux': { - if (linuxCopy === null) { - return - } - - if (linuxCopy === 'wl-copy') { - void execFileNoThrow('wl-copy', [], opts) - - return - } - - if (linuxCopy === 'xclip') { - void execFileNoThrow('xclip', ['-selection', 'clipboard'], opts) + case 'linux': { + // If we already probed (success or hard-fail), short-circuit. + if (linuxCopy !== undefined) { + if (linuxCopy === null) { + // No working native tool — skip silently. + return + } + // linuxCopy is a known-working tool; fire-and-forget. + void execFileNoThrow(linuxCopy, linuxCopy === 'wl-copy' ? [] : ['-selection', 'clipboard'], opts) return } - if (linuxCopy === 'xsel') { - void execFileNoThrow('xsel', ['--clipboard', '--input'], opts) - + // No display server → native tools will fail immediately. Cache null. + if (!process.env.DISPLAY && !process.env.WAYLAND_DISPLAY) { + if (process.env.HERMES_TUI_DEBUG_CLIPBOARD) { + console.error('[clipboard] [native] Linux: no DISPLAY or WAYLAND_DISPLAY — native clipboard unavailable') + } + linuxCopy = null return } - // First call: probe wl-copy (Wayland) then xclip/xsel (X11), cache winner. - void execFileNoThrow('wl-copy', [], opts).then(r => { - if (r.code === 0) { - linuxCopy = 'wl-copy' + // First call: probe in the background and cache the result for future copies. + // We don't await — this is fire-and-forget. + void (async () => { + const winner = await probeLinuxCopy() + linuxCopy = winner - return + if (process.env.HERMES_TUI_DEBUG_CLIPBOARD) { + console.error(`[clipboard] [native] Linux: clipboard probe complete → ${winner ?? 'no tool available'}`) } - void execFileNoThrow('xclip', ['-selection', 'clipboard'], opts).then(r2 => { - if (r2.code === 0) { - linuxCopy = 'xclip' - - return - } - - void execFileNoThrow('xsel', ['--clipboard', '--input'], opts).then(r3 => { - linuxCopy = r3.code === 0 ? 'xsel' : null - }) - }) - }) + // Actually perform the copy with the discovered tool. + if (winner) { + void execFileNoThrow(winner, winner === 'wl-copy' ? [] : ['-selection', 'clipboard'], opts) + } + })() return } @@ -263,7 +282,6 @@ function copyNative(text: string): void { // clip.exe is always available on Windows. Unicode handling is // imperfect (system locale encoding) but good enough for a fallback. void execFileNoThrow('clip', [], opts) - return } } diff --git a/ui-tui/src/app/slash/commands/core.ts b/ui-tui/src/app/slash/commands/core.ts index 6d927fedcccfb..a792fe117cb16 100644 --- a/ui-tui/src/app/slash/commands/core.ts +++ b/ui-tui/src/app/slash/commands/core.ts @@ -251,11 +251,17 @@ export const coreCommands: SlashCommand[] = [ { help: 'copy selection or assistant message', name: 'copy', - run: (arg, ctx) => { + run: async (arg, ctx) => { const { sys } = ctx.transcript - if (!arg && ctx.composer.hasSelection && ctx.composer.selection.copySelection()) { - return sys('copied selection') + if (!arg && ctx.composer.hasSelection) { + const text = await ctx.composer.selection.copySelection() + if (text) { + // Include character count to match user's reported message format + return sys(`copied ${text.length} characters`) + } else { + return sys('clipboard copy failed — no OSC 52 emitted; see HERMES_TUI_DEBUG_CLIPBOARD') + } } if (arg && Number.isNaN(parseInt(arg, 10))) { diff --git a/web/src/pages/ChatPage.tsx b/web/src/pages/ChatPage.tsx index 80398104a1cb1..cd5afcbb3b157 100644 --- a/web/src/pages/ChatPage.tsx +++ b/web/src/pages/ChatPage.tsx @@ -269,17 +269,17 @@ export default function ChatPage() { const payload = data.slice(semi + 1); if (payload === "?" || payload === "") return false; // read/clear — ignore try { - // atob returns a binary string (one byte per char); we need UTF-8 - // decode so multi-byte codepoints (≥, →, emoji, CJK) round-trip - // correctly. Without this step, the three UTF-8 bytes of `≥` - // would land in the clipboard as the three separate Latin-1 - // characters `≥`. const binary = atob(payload); const bytes = Uint8Array.from(binary, (c) => c.charCodeAt(0)); const text = new TextDecoder("utf-8").decode(bytes); - navigator.clipboard.writeText(text).catch(() => {}); - } catch { - // Malformed base64 — silently drop. + navigator.clipboard.writeText(text).catch((err) => { + // Most common reason: the Clipboard API requires a user gesture. + // This can fail when the OSC 52 response arrives outside the + // original keydown event's activation. Log to aid debugging. + console.warn("[dashboard clipboard] OSC 52 write failed:", err.message); + }); + } catch (e) { + console.warn("[dashboard clipboard] malformed OSC 52 payload"); } return true; }); @@ -290,16 +290,23 @@ export default function ChatPage() { term.attachCustomKeyEventHandler((ev) => { if (ev.type !== "keydown") return true; - const copyModifier = isMac ? ev.metaKey : ev.ctrlKey && ev.shiftKey; + // Copy: Cmd+C on macOS, Ctrl+C on other platforms (when selection exists) + // Paste: Cmd+Shift+V on macOS, Ctrl+Shift+V on others + const copyModifier = isMac ? ev.metaKey : ev.ctrlKey; const pasteModifier = isMac ? ev.metaKey : ev.ctrlKey && ev.shiftKey; if (copyModifier && ev.key.toLowerCase() === "c") { const sel = term.getSelection(); if (sel) { - navigator.clipboard.writeText(sel).catch(() => {}); + navigator.clipboard.writeText(sel).catch((err) => { + console.warn("[dashboard clipboard] direct copy failed:", err.message); + }); + // Send Escape to the TUI to clear its selection overlay + term.write("\x1b"); ev.preventDefault(); return false; } + // No selection → let Ctrl+C pass through as interrupt } if (pasteModifier && ev.key.toLowerCase() === "v") { @@ -308,7 +315,9 @@ export default function ChatPage() { .then((text) => { if (text) term.paste(text); }) - .catch(() => {}); + .catch((err) => { + console.warn("[dashboard clipboard] paste failed:", err.message); + }); ev.preventDefault(); return false; }