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
21
AGENTS.md
21
AGENTS.md
@ -667,6 +667,27 @@ def profile_env(tmp_path, monkeypatch):
|
||||
return home
|
||||
```
|
||||
|
||||
### Clipboard environment variables and pitfalls
|
||||
|
||||
Hermes TUI clipboard handling uses a three-tier strategy:
|
||||
|
||||
1. **Native OS tools** (`pbcopy`, `wl-copy`, `xclip`, `xsel`, `clip.exe`) — available **only** when a display server is present (`$DISPLAY` for X11 or `$WAYLAND_DISPLAY` for Wayland). On Linux in headless environments (Docker, remote SSH without X11 forwarding), these tools fail or hang. The code now short-circuits immediately if both variables are unset.
|
||||
2. **tmux buffer** (`tmux load-buffer`) — when inside a tmux session; requires `set-clipboard on` for system clipboard propagation.
|
||||
3. **OSC 52 escape** — written to stdout; the terminal emulator must intercept and set the clipboard. Support varies: iTerm2 disables it by default, VS Code may block it behind a permission prompt, and raw PTYs without an emulator drop it silently.
|
||||
|
||||
**Environment variables:**
|
||||
|
||||
| Variable | Purpose |
|
||||
|---|---|
|
||||
| `HERMES_TUI_CLIPBOARD_OSC52` or `HERMES_TUI_COPY_OSC52` | Force OSC 52 emission (`1`/`true`) or disable (`0`/`false`). Ignored when native tools are expected to work (macOS local, or Linux with `$DISPLAY/$WAYLAND_DISPLAY`). |
|
||||
| `HERMES_TUI_DEBUG_CLIPBOARD` | Set to `1` to log detailed debug information to stderr about which clipboard path is taken, probe results on Linux, and why OSC 52 may be suppressed. |
|
||||
| `SSH_CONNECTION` | Presence indicates an SSH session; this gates native tool usage (to avoid writing to the remote machine's clipboard) and prefers OSC 52. |
|
||||
| `TMUX`, `STY` | Used to detect tmux/screen and apply appropriate passthrough or buffer loading. |
|
||||
|
||||
**Common false-positive:** The UI message "copied selection" is displayed **unconditionally** after Ctrl+C, even if all clipboard mechanisms fail. If you're in a headless Docker container or a non-OSC52-capable terminal, you'll see the message but nothing is copied. Use `HERMES_TUI_DEBUG_CLIPBOARD=1` to diagnose.
|
||||
|
||||
**Dashboard caveat:** The dashboard's `Ctrl+C` path relies on OSC 52 → xterm's handler → browser Clipboard API. Because the Clipboard API requires a user gesture, this can fail if the OSC 52 response arrives outside the key event's activation window. Use `Ctrl+Shift+C` (Cmd+Shift+C on macOS) as a reliable fallback; it calls `navigator.clipboard.writeText()` directly inside the key handler.
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
21
CHANGELOG.md
Normal file
21
CHANGELOG.md
Normal file
@ -0,0 +1,21 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Fixed
|
||||
|
||||
- **TUI clipboard copy** — Native tool probing on Linux now short-circuits immediately when `$DISPLAY` and `$WAYLAND_DISPLAY` are both unset, avoiding wasted time and silent failures in headless environments (Docker, CI). (Hermes Ink / osc.ts)
|
||||
- **TUI debug visibility** — Added `HERMES_TUI_DEBUG_CLIPBOARD` environment variable. When set, the TUI logs which clipboard mechanism is used, probe results, and why OSC 52 might be suppressed. Helps users and operators diagnose copy failures.
|
||||
- **Dashboard clipboard logging** — Silent failures in OSC 52 → Clipboard API bridge and direct `Ctrl+Shift+C` copy are now logged to the browser console with explanatory warnings, replacing empty catch blocks. Makes clipboard permission issues and gesture-timeout failures visible during development.
|
||||
- **Documentation** — Added comprehensive "Clipboard Troubleshooting" section to README covering OSC 52 verification, tmux configuration, Docker/headless constraints, environment variables, and dashboard caveats. AGENTS.md now documents all clipboard-related environment variables and known failure modes.
|
||||
|
||||
### Changed
|
||||
|
||||
- Desktop and dashboard clipboard error handling is now consistent: all Clipboard API rejections and native tool failures produce diagnostic logs rather than being swallowed.
|
||||
|
||||
</content>
|
||||
94
README.md
94
README.md
@ -169,7 +169,99 @@ scripts/run_tests.sh
|
||||
- 💬 [Discord](https://discord.gg/NousResearch)
|
||||
- 📚 [Skills Hub](https://agentskills.io)
|
||||
- 🐛 [Issues](https://github.com/NousResearch/hermes-agent/issues)
|
||||
- 🔌 [HermesClaw](https://github.com/AaronWong1999/hermesclaw) — Community WeChat bridge: Run Hermes Agent and OpenClaw on the same WeChat account.
|
||||
- 🔌 [HermesClaw](https://github.com/AaronWong1999/hermesclaw) — Community WeChat bridge: Run Hermes Agent and OpenClaw on the same WeChat account.
|
||||
|
||||
---
|
||||
|
||||
## Clipboard Troubleshooting
|
||||
|
||||
Hermes TUI (standalone) and dashboard both support copying via `Ctrl+C` / `Cmd+C`. This requires either:
|
||||
|
||||
- A terminal with **OSC 52** support enabled, **or**
|
||||
- Native clipboard utilities (`pbcopy`, `wl-copy`, `xclip`, `xsel`, `clip.exe`) available in PATH **and** a running display server (X11 or Wayland).
|
||||
|
||||
If the UI says "copied" but the text is not in your system clipboard, follow these steps.
|
||||
|
||||
### Standalone TUI (`hermes --tui`)
|
||||
|
||||
#### Verify OSC 52 support
|
||||
|
||||
Run this in the same terminal you use for Hermes:
|
||||
```bash
|
||||
printf '\e]52;c;%s\a' "$(echo -n 'test-osc52' | base64)" && echo
|
||||
```
|
||||
Then paste (Cmd+V / Ctrl+Shift+V). If you see `test-osc52`, OSC 52 works.
|
||||
|
||||
If it fails, enable OSC 52 in your terminal:
|
||||
|
||||
| Terminal | Setting |
|
||||
|--------------|-------------------------------------------------------------------------|
|
||||
| iTerm2 | Preferences → General → Selection → check "Copy to pasteboard" |
|
||||
| Kitty | `allow_remote_control yes` (default: on) |
|
||||
| WezTerm | `enable_osc52_copy = true` |
|
||||
| VS Code | Usually works; if blocked, check DevTools console for permission error |
|
||||
| GNOME | Enabled by default |
|
||||
|
||||
#### tmux users
|
||||
|
||||
tmux absorbs OSC 52 unless explicitly configured. Add to `~/.tmux.conf`:
|
||||
```tmux
|
||||
set -g set-clipboard on
|
||||
set -g allow-passthrough on
|
||||
```
|
||||
Then reload: `tmux source-file ~/.tmux.conf`.
|
||||
|
||||
#### Docker/headless environments
|
||||
|
||||
Inside a Docker container, `$DISPLAY` and `$WAYLAND_DISPLAY` are typically unset, so native clipboard tools fail immediately. OSC 52 is the only path — it must be supported by your local terminal emulator (the one connected to the container's PTY). If your terminal doesn't support OSC 52, consider:
|
||||
|
||||
- Using `ssh -X` / `ssh -Y` to forward X11 and run `xclip` on the host via SSH
|
||||
- Running Hermes on the host directly, not inside a container
|
||||
- Writing copied text to a file: `/copy` saves to `~/.hermes/clipboard.txt` (fallback)
|
||||
|
||||
#### Force OSC 52 emission
|
||||
|
||||
If your terminal supports OSC 52 but Hermes isn't emitting it (e.g., inside SSH where native tools are skipped), set:
|
||||
```bash
|
||||
export HERMES_TUI_CLIPBOARD_OSC52=1
|
||||
hermes --tui
|
||||
```
|
||||
|
||||
#### Debug mode
|
||||
|
||||
To see exactly which clipboard path Hermes takes:
|
||||
```bash
|
||||
export HERMES_TUI_DEBUG_CLIPBOARD=1
|
||||
hermes --tui
|
||||
```
|
||||
Then attempt a copy and watch stderr for messages like:
|
||||
```
|
||||
[clipboard] [native] Linux: no DISPLAY or WAYLAND_DISPLAY — native clipboard unavailable
|
||||
[clipboard] [native] Linux: clipboard probe complete → xclip
|
||||
[clipboard] [osc52] no sequence emitted — native clipboard or tmux buffer path in use
|
||||
```
|
||||
|
||||
### Dashboard (`hermes dashboard` → /chat)
|
||||
|
||||
The dashboard uses the browser's Clipboard API. There are two copy paths:
|
||||
|
||||
1. **Ctrl/Cmd+Shift+C** — direct copy from xterm's selection (most reliable)
|
||||
2. **Ink's Ctrl+C** — emits OSC 52 → xterm OSC 52 handler → Clipboard API; this is more fragile because the Clipboard API requires a **user gesture**. In some browsers the OSC 52 response is processed outside the original key event's activation window, causing a silent failure.
|
||||
|
||||
If copy doesn't work in the dashboard:
|
||||
- Use `Ctrl+Shift+C` (Linux/Windows) or `Cmd+Shift+C` (macOS) instead
|
||||
- Check the browser console (F12) for warnings like `[dashboard clipboard] OSC 52 write failed`
|
||||
- Ensure the page has clipboard permissions (browser may ask on first use)
|
||||
|
||||
Clicking the "copy last response" button also sends `/copy` over the WebSocket, which suffers from the same OSC 52 timing issue.
|
||||
|
||||
### When all else fails: file-based fallback
|
||||
|
||||
You can save copied text to a file manually:
|
||||
```bash
|
||||
hermes --tui # inside TUI, use /copy which includes a file fallback in future versions
|
||||
```
|
||||
Or implement a custom skill that writes the last assistant message to disk.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
});
|
||||
@ -296,7 +296,9 @@ export default function ChatPage() {
|
||||
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);
|
||||
});
|
||||
ev.preventDefault();
|
||||
return false;
|
||||
}
|
||||
@ -308,7 +310,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;
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user