From 5988e21ed74d8b0effdadd71af61968243741649 Mon Sep 17 00:00:00 2001 From: alt-glitch Date: Sat, 13 Jun 2026 21:44:27 +0530 Subject: [PATCH] =?UTF-8?q?opentui(v6):=20background-activity=20P1=20?= =?UTF-8?q?=E2=80=94=20inline=20notification=20cards=20+=20OSC=20(no=20cor?= =?UTF-8?q?e=20change)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The TUI now consumes notification.show/clear gateway events (it dropped them before — they leaked into the transcript as plain model-output-looking lines, the qxpe pain). They render as a distinct, level-tinted inline card (role 'notification'): gold ◆ for info, amber for warn, red for error — clearly chrome, not the agent. Important ones (error/warn/'complete'|'done'|'finish' kinds) also fire the EXISTING focus-gated OSC desktop notification via termChrome. Shared substrate (pure, unit-tested) for the rest of the campaign: - logic/backgroundActivity.ts — parse notification.show/agents.list payloads, dedupe-by-id upsert, clear-by-key, runningCount (badge, used in P3). - logic/notificationDispatcher.ts — card-always + OSC-when-important decision. - store: notification.show → pushNotification (distinct clones to avoid Solid createStore reference-aliasing), notification.clear → drop matching cards, lastNotification → OSC seam (terminalChrome). All TUI-layer; builds only on events/RPCs the gateway already emits. Gate green (737 tests incl. new unit + frame coverage); verified live in tmux. --- ui-opentui/src/logic/backgroundActivity.ts | 150 ++++++++++++++++ .../src/logic/notificationDispatcher.ts | 38 ++++ ui-opentui/src/logic/store.ts | 50 +++++- .../src/test/backgroundActivity.test.ts | 161 +++++++++++++++++ ui-opentui/src/test/notificationCard.test.tsx | 62 +++++++ .../src/test/notificationDispatcher.test.ts | 43 +++++ ui-opentui/src/view/messageLine.tsx | 163 ++++++++++-------- ui-opentui/src/view/notificationCard.tsx | 41 +++++ ui-opentui/src/view/terminalChrome.tsx | 16 ++ 9 files changed, 647 insertions(+), 77 deletions(-) create mode 100644 ui-opentui/src/logic/backgroundActivity.ts create mode 100644 ui-opentui/src/logic/notificationDispatcher.ts create mode 100644 ui-opentui/src/test/backgroundActivity.test.ts create mode 100644 ui-opentui/src/test/notificationCard.test.tsx create mode 100644 ui-opentui/src/test/notificationDispatcher.test.ts create mode 100644 ui-opentui/src/view/notificationCard.tsx diff --git a/ui-opentui/src/logic/backgroundActivity.ts b/ui-opentui/src/logic/backgroundActivity.ts new file mode 100644 index 00000000000..09d401aae94 --- /dev/null +++ b/ui-opentui/src/logic/backgroundActivity.ts @@ -0,0 +1,150 @@ +/** + * Background-activity logic — pure parsers + derive helpers for the "ambient + * activity" feature (notifications, long-running processes, background runs). + * No state container here: the store owns the arrays; these functions parse + * loose wire payloads (everything off the gateway is `unknown`) and compute + * derived values over immutable arrays. Mirrors the defensive loose-read style + * of `logic/slash.ts` (`readStr`) and the snake_case→camel mapping the wire + * needs. + * + * Wire shapes (see boundary/schema/GatewayEvent.ts ~134): + * notification.show payload {text, level, kind, ttl_ms, key, id} (loose Record) + * notification.clear payload {key} + * agents.list result {processes:[{session_id, command, status, uptime_seconds}]} + */ + +export interface ActivityNotification { + id: string + key?: string + text: string + level: 'info' | 'warn' | 'error' + kind: string + ttlMs?: number +} + +export interface BackgroundProcess { + sessionId: string + command: string + status: string + uptimeSeconds: number +} + +export interface BackgroundRun { + id: string + label: string + status: 'running' | 'complete' | 'failed' | 'cancelled' + startedAt?: number + summary?: string +} + +/** Loose-read a string field off an `unknown` object (slash.ts `readStr` style). */ +function readStr(value: unknown, key: string): string | undefined { + if (!value || typeof value !== 'object') return undefined + const v = (value as { [k: string]: unknown })[key] + return typeof v === 'string' ? v : undefined +} + +/** Loose-read a finite number off an `unknown` object. */ +function readNum(value: unknown, key: string): number | undefined { + if (!value || typeof value !== 'object') return undefined + const v = (value as { [k: string]: unknown })[key] + return typeof v === 'number' && Number.isFinite(v) ? v : undefined +} + +/** Coerce any wire `level` to the closed union; anything that isn't a known + * level (absent, garbage, wrong-typed) falls back to 'info'. */ +function coerceLevel(value: unknown): ActivityNotification['level'] { + return value === 'warn' || value === 'error' ? value : 'info' +} + +/** + * Parse a `notification.show` payload (unknown) → ActivityNotification, or null + * when there's no usable text (text is the load-bearing field — without it the + * card has nothing to show). Maps snake_case `ttl_ms` → `ttlMs`, coerces a + * garbage/missing `level` to 'info', and defaults `kind` to ''. + * + * id resolution (dedupe key): prefer the wire `id`, then fall back to `key`. If + * BOTH are missing we synthesize `id = `n:${text}`` rather than minting a random + * id — a random id would make every re-emit of the same text a NEW card, so a + * text-derived stable id keeps dedupe (upsertNotification) working for + * gateways that don't send ids. The original `key` (if any) is preserved + * separately so notification.clear by key still targets the right rows. + */ +export function parseNotification(payload: unknown): ActivityNotification | null { + const text = readStr(payload, 'text') + if (!text) return null + const key = readStr(payload, 'key') + const id = readStr(payload, 'id') ?? key ?? `n:${text}` + const out: ActivityNotification = { + id, + kind: readStr(payload, 'kind') ?? '', + level: coerceLevel((payload as { level?: unknown } | null | undefined)?.level), + text + } + if (key !== undefined) out.key = key + const ttlMs = readNum(payload, 'ttl_ms') + if (ttlMs !== undefined) out.ttlMs = ttlMs + return out +} + +/** Dedupe-by-id upsert: replace an existing item with the same id, else append. + * Returns a NEW array (never mutates the input). */ +export function upsertNotification( + list: readonly ActivityNotification[], + n: ActivityNotification +): ActivityNotification[] { + const idx = list.findIndex(item => item.id === n.id) + if (idx === -1) return [...list, n] + const next = list.slice() + next[idx] = n + return next +} + +/** Drop every notification whose `key` matches (notification.clear). Returns a + * NEW array. Notifications without a key are never cleared this way. */ +export function clearNotificationsByKey(list: readonly ActivityNotification[], key: string): ActivityNotification[] { + return list.filter(n => n.key !== key) +} + +/** Parse an `agents.list` result ({processes:[...]}) → BackgroundProcess[], + * skipping malformed rows (a row missing session_id/command is dropped, not + * defaulted). snake_case `session_id`/`uptime_seconds` → camelCase; a missing + * uptime defaults to 0, a missing status to ''. */ +export function parseProcessList(result: unknown): BackgroundProcess[] { + if (!result || typeof result !== 'object') return [] + const processes = (result as { processes?: unknown }).processes + if (!Array.isArray(processes)) return [] + const out: BackgroundProcess[] = [] + for (const row of processes) { + const sessionId = readStr(row, 'session_id') + const command = readStr(row, 'command') + if (!sessionId || !command) continue + out.push({ + command, + sessionId, + status: readStr(row, 'status') ?? '', + uptimeSeconds: readNum(row, 'uptime_seconds') ?? 0 + }) + } + return out +} + +/** Terminal (no-longer-running) process statuses. A process whose status is + * NOT one of these is treated as running — leniently, because the gateway's + * status vocabulary is open-ended and we'd rather over-count the ambient badge + * than silently hide a still-live process under an unfamiliar status string. + * Matched case-insensitively after trimming. */ +const DONE_STATUSES = new Set(['exited', 'failed', 'complete', 'done', 'killed']) + +function procIsRunning(status: string): boolean { + return !DONE_STATUSES.has(status.trim().toLowerCase()) +} + +/** Count of "currently running" things for the ambient badge: runs whose + * status is 'running', plus processes whose status is running-ish (anything + * that isn't a terminal status — see DONE_STATUSES). */ +export function runningCount(runs: readonly BackgroundRun[], procs: readonly BackgroundProcess[]): number { + const runningRuns = runs.filter(r => r.status === 'running').length + const runningProcs = procs.filter(p => procIsRunning(p.status)).length + return runningRuns + runningProcs +} diff --git a/ui-opentui/src/logic/notificationDispatcher.ts b/ui-opentui/src/logic/notificationDispatcher.ts new file mode 100644 index 00000000000..7060f64441e --- /dev/null +++ b/ui-opentui/src/logic/notificationDispatcher.ts @@ -0,0 +1,38 @@ +/** + * Notification channel decision — pure routing for an ActivityNotification. + * Every notification gets the inline transcript card; only "important" ones + * additionally fire a desktop/terminal OSC notification (to pull the user back + * to the terminal). The OSC payload shape is termChrome's `TermNotification`; + * the boundary owns the actual escape-sequence write (termChrome.notifySequences). + */ +import type { ActivityNotification } from './backgroundActivity.ts' +import type { TermNotification } from './termChrome.ts' + +export interface NotificationChannels { + /** Always true — every notification gets the inline transcript card. */ + card: boolean + /** Present only for "terminal/important" notifications (see notificationChannels). */ + osc?: TermNotification +} + +/** Kind substrings that mark a "the work finished, look here" notification — + * matched case-insensitively anywhere in the kind. */ +const COMPLETION_KIND_HINTS = ['complete', 'done', 'finish'] + +function isImportant(n: ActivityNotification): boolean { + if (n.level === 'error' || n.level === 'warn') return true + const kind = n.kind.toLowerCase() + return COMPLETION_KIND_HINTS.some(hint => kind.includes(hint)) +} + +/** + * Decide the output channels for `n`: ALWAYS the card; ADD an OSC desktop + * notification when the notification is important enough to interrupt — + * level 'error'/'warn', or a kind containing 'complete'/'done'/'finish' + * (case-insensitive). The OSC always titles 'Hermes' with the notification + * text as the body. + */ +export function notificationChannels(n: ActivityNotification): NotificationChannels { + if (!isImportant(n)) return { card: true } + return { card: true, osc: { body: n.text, title: 'Hermes' } } +} diff --git a/ui-opentui/src/logic/store.ts b/ui-opentui/src/logic/store.ts index eb156d19951..d9974827759 100644 --- a/ui-opentui/src/logic/store.ts +++ b/ui-opentui/src/logic/store.ts @@ -26,6 +26,7 @@ import { diffStats, type DiffStats } from './diff.ts' import type { SessionTabId } from './sessionPicker.ts' import { envFlag, envOutputUnlimited } from './env.ts' import { registerNotifier } from './notify.ts' +import { parseNotification, type ActivityNotification } from './backgroundActivity.ts' import { stripAnsi, stripOmittedNote, stripToolEnvelope } from './toolOutput.ts' import { DEFAULT_THEME, type Theme, themeFromSkin } from './theme.ts' @@ -74,12 +75,15 @@ export type Part = | ToolPartState export interface Message { - readonly role: 'user' | 'assistant' | 'system' + readonly role: 'user' | 'assistant' | 'system' | 'notification' /** Flat body for user/system rows (and settled/resumed assistant rows). */ text: string /** Ordered parts for a live assistant turn; absent for user/system. */ parts?: Part[] streaming?: boolean + /** Background-activity card payload (role `'notification'` only) — rendered as + * an inline NotificationCard instead of a normal role row. */ + notification?: ActivityNotification } /** @@ -249,6 +253,10 @@ export interface StoreState { /** Transient busy indicator (the kaomoji face/verb from `thinking.delta`/`status.update`); * shown above the composer WHILE a turn runs, cleared on `message.complete`. NOT transcript. */ status: string | undefined + /** Most recent background-activity notification (`notification.show`) — the OSC + * seam (terminalChrome) watches this to fire a desktop ping; the inline card + * lives in `messages`. Undefined until the first notification. */ + lastNotification: ActivityNotification | undefined /** Live session chrome for the status bar (model/effort/cwd/branch/context/running). */ info: SessionInfo /** Transient hint shown above the composer (e.g. "Ctrl+C again to quit" — item 11); @@ -453,6 +461,7 @@ export function createSessionStore(options?: SessionStoreOptions) { subagents: [], dashboard: false, dashboardAgent: undefined, + lastNotification: undefined, status: undefined, // startedAt is set ONCE here (store creation ≈ session start) — the status // bar's session-duration segment ticks from it; wire patches never carry it. @@ -576,6 +585,31 @@ export function createSessionStore(options?: SessionStoreOptions) { ) } + /** Push a background-activity notification as a distinct inline card (role + * `'notification'`) and record it as `lastNotification` for the OSC seam. + * NOT a plain system line — the card renders level-tinted, clearly chrome. */ + function pushNotification(n: ActivityNotification) { + // Store INDEPENDENT clones in the message vs lastNotification: createStore + // wraps a shared object reference into one node, which would alias every + // card to the most-recent notification (the message text stays right, but the + // nested payload bleeds). Distinct copies keep each card's payload its own. + setState( + produce(draft => { + draft.messages.push({ role: 'notification', text: n.text, notification: { ...n } }) + capMessages(draft) + }) + ) + setState('lastNotification', { ...n }) + } + + /** Drop the inline cards for a cleared notification key (`notification.clear`). */ + function clearNotificationCards(key: string) { + setState( + 'messages', + state.messages.filter(m => !(m.role === 'notification' && m.notification?.key === key)) + ) + } + /** Clear the transcript (e.g. /clear, /new) and any tracked subagents. */ function clearTranscript() { setState('messages', []) @@ -754,6 +788,19 @@ export function createSessionStore(options?: SessionStoreOptions) { if (text) setState('status', text) break } + // notification.show — background-activity notice (process/run state change, + // credits, etc.). Renders as a distinct inline card (NOT a plain line) and + // records lastNotification so the OSC seam can ping a blurred terminal. + case 'notification.show': { + const n = parseNotification(event.payload) + if (n) pushNotification(n) + break + } + case 'notification.clear': { + const key = event.payload?.key + if (key) clearNotificationCards(key) + break + } // reasoning.delta is the model's actual reasoning — a (dim) transcript part. case 'reasoning.delta': { const text = event.payload?.text ?? '' @@ -1045,6 +1092,7 @@ export function createSessionStore(options?: SessionStoreOptions) { apply, pushUser, pushSystem, + pushNotification, setCatalog, setSessionId, clearTranscript, diff --git a/ui-opentui/src/test/backgroundActivity.test.ts b/ui-opentui/src/test/backgroundActivity.test.ts new file mode 100644 index 00000000000..a973f2e9e8d --- /dev/null +++ b/ui-opentui/src/test/backgroundActivity.test.ts @@ -0,0 +1,161 @@ +/** + * Background-activity logic tests — pure parsers + derive helpers. Everything + * off the wire is `unknown`, so the parsers must defend against garbage/missing + * fields and map snake_case → camelCase. + */ +import { describe, expect, test } from 'vitest' + +import { + type ActivityNotification, + type BackgroundProcess, + type BackgroundRun, + clearNotificationsByKey, + parseNotification, + parseProcessList, + runningCount, + upsertNotification +} from '../logic/backgroundActivity.ts' + +describe('parseNotification', () => { + test('happy path: full payload, snake_case ttl_ms → ttlMs', () => { + expect( + parseNotification({ id: 'job-1', key: 'k1', kind: 'task.complete', level: 'warn', text: 'done', ttl_ms: 5000 }) + ).toEqual({ id: 'job-1', key: 'k1', kind: 'task.complete', level: 'warn', text: 'done', ttlMs: 5000 }) + }) + + test('garbage / missing level coerces to info; missing kind → ""', () => { + expect(parseNotification({ id: 'a', level: 'screaming', text: 'hi' })).toEqual({ + id: 'a', + kind: '', + level: 'info', + text: 'hi' + }) + expect(parseNotification({ id: 'b', text: 'no level' })?.level).toBe('info') + }) + + test('missing/empty text → null (text is load-bearing for the card)', () => { + expect(parseNotification({ id: 'a', level: 'info' })).toBeNull() + expect(parseNotification({ id: 'a', text: '' })).toBeNull() + expect(parseNotification(null)).toBeNull() + expect(parseNotification('nope')).toBeNull() + }) + + test('id falls back to key when id is absent', () => { + const n = parseNotification({ key: 'k-only', text: 'hello' }) + expect(n?.id).toBe('k-only') + expect(n?.key).toBe('k-only') + }) + + test('no id and no key → synthesized id `n:${text}` so dedupe still works', () => { + const n = parseNotification({ text: 'build finished' }) + expect(n?.id).toBe('n:build finished') + expect(n?.key).toBeUndefined() + // two re-emits of the same text dedupe to one row + const list = upsertNotification(upsertNotification([], n!), parseNotification({ text: 'build finished' })!) + expect(list).toHaveLength(1) + }) + + test('id is preferred over key when both present', () => { + expect(parseNotification({ id: 'real', key: 'k', text: 'x' })?.id).toBe('real') + }) + + test('non-number ttl_ms is dropped (no ttlMs)', () => { + const n = parseNotification({ id: 'a', text: 'x', ttl_ms: 'soon' }) + expect(n?.ttlMs).toBeUndefined() + }) +}) + +describe('upsertNotification', () => { + const base: ActivityNotification = { id: 'a', kind: '', level: 'info', text: 'first' } + + test('appends a new id, returns a NEW array', () => { + const list: readonly ActivityNotification[] = [base] + const next = upsertNotification(list, { id: 'b', kind: '', level: 'info', text: 'second' }) + expect(next).toHaveLength(2) + expect(next).not.toBe(list) + expect(list).toHaveLength(1) // input untouched + }) + + test('replaces an existing id in place (dedupe)', () => { + const list: ActivityNotification[] = [base, { id: 'b', kind: '', level: 'info', text: 'second' }] + const next = upsertNotification(list, { id: 'a', kind: 'x', level: 'error', text: 'updated' }) + expect(next).toHaveLength(2) + expect(next[0]).toEqual({ id: 'a', kind: 'x', level: 'error', text: 'updated' }) + expect(next[1]!.id).toBe('b') + }) +}) + +describe('clearNotificationsByKey', () => { + test('removes all with the matching key; keeps others and keyless rows', () => { + const list: ActivityNotification[] = [ + { id: '1', key: 'k', kind: '', level: 'info', text: 'a' }, + { id: '2', key: 'other', kind: '', level: 'info', text: 'b' }, + { id: '3', kind: '', level: 'info', text: 'c' }, + { id: '4', key: 'k', kind: '', level: 'info', text: 'd' } + ] + const next = clearNotificationsByKey(list, 'k') + expect(next.map(n => n.id)).toEqual(['2', '3']) + expect(next).not.toBe(list) + }) +}) + +describe('parseProcessList', () => { + test('maps good rows, snake_case → camelCase', () => { + expect( + parseProcessList({ + processes: [ + { command: 'npm test', session_id: 's1', status: 'running', uptime_seconds: 12 }, + { command: 'build', session_id: 's2', status: 'exited', uptime_seconds: 99 } + ] + }) + ).toEqual([ + { command: 'npm test', sessionId: 's1', status: 'running', uptimeSeconds: 12 }, + { command: 'build', sessionId: 's2', status: 'exited', uptimeSeconds: 99 } + ]) + }) + + test('skips malformed rows (missing session_id or command); defaults status/uptime', () => { + expect( + parseProcessList({ + processes: [ + { command: 'ok', session_id: 's1' }, // no status/uptime → defaults + { command: 'no-session' }, // dropped + { session_id: 's3' }, // dropped + null, // dropped + 'garbage' // dropped + ] + }) + ).toEqual([{ command: 'ok', sessionId: 's1', status: '', uptimeSeconds: 0 }]) + }) + + test('non-object / missing processes → []', () => { + expect(parseProcessList(null)).toEqual([]) + expect(parseProcessList({})).toEqual([]) + expect(parseProcessList({ processes: 'nope' })).toEqual([]) + }) +}) + +describe('runningCount', () => { + const runs: BackgroundRun[] = [ + { id: 'r1', label: 'A', status: 'running' }, + { id: 'r2', label: 'B', status: 'complete' }, + { id: 'r3', label: 'C', status: 'running' }, + { id: 'r4', label: 'D', status: 'failed' } + ] + const procs: BackgroundProcess[] = [ + { command: 'a', sessionId: 's1', status: 'running', uptimeSeconds: 1 }, + { command: 'b', sessionId: 's2', status: 'exited', uptimeSeconds: 1 }, + { command: 'c', sessionId: 's3', status: 'Sleeping', uptimeSeconds: 1 }, // unknown → running (lenient) + { command: 'd', sessionId: 's4', status: 'DONE', uptimeSeconds: 1 }, // case-insensitive terminal + { command: 'e', sessionId: 's5', status: 'killed', uptimeSeconds: 1 } + ] + + test('counts running runs + running-ish processes (lenient on unknown statuses)', () => { + // runs: r1, r3 = 2; procs: running + Sleeping = 2 (exited/DONE/killed excluded) + expect(runningCount(runs, procs)).toBe(4) + }) + + test('empty inputs → 0', () => { + expect(runningCount([], [])).toBe(0) + }) +}) diff --git a/ui-opentui/src/test/notificationCard.test.tsx b/ui-opentui/src/test/notificationCard.test.tsx new file mode 100644 index 00000000000..a834252148f --- /dev/null +++ b/ui-opentui/src/test/notificationCard.test.tsx @@ -0,0 +1,62 @@ +/** + * Background-activity notifications (P1) — the inline card + store wiring. + * 1. store: notification.show → a distinct `notification` message + lastNotification; + * notification.clear {key} drops the matching card; bad payloads are ignored. + * 2. frame: NotificationCard renders the `◆` marker + kind + text (distinct chrome). + */ +import { describe, expect, test } from 'vitest' + +import { createSessionStore } from '../logic/store.ts' +import { NotificationCard } from '../view/notificationCard.tsx' +import { ThemeProvider } from '../view/theme.tsx' +import { captureFrame } from './lib/render.ts' + +describe('notification store wiring', () => { + test('notification.show pushes a distinct notification card + records lastNotification', () => { + const store = createSessionStore() + store.apply({ type: 'gateway.ready' }) + store.apply({ + type: 'notification.show', + payload: { text: 'dev server ready on :3000', level: 'info', kind: 'process.complete', key: 'p1', id: 'n1' } + }) + const last = store.state.messages.at(-1) + expect(last?.role).toBe('notification') + expect(last?.notification?.text).toBe('dev server ready on :3000') + expect(last?.notification?.kind).toBe('process.complete') + expect(store.state.lastNotification?.id).toBe('n1') + }) + + test('notification.clear {key} drops only the matching card', () => { + const store = createSessionStore() + store.apply({ type: 'gateway.ready' }) + store.apply({ type: 'notification.show', payload: { text: 'a', key: 'k1', id: 'n1' } }) + store.apply({ type: 'notification.show', payload: { text: 'b', key: 'k2', id: 'n2' } }) + store.apply({ type: 'notification.clear', payload: { key: 'k1' } }) + const notifs = store.state.messages.filter(m => m.role === 'notification') + expect(notifs).toHaveLength(1) + expect(notifs[0]?.notification?.key).toBe('k2') + }) + + test('a notification with no text is ignored (no empty card)', () => { + const store = createSessionStore() + store.apply({ type: 'gateway.ready' }) + store.apply({ type: 'notification.show', payload: { level: 'warn', kind: 'x' } }) + expect(store.state.messages.some(m => m.role === 'notification')).toBe(false) + }) +}) + +describe('NotificationCard frame', () => { + test('renders the ◆ marker, the kind label, and the text', async () => { + const frame = await captureFrame( + () => ( + createSessionStore().state.theme}> + + + ), + { width: 60, height: 4 } + ) + expect(frame).toContain('◆') + expect(frame).toContain('task.done') + expect(frame).toContain('build finished') + }) +}) diff --git a/ui-opentui/src/test/notificationDispatcher.test.ts b/ui-opentui/src/test/notificationDispatcher.test.ts new file mode 100644 index 00000000000..81db216a7d7 --- /dev/null +++ b/ui-opentui/src/test/notificationDispatcher.test.ts @@ -0,0 +1,43 @@ +/** + * Notification channel-decision tests. Card is always on; OSC fires only for + * important notifications (error/warn level, or a completion-ish kind). + */ +import { describe, expect, test } from 'vitest' + +import type { ActivityNotification } from '../logic/backgroundActivity.ts' +import { notificationChannels } from '../logic/notificationDispatcher.ts' + +function notif(over: Partial): ActivityNotification { + return { id: 'n', kind: '', level: 'info', text: 'something happened', ...over } +} + +describe('notificationChannels', () => { + test('card is always true', () => { + expect(notificationChannels(notif({})).card).toBe(true) + expect(notificationChannels(notif({ level: 'error' })).card).toBe(true) + }) + + test('plain info → no osc', () => { + expect(notificationChannels(notif({ level: 'info', kind: 'progress' })).osc).toBeUndefined() + }) + + test('error and warn levels fire osc', () => { + expect(notificationChannels(notif({ level: 'error' })).osc).toBeDefined() + expect(notificationChannels(notif({ level: 'warn' })).osc).toBeDefined() + }) + + test('completion-ish kinds fire osc, case-insensitive (complete/done/finish)', () => { + expect(notificationChannels(notif({ kind: 'task.complete' })).osc).toBeDefined() + expect(notificationChannels(notif({ kind: 'JOB_DONE' })).osc).toBeDefined() + expect(notificationChannels(notif({ kind: 'Finished' })).osc).toBeDefined() + // substring match anywhere in the kind + expect(notificationChannels(notif({ kind: 'agent.run.completed' })).osc).toBeDefined() + // a non-completion info kind stays card-only + expect(notificationChannels(notif({ kind: 'started' })).osc).toBeUndefined() + }) + + test('osc body == text, title == "Hermes"', () => { + const ch = notificationChannels(notif({ level: 'error', text: 'build broke' })) + expect(ch.osc).toEqual({ body: 'build broke', title: 'Hermes' }) + }) +}) diff --git a/ui-opentui/src/view/messageLine.tsx b/ui-opentui/src/view/messageLine.tsx index d00bde83f6c..af9c964ac01 100644 --- a/ui-opentui/src/view/messageLine.tsx +++ b/ui-opentui/src/view/messageLine.tsx @@ -38,6 +38,7 @@ import type { Message, Part } from '../logic/store.ts' import type { ThemeColors } from '../logic/theme.ts' import { useDisplay } from './display.tsx' import { Markdown } from './markdown.tsx' +import { NotificationCard } from './notificationCard.tsx' import { ReasoningPart } from './reasoningPart.tsx' import { useTheme } from './theme.tsx' import { ToolPart } from './toolPart.tsx' @@ -126,98 +127,108 @@ export function MessageLine(props: { message: Message; latest?: boolean }) { const textFg = (id: string) => !m().streaming && id !== lastTextId(m().parts) ? theme().color.muted : theme().color.text + // A `notification` row is background-activity chrome — render the distinct + // inline card instead of the normal role row (glitch 2026-06-13). + const notif = () => (m().role === 'notification' ? m().notification : undefined) return ( - // Turn-boundary spacing > part gap (see turnSpacing); /compact collapses it - // so long sessions read denser. The earned-gold glyphs do the rest. - - - {/* the role glyph is decorative — exclude it from mouse selection (item 4). + part gap (see turnSpacing); /compact collapses it + // so long sessions read denser. The earned-gold glyphs do the rest. + + + {/* the role glyph is decorative — exclude it from mouse selection (item 4). Bold so the user `❯` / assistant `⚕` turn boundaries pop (item 8). */} - - - {glyph()} - - - - {/* gap owns ALL inter-part spacing (item 5) — uniform 1 line between text / + + + {glyph()} + + + + {/* gap owns ALL inter-part spacing (item 5) — uniform 1 line between text / reasoning / tool regardless of order or stream timing, so blank lines don't pop in and out as parts are created/merged mid-stream. /compact drops the gap along with the per-turn margins above. */} - - - - {m().text} + // No parts yet: the just-started streaming turn shows ONLY the caret, + // inline with the glyph (not an empty line + a dangling caret below — + // item 10 cursor misalignment); a settled row shows its flat text. + + + {m().text} + + + m().text} /> + + + } + > + + {/* streaming caret — a cursor glyph, not content (item 4) */} + - - m().text} /> - - + } > - - {/* streaming caret — a cursor glyph, not content (item 4) */} - - - - } - > - - {part => ( - - {tool => } - - {r => } - - - {/* /details hidden — the honest minimal render for a folded + + {part => ( + + {tool => } + + {r => } + + + {/* /details hidden — the honest minimal render for a folded tool/reasoning run; chrome, not copyable content. */} - {run => ( - - {`⚡ ${hiddenRunLabel(run())}`} - - )} - - - {/* ONE stable native fed the growing text in place (no + {run => ( + + {`⚡ ${hiddenRunLabel(run())}`} + + )} + + + {/* ONE stable native fed the growing text in place (no per-delta remount → no scrollbar flicker, #2); it renders GFM tables natively (#3). Leading/trailing blanks stripped so the column `gap` is the sole inter-part spacing (item 5). Interstitial narration demotes to muted once settled; a quiet ⧉ copy run sits bottom-left under the settled block (off the scrollbar's right-edge column). */} - {t => ( - - - - t().text} /> - - - )} - - - )} - - - - + {t => ( + + + + t().text} /> + + + )} + + + )} + + + + + } + > + {n => } + ) } diff --git a/ui-opentui/src/view/notificationCard.tsx b/ui-opentui/src/view/notificationCard.tsx new file mode 100644 index 00000000000..6f66993a33a --- /dev/null +++ b/ui-opentui/src/view/notificationCard.tsx @@ -0,0 +1,41 @@ +/** + * NotificationCard — the inline transcript card for a background-activity + * notification (glitch 2026-06-13). Renders a `notification.show` as a distinct, + * level-tinted one-line card so it's UNMISTAKABLY chrome, not model output (the + * old behaviour leaked these as plain transcript lines that read like the agent + * talking). Lives in the message stream (role `'notification'`) so it scrolls in + * context; `selectable=false` keeps it out of copy/selection. + * + * Compact: a colored `◆` marker (distinct from the `●` status dot, the `·` + * system glyph, and the `⚕`/`❯` turn glyphs) + a bold kind label + the text. + */ +import { Show } from 'solid-js' + +import type { ActivityNotification } from '../logic/backgroundActivity.ts' +import { useTheme } from './theme.tsx' + +export function NotificationCard(props: { notification: ActivityNotification; compact?: boolean }) { + const theme = useTheme() + const n = () => props.notification + const levelColor = () => { + const c = theme().color + return n().level === 'error' ? c.error : n().level === 'warn' ? c.warn : c.accent + } + // A label for the card head: the kind if the gateway sent one, else a neutral word. + const label = () => n().kind || 'notice' + return ( + + + + {'◆ '} + + + {label()} + + + {` ${n().text}`} + + + + ) +} diff --git a/ui-opentui/src/view/terminalChrome.tsx b/ui-opentui/src/view/terminalChrome.tsx index 748f231a25c..c82ff855224 100644 --- a/ui-opentui/src/view/terminalChrome.tsx +++ b/ui-opentui/src/view/terminalChrome.tsx @@ -20,6 +20,7 @@ import { useRenderer } from '@opentui/solid' import { createEffect, on } from 'solid-js' import { installTerminalChrome, type TerminalChromeSeam } from '../boundary/termChrome.ts' +import { notificationChannels } from '../logic/notificationDispatcher.ts' import type { createSessionStore } from '../logic/store.ts' import { promptNotification, TURN_COMPLETE_NOTIFICATION } from '../logic/termChrome.ts' @@ -57,5 +58,20 @@ export function TerminalChrome(props: { store: Store; chrome?: TerminalChromeSea ) ) + // Background-activity notification → desktop OSC ping for the "important" ones + // (errors/warnings/completions); the inline card already covers in-transcript. + // The seam's own focus gate suppresses it when the terminal is focused. + createEffect( + on( + () => props.store.state.lastNotification, + n => { + if (!n) return + const osc = notificationChannels(n).osc + if (osc) chrome.notify(osc) + }, + { defer: true } + ) + ) + return null }