diff --git a/AGENTS.md b/AGENTS.md index 05a6742d41..92f8f355f8 100644 --- a/AGENTS.md +++ b/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 diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000000..c7ce7f76c2 --- /dev/null +++ b/CHANGELOG.md @@ -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. + + \ No newline at end of file diff --git a/README.md b/README.md index 11390fb2b2..a604207500 100644 --- a/README.md +++ b/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. --- diff --git a/ui-tui/packages/hermes-ink/src/ink/ink.tsx b/ui-tui/packages/hermes-ink/src/ink/ink.tsx index 7422cf4637..481fae8cb7 100644 --- a/ui-tui/packages/hermes-ink/src/ink/ink.tsx +++ b/ui-tui/packages/hermes-ink/src/ink/ink.tsx @@ -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') } }) } 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 3230767e7e..8fce739e33 100644 --- a/ui-tui/packages/hermes-ink/src/ink/termio/osc.ts +++ b/ui-tui/packages/hermes-ink/src/ink/termio/osc.ts @@ -198,11 +198,33 @@ export async function setClipboard(text: string): Promise { // 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 } } diff --git a/web/src/pages/ChatPage.tsx b/web/src/pages/ChatPage.tsx index 80398104a1..372653c50e 100644 --- a/web/src/pages/ChatPage.tsx +++ b/web/src/pages/ChatPage.tsx @@ -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; }