fix(clipboard): report native/tmux success, keep Ctrl+Shift+C on dashboard

Follow-up on #16020 salvage. Three corrections:

1. Truth signal for /copy
   Before: success was 'OSC 52 sequence was emitted to stdout'. That's
   false on local Linux inside tmux (emitSequence=false), so /copy kept
   printing 'clipboard copy failed' to users whose xclip/wl-copy had
   already succeeded fire-and-forget.
   Fix: setClipboard() now returns { sequence, success } where success =
   native-fired OR tmux-buffer-loaded OR osc52-emitted. copyNative()
   returns a boolean telling setClipboard whether a native attempt was
   made. /copy only shows 'failed' when literally no path was taken.

2. Dashboard keybinding
   Before: Ctrl+C for copy on non-Mac (Ctrl+Shift+C for paste).
   That swallows SIGINT when a stale selection is present and breaks
   the xterm/gnome-terminal/konsole/Windows-Terminal convention where
   Ctrl+C in a terminal emulator is always SIGINT. The real bug was
   that clipboard writes lost user-gesture through OSC-52 round-trips,
   which the direct writeText already fixes.
   Fix: revert copyModifier to Ctrl+Shift+C on non-Mac. Direct
   writeText in the keydown handler preserves user gesture. term.write
   Escape replaced with term.clearSelection() (works without relying
   on TUI input mode).

3. Error toast text
   Before: 'see HERMES_TUI_DEBUG_CLIPBOARD' — tells users how to
   debug but not how to fix.
   Fix: point users at HERMES_TUI_FORCE_OSC52=1 first (the actual
   escape hatch), mention the debug var second.
This commit is contained in:
Teknium
2026-04-26 05:44:38 -07:00
committed by Teknium
parent 2511207cb0
commit e8441c4c0f
9 changed files with 120 additions and 50 deletions

View File

@@ -9,9 +9,9 @@ import { type FocusMove, type SelectionState, shiftAnchor } from '../selection.j
* Returns no-op functions when fullscreen mode is disabled. * Returns no-op functions when fullscreen mode is disabled.
*/ */
export function useSelection(): { export function useSelection(): {
copySelection: () => string copySelection: () => Promise<string>
/** Copy without clearing the highlight (for copy-on-select). */ /** Copy without clearing the highlight (for copy-on-select). */
copySelectionNoClear: () => string copySelectionNoClear: () => Promise<string>
clearSelection: () => void clearSelection: () => void
hasSelection: () => boolean hasSelection: () => boolean
/** Read the raw mutable selection state (for drag-to-scroll). */ /** Read the raw mutable selection state (for drag-to-scroll). */
@@ -48,8 +48,8 @@ export function useSelection(): {
return useMemo(() => { return useMemo(() => {
if (!ink) { if (!ink) {
return { return {
copySelection: () => '', copySelection: async () => '',
copySelectionNoClear: () => '', copySelectionNoClear: async () => '',
clearSelection: () => {}, clearSelection: () => {},
hasSelection: () => false, hasSelection: () => false,
getState: () => null, getState: () => null,

View File

@@ -1296,16 +1296,12 @@ export default class Ink {
this.prevFrameContaminated = true 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 * Copy the current text selection to the system clipboard without clearing the
* selection. Returns the copied text on success (empty if no selection or * selection. Returns the copied text when a clipboard path succeeded (native
* clipboard operation failed). Success is determined by whether an OSC 52 * tool fired, tmux buffer loaded, or OSC 52 emitted), or '' when no path was
* sequence was emitted (native/tmux paths do not produce a sequence). * 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<string> { async copySelectionNoClear(): Promise<string> {
if (!hasSelection(this.selection)) { if (!hasSelection(this.selection)) {
@@ -1316,17 +1312,22 @@ export default class Ink {
if (text) { if (text) {
try { try {
const raw = await setClipboard(text) const { sequence, success } = await setClipboard(text)
if (raw) {
this.options.stdout.write(raw) if (sequence) {
this.options.stdout.write(sequence)
}
if (success) {
return text return text
} }
if (process.env.HERMES_TUI_DEBUG_CLIPBOARD) { 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) { } catch (err) {
if (process.env.HERMES_TUI_DEBUG_CLIPBOARD) { if (process.env.HERMES_TUI_DEBUG_CLIPBOARD) {
console.error('[clipboard] [osc52] error:', err) console.error('[clipboard] error:', err)
} }
} }
} }

View File

@@ -26,4 +26,26 @@ describe('shouldEmitClipboardSequence', () => {
shouldEmitClipboardSequence({ HERMES_TUI_COPY_OSC52: '0', TERM: 'xterm-256color' } as NodeJS.ProcessEnv) shouldEmitClipboardSequence({ HERMES_TUI_COPY_OSC52: '0', TERM: 'xterm-256color' } as NodeJS.ProcessEnv)
).toBe(false) ).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)
})
}) })

View File

@@ -166,10 +166,23 @@ export async function tmuxLoadBuffer(text: string): Promise<boolean> {
* utilities (pbcopy/wl-copy/xclip/xsel/clip.exe) always work locally. Over * 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. * 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 * Returns { sequence, success }:
* outside tmux, DCS-wrapped inside). * - `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<string> { export type ClipboardResult = {
sequence: string
success: boolean
}
export async function setClipboard(text: string): Promise<ClipboardResult> {
const b64 = Buffer.from(text, 'utf8').toString('base64') const b64 = Buffer.from(text, 'utf8').toString('base64')
const raw = osc(OSC.CLIPBOARD, 'c', b64) const raw = osc(OSC.CLIPBOARD, 'c', b64)
const emitSequence = shouldEmitClipboardSequence(process.env) const emitSequence = shouldEmitClipboardSequence(process.env)
@@ -181,20 +194,28 @@ export async function setClipboard(text: string): Promise<string> {
// (https://anthropic.slack.com/archives/C07VBSHV7EV/p1773943921788829). // (https://anthropic.slack.com/archives/C07VBSHV7EV/p1773943921788829).
// Gated on SSH_CONNECTION (not SSH_TTY) since tmux panes inherit SSH_TTY // 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 // forever but SSH_CONNECTION is in tmux's default update-environment and
// clears on local attach. Fire-and-forget. // clears on local attach. Fire-and-forget, but `copyNativeAttempted`
if (!process.env['SSH_CONNECTION']) { // tells us whether ANY native path will be tried on this platform.
copyNative(text) const nativeAttempted =
} !process.env['SSH_CONNECTION'] && copyNative(text)
const tmuxBufferLoaded = await tmuxLoadBuffer(text) const tmuxBufferLoaded = await tmuxLoadBuffer(text)
// Inner OSC uses BEL directly (not osc()) — ST's ESC would need doubling // Inner OSC uses BEL directly (not osc()) — ST's ESC would need doubling
// too, and BEL works everywhere for OSC 52. // too, and BEL works everywhere for OSC 52.
if (tmuxBufferLoaded) { const sequence = tmuxBufferLoaded
return emitSequence ? tmuxPassthrough(`${ESC}]52;c;${b64}${BEL}`) : '' ? (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. // 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 opts = { useCwd: false, timeout: 500 }
const r = await execFileNoThrow('wl-copy', [], opts) const r = await execFileNoThrow('wl-copy', [], opts)
if (r.code === 0) { if (r.code === 0) {
return 'wl-copy' return 'wl-copy'
} }
const r2 = await execFileNoThrow('xclip', ['-selection', 'clipboard'], opts) const r2 = await execFileNoThrow('xclip', ['-selection', 'clipboard'], opts)
if (r2.code === 0) { if (r2.code === 0) {
return 'xclip' return 'xclip'
} }
const r3 = await execFileNoThrow('xsel', ['--clipboard', '--input'], opts) const r3 = await execFileNoThrow('xsel', ['--clipboard', '--input'], opts)
return r3.code === 0 ? 'xsel' : null 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). * the remote machine's clipboard — OSC 52 is the right path there).
* Fire-and-forget: failures are silent since OSC 52 may have succeeded. * 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 * Linux behaviour: if DISPLAY and WAYLAND_DISPLAY are both unset, native
* clipboard tools cannot work (they need a display server). In that case * clipboard tools cannot work (they need a display server). In that case
* we skip probing entirely and treat linuxCopy as permanently null. * 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 } const opts = { input: text, useCwd: false, timeout: 2000 }
switch (process.platform) { switch (process.platform) {
case 'darwin': case 'darwin':
void execFileNoThrow('pbcopy', [], opts) void execFileNoThrow('pbcopy', [], opts)
return
return true
case 'linux': { case 'linux': {
// If we already probed (success or hard-fail), short-circuit. // If we already probed (success or hard-fail), short-circuit.
if (linuxCopy !== undefined) { if (linuxCopy !== undefined) {
if (linuxCopy === null) { if (linuxCopy === null) {
// No working native tool — skip silently. // No working native tool — skip silently.
return return false
} }
// linuxCopy is a known-working tool; fire-and-forget. // linuxCopy is a known-working tool; fire-and-forget.
void execFileNoThrow(linuxCopy, linuxCopy === 'wl-copy' ? [] : ['-selection', 'clipboard'], opts) void execFileNoThrow(linuxCopy, linuxCopy === 'wl-copy' ? [] : ['-selection', 'clipboard'], opts)
return
return true
} }
// No display server → native tools will fail immediately. Cache null. // 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) { if (process.env.HERMES_TUI_DEBUG_CLIPBOARD) {
console.error('[clipboard] [native] Linux: no DISPLAY or WAYLAND_DISPLAY — native clipboard unavailable') 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. // 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 () => { void (async () => {
const winner = await probeLinuxCopy() const winner = await probeLinuxCopy()
linuxCopy = winner linuxCopy = winner
@@ -275,15 +311,18 @@ function copyNative(text: string): void {
} }
})() })()
return return true
} }
case 'win32': case 'win32':
// clip.exe is always available on Windows. Unicode handling is // clip.exe is always available on Windows. Unicode handling is
// imperfect (system locale encoding) but good enough for a fallback. // imperfect (system locale encoding) but good enough for a fallback.
void execFileNoThrow('clip', [], opts) void execFileNoThrow('clip', [], opts)
return
return true
} }
return false
} }
/** @internal test-only */ /** @internal test-only */

View File

@@ -363,7 +363,7 @@ const buildComposer = () => ({
hasSelection: false, hasSelection: false,
paste: vi.fn(), paste: vi.fn(),
queueRef: { current: [] as string[] }, queueRef: { current: [] as string[] },
selection: { copySelection: vi.fn(() => '') }, selection: { copySelection: vi.fn(async () => '') },
setInput: vi.fn() setInput: vi.fn()
}) })

View File

@@ -32,7 +32,7 @@ export type StatusBarMode = 'bottom' | 'off' | 'top'
export interface SelectionApi { export interface SelectionApi {
clearSelection: () => void clearSelection: () => void
copySelection: () => string copySelection: () => Promise<string>
} }
export interface CompletionItem { export interface CompletionItem {

View File

@@ -256,11 +256,11 @@ export const coreCommands: SlashCommand[] = [
if (!arg && ctx.composer.hasSelection) { if (!arg && ctx.composer.hasSelection) {
const text = await ctx.composer.selection.copySelection() const text = await ctx.composer.selection.copySelection()
if (text) { if (text) {
// Include character count to match user's reported message format
return sys(`copied ${text.length} characters`) return sys(`copied ${text.length} characters`)
} else { } 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')
} }
} }

View File

@@ -83,8 +83,8 @@ declare module '@hermes/ink' {
export function withInkSuspended(run: RunExternalProcess): Promise<void> export function withInkSuspended(run: RunExternalProcess): Promise<void>
export function useInput(handler: InputHandler, options?: { readonly isActive?: boolean }): void export function useInput(handler: InputHandler, options?: { readonly isActive?: boolean }): void
export function useSelection(): { export function useSelection(): {
readonly copySelection: () => string readonly copySelection: () => Promise<string>
readonly copySelectionNoClear: () => string readonly copySelectionNoClear: () => Promise<string>
readonly clearSelection: () => void readonly clearSelection: () => void
readonly hasSelection: () => boolean readonly hasSelection: () => boolean
readonly getState: () => unknown readonly getState: () => unknown

View File

@@ -290,23 +290,31 @@ export default function ChatPage() {
term.attachCustomKeyEventHandler((ev) => { term.attachCustomKeyEventHandler((ev) => {
if (ev.type !== "keydown") return true; if (ev.type !== "keydown") return true;
// Copy: Cmd+C on macOS, Ctrl+C on other platforms (when selection exists) // Copy: Cmd+C on macOS, Ctrl+Shift+C on other platforms. Bare Ctrl+C
// Paste: Cmd+Shift+V on macOS, Ctrl+Shift+V on others // is reserved for SIGINT to the TUI child — matches xterm / gnome-terminal /
const copyModifier = isMac ? ev.metaKey : ev.ctrlKey; // 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; const pasteModifier = isMac ? ev.metaKey : ev.ctrlKey && ev.shiftKey;
if (copyModifier && ev.key.toLowerCase() === "c") { if (copyModifier && ev.key.toLowerCase() === "c") {
const sel = term.getSelection(); const sel = term.getSelection();
if (sel) { 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) => { navigator.clipboard.writeText(sel).catch((err) => {
console.warn("[dashboard clipboard] direct copy failed:", err.message); console.warn("[dashboard clipboard] direct copy failed:", err.message);
}); });
// Send Escape to the TUI to clear its selection overlay // Clear xterm.js's highlight after copy (matches gnome-terminal).
term.write("\x1b"); term.clearSelection();
ev.preventDefault(); ev.preventDefault();
return false; 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") { if (pasteModifier && ev.key.toLowerCase() === "v") {