opentui(v6): background-activity P1 — inline notification cards + OSC (no core change)

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.
This commit is contained in:
alt-glitch 2026-06-13 21:44:27 +05:30
parent 965226fd52
commit 5988e21ed7
9 changed files with 647 additions and 77 deletions

View file

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

View file

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

View file

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

View file

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

View file

@ -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(
() => (
<ThemeProvider theme={() => createSessionStore().state.theme}>
<NotificationCard notification={{ id: 'n1', text: 'build finished', level: 'info', kind: 'task.done' }} />
</ThemeProvider>
),
{ width: 60, height: 4 }
)
expect(frame).toContain('◆')
expect(frame).toContain('task.done')
expect(frame).toContain('build finished')
})
})

View file

@ -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>): 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' })
})
})

View file

@ -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.
<box style={{ flexDirection: 'row', flexShrink: 0, marginTop: spacing().top, marginBottom: spacing().bottom }}>
<box style={{ flexShrink: 0, width: GUTTER }}>
{/* the role glyph is decorative exclude it from mouse selection (item 4).
<Show
when={notif()}
fallback={
// Turn-boundary spacing > part gap (see turnSpacing); /compact collapses it
// so long sessions read denser. The earned-gold glyphs do the rest.
<box style={{ flexDirection: 'row', flexShrink: 0, marginTop: spacing().top, marginBottom: spacing().bottom }}>
<box style={{ flexShrink: 0, width: GUTTER }}>
{/* the role glyph is decorative exclude it from mouse selection (item 4).
Bold so the user `` / assistant `` turn boundaries pop (item 8). */}
<text selectable={false}>
<span style={{ fg: glyphFg() }}>
<b>{glyph()}</b>
</span>
</text>
</box>
{/* gap owns ALL inter-part spacing (item 5) — uniform 1 line between text /
<text selectable={false}>
<span style={{ fg: glyphFg() }}>
<b>{glyph()}</b>
</span>
</text>
</box>
{/* 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. */}
<box style={{ flexDirection: 'column', flexGrow: 1, minWidth: 0, gap: display().compact ? 0 : 1 }}>
<Show
when={m().role === 'assistant' && hasParts()}
fallback={
// 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.
<box style={{ flexDirection: 'column', flexGrow: 1, minWidth: 0, gap: display().compact ? 0 : 1 }}>
<Show
when={m().streaming && !hasParts()}
when={m().role === 'assistant' && hasParts()}
fallback={
// themed selection: a solid muted/accent bar that preserves the
// text fg (no selectionFg → the original color shows through, so a
// highlight over content reads as a clean bar, not SGR-inverse).
// A quiet ⧉ copy run sits bottom-left under the block (user
// prompts + settled assistant rows; system notes are chrome,
// nothing to copy) — never the right edge: scrollbar column.
<box style={{ flexDirection: 'column', flexShrink: 0 }}>
<text selectionBg={theme().color.selectionBg}>
<span style={{ fg: bodyFg() }}>{m().text}</span>
// 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.
<Show
when={m().streaming && !hasParts()}
fallback={
// themed selection: a solid muted/accent bar that preserves the
// text fg (no selectionFg → the original color shows through, so a
// highlight over content reads as a clean bar, not SGR-inverse).
// A quiet ⧉ copy run sits bottom-left under the block (user
// prompts + settled assistant rows; system notes are chrome,
// nothing to copy) — never the right edge: scrollbar column.
<box style={{ flexDirection: 'column', flexShrink: 0 }}>
<text selectionBg={theme().color.selectionBg}>
<span style={{ fg: bodyFg() }}>{m().text}</span>
</text>
<Show when={m().role !== 'system' && m().text.trim() && !display().compact}>
<CopyChip source={() => m().text} />
</Show>
</box>
}
>
<text selectable={false}>
{/* streaming caret — a cursor glyph, not content (item 4) */}
<span style={{ fg: theme().color.muted }}></span>
</text>
<Show when={m().role !== 'system' && m().text.trim() && !display().compact}>
<CopyChip source={() => m().text} />
</Show>
</box>
</Show>
}
>
<text selectable={false}>
{/* streaming caret — a cursor glyph, not content (item 4) */}
<span style={{ fg: theme().color.muted }}></span>
</text>
</Show>
}
>
<For each={displayParts()}>
{part => (
<Switch>
<Match when={part.type === 'tool' && part}>{tool => <ToolPart part={tool()} />}</Match>
<Match when={part.type === 'reasoning' && part}>
{r => <ReasoningPart text={r().text} streaming={m().streaming ?? false} />}
</Match>
<Match when={part.type === 'hiddenRun' && part}>
{/* /details hidden the honest minimal render for a folded
<For each={displayParts()}>
{part => (
<Switch>
<Match when={part.type === 'tool' && part}>{tool => <ToolPart part={tool()} />}</Match>
<Match when={part.type === 'reasoning' && part}>
{r => <ReasoningPart text={r().text} streaming={m().streaming ?? false} />}
</Match>
<Match when={part.type === 'hiddenRun' && part}>
{/* /details hidden the honest minimal render for a folded
tool/reasoning run; chrome, not copyable content. */}
{run => (
<text selectable={false}>
<span style={{ fg: theme().color.muted }}>{`${hiddenRunLabel(run())}`}</span>
</text>
)}
</Match>
<Match when={part.type === 'text' && part}>
{/* ONE stable native <markdown> fed the growing text in place (no
{run => (
<text selectable={false}>
<span style={{ fg: theme().color.muted }}>{`${hiddenRunLabel(run())}`}</span>
</text>
)}
</Match>
<Match when={part.type === 'text' && part}>
{/* ONE stable native <markdown> 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 => (
<box style={{ flexDirection: 'column', flexShrink: 0 }}>
<Markdown
text={t().text.replace(/^\n+|\n+$/g, '')}
streaming={m().streaming ?? false}
fg={textFg(t().id)}
/>
<Show when={!m().streaming && !display().compact}>
<CopyChip source={() => t().text} />
</Show>
</box>
)}
</Match>
</Switch>
)}
</For>
</Show>
</box>
</box>
{t => (
<box style={{ flexDirection: 'column', flexShrink: 0 }}>
<Markdown
text={t().text.replace(/^\n+|\n+$/g, '')}
streaming={m().streaming ?? false}
fg={textFg(t().id)}
/>
<Show when={!m().streaming && !display().compact}>
<CopyChip source={() => t().text} />
</Show>
</box>
)}
</Match>
</Switch>
)}
</For>
</Show>
</box>
</box>
}
>
{n => <NotificationCard notification={n()} compact={display().compact} />}
</Show>
)
}

View file

@ -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 (
<box style={{ flexDirection: 'row', flexShrink: 0, marginTop: props.compact ? 0 : 1, paddingLeft: 1 }}>
<text selectable={false}>
<span style={{ fg: levelColor() }}>
<b>{'◆ '}</b>
</span>
<span style={{ fg: levelColor() }}>
<b>{label()}</b>
</span>
<Show when={n().text}>
<span style={{ fg: theme().color.muted }}>{` ${n().text}`}</span>
</Show>
</text>
</box>
)
}

View file

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