fix(clipboard): dashboard Ctrl+C direct copy; TUI honest feedback; HERMES_TUI_FORCE_OSC52

- Dashboard copy: direct Clipboard API on Ctrl+C/Cmd+C (user gesture);
  send Escape to TUI to clear selection; Ctrl+Shift+C kept as fallback.
- TUI /copy: copySelection() async; only reports success if OSC52 emitted.
- Add HERMES_TUI_FORCE_OSC52 env var to override native-tool detection.
- Fixes "copied N chars" false-positive when clipboard backend absent.

Changes:
  web/src/pages/ChatPage.tsx — direct navigator.clipboard.writeText
  ui-tui/packages/hermes-ink/src/ink/ink.tsx — async copySelection
  ui-tui/packages/hermes-ink/src/ink/termio/osc.ts — HERMES_TUI_FORCE_OSC52
  ui-tui/src/app/slash/commands/core.ts — async /copy with honest feedback
This commit is contained in:
Harry Riddle
2026-04-26 18:37:21 +07:00
committed by Teknium
parent a562420383
commit 0f3a6f0fb3
4 changed files with 42 additions and 13 deletions

View File

@@ -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<string> {
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<string> {
if (!hasSelection(this.selection)) {
return ''
}
const text = this.copySelectionNoClear()
const text = await this.copySelectionNoClear()
clearSelection(this.selection)
this.notifySelectionChange()

View File

@@ -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

View File

@@ -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))) {

View File

@@ -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") {