Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 23 additions & 9 deletions ui-tui/packages/hermes-ink/src/ink/ink.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1301,36 +1301,50 @@ 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 ''
}

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<string> {
if (!hasSelection(this.selection)) {
return ''
}

const text = this.copySelectionNoClear()
const text = await this.copySelectionNoClear()
clearSelection(this.selection)
this.notifySelectionChange()

Expand Down
90 changes: 54 additions & 36 deletions ui-tui/packages/hermes-ink/src/ink/termio/osc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -198,63 +202,78 @@ export async function setClipboard(text: string): Promise<string> {
// 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 }

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
}
Expand All @@ -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
}
}
Expand Down
12 changes: 9 additions & 3 deletions ui-tui/src/app/slash/commands/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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))) {
Expand Down
31 changes: 20 additions & 11 deletions web/src/pages/ChatPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
});
Expand All @@ -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") {
Expand All @@ -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;
}
Expand Down
Loading