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:
alt-glitch 2026-06-12 13:09:23 +05:30
parent 3d7a64c383
commit e9fe618fce
9 changed files with 517 additions and 0 deletions

View file

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

View file

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

View file

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

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

View file

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

View file

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

View 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'
}

View 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()
}
})
})

View 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
}