Skip to content

fix(tui): clipboard copy on linux/wayland#29342

Merged
ethernet8023 merged 2 commits into
mainfrom
fix/tui-linux-copy
May 21, 2026
Merged

fix(tui): clipboard copy on linux/wayland#29342
ethernet8023 merged 2 commits into
mainfrom
fix/tui-linux-copy

Conversation

@ethernet8023

Copy link
Copy Markdown
Collaborator

What does this PR do?

Fixes ctrl-c → clipboard silently failing on linux/wayland inside the TUI.

Native clipboard tools (wl-copy, xclip, xsel, pbcopy, clip) double-fork a daemon that holds the system selection live. When we spawn them with stdio: 'pipe', the daemon inherits those stdio pipes, so node's 'close' event never fires — even after the immediate child has exited. execFileNoThrow was awaiting 'close', so the await leaked past its timeout (and kill(SIGTERM) on the already-exited immediate child was a no-op while the daemon kept living). Result: probeLinuxCopy never returned, the linuxCopy cache stayed undefined forever, and the actual copy spawn never ran. ctrl-c → nothing.

Fix is small and surgical: add a resolveOnExit option to execFileNoThrow that settles on the immediate child's 'exit' event instead of waiting for stdio drainage. Wire it into every clipboard-tool spawn site (probe + actual copy, across all five tools). 'exit' is what we actually care about for daemonizing tools — once the immediate child is gone, the daemon has the data and the OS owns the rest.

Adds clipboard deps to the Nix derivation.

Related Issue

Fixes #

Type of Change

  • 🐛 Bug fix (non-breaking change that fixes an issue)
  • ✨ New feature (non-breaking change that adds functionality)
  • 🔒 Security fix
  • 📝 Documentation update
  • ✅ Tests (adding or improving test coverage)
  • ♻️ Refactor (no behavior change)
  • 🎯 New skill (bundled or hub)

Changes Made

  • nix/hermes-agent.nix — added wl-clipboard and xclip to hermes-agent derivation
  • ui-tui/packages/hermes-ink/src/utils/execFileNoThrow.ts — added resolveOnExit opt; when set, the returned promise settles on the child's 'exit' event instead of 'close'. Double-resolve guarded.
  • ui-tui/packages/hermes-ink/src/utils/execFileNoThrow.test.ts — new file, 4 passing cases (daemon-style child, non-zero exit propagation, timeout, double-resolve) + 1 it.skip documenting the forever-hang so a reviewer can reproduce the bug by hand.
  • ui-tui/packages/hermes-ink/src/ink/termio/osc.ts — wired resolveOnExit: true into probeLinuxCopy (500ms timeout) and copyNative (2000ms); dropped two dead HERMES_TUI_DEBUG_CLIPBOARD console.error sites that emitted into the alt-screen patchStderr no-op.
  • ui-tui/packages/hermes-ink/src/ink/ink.tsx — dropped two more dead HERMES_TUI_DEBUG_CLIPBOARD console.error sites in the OSC52 fallback path; replaced with a clean catch {} + comment.
  • ui-tui/src/app/slash/commands/core.ts — dropped the HERMES_TUI_DEBUG_CLIPBOARD=1 hint from the clipboard copy failed slash-command message (the env var no longer routes anywhere useful).

Net diff: +165 / −32, 5 files.

How to Test

  1. On linux (wayland or x11), make sure you have one of wl-copy / xclip / xsel installed.
  2. Run nix run .# -- --tui (or however you launch the TUI on your box).
  3. Select some text in the transcript, press ctrl-c.
  4. Paste anywhere outside the TUI — the text should be there. Before this PR: clipboard stays empty, ctrl-c silently does nothing.
  5. Bonus: run cd ui-tui && npm test -- packages/hermes-ink/src/utils/execFileNoThrow.test.ts covers daemon-style children, timeout, non-zero exit, and double-resolve.

Checklist

Code

  • I've read the Contributing Guide
  • My commit messages follow Conventional Commits (fix(scope):, feat(scope):, etc.)
  • I searched for existing PRs to make sure this isn't a duplicate
  • My PR contains only changes related to this fix/feature (no unrelated commits)
  • I've run pytest tests/ -q and all tests pass
  • I've added tests for my changes (required for bug fixes, strongly encouraged for features)
  • I've tested on my platform: Linux / NixOS + wayland (wl-copy)

Documentation & Housekeeping

  • I've updated relevant documentation (README, docs/, docstrings) — or N/A
  • I've updated cli-config.yaml.example if I added/changed config keys — or N/A
  • I've updated CONTRIBUTING.md or AGENTS.md if I changed architecture or workflows — or N/A
  • I've considered cross-platform impact (Windows, macOS) per the compatibility guide — or N/A
  • I've updated tool descriptions/schemas if I changed tool behavior — or N/A

@ethernet8023 ethernet8023 requested review from OutThisLife and alt-glitch and removed request for OutThisLife May 20, 2026 14:38
@github-actions

github-actions Bot commented May 20, 2026

Copy link
Copy Markdown
Contributor

🔎 Lint report: fix/tui-linux-copy vs origin/main

ruff

Total: 0 on HEAD, 0 on base (➖ 0)

🆕 New issues: none

✅ Fixed issues: none

Unchanged: 0 pre-existing issues carried over.

ty (type checker)

Total: 8983 on HEAD, 8983 on base (➖ 0)

🆕 New issues: none

✅ Fixed issues: none

Unchanged: 4741 pre-existing issues carried over.

Diagnostics are surfaced as warnings — this check never fails the build.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR fixes clipboard-copy silently failing in the TUI on Linux/Wayland by preventing execFileNoThrow from hanging on daemonizing clipboard tools (e.g. wl-copy) that keep inherited stdio pipes open, and wires that behavior into the native clipboard path. It also updates Nix runtime deps and cleans up now-dead debug messaging.

Changes:

  • Add resolveOnExit to execFileNoThrow and corresponding unit tests covering daemon-style subprocess behavior and timeout races.
  • Use resolveOnExit: true for native clipboard probing/copy across platforms in OSC clipboard code; remove unused debug logging.
  • Add Linux clipboard tools to the Nix derivation and simplify user-facing failure messaging.

Reviewed changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
ui-tui/src/app/slash/commands/core.ts Simplifies /copy failure hint after debug path removal.
ui-tui/packages/hermes-ink/src/utils/execFileNoThrow.ts Adds resolveOnExit settling behavior to avoid hangs on daemonizing tools.
ui-tui/packages/hermes-ink/src/utils/execFileNoThrow.test.ts New tests for resolveOnExit, timeout, non-zero exit, and race behavior.
ui-tui/packages/hermes-ink/src/ink/termio/osc.ts Wires resolveOnExit into native clipboard probe/copy; removes dead debug logging.
ui-tui/packages/hermes-ink/src/ink/ink.tsx Removes dead debug logging; swallows clipboard exceptions with comment.
nix/hermes-agent.nix Adds wl-clipboard and xclip to Linux runtime dependencies.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +25 to +30
// Posix sh: the `sleep 30 &` child inherits stdin/stdout/stderr from the
// shell, which inherited them from `spawn(stdio: 'pipe')`. The shell
// exits but its child (the sleeper) keeps the pipes open. Mirrors how
// wl-copy double-forks then exits while the daemon holds the selection.
writeFileSync(daemonScript, '#!/bin/sh\nsleep 30 &\nexit 0\n')
chmodSync(daemonScript, 0o755)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in e17da78. Shortened to sleep 3, the daemon script now writes the sleeper PID to a file, and afterEach reads it back and SIGKILLs the orphan. No more accumulation across watch runs.

Comment on lines 392 to 394
// Actually perform the copy with the discovered tool.
if (winner) {
void execFileNoThrow(winner, winner === 'wl-copy' ? [] : ['-selection', 'clipboard'], opts)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in e17da78. Extracted a linuxCopyArgs() helper with per-tool argument sets: wl-copy: [], xclip: [-selection clipboard], xsel: [--clipboard --input]. Both call sites (cached-path and probe-winner) now use it instead of the old ternary that passed xclip args to xsel.

Comment on lines +7 to +14
/** Resolve as soon as the child *exits*, instead of waiting for its
* stdio streams to close. Use this for tools that fork a daemon and
* let the daemon inherit the parent's stdio (e.g. `wl-copy`): the
* child exits immediately, but `'close'` never fires because the
* daemon holds the pipes open. The caller must not depend on
* collecting stdout/stderr from that point on — only what arrived
* before exit is included in the resolved value. */
resolveOnExit?: boolean

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in e17da78. When resolveOnExit=true, stdio is now ['pipe', 'ignore', 'ignore'] so daemonizing children can't inherit stdout/stderr pipe FDs. Also added child.stdout?.destroy() / child.stderr?.destroy() after settle() to release remaining FDs promptly. Safe because resolveOnExit already documents that the caller must not depend on stdout/stderr after exit.

@alt-glitch alt-glitch added type/bug Something isn't working comp/tui Terminal UI (ui-tui/ + tui_gateway/) area/nix Nix flake, NixOS module, container packaging P2 Medium — degraded but workaround exists labels May 20, 2026
@ethernet8023

Copy link
Copy Markdown
Collaborator Author

@copilot review

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 6 out of 6 changed files in this pull request and generated 3 comments.

Comment on lines +95 to +104
if (options.resolveOnExit) {
// 'exit' fires when the child process itself exits — even if the
// daemon it forked still holds the inherited stdio pipes open.
child.on('exit', code => {
settle(timedOut ? 124 : (code ?? 0))
})
} else {
child.on('close', code => {
settle(timedOut ? 124 : (code ?? 0))
})
Comment on lines +7 to +17
/** Resolve as soon as the child *exits*, instead of waiting for its
* stdio streams to close. Use this for tools that fork a daemon and
* let the daemon inherit the parent's stdio (e.g. `wl-copy`): the
* child exits immediately, but `'close'` never fires because the
* daemon holds the pipes open.
*
* When true, stdout and stderr are set to 'ignore' to prevent the
* daemon from inheriting those pipe FDs — the caller must not
* depend on collecting stdout/stderr content. Only what arrived
* before exit is included in the resolved value (typically empty
* since the child exits immediately). */
Comment on lines +1 to +6
import { chmodSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'

import { afterEach, beforeEach, describe, expect, it } from 'vitest'

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 6 out of 6 changed files in this pull request and generated 1 comment.

Comment on lines +311 to +315
/** Per-tool copy arguments: wl-copy reads stdin, xclip/xsel need clipboard flags. */
function linuxCopyArgs(tool: 'wl-copy' | 'xclip' | 'xsel'): string[] {
switch (tool) {
case 'wl-copy':
return []
ethernet and others added 2 commits May 20, 2026 19:47
`probeLinuxCopy` and `copyNative` in `osc.ts` await `execFileNoThrow`
for wl-copy / xclip / xsel. Those tools double-fork a daemon that
holds the system selection live, and the daemon inherits stdio pipes
from `spawn(stdio: 'pipe')`. Node's 'close' event only fires when
stdio is fully closed → the daemon keeps the pipes open → 'close'
never fires → the await leaks past the timeout (kill(SIGTERM) on an
already-exited child is a no-op, daemon survives).

Result: `linuxCopy` cache stays `undefined` permanently, the actual
copy never runs, ctrl-c silently does nothing on wayland/x11.
Reproduced in isolation, confirmed across wl-copy and a
daemonization-shaped fixture.

Fix: add `resolveOnExit` option to `execFileNoThrow`. When set, the
promise settles on the immediate child's 'exit' event instead of
waiting for stdio drainage. Wired into both the probe and the actual
copy spawns for every clipboard tool (pbcopy, wl-copy, xclip, xsel,
clip).

Tests: 5 new vitest cases covering daemon-style child handling,
non-zero exit propagation, timeout behavior, and double-resolve
guard. The forever-hang case is committed as `it.skip` with
documentation so a reviewer can verify the bug by hand.
@ethernet8023 ethernet8023 merged commit 1566d71 into main May 21, 2026
12 of 13 checks passed
@ethernet8023 ethernet8023 deleted the fix/tui-linux-copy branch May 21, 2026 01:40
gweeteve pushed a commit to gweeteve/hermes-agent that referenced this pull request Jun 2, 2026
…-copy

fix(tui): clipboard copy on linux/wayland
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/nix Nix flake, NixOS module, container packaging comp/tui Terminal UI (ui-tui/ + tui_gateway/) P2 Medium — degraded but workaround exists type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants