diff --git a/hermes_cli/pty_bridge.py b/hermes_cli/pty_bridge.py index c681d729b2..faaed7344b 100644 --- a/hermes_cli/pty_bridge.py +++ b/hermes_cli/pty_bridge.py @@ -27,7 +27,12 @@ except ImportError: # pragma: no cover - dev env without ptyprocess _PTY_AVAILABLE = False -__all__ = ["PtyBridge", "PtyUnavailableError"] +__all__ = ["PTY_HOST_DASHBOARD", "PTY_HOST_ENV", "PtyBridge", "PtyUnavailableError"] + +# Set on the spawned TUI so Ink knows which emulator is hosting it. Mirrored in +# ui-tui/packages/hermes-ink/src/ink/termio/host.ts — keep the two in sync. +PTY_HOST_ENV = "HERMES_PTY_HOST" +PTY_HOST_DASHBOARD = "dashboard" # ``struct winsize`` packs rows/cols as unsigned short; we clamp well below that ceiling because a @@ -88,6 +93,11 @@ class PtyBridge: spawn_env = build_subprocess_env(scrub_secrets=False, inherit_profile_home=False) if env is None else env.copy() if not spawn_env.get("TERM"): spawn_env["TERM"] = "xterm-256color" + # Tell the child TUI it is hosted by the dashboard's xterm.js. Ink uses this to skip the + # focus-in erase+repaint it does for native emulators that coalesce hidden-tab output + # (xterm.js never drops frames, so under the dashboard that repaint was a visible flash on + # every OS app-switch). + spawn_env[PTY_HOST_ENV] = PTY_HOST_DASHBOARD proc = ptyprocess.PtyProcess.spawn(list(argv), cwd=cwd, env=spawn_env, dimensions=(rows, cols)) # type: ignore[union-attr] return cls(proc) diff --git a/tests/hermes_cli/test_pty_bridge.py b/tests/hermes_cli/test_pty_bridge.py index b52a3c6452..c260b9b609 100644 --- a/tests/hermes_cli/test_pty_bridge.py +++ b/tests/hermes_cli/test_pty_bridge.py @@ -54,6 +54,18 @@ class TestPtyBridgeSpawn: with pytest.raises((FileNotFoundError, OSError)): PtyBridge.spawn([str(tmp_path / "definitely-not-a-real-binary")]) + def test_spawn_marks_child_as_dashboard_hosted(self): + # Ink reads this to skip its focus-in erase+repaint, which under + # xterm.js was a visible reload on every OS app-switch (#94337). + from hermes_cli.pty_bridge import PTY_HOST_DASHBOARD, PTY_HOST_ENV + + bridge = PtyBridge.spawn([shutil.which("sh") or "sh", "-c", f'printf "%s" "${PTY_HOST_ENV}"']) + try: + output = _read_until(bridge, PTY_HOST_DASHBOARD.encode()) + assert PTY_HOST_DASHBOARD.encode() in output + finally: + bridge.close() + @skip_on_windows class TestPtyBridgeIO: diff --git a/ui-tui/packages/hermes-ink/src/ink/ink-focus-redraw.test.ts b/ui-tui/packages/hermes-ink/src/ink/ink-focus-redraw.test.ts index 5300b161fe..4925de1590 100644 --- a/ui-tui/packages/hermes-ink/src/ink/ink-focus-redraw.test.ts +++ b/ui-tui/packages/hermes-ink/src/ink/ink-focus-redraw.test.ts @@ -317,4 +317,22 @@ describe.each([ expect(chunks.join('')).toContain(DISABLE_MOUSE_TRACKING) } }) + + it('under the dashboard PTY, re-asserts modes but never clears or repaints', async () => { + // xterm.js fed by the dashboard WebSocket never drops hidden-tab writes, + // so there is no stale row to heal — the clear+repaint is only a flash on + // every OS app-switch (hermes-agent#94337). The focus report itself must + // still be processed (this handler runs), only the repaint is skipped. + const { beforeFocus, afterFocus, chunks } = await focusRegain(altScreen, { HERMES_PTY_HOST: 'dashboard' }) + + const out = chunks.join('') + + expect(out).not.toContain(ERASE_SCREEN) + expect(out).not.toContain('hello') + expect(afterFocus).toBe(beforeFocus) + + if (altScreen) { + expect(out).toContain(DISABLE_MOUSE_TRACKING) + } + }) }) diff --git a/ui-tui/packages/hermes-ink/src/ink/ink.tsx b/ui-tui/packages/hermes-ink/src/ink/ink.tsx index ca4f239b70..769d11b8e6 100644 --- a/ui-tui/packages/hermes-ink/src/ink/ink.tsx +++ b/ui-tui/packages/hermes-ink/src/ink/ink.tsx @@ -104,6 +104,7 @@ import { type MouseTrackingMode, SHOW_CURSOR } from './termio/dec.js' +import { isDashboardHosted } from './termio/host.js' import { CLEAR_ITERM2_PROGRESS, CLEAR_TAB_STATUS, @@ -634,6 +635,11 @@ export default class Ink { // watchdog's next 2s probe. reassertTerminalModes(false) is the // non-destructive form — extended keys + mouse preset, no alt-screen // re-entry, no erase — so it costs a few idempotent bytes and no flicker. + // + // Under the dashboard the emulator is xterm.js over a WebSocket: it never + // drops hidden-tab writes, so the clear+repaint is only a flash on every + // OS app-switch. Re-assert modes and stop; the focus report still reaches + // TerminalFocusProvider. queueMicrotask(() => { if (this.isUnmounted || this.isPaused || !this.options.stdout.isTTY || this.currentNode === null) { return @@ -641,6 +647,10 @@ export default class Ink { this.reassertTerminalModes(false) + if (isDashboardHosted()) { + return + } + if (this.altScreenActive) { this.resetFramesForAltScreen() } else { diff --git a/ui-tui/packages/hermes-ink/src/ink/termio/host.ts b/ui-tui/packages/hermes-ink/src/ink/termio/host.ts new file mode 100644 index 0000000000..7b5e5234b4 --- /dev/null +++ b/ui-tui/packages/hermes-ink/src/ink/termio/host.ts @@ -0,0 +1,13 @@ +/** + * Which emulator is hosting this TUI, when the host tells us. + * + * `hermes dashboard` spawns the TUI behind a PTY and mirrors it into xterm.js + * in the browser; the bridge sets HERMES_PTY_HOST=dashboard on the child + * (hermes_cli/pty_bridge.py — keep the two constants in sync). Native + * terminals never set it. + */ +export const PTY_HOST_ENV = 'HERMES_PTY_HOST' +export const PTY_HOST_DASHBOARD = 'dashboard' + +export const isDashboardHosted = (env: NodeJS.ProcessEnv = process.env): boolean => + env[PTY_HOST_ENV] === PTY_HOST_DASHBOARD diff --git a/web/src/lib/pty-focus.test.ts b/web/src/lib/pty-focus.test.ts new file mode 100644 index 0000000000..fdcad64dda --- /dev/null +++ b/web/src/lib/pty-focus.test.ts @@ -0,0 +1,31 @@ +// @vitest-environment jsdom +import { describe, expect, it } from "vitest"; + +import { shouldRestoreTerminalFocus } from "./pty-focus"; + +describe("shouldRestoreTerminalFocus", () => { + const body = document.body; + const host = document.createElement("div"); + const textarea = document.createElement("textarea"); + const sidebarInput = document.createElement("input"); + host.appendChild(textarea); + body.appendChild(host); + body.appendChild(sidebarInput); + + it("restores when nothing holds focus (OS app-switch drops focus onto )", () => { + expect(shouldRestoreTerminalFocus(null, host)).toBe(true); + expect(shouldRestoreTerminalFocus(body, host)).toBe(true); + }); + + it("restores when the terminal itself already had focus", () => { + expect(shouldRestoreTerminalFocus(textarea, host)).toBe(true); + }); + + it("does not steal focus from another control on the page", () => { + expect(shouldRestoreTerminalFocus(sidebarInput, host)).toBe(false); + }); + + it("restores when the terminal host is not mounted yet", () => { + expect(shouldRestoreTerminalFocus(sidebarInput, null)).toBe(true); + }); +}); diff --git a/web/src/lib/pty-focus.ts b/web/src/lib/pty-focus.ts new file mode 100644 index 0000000000..03e603f56e --- /dev/null +++ b/web/src/lib/pty-focus.ts @@ -0,0 +1,17 @@ +// Whether ChatPage may pull keyboard focus back into the xterm textarea. +// +// Only when nothing else on the page is holding focus: `document.activeElement` +// is null / (first activation after mount, or a return from another OS +// app or browser tab where focus fell back to ), or already inside the +// terminal host. If the user had clicked into the sidebar (model picker, +// tool-call entry) we must not yank focus away from wherever they left it — +// that's a surprise and an a11y foot-gun. +export function shouldRestoreTerminalFocus( + active: Element | null, + host: Element | null, +): boolean { + if (active === null || active === document.body || host === null) { + return true; + } + return host.contains(active); +} diff --git a/web/src/pages/ChatPage.tsx b/web/src/pages/ChatPage.tsx index 244fa16807..6bca795bde 100644 --- a/web/src/pages/ChatPage.tsx +++ b/web/src/pages/ChatPage.tsx @@ -39,6 +39,7 @@ import { latchChatActivation } from "@/lib/chat-activation"; import { copyTextToClipboard } from "@/lib/clipboard"; import { normalizeSessionTitle } from "@/lib/chat-title"; import { createPtyCompositionForwarder } from "@/lib/pty-composition"; +import { shouldRestoreTerminalFocus } from "@/lib/pty-focus"; import { PtyResumeSanitizer } from "@/lib/pty-resume-sanitizer"; import { PTY_CONNECTING_TIMEOUT_MS, @@ -1610,16 +1611,10 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) { raf2 = requestAnimationFrame(() => { raf2 = 0; syncMetricsRef.current?.(); - const host = hostRef.current; const active = typeof document !== "undefined" ? document.activeElement : null; - const focusIsElsewhereInChatPage = - active !== null && - active !== document.body && - host !== null && - !host.contains(active); - if (!focusIsElsewhereInChatPage) { + if (shouldRestoreTerminalFocus(active, hostRef.current)) { termRef.current?.focus(); } }); @@ -1630,6 +1625,22 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) { }; }, [isActive]); + // Returning from another OS app (alt-tab to copy text, then back) lands + // browser focus on , not on the xterm textarea, so the next Ctrl+V + // goes nowhere. Pull focus back into the terminal under the same + // ownership rule as tab activation above. This listener must not touch + // the PTY connection — the resume/reconnect path is separate. + useEffect(() => { + if (!isActive || typeof window === "undefined") return; + const onWindowFocus = () => { + if (shouldRestoreTerminalFocus(document.activeElement, hostRef.current)) { + termRef.current?.focus(); + } + }; + window.addEventListener("focus", onWindowFocus); + return () => window.removeEventListener("focus", onWindowFocus); + }, [isActive]); + const maybeReconnectOnPageResume = useCallback(() => { const visibilityState = typeof document !== "undefined" ? document.visibilityState : "visible";