diff --git a/ui-tui/packages/hermes-ink/src/ink/ink.tsx b/ui-tui/packages/hermes-ink/src/ink/ink.tsx index 481fae8cb7..40e3762800 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) { - void setClipboard(text).then(raw => { + try { + const raw = await setClipboard(text) if (raw) { this.options.stdout.write(raw) - } else if (process.env.HERMES_TUI_DEBUG_CLIPBOARD) { + 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 8fce739e33..a7e232c96e 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 diff --git a/ui-tui/src/app/slash/commands/core.ts b/ui-tui/src/app/slash/commands/core.ts index 6d927fedcc..a792fe117c 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 372653c50e..cd5afcbb3b 100644 --- a/web/src/pages/ChatPage.tsx +++ b/web/src/pages/ChatPage.tsx @@ -290,7 +290,9 @@ 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") { @@ -299,9 +301,12 @@ export default function ChatPage() { 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") {