mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-07 02:51:50 +00:00
fix(tui): robust clipboard handling with debug logging and headless detection
Problem: Ctrl+C in Hermes TUI shows 'copied' but clipboard often empty.
Root causes:
- Native Linux tools (xclip, wl-copy) require DISPLAY/WAYLAND_DISPLAY; in
headless Docker/SSH they fail or hang.
- OSC 52 fallback requires terminal emulator support; when absent, sequence
is dropped silently.
- Dashboard OSC 52 → Clipboard API path fails due to missing user gesture;
errors were silently caught.
- User feedback 'copied selection' was shown unconditionally, regardless of
success.
Solution implemented:
- Short-circuit Linux native clipboard probing when no display server is
present (no DISPLAY and no WAYLAND_DISPLAY). Avoids futile attempts and
timeouts.
- Add HERMES_TUI_DEBUG_CLIPBOARD env var (1/true). When set, TUI logs to
stderr which clipboard path is used, probe results on Linux, and whether
OSC 52 was emitted. Greatly improves diagnosability.
- Improve dashboard clipboard error handling: replace empty catch blocks
with console.warn messages for OSC 52 decode/Write failures and direct
copy/paste errors. Makes browser permission/user-gesture failures visible
in DevTools.
- Add comprehensive clipboard troubleshooting documentation to README and
AGENTS, covering OSC 52 verification, tmux config, Docker/headless
constraints, env vars, dashboard caveats, and fallback strategies.
Technical details:
- in ui-tui/packages/hermes-ink/src/ink/termio/osc.ts:
- Early return on Linux if both DISPLAY and WAYLAND_DISPLAY unset.
- Refactor probe sequence to async with 500ms timeout,
caching result; subsequent copies use cached tool immediately.
- Emit debug logs when HERMES_TUI_DEBUG_CLIPBOARD=1.
- in ink.tsx: log when OSC 52 not emitted (native
or tmux path in use) in debug mode.
- : OSC 52 handler and Ctrl+Shift+C handler now
log warnings to console on Clipboard API rejection with error message.
- Documentation: new 'Clipboard Troubleshooting' section in README; new
'Clipboard environment variables and pitfalls' subsection in AGENTS.md
(Known Pitfalls).
Tests: full ui-tui test suite (292 tests) passes; clipboard and OSC tests
unaffected. No breaking changes.
Files changed:
- ui-tui/packages/hermes-ink/src/ink/termio/osc.ts
- ui-tui/packages/hermes-ink/src/ink/ink.tsx
- web/src/pages/ChatPage.tsx
- README.md
- AGENTS.md
- CHANGELOG.md (new)
This commit is contained in:
parent
855366909f
commit
a562420383
6 changed files with 204 additions and 52 deletions
|
|
@ -1309,11 +1309,11 @@ export default class Ink {
|
|||
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 => {
|
||||
if (raw) {
|
||||
this.options.stdout.write(raw)
|
||||
} else if (process.env.HERMES_TUI_DEBUG_CLIPBOARD) {
|
||||
console.error('[clipboard] [osc52] no sequence emitted — native clipboard or tmux buffer path in use')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -198,11 +198,33 @@ 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 }
|
||||
|
|
@ -210,51 +232,44 @@ function copyNative(text: string): void {
|
|||
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)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (linuxCopy === 'xsel') {
|
||||
void execFileNoThrow('xsel', ['--clipboard', '--input'], opts)
|
||||
|
||||
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'
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
void execFileNoThrow('xclip', ['-selection', 'clipboard'], opts).then(r2 => {
|
||||
if (r2.code === 0) {
|
||||
linuxCopy = 'xclip'
|
||||
// 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
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
// 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
|
||||
|
||||
void execFileNoThrow('xsel', ['--clipboard', '--input'], opts).then(r3 => {
|
||||
linuxCopy = r3.code === 0 ? 'xsel' : null
|
||||
})
|
||||
})
|
||||
})
|
||||
if (process.env.HERMES_TUI_DEBUG_CLIPBOARD) {
|
||||
console.error(`[clipboard] [native] Linux: clipboard probe complete → ${winner ?? 'no tool available'}`)
|
||||
}
|
||||
|
||||
// Actually perform the copy with the discovered tool.
|
||||
if (winner) {
|
||||
void execFileNoThrow(winner, winner === 'wl-copy' ? [] : ['-selection', 'clipboard'], opts)
|
||||
}
|
||||
})()
|
||||
|
||||
return
|
||||
}
|
||||
|
|
@ -263,7 +278,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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue