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
}