fix(dashboard): stop the embedded TUI repainting on every OS app-switch (#103165)

* feat(pty_bridge): mark the dashboard-spawned TUI with HERMES_PTY_HOST

Ink needs to know when its emulator is the dashboard's xterm.js rather
than a native terminal, so it can drop hidden-tab recovery work that only
makes sense for emulators that coalesce output.

Co-authored-by: Raymond <supere989@users.noreply.github.com>

* fix(tui): skip the focus-in erase+repaint under the dashboard PTY

Ink answers a DECSET 1004 focus-in with a full clear+repaint to heal rows
a native emulator may have dropped while the tab was hidden. xterm.js fed
by the dashboard WebSocket never drops frames, and it reports focus on
every OS window blur/focus, so under the dashboard that repaint was a
visible "session reloaded" flash on every alt-tab. Keep the mode
re-assert and keep delivering the focus report to TerminalFocusProvider
(the composer hides its cursor on blur); only the repaint is skipped.

Co-authored-by: Raymond <supere989@users.noreply.github.com>

* fix(web): restore terminal focus after an OS app-switch

Alt-tabbing away and back lands browser focus on <body>, so Ctrl+V never
reached the composer. Pull focus back into xterm on window focus under
the same ownership rule tab activation already uses, extracted into
shouldRestoreTerminalFocus so both paths share it.

Co-authored-by: Raymond <supere989@users.noreply.github.com>

---------

Co-authored-by: Raymond <supere989@users.noreply.github.com>
This commit is contained in:
Austin Pickett
2026-09-04 20:24:02 -04:00
committed by GitHub
parent f159e581c7
commit 04fd0172cd
8 changed files with 130 additions and 8 deletions

View File

@@ -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)

View File

@@ -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:

View File

@@ -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)
}
})
})

View File

@@ -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 {

View File

@@ -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

View File

@@ -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 <body>)", () => {
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);
});
});

17
web/src/lib/pty-focus.ts Normal file
View File

@@ -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 / <body> (first activation after mount, or a return from another OS
// app or browser tab where focus fell back to <body>), 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);
}

View File

@@ -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 <body>, 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";