diff --git a/ui-tui/packages/hermes-ink/src/ink/hooks/use-selection.ts b/ui-tui/packages/hermes-ink/src/ink/hooks/use-selection.ts index 58761fe241..bd4ef87fc7 100644 --- a/ui-tui/packages/hermes-ink/src/ink/hooks/use-selection.ts +++ b/ui-tui/packages/hermes-ink/src/ink/hooks/use-selection.ts @@ -9,9 +9,9 @@ import { type FocusMove, type SelectionState, shiftAnchor } from '../selection.j * Returns no-op functions when fullscreen mode is disabled. */ export function useSelection(): { - copySelection: () => string + copySelection: () => Promise /** Copy without clearing the highlight (for copy-on-select). */ - copySelectionNoClear: () => string + copySelectionNoClear: () => Promise clearSelection: () => void hasSelection: () => boolean /** Read the raw mutable selection state (for drag-to-scroll). */ @@ -48,8 +48,8 @@ export function useSelection(): { return useMemo(() => { if (!ink) { return { - copySelection: () => '', - copySelectionNoClear: () => '', + copySelection: async () => '', + copySelectionNoClear: async () => '', clearSelection: () => {}, hasSelection: () => false, getState: () => null, diff --git a/ui-tui/packages/hermes-ink/src/ink/ink.tsx b/ui-tui/packages/hermes-ink/src/ink/ink.tsx index 40e3762800..93b10f6520 100644 --- a/ui-tui/packages/hermes-ink/src/ink/ink.tsx +++ b/ui-tui/packages/hermes-ink/src/ink/ink.tsx @@ -1296,16 +1296,12 @@ export default class Ink { this.prevFrameContaminated = true } - /** - * Copy the current selection to the clipboard without clearing the - * highlight. Matches iTerm2's copy-on-select behavior where the selected - * region stays visible after the automatic copy. - */ /** * 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). + * selection. Returns the copied text when a clipboard path succeeded (native + * tool fired, tmux buffer loaded, or OSC 52 emitted), or '' when no path was + * taken (e.g. headless Linux without tmux). Matches iTerm2's copy-on-select + * behavior where the selected region stays visible after the automatic copy. */ async copySelectionNoClear(): Promise { if (!hasSelection(this.selection)) { @@ -1316,17 +1312,22 @@ export default class Ink { if (text) { try { - const raw = await setClipboard(text) - if (raw) { - this.options.stdout.write(raw) + const { sequence, success } = await setClipboard(text) + + if (sequence) { + this.options.stdout.write(sequence) + } + + if (success) { return text } + if (process.env.HERMES_TUI_DEBUG_CLIPBOARD) { - console.error('[clipboard] [osc52] no sequence emitted — native clipboard or tmux buffer path in use') + console.error('[clipboard] no path reached the clipboard (headless + no tmux?) — set HERMES_TUI_FORCE_OSC52=1 to force the escape sequence') } } catch (err) { if (process.env.HERMES_TUI_DEBUG_CLIPBOARD) { - console.error('[clipboard] [osc52] error:', err) + console.error('[clipboard] error:', err) } } } diff --git a/ui-tui/packages/hermes-ink/src/ink/termio/osc.test.ts b/ui-tui/packages/hermes-ink/src/ink/termio/osc.test.ts index 4860544479..4c54f8d18a 100644 --- a/ui-tui/packages/hermes-ink/src/ink/termio/osc.test.ts +++ b/ui-tui/packages/hermes-ink/src/ink/termio/osc.test.ts @@ -26,4 +26,26 @@ describe('shouldEmitClipboardSequence', () => { shouldEmitClipboardSequence({ HERMES_TUI_COPY_OSC52: '0', TERM: 'xterm-256color' } as NodeJS.ProcessEnv) ).toBe(false) }) + + it('HERMES_TUI_FORCE_OSC52 takes precedence over TMUX suppression', () => { + // Without the override, local-in-tmux suppresses the OSC 52 sequence + // so the terminal multiplexer path wins. FORCE_OSC52=1 flips that + // back on for users whose tmux config supports passthrough. + expect(shouldEmitClipboardSequence({ TMUX: '/tmp/t,1,0' } as NodeJS.ProcessEnv)).toBe(false) + expect( + shouldEmitClipboardSequence({ + HERMES_TUI_FORCE_OSC52: '1', + TMUX: '/tmp/t,1,0' + } as NodeJS.ProcessEnv) + ).toBe(true) + }) + + it('HERMES_TUI_FORCE_OSC52=0 suppresses OSC 52 even for remote or plain terminals', () => { + expect( + shouldEmitClipboardSequence({ + HERMES_TUI_FORCE_OSC52: '0', + SSH_CONNECTION: '1' + } as NodeJS.ProcessEnv) + ).toBe(false) + }) }) 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 a7e232c96e..c60196b8c1 100644 --- a/ui-tui/packages/hermes-ink/src/ink/termio/osc.ts +++ b/ui-tui/packages/hermes-ink/src/ink/termio/osc.ts @@ -166,10 +166,23 @@ export async function tmuxLoadBuffer(text: string): Promise { * utilities (pbcopy/wl-copy/xclip/xsel/clip.exe) always work locally. Over * SSH these would write to the remote clipboard — OSC 52 is the right path there. * - * Returns the sequence for the caller to write to stdout (raw OSC 52 - * outside tmux, DCS-wrapped inside). + * Returns { sequence, success }: + * - `sequence` is the bytes to write to stdout (raw OSC 52 outside tmux, + * DCS-wrapped inside; empty string when we shouldn't emit). + * - `success` is true when we believe SOME path reached the clipboard: + * native tool fired (local), tmux buffer loaded, or an OSC 52 sequence + * was emitted to the terminal. False only when no path was taken at + * all (headless Linux with no tmux + osc52 suppressed, effectively). + * This is best-effort — pbcopy/xclip are fire-and-forget, and OSC 52 + * depends on the outer terminal honoring the sequence — but it lets + * callers distinguish "nothing attempted" from "attempted". */ -export async function setClipboard(text: string): Promise { +export type ClipboardResult = { + sequence: string + success: boolean +} + +export async function setClipboard(text: string): Promise { const b64 = Buffer.from(text, 'utf8').toString('base64') const raw = osc(OSC.CLIPBOARD, 'c', b64) const emitSequence = shouldEmitClipboardSequence(process.env) @@ -181,20 +194,28 @@ export async function setClipboard(text: string): Promise { // (https://anthropic.slack.com/archives/C07VBSHV7EV/p1773943921788829). // Gated on SSH_CONNECTION (not SSH_TTY) since tmux panes inherit SSH_TTY // forever but SSH_CONNECTION is in tmux's default update-environment and - // clears on local attach. Fire-and-forget. - if (!process.env['SSH_CONNECTION']) { - copyNative(text) - } + // clears on local attach. Fire-and-forget, but `copyNativeAttempted` + // tells us whether ANY native path will be tried on this platform. + const nativeAttempted = + !process.env['SSH_CONNECTION'] && copyNative(text) const tmuxBufferLoaded = await tmuxLoadBuffer(text) // Inner OSC uses BEL directly (not osc()) — ST's ESC would need doubling // too, and BEL works everywhere for OSC 52. - if (tmuxBufferLoaded) { - return emitSequence ? tmuxPassthrough(`${ESC}]52;c;${b64}${BEL}`) : '' - } + const sequence = tmuxBufferLoaded + ? (emitSequence ? tmuxPassthrough(`${ESC}]52;c;${b64}${BEL}`) : '') + : (emitSequence ? raw : '') - return emitSequence ? raw : '' + // Success if any path was taken. Native and tmux are fire-and-forget, + // so we can't truly confirm the clipboard was written — but if native + // was attempted OR tmux buffer loaded OR we emitted OSC 52, the user's + // paste is likely to work. The only false case is "we did literally + // nothing" (e.g. local-in-tmux with osc52 suppressed and tmux buffer + // load failed), in which case reporting failure to the user is honest. + const success = nativeAttempted || tmuxBufferLoaded || sequence.length > 0 + + return { sequence, success } } // Linux clipboard tool: undefined = not yet probed, null = none available. @@ -207,16 +228,19 @@ 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 } @@ -226,28 +250,37 @@ async function probeLinuxCopy(): Promise<'wl-copy' | 'xclip' | 'xsel' | null> { * the remote machine's clipboard — OSC 52 is the right path there). * Fire-and-forget: failures are silent since OSC 52 may have succeeded. * + * Returns true when a native copy path was (or will be) attempted — i.e. + * we'll spawn pbcopy on macOS, clip on Windows, or a known-working Linux + * tool. Returns false only when we know no native tool is viable (Linux + * without DISPLAY/WAYLAND_DISPLAY, or previously-probed-to-null). The + * return value is used to decide whether to tell the user the copy + * succeeded — spawning is best-effort but good enough to claim success. + * * 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 { +function copyNative(text: string): boolean { const opts = { input: text, useCwd: false, timeout: 2000 } switch (process.platform) { case 'darwin': void execFileNoThrow('pbcopy', [], opts) - return + return true 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 + return false } + // linuxCopy is a known-working tool; fire-and-forget. void execFileNoThrow(linuxCopy, linuxCopy === 'wl-copy' ? [] : ['-selection', 'clipboard'], opts) - return + + return true } // No display server → native tools will fail immediately. Cache null. @@ -255,12 +288,15 @@ function copyNative(text: string): void { if (process.env.HERMES_TUI_DEBUG_CLIPBOARD) { console.error('[clipboard] [native] Linux: no DISPLAY or WAYLAND_DISPLAY — native clipboard unavailable') } - linuxCopy = null - return - } + linuxCopy = null + + return false + } // First call: probe in the background and cache the result for future copies. - // We don't await — this is fire-and-forget. + // We don't await — this is fire-and-forget. Treat as an attempt: + // the probe will discover a tool and spawn it. If probing finds + // nothing, the NEXT copy will short-circuit above. void (async () => { const winner = await probeLinuxCopy() linuxCopy = winner @@ -275,15 +311,18 @@ function copyNative(text: string): void { } })() - return + return true } case 'win32': // 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 + + return true } + + return false } /** @internal test-only */ diff --git a/ui-tui/src/__tests__/createSlashHandler.test.ts b/ui-tui/src/__tests__/createSlashHandler.test.ts index 4bd3503103..01c20bba61 100644 --- a/ui-tui/src/__tests__/createSlashHandler.test.ts +++ b/ui-tui/src/__tests__/createSlashHandler.test.ts @@ -363,7 +363,7 @@ const buildComposer = () => ({ hasSelection: false, paste: vi.fn(), queueRef: { current: [] as string[] }, - selection: { copySelection: vi.fn(() => '') }, + selection: { copySelection: vi.fn(async () => '') }, setInput: vi.fn() }) diff --git a/ui-tui/src/app/interfaces.ts b/ui-tui/src/app/interfaces.ts index 9049c17f9a..5386a4e149 100644 --- a/ui-tui/src/app/interfaces.ts +++ b/ui-tui/src/app/interfaces.ts @@ -32,7 +32,7 @@ export type StatusBarMode = 'bottom' | 'off' | 'top' export interface SelectionApi { clearSelection: () => void - copySelection: () => string + copySelection: () => Promise } export interface CompletionItem { diff --git a/ui-tui/src/app/slash/commands/core.ts b/ui-tui/src/app/slash/commands/core.ts index a792fe117c..7aea2fa47a 100644 --- a/ui-tui/src/app/slash/commands/core.ts +++ b/ui-tui/src/app/slash/commands/core.ts @@ -256,11 +256,11 @@ export const coreCommands: SlashCommand[] = [ 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') + return sys('clipboard copy failed — try HERMES_TUI_FORCE_OSC52=1 to force the escape sequence; HERMES_TUI_DEBUG_CLIPBOARD=1 for details') } } diff --git a/ui-tui/src/types/hermes-ink.d.ts b/ui-tui/src/types/hermes-ink.d.ts index 507be85a34..ad69348486 100644 --- a/ui-tui/src/types/hermes-ink.d.ts +++ b/ui-tui/src/types/hermes-ink.d.ts @@ -83,8 +83,8 @@ declare module '@hermes/ink' { export function withInkSuspended(run: RunExternalProcess): Promise export function useInput(handler: InputHandler, options?: { readonly isActive?: boolean }): void export function useSelection(): { - readonly copySelection: () => string - readonly copySelectionNoClear: () => string + readonly copySelection: () => Promise + readonly copySelectionNoClear: () => Promise readonly clearSelection: () => void readonly hasSelection: () => boolean readonly getState: () => unknown diff --git a/web/src/pages/ChatPage.tsx b/web/src/pages/ChatPage.tsx index cd5afcbb3b..525739b192 100644 --- a/web/src/pages/ChatPage.tsx +++ b/web/src/pages/ChatPage.tsx @@ -290,23 +290,31 @@ export default function ChatPage() { term.attachCustomKeyEventHandler((ev) => { if (ev.type !== "keydown") return true; - // 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; + // Copy: Cmd+C on macOS, Ctrl+Shift+C on other platforms. Bare Ctrl+C + // is reserved for SIGINT to the TUI child — matches xterm / gnome-terminal / + // konsole / Windows Terminal. Ctrl+Shift+C only copies if a selection exists; + // without a selection it passes through to the TUI so agents can still + // react to the keypress. + // Paste: Cmd+Shift+V on macOS, Ctrl+Shift+V on others. + const copyModifier = isMac ? ev.metaKey : ev.ctrlKey && ev.shiftKey; const pasteModifier = isMac ? ev.metaKey : ev.ctrlKey && ev.shiftKey; if (copyModifier && ev.key.toLowerCase() === "c") { const sel = term.getSelection(); if (sel) { + // Direct writeText inside the keydown handler preserves the user + // gesture — async round-trips through OSC 52 can lose activation + // and fail with "Document is not focused". 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"); + // Clear xterm.js's highlight after copy (matches gnome-terminal). + term.clearSelection(); ev.preventDefault(); return false; } - // No selection → let Ctrl+C pass through as interrupt + // No selection → fall through so the TUI receives Ctrl+Shift+C + // (or the bare ev if the user used a different modifier). } if (pasteModifier && ev.key.toLowerCase() === "v") {