mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-14 14:12:44 +00:00
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:
parent
965226fd52
commit
5988e21ed7
9 changed files with 647 additions and 77 deletions
150
ui-opentui/src/logic/backgroundActivity.ts
Normal file
150
ui-opentui/src/logic/backgroundActivity.ts
Normal 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_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
|
||||
}
|
||||
38
ui-opentui/src/logic/notificationDispatcher.ts
Normal file
38
ui-opentui/src/logic/notificationDispatcher.ts
Normal 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' } }
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
161
ui-opentui/src/test/backgroundActivity.test.ts
Normal file
161
ui-opentui/src/test/backgroundActivity.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
62
ui-opentui/src/test/notificationCard.test.tsx
Normal file
62
ui-opentui/src/test/notificationCard.test.tsx
Normal 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')
|
||||
})
|
||||
})
|
||||
43
ui-opentui/src/test/notificationDispatcher.test.ts
Normal file
43
ui-opentui/src/test/notificationDispatcher.test.ts
Normal 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' })
|
||||
})
|
||||
})
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
41
ui-opentui/src/view/notificationCard.tsx
Normal file
41
ui-opentui/src/view/notificationCard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue