mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-10-20 05:15:31 +00:00
opentui(v6): terminal window title (OSC 0/2) + waiting-on-you notifications (OSC 9/99/777)
Window title: a render-nothing <TerminalChrome> 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.
This commit is contained in:
parent
3d7a64c383
commit
e9fe618fce
9 changed files with 517 additions and 0 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
106
ui-opentui/src/boundary/termChrome.ts
Normal file
106
ui-opentui/src/boundary/termChrome.ts
Normal file
|
|
@ -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) })
|
||||
}
|
||||
}
|
||||
|
|
@ -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) {
|
|||
() => (
|
||||
<KeymapProvider keymap={keymap}>
|
||||
<ThemeProvider theme={() => store.state.theme}>
|
||||
<TerminalChrome store={store} />
|
||||
<App
|
||||
store={store}
|
||||
onSubmit={submit}
|
||||
|
|
|
|||
|
|
@ -181,6 +181,9 @@ export interface SessionInfo {
|
|||
fast?: boolean
|
||||
cwd?: string
|
||||
branch?: string
|
||||
/** Session title (auto-titled after the first exchange / renamed via the
|
||||
* picker) — drives the terminal window-title chrome; unset until titled. */
|
||||
title?: string
|
||||
running?: boolean
|
||||
contextUsed?: number
|
||||
contextMax?: number
|
||||
|
|
@ -324,6 +327,7 @@ function infoPatchFrom(d: SessionInfoPatchDecoded): Partial<SessionInfo> {
|
|||
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
|
||||
|
|
|
|||
105
ui-opentui/src/logic/termChrome.ts
Normal file
105
ui-opentui/src/logic/termChrome.ts
Normal file
|
|
@ -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'
|
||||
}
|
||||
149
ui-opentui/src/test/termChrome.test.tsx
Normal file
149
ui-opentui/src/test/termChrome.test.tsx
Normal file
|
|
@ -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: <TerminalChrome> 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('<TerminalChrome> wiring — store edges drive the seam', () => {
|
||||
function mount() {
|
||||
const store = createSessionStore()
|
||||
const titles: Array<string | undefined> = []
|
||||
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()
|
||||
}
|
||||
})
|
||||
})
|
||||
61
ui-opentui/src/view/terminalChrome.tsx
Normal file
61
ui-opentui/src/view/terminalChrome.tsx
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
/**
|
||||
* <TerminalChrome> — 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<typeof createSessionStore>
|
||||
|
||||
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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue