From e9fe618fceb67e97a254d771e577e00e4785f70d Mon Sep 17 00:00:00 2001 From: alt-glitch Date: Fri, 12 Jun 2026 13:09:23 +0530 Subject: [PATCH] opentui(v6): terminal window title (OSC 0/2) + waiting-on-you notifications (OSC 9/99/777) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Window title: a render-nothing tracks session.info — the native renderer.setTerminalTitle (frame-safe, zig-side OSC emit) shows '{session title} — Hermes' once the session is titled, 'Hermes Agent' until then. The user's previous title is bracketed with the XTWINOPS title stack (save on boot, best-effort restore on quit). Gateway: _session_info now carries the live title (DB row, pending_title fallback) and a session.info refresh follows every title change — pending-title application, the auto-title worker landing (via maybe_auto_title's title_callback), and session.title renames — so the window retitles without waiting for the next turn. Notifications: when the TUI starts waiting on the user — any blocking prompt (clarify/approval/sudo/secret/confirm) or turn completion — three dialect sequences go out through renderer.writeOut: OSC 9 (iTerm2/wezterm), OSC 99 (kitty), OSC 777 (urxvt/foot); terminals ignore what they don't speak. Suppressed while the terminal reports focused (core's mode-1004 focus/blur events; until a first blur proves reporting works, notify unconditionally). HERMES_TUI_NOTIFY=0/false/off kills notifications; the title is not gated. All text is OSC-sanitized (control chars stripped, 777's semicolon fields spliced-proof, length-capped). 13 new TUI tests (pure shaping/sequences/env gate + store-edge wiring via an injected seam) and 2 gateway tests (title resolution order, thread-safe refresh emitter). Live-smoked: tmux pane_title shows 'Hermes Agent' from the native title path. --- tests/test_tui_gateway_server.py | 45 ++++++ tui_gateway/server.py | 42 +++++ ui-opentui/src/boundary/schema/SessionInfo.ts | 3 + ui-opentui/src/boundary/termChrome.ts | 106 +++++++++++++ ui-opentui/src/entry/main.tsx | 2 + ui-opentui/src/logic/store.ts | 4 + ui-opentui/src/logic/termChrome.ts | 105 ++++++++++++ ui-opentui/src/test/termChrome.test.tsx | 149 ++++++++++++++++++ ui-opentui/src/view/terminalChrome.tsx | 61 +++++++ 9 files changed, 517 insertions(+) create mode 100644 ui-opentui/src/boundary/termChrome.ts create mode 100644 ui-opentui/src/logic/termChrome.ts create mode 100644 ui-opentui/src/test/termChrome.test.tsx create mode 100644 ui-opentui/src/view/terminalChrome.tsx diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index 54cc1640e5b7..886744d6880d 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -3804,6 +3804,51 @@ def test_session_info_includes_mcp_servers(monkeypatch): assert info["mcp_servers"] == fake_status +def test_session_info_includes_session_title(monkeypatch): + """session.info carries the live session title (window-title chrome). + + Resolution order mirrors _session_live_title: DB row wins, a queued + pending_title fills in before the row exists, "" until either lands. + """ + agent = types.SimpleNamespace(tools=[], model="m", provider="p") + + # No session at all -> "" (and never a crash). + assert server._session_info(agent, None)["title"] == "" + + # pending_title before the DB row exists. + session = {"session_key": "k1", "pending_title": "rename the moon"} + monkeypatch.setattr(server, "_get_db", lambda: None) + assert server._session_info(agent, session)["title"] == "rename the moon" + + # DB row wins over pending. + fake_db = types.SimpleNamespace(get_session_title=lambda key: "db title") + monkeypatch.setattr(server, "_get_db", lambda: fake_db) + assert server._session_info(agent, session)["title"] == "db title" + + +def test_emit_title_refresh_pushes_session_info(monkeypatch): + """_emit_title_refresh emits a session.info for a live session and is a + silent no-op for unknown/agent-less sessions (it runs on the auto-title + worker thread -- it must never raise).""" + events = [] + monkeypatch.setattr( + server, "_emit", lambda event_type, sid, payload: events.append((event_type, sid, payload)) + ) + monkeypatch.setattr(server, "_session_info", lambda agent, session: {"title": "t"}) + + agent = types.SimpleNamespace(tools=[], model="m", provider="p") + monkeypatch.setitem(server._sessions, "title-test", {"agent": agent, "session_key": "k"}) + + server._emit_title_refresh("title-test") + assert events == [("session.info", "title-test", {"title": "t"})] + + # Unknown sid / no agent -> no emission, no exception. + server._emit_title_refresh("missing-sid") + monkeypatch.setitem(server._sessions, "agentless", {"session_key": "k2"}) + server._emit_title_refresh("agentless") + assert len(events) == 1 + + # --------------------------------------------------------------------------- # History-mutating commands must reject while session.running is True. # Without these guards, prompt.submit's post-run history write either diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 83304a9ba12e..9364dd2c07ac 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -2373,6 +2373,17 @@ def _session_info(agent, session: dict | None = None) -> dict: yolo = bool(_YOLO_MODE_FROZEN) or session_yolo or _get_approval_mode() == "off" except Exception: yolo = False + # Session title (DB row, falling back to a not-yet-applied pending_title). + # Drives client window-title chrome (OSC 0/2 in the native TUI); "" until + # the first exchange titles the session. + title = "" + if session is not None: + try: + title = _session_live_title( + session, str(session.get("session_key") or "") + ) + except Exception: + title = "" info: dict = { "model": getattr(agent, "model", ""), "provider": getattr(agent, "provider", ""), @@ -2385,6 +2396,7 @@ def _session_info(agent, session: dict | None = None) -> dict: "cwd": cwd, "branch": _git_branch_for_cwd(cwd), "personality": str(personality or ""), + "title": title, "running": bool((session or {}).get("running")), "desktop_contract": DESKTOP_BACKEND_CONTRACT, "version": "", @@ -4399,6 +4411,22 @@ def _session_live_title(session: dict, key: str) -> str: return title +def _emit_title_refresh(sid: str) -> None: + """Push a session.info refresh after a title change (pending-title + application, auto-title landing, or a session.title rename) so clients' + window-title chrome updates immediately. Thread-safe (auto-title calls + this from its worker thread; _emit serializes on the stdout lock). + Never raises.""" + try: + session = _sessions.get(sid) + agent = (session or {}).get("agent") + if session is None or agent is None: + return + _emit("session.info", sid, _session_info(agent, session)) + except Exception: + pass + + def _session_live_item(sid: str, session: dict, current_sid: str = "") -> dict: key = str(session.get("session_key") or sid) agent = session.get("agent") @@ -4624,15 +4652,18 @@ def _(rid, params: dict) -> dict: title = (params.get("title", "") or "").strip() if not title: return _err(rid, 4021, "title required") + sid = str(params.get("session_id") or "") try: if db.set_session_title(key, title): session["pending_title"] = None + _emit_title_refresh(sid) return _ok(rid, {"pending": False, "title": title}) # rowcount == 0 can mean "same value" as well as "missing row". # Queue only when the session row truly does not exist yet. existing_row = db.get_session(key) if existing_row: session["pending_title"] = None + _emit_title_refresh(sid) return _ok( rid, { @@ -4641,6 +4672,7 @@ def _(rid, params: dict) -> dict: }, ) session["pending_title"] = title + _emit_title_refresh(sid) return _ok(rid, {"pending": True, "title": title}) except ValueError as e: return _err(rid, 4022, str(e)) @@ -5997,10 +6029,20 @@ def _run_prompt_submit(rid, sid: str, session: dict, text: Any) -> None: text, raw, session.get("history", []), + # Auto-title lands on a background thread — refresh + # session.info when it does so clients' window-title + # chrome (OSC 0/2) updates without waiting for the + # next turn. _emit is stdout-lock-guarded (thread-safe). + title_callback=lambda _title: _emit_title_refresh(sid), ) except Exception: pass + # The pending title (applied synchronously above) is visible NOW — + # refresh session.info so window-title chrome picks it up. + if status == "complete" and _pending: + _emit_title_refresh(sid) + # CLI parity: when voice-mode TTS is on, speak the agent reply # (cli.py:_voice_speak_response). Only the final text — tool # calls / reasoning already stream separately and would be diff --git a/ui-opentui/src/boundary/schema/SessionInfo.ts b/ui-opentui/src/boundary/schema/SessionInfo.ts index 20c3425ee872..5e56c55931ff 100644 --- a/ui-opentui/src/boundary/schema/SessionInfo.ts +++ b/ui-opentui/src/boundary/schema/SessionInfo.ts @@ -52,6 +52,9 @@ export const SessionInfoPatchSchema = Schema.Struct({ fast: opt(Bool), cwd: opt(Str), branch: opt(Str), + // session title ("" until the first exchange titles it) — drives the + // terminal window-title chrome (OSC 0/2 via renderer.setTerminalTitle). + title: opt(Str), running: opt(Bool), // status-bar chrome extras (Epic 1.3): update banner, profile badge, MCP count. // `update_behind` is null on the wire until the async update check resolves. diff --git a/ui-opentui/src/boundary/termChrome.ts b/ui-opentui/src/boundary/termChrome.ts new file mode 100644 index 000000000000..8b3246c25da3 --- /dev/null +++ b/ui-opentui/src/boundary/termChrome.ts @@ -0,0 +1,106 @@ +/** + * Terminal chrome seam — window title (OSC 0/2) + desktop notifications + * (OSC 9/99/777) through the renderer's native output path. + * + * Why the renderer and not process.stdout: the zig side owns the terminal — + * `setTerminalTitle` is a native FFI call and `writeOut` serializes raw + * control bytes with frame presentation (core itself uses it for OSC 111), + * so chrome writes can never tear a frame. + * + * Focus suppression: core parses mode-1004 focus reports (`ESC[I`/`ESC[O`) + * and re-emits them as renderer `focus`/`blur` events — notifications are + * skipped while the terminal reports focused (you're already looking at it). + * Terminals that never report focus leave the state at the assumed-focused + * initial value… which would swallow every notification, so the FIRST blur + * is what arms suppression: until a blur arrives we treat focus as unknown + * and notify unconditionally (worst case: a redundant ping while focused). + * + * Everything here is total — chrome must never throw into the render loop + * or a teardown path. + */ +import type { CliRenderer } from '@opentui/core' + +import type { TermNotification } from '../logic/termChrome.ts' +import { + notifyEnabled, + notifySequences, + TITLE_STACK_RESTORE, + TITLE_STACK_SAVE, + windowTitleFor +} from '../logic/termChrome.ts' +import { getLog } from './log.ts' + +/** What the view layer needs from the chrome seam (DI-friendly for tests). */ +export interface TerminalChromeSeam { + /** Set the window title from the session title (undefined → generic). */ + readonly setTitle: (sessionTitle: string | undefined) => void + /** Announce "waiting on you" to the hosting terminal (no-op while focused). */ + readonly notify: (notification: TermNotification) => void +} + +/** The renderer surface the seam writes through (runtime-verified shapes). */ +interface RendererSeam { + setTerminalTitle(title: string): void + writeOut(chunk: string): void + on(event: 'focus' | 'blur', listener: () => void): unknown + once(event: 'destroy', listener: () => void): unknown + readonly isDestroyed: boolean +} + +/** Install the chrome seam on a live renderer. Idempotent per renderer use — + * the entry calls it once, right next to the render bridge. */ +export function installTerminalChrome(renderer: CliRenderer): TerminalChromeSeam { + const seam = renderer as unknown as RendererSeam + const notificationsOn = notifyEnabled() + + // unknown (null) until the terminal proves it reports focus; then boolean. + let focused: boolean | null = null + try { + seam.on('focus', () => { + focused = true + }) + seam.on('blur', () => { + focused = false + }) + } catch (cause) { + getLog().warn('chrome', 'focus tracking unavailable', { cause: String(cause) }) + } + + // Bracket our title ownership: save the user's title now, restore on quit. + // Best-effort — terminals without the XTWINOPS title stack ignore both. + writeRaw(seam, TITLE_STACK_SAVE) + seam.once('destroy', () => writeRaw(seam, TITLE_STACK_RESTORE, { evenIfDestroyed: true })) + + let lastTitle = '' + return { + setTitle: sessionTitle => { + const title = windowTitleFor(sessionTitle) + if (title === lastTitle) return + lastTitle = title + try { + if (!seam.isDestroyed) seam.setTerminalTitle(title) + } catch (cause) { + getLog().warn('chrome', 'setTerminalTitle failed', { cause: String(cause) }) + } + }, + notify: notification => { + if (!notificationsOn || focused === true) return + for (const sequence of notifySequences(notification)) writeRaw(seam, sequence) + } + } +} + +/** Raw control write through the renderer; falls back to process.stdout when + * the renderer is already gone (the title-stack restore on destroy — at that + * point there is no frame left to tear). */ +function writeRaw(seam: RendererSeam, chunk: string, options?: { evenIfDestroyed?: boolean }): void { + try { + if (!seam.isDestroyed) { + seam.writeOut(chunk) + return + } + if (options?.evenIfDestroyed) process.stdout.write(chunk) + } catch (cause) { + getLog().warn('chrome', 'control write failed', { cause: String(cause) }) + } +} diff --git a/ui-opentui/src/entry/main.tsx b/ui-opentui/src/entry/main.tsx index a57071520f07..77b9064ca799 100644 --- a/ui-opentui/src/entry/main.tsx +++ b/ui-opentui/src/entry/main.tsx @@ -47,6 +47,7 @@ import { } from '../logic/slash.ts' import { createSessionStore, type SessionStore } from '../logic/store.ts' import { App } from '../view/App.tsx' +import { TerminalChrome } from '../view/terminalChrome.tsx' import type { SessionPickerOps } from '../view/overlays/sessionPicker.tsx' import { ThemeProvider } from '../view/theme.tsx' import { makeFakeGatewayLayer, type FakeGatewayController } from './fakeGateway.ts' @@ -537,6 +538,7 @@ export const run = Effect.fn('Tui.run')(function* (input: TuiInput) { () => ( store.state.theme}> + { if (d.fast !== undefined) patch.fast = d.fast if (d.cwd) patch.cwd = d.cwd if (d.branch) patch.branch = d.branch + if (d.title) patch.title = d.title if (d.running !== undefined) patch.running = d.running // prefer the nested usage.context_* numbers, else the top-level fallback. const used = d.usage?.context_used ?? d.context_used diff --git a/ui-opentui/src/logic/termChrome.ts b/ui-opentui/src/logic/termChrome.ts new file mode 100644 index 000000000000..54150581ab1b --- /dev/null +++ b/ui-opentui/src/logic/termChrome.ts @@ -0,0 +1,105 @@ +/** + * Terminal chrome logic — window-title text and desktop-notification OSC + * sequences. Pure string work (no OpenTUI imports); the boundary shim + * (`boundary/termChrome.ts`) owns the renderer writes and focus tracking. + * + * Title: OSC 0/2 content is set natively via `renderer.setTerminalTitle` + * (the zig side emits the escape) — this module only SHAPES the text: + * `"{session title} — Hermes"` once the gateway titles the session, + * `"Hermes Agent"` until then. + * + * Notifications: emitted when the TUI starts waiting on the user (blocking + * prompt, turn complete). Three dialects, terminals ignore what they don't + * speak: + * OSC 9 `ESC ] 9 ; message BEL` (iTerm2 / ConEmu / wezterm) + * OSC 99 `ESC ] 99 ; i=hermes ; title ST` (kitty desktop-notification + * protocol — `p` defaults to title, `d` defaults to done) + * OSC 777 `ESC ] 777 ; notify ; title ; body BEL` (urxvt / foot) + */ + +const ESC = '\u001b' +const BEL = '\u0007' +const ST = `${ESC}\\` + +/** Strip control chars (C0/C1, incl. ESC/BEL) so user text can never + * terminate or splice an escape sequence; collapse runs of whitespace; + * cap the length. */ +export function sanitizeOscText(text: string, max = 120): string { + const clean = (text ?? '') + // eslint-disable-next-line no-control-regex + .replace(/[\u0000-\u001f\u007f-\u009f]/g, ' ') + .replace(/\s+/g, ' ') + .trim() + return clean.length > max ? clean.slice(0, Math.max(1, max - 1)) + '…' : clean +} + +/** The window-title string: session title when known, Ink-era generic otherwise. */ +export function windowTitleFor(sessionTitle: string | undefined): string { + const title = sanitizeOscText(sessionTitle ?? '', 80) + return title ? `${title} — Hermes` : 'Hermes Agent' +} + +/** A notification's two text parts (body optional). */ +export interface TermNotification { + readonly title: string + readonly body?: string +} + +/** The raw escape sequences announcing `n` to the hosting terminal. OSC 9 and + * 777 conventionally terminate with BEL; kitty's OSC 99 spec uses ST. + * Semicolons are swapped out of the OSC 777 fields (its field separator). */ +export function notifySequences(n: TermNotification): string[] { + const title = sanitizeOscText(n.title) + const body = sanitizeOscText(n.body ?? '') + if (!title) return [] + const combined = body ? `${title}: ${body}` : title + const f777 = (s: string) => s.replace(/;/g, ',') + return [ + `${ESC}]9;${combined}${BEL}`, + `${ESC}]99;i=hermes;${combined}${ST}`, + `${ESC}]777;notify;${f777(title)};${f777(body || title)}${BEL}` + ] +} + +/** The XTWINOPS title-stack pushes/pops bracketing our title ownership: save + * the user's title on install, restore it on teardown (terminals without the + * stack ignore these — they just keep our last title, same as today). */ +export const TITLE_STACK_SAVE = `${ESC}[22;0t` +export const TITLE_STACK_RESTORE = `${ESC}[23;0t` + +/** What to announce for a blocking prompt, by kind. Kinds arrive from the + * store's ActivePrompt union; unknown kinds get the generic line so a new + * prompt type can never silently drop notifications. */ +export function promptNotification(kind: string): TermNotification { + switch (kind) { + case 'clarify': + return { title: 'Hermes', body: 'needs an answer to continue' } + case 'approval': + return { title: 'Hermes', body: 'wants approval to run a command' } + case 'sudo': + return { title: 'Hermes', body: 'needs your sudo password' } + case 'secret': + return { title: 'Hermes', body: 'needs a secret/API key' } + case 'confirm': + return { title: 'Hermes', body: 'is asking you to confirm' } + default: + return { title: 'Hermes', body: 'is waiting for your input' } + } +} + +/** Turn-complete announcement. */ +export const TURN_COMPLETE_NOTIFICATION: TermNotification = { + title: 'Hermes', + body: 'finished — awaiting your input' +} + +/** + * `HERMES_TUI_NOTIFY` kill-switch (TUI-only env var, same family as + * HERMES_TUI_TOOL_OUTPUT_LINES): unset/anything-else = on, `0`/`false`/`off` + * = no notification sequences are ever written. The window title is NOT + * gated by this — it's chrome, not interruption. + */ +export function notifyEnabled(env: { readonly [k: string]: string | undefined } = process.env): boolean { + const raw = (env.HERMES_TUI_NOTIFY ?? '').trim().toLowerCase() + return raw !== '0' && raw !== 'false' && raw !== 'off' +} diff --git a/ui-opentui/src/test/termChrome.test.tsx b/ui-opentui/src/test/termChrome.test.tsx new file mode 100644 index 000000000000..52c765d76464 --- /dev/null +++ b/ui-opentui/src/test/termChrome.test.tsx @@ -0,0 +1,149 @@ +/** + * Terminal chrome: OSC 0/2 window title + OSC 9/99/777 waiting-on-you + * notifications. Layers: + * 1. pure: title shaping, OSC sanitization, the three notification + * dialect sequences, the prompt-kind copy, the env kill-switch + * (logic/termChrome.ts). + * 2. wiring: with an injected fake seam over a real + * store — the title tracks session.info, prompts and turn-completion + * edges notify exactly once, initial state stays silent. + */ +import { createRoot } from 'solid-js' +import { describe, expect, test } from 'vitest' + +import type { TerminalChromeSeam } from '../boundary/termChrome.ts' +import { createSessionStore } from '../logic/store.ts' +import { + notifyEnabled, + notifySequences, + promptNotification, + sanitizeOscText, + TURN_COMPLETE_NOTIFICATION, + windowTitleFor +} from '../logic/termChrome.ts' +import { TerminalChrome } from '../view/terminalChrome.tsx' + +const ESC = '\u001b' +const BEL = '\u0007' + +describe('windowTitleFor — title shaping', () => { + test('generic until the session is titled', () => { + expect(windowTitleFor(undefined)).toBe('Hermes Agent') + expect(windowTitleFor('')).toBe('Hermes Agent') + expect(windowTitleFor(' ')).toBe('Hermes Agent') + }) + + test('session title gets the — Hermes suffix', () => { + expect(windowTitleFor('fix the flaky tests')).toBe('fix the flaky tests — Hermes') + }) + + test('long titles are capped', () => { + const long = 'x'.repeat(200) + expect(windowTitleFor(long).length).toBeLessThanOrEqual(80 + ' — Hermes'.length) + expect(windowTitleFor(long)).toContain('…') + }) +}) + +describe('sanitizeOscText — escape-splice safety', () => { + test('control chars (incl. ESC/BEL) can never splice a sequence', () => { + expect(sanitizeOscText(`evil${ESC}]0;pwned${BEL}title`)).toBe('evil ]0;pwned title') + }) + + test('whitespace collapses; ends trimmed', () => { + expect(sanitizeOscText(' a\n\tb ')).toBe('a b') + }) +}) + +describe('notifySequences — the three dialects', () => { + test('emits OSC 9, kitty OSC 99, and OSC 777', () => { + const [osc9, osc99, osc777] = notifySequences({ title: 'Hermes', body: 'finished' }) + expect(osc9).toBe(`${ESC}]9;Hermes: finished${BEL}`) + expect(osc99).toBe(`${ESC}]99;i=hermes;Hermes: finished${ESC}\\`) + expect(osc777).toBe(`${ESC}]777;notify;Hermes;finished${BEL}`) + }) + + test('semicolons cannot splice OSC 777 fields', () => { + const [, , osc777] = notifySequences({ title: 'a;b', body: 'c;d' }) + expect(osc777).toBe(`${ESC}]777;notify;a,b;c,d${BEL}`) + }) + + test('an empty title produces nothing', () => { + expect(notifySequences({ title: ' ' })).toEqual([]) + }) +}) + +describe('promptNotification + env gate', () => { + test('every known prompt kind has copy; unknown kinds fall back', () => { + for (const kind of ['clarify', 'approval', 'sudo', 'secret', 'confirm', 'someday-new']) { + const n = promptNotification(kind) + expect(n.title).toBe('Hermes') + expect(n.body).toBeTruthy() + } + expect(promptNotification('someday-new').body).toBe('is waiting for your input') + }) + + test('HERMES_TUI_NOTIFY=0/false/off disables; default on', () => { + expect(notifyEnabled({})).toBe(true) + expect(notifyEnabled({ HERMES_TUI_NOTIFY: '1' })).toBe(true) + expect(notifyEnabled({ HERMES_TUI_NOTIFY: '0' })).toBe(false) + expect(notifyEnabled({ HERMES_TUI_NOTIFY: 'false' })).toBe(false) + expect(notifyEnabled({ HERMES_TUI_NOTIFY: 'off' })).toBe(false) + }) +}) + +describe(' wiring — store edges drive the seam', () => { + function mount() { + const store = createSessionStore() + const titles: Array = [] + const notifications: string[] = [] + const seam: TerminalChromeSeam = { + setTitle: t => titles.push(t), + notify: n => notifications.push(n.body ?? n.title) + } + const dispose = createRoot(d => { + TerminalChrome({ chrome: seam, store }) + return d + }) + return { dispose, notifications, store, titles } + } + + test('sets the generic title immediately, then tracks session.info title', () => { + const { dispose, store, titles } = mount() + try { + expect(titles).toEqual([undefined]) // boot → windowTitleFor(undefined) inside the seam + store.apply({ type: 'session.info', payload: { title: 'rename the moon' } }) + expect(titles).toEqual([undefined, 'rename the moon']) + } finally { + dispose() + } + }) + + test('a blocking prompt notifies once; initial state is silent', () => { + const { dispose, notifications, store } = mount() + try { + expect(notifications).toEqual([]) + store.apply({ + type: 'clarify.request', + payload: { choices: null, question: 'which one?', request_id: 'r1' } + }) + expect(notifications).toEqual(['needs an answer to continue']) + // clearing the prompt does not notify again + store.clearPrompt() + expect(notifications).toEqual(['needs an answer to continue']) + } finally { + dispose() + } + }) + + test('turn completion (running true→false) notifies; boot idle does not', () => { + const { dispose, notifications, store } = mount() + try { + store.apply({ type: 'message.start' }) + expect(notifications).toEqual([]) + store.apply({ type: 'message.complete', payload: { text: 'done' } }) + expect(notifications).toEqual([TURN_COMPLETE_NOTIFICATION.body]) + } finally { + dispose() + } + }) +}) diff --git a/ui-opentui/src/view/terminalChrome.tsx b/ui-opentui/src/view/terminalChrome.tsx new file mode 100644 index 000000000000..748f231a25c2 --- /dev/null +++ b/ui-opentui/src/view/terminalChrome.tsx @@ -0,0 +1,61 @@ +/** + * — render-nothing wiring between store state and the + * terminal-chrome seam (window title + waiting-on-you notifications). + * + * title: tracks `info.title` — "Hermes Agent" until the gateway titles + * the session (first-exchange auto-title / picker rename), then + * "{title} — Hermes". Runs immediately on mount so the generic + * title is set at boot. + * notify: fires on the EDGES the user must act on — + * · a blocking prompt appearing (clarify/approval/sudo/secret/ + * confirm — `state.prompt` undefined → defined), and + * · a turn finishing (`info.running` true → false). + * Both deferred (no notification for the initial state) and + * de-duplicated by the seam's focus gate. + * + * The seam is injectable for headless tests; production resolves it from + * the live renderer via useRenderer(). + */ +import { useRenderer } from '@opentui/solid' +import { createEffect, on } from 'solid-js' + +import { installTerminalChrome, type TerminalChromeSeam } from '../boundary/termChrome.ts' +import type { createSessionStore } from '../logic/store.ts' +import { promptNotification, TURN_COMPLETE_NOTIFICATION } from '../logic/termChrome.ts' + +type Store = ReturnType + +export function TerminalChrome(props: { store: Store; chrome?: TerminalChromeSeam }) { + // Injected seam (tests) skips useRenderer() — headless mounts have no + // opentui context to read. + const chrome = props.chrome ?? installTerminalChrome(useRenderer()) + + // Window title — immediate (sets the generic title at boot) and reactive. + createEffect(() => { + chrome.setTitle(props.store.state.info.title) + }) + + // Blocking prompt appeared → the agent is waiting on the user. + createEffect( + on( + () => props.store.state.prompt, + (prompt, previous) => { + if (prompt && !previous) chrome.notify(promptNotification(prompt.kind)) + }, + { defer: true } + ) + ) + + // Turn finished → control is back with the user. + createEffect( + on( + () => props.store.state.info.running, + (running, previous) => { + if (previous === true && running === false) chrome.notify(TURN_COMPLETE_NOTIFICATION) + }, + { defer: true } + ) + ) + + return null +}