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:
@@ -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)
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 {
|
||||
|
||||
13
ui-tui/packages/hermes-ink/src/ink/termio/host.ts
Normal file
13
ui-tui/packages/hermes-ink/src/ink/termio/host.ts
Normal 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
|
||||
31
web/src/lib/pty-focus.test.ts
Normal file
31
web/src/lib/pty-focus.test.ts
Normal 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
17
web/src/lib/pty-focus.ts
Normal 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);
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user