opentui(v6): simplify the background-activity work (dedup + dead-code removal)

/simplify pass over this session's diff (4 cleanup agents → applied the high-value
findings):
- reuse: extract the duplicated truncRight/truncLeft (statusBar/agentsDashboard/
  backgroundPanel) to logic/truncate.ts; export DONE_STATUSES/procIsRunning from
  backgroundActivity and drop backgroundPanel's re-declared copy.
- simplification: remove dead/speculative exports (upsertNotification,
  clearNotificationsByKey, BackgroundRun) — the campaign models notifications as
  Message rows, not a notifications array — and drop runningCount's always-empty
  `runs` param; collapse notificationDispatcher's always-true `card` field into a
  plain notificationOsc(): TermNotification | undefined.
- efficiency: the bg-process poll now idles at 30s (was a flat 8s) and tightens to
  8s only when something is running — most sessions have zero background processes.
- altitude: the ` agents` chip joins the statusSegments width-ladder (was an inline
  width gate) so all bar segments share one drop policy; refreshed the stale
  statusBar header (bg: is wired now, not "reserved").

Net −95 LOC. Gate green. Skipped (noted): statusColor merge (divergent domains),
the pushNotification double-clone (necessary — guards the Solid aliasing footgun),
moving the notification-card dispatch out of messageLine, and the Message-row model
(deliberate "inline in transcript" design).
This commit is contained in:
alt-glitch 2026-06-13 22:30:27 +05:30
parent 5c5a1fec4b
commit 76eab10b14
11 changed files with 95 additions and 190 deletions

View file

@ -594,14 +594,17 @@ export const run = Effect.fn('Tui.run')(function* (input: TuiInput) {
// Live backend: drive a session (create + optional initial prompt) concurrently.
if (!input.fake) yield* Effect.forkScoped(bootstrapSession(gateway, store, input))
// Ambient `bg:` badge (A): poll the OS-process registry on a slow interval so
// the status bar reflects running background processes even with the panel
// closed. Cheap local RPC; scoped fiber → auto-cancelled on shutdown.
// Ambient `bg:` badge (A): poll the OS-process registry so the status bar
// reflects running background processes even with the panel closed. Cheap
// local RPC; scoped fiber → auto-cancelled on shutdown. Adaptive interval:
// most sessions have ZERO background processes, so idle-poll slowly (30s)
// and tighten to 8s only once something is running.
if (!input.fake)
yield* Effect.forkScoped(
Effect.gen(function* () {
while (true) {
yield* Effect.sleep('8 seconds')
const idle = store.state.backgroundProcesses.length === 0
yield* Effect.sleep(idle ? '30 seconds' : '8 seconds')
yield* Effect.promise(() =>
backgroundOps
.list()

View file

@ -29,14 +29,6 @@ export interface BackgroundProcess {
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
@ -63,12 +55,10 @@ function coerceLevel(value: unknown): ActivityNotification['level'] {
* 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.
* id resolution: prefer the wire `id`, then fall back to `key`, else synthesize
* `id = `n:${text}`` (a stable, text-derived id rather than a random one). The
* original `key` (if any) is preserved separately so notification.clear by key
* still targets the right cards.
*/
export function parseNotification(payload: unknown): ActivityNotification | null {
const text = readStr(payload, 'text')
@ -87,25 +77,6 @@ export function parseNotification(payload: unknown): ActivityNotification | null
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
@ -134,17 +105,18 @@ export function parseProcessList(result: unknown): BackgroundProcess[] {
* 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'])
/** Terminal (no-longer-running) process statuses exported as the single
* source of truth (the panel imports `procIsRunning` rather than re-declaring). */
export const DONE_STATUSES = new Set(['exited', 'failed', 'complete', 'done', 'killed'])
function procIsRunning(status: string): boolean {
/** Whether a process status is "running-ish": NOT one of DONE_STATUSES. Lenient
* by design the gateway's status vocabulary is open-ended, so we over-count
* rather than hide a live process under an unfamiliar status. Case-insensitive. */
export 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
/** Count of running background processes (the ambient `bg:` badge). */
export function runningCount(procs: readonly BackgroundProcess[]): number {
return procs.filter(p => procIsRunning(p.status)).length
}

View file

@ -1,20 +1,13 @@
/**
* 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).
* Notification desktop-OSC decision. EVERY notification renders an inline
* transcript card (so there's nothing to decide there); this only decides whether
* a notification is important enough to ALSO fire a desktop/terminal OSC ping
* (to pull the user back). The OSC payload is termChrome's `TermNotification`;
* the boundary (terminalChrome) owns the actual escape-sequence write.
*/
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']
@ -26,13 +19,11 @@ function isImportant(n: ActivityNotification): boolean {
}
/**
* 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.
* The desktop OSC notification for `n`, or `undefined` when it's not important
* enough to interrupt level 'error'/'warn', or a kind containing
* 'complete'/'done'/'finish' (case-insensitive). Title is always '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' } }
export function notificationOsc(n: ActivityNotification): TermNotification | undefined {
return isImportant(n) ? { body: n.text, title: 'Hermes' } : undefined
}

View file

@ -0,0 +1,17 @@
/**
* One-line string truncation helpers shared by the chrome views (status bar,
* agents dashboard, background panel) keep them here so the ellipsis rule
* doesn't drift between copies.
*/
/** Keep the HEAD of a string, suffixing `…` when it must clip (e.g. a goal/command row). */
export function truncRight(s: string, max: number): string {
if (max <= 1) return s.length > max ? '…' : s
return s.length <= max ? s : s.slice(0, max - 1) + '…'
}
/** Keep the TAIL of a string, prefixing `…` when it must clip (e.g. a deep cwd path). */
export function truncLeft(s: string, max: number): string {
if (max <= 1) return s.length > max ? '…' : s
return s.length <= max ? s : '…' + s.slice(s.length - max + 1)
}

View file

@ -6,14 +6,10 @@
import { describe, expect, test } from 'vitest'
import {
type ActivityNotification,
type BackgroundProcess,
type BackgroundRun,
clearNotificationsByKey,
parseNotification,
parseProcessList,
runningCount,
upsertNotification
runningCount
} from '../logic/backgroundActivity.ts'
describe('parseNotification', () => {
@ -46,13 +42,10 @@ describe('parseNotification', () => {
expect(n?.key).toBe('k-only')
})
test('no id and no key → synthesized id `n:${text}` so dedupe still works', () => {
test('no id and no key → synthesized stable id `n:${text}`', () => {
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', () => {
@ -65,40 +58,6 @@ describe('parseNotification', () => {
})
})
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(
@ -136,12 +95,6 @@ describe('parseProcessList', () => {
})
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 },
@ -150,12 +103,12 @@ describe('runningCount', () => {
{ 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('counts running-ish processes (lenient on unknown statuses)', () => {
// running + Sleeping = 2 (exited/DONE/killed excluded)
expect(runningCount(procs)).toBe(2)
})
test('empty inputs → 0', () => {
expect(runningCount([], [])).toBe(0)
test('empty input → 0', () => {
expect(runningCount([])).toBe(0)
})
})

View file

@ -1,43 +1,38 @@
/**
* Notification channel-decision tests. Card is always on; OSC fires only for
* important notifications (error/warn level, or a completion-ish kind).
* notificationOsc every notification shows an inline card; this only decides
* whether one ALSO fires a desktop OSC (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'
import { notificationOsc } 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)
})
describe('notificationOsc', () => {
test('plain info → no osc', () => {
expect(notificationChannels(notif({ level: 'info', kind: 'progress' })).osc).toBeUndefined()
expect(notificationOsc(notif({ level: 'info', kind: 'progress' }))).toBeUndefined()
expect(notificationOsc(notif({ kind: 'started' }))).toBeUndefined()
})
test('error and warn levels fire osc', () => {
expect(notificationChannels(notif({ level: 'error' })).osc).toBeDefined()
expect(notificationChannels(notif({ level: 'warn' })).osc).toBeDefined()
expect(notificationOsc(notif({ level: 'error' }))).toBeDefined()
expect(notificationOsc(notif({ level: 'warn' }))).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('completion-ish kinds fire osc, case-insensitive (complete/done/finish, substring)', () => {
expect(notificationOsc(notif({ kind: 'task.complete' }))).toBeDefined()
expect(notificationOsc(notif({ kind: 'JOB_DONE' }))).toBeDefined()
expect(notificationOsc(notif({ kind: 'Finished' }))).toBeDefined()
expect(notificationOsc(notif({ kind: 'agent.run.completed' }))).toBeDefined()
})
test('osc body == text, title == "Hermes"', () => {
const ch = notificationChannels(notif({ level: 'error', text: 'build broke' }))
expect(ch.osc).toEqual({ body: 'build broke', title: 'Hermes' })
expect(notificationOsc(notif({ level: 'error', text: 'build broke' }))).toEqual({
body: 'build broke',
title: 'Hermes'
})
})
})

View file

@ -109,6 +109,7 @@ describe('store.applyInfo — chrome merge', () => {
describe('statusSegments — progressive disclosure table (chrome v3 order)', () => {
test('full width shows everything', () => {
expect(statusSegments(220)).toEqual({
agents: true,
ctxDetail: true,
cost: true,
up: true,

View file

@ -13,6 +13,7 @@ import { useKeyboard } from '@opentui/solid'
import { createSignal, For, onMount, Show } from 'solid-js'
import type { SubagentInfo, TraceEntry } from '../../logic/store.ts'
import { truncRight } from '../../logic/truncate.ts'
import { useDimensions } from '../dimensions.tsx'
import { useCloseLayer } from '../keymap.tsx'
import { useTheme } from '../theme.tsx'
@ -27,12 +28,6 @@ function statusColor(status: string, theme: ReturnType<typeof useTheme>): string
return c.warn
}
/** Keep the head of a string, ellipsizing when it must clip (one-line master rows). */
function truncRight(s: string, max: number): string {
if (max <= 1) return s.length > max ? '…' : s
return s.length <= max ? s : s.slice(0, max - 1) + '…'
}
/** Per-kind glyph + color for a trace entry makes the detail pane read like a
* transcript (tool calls pop, progress is quiet, the summary is the payoff). */
function traceGlyph(kind: TraceEntry['kind']): string {

View file

@ -6,41 +6,26 @@
* The gateway only exposes a single STOP-ALL (`kill_all`), NOT per-process kill,
* so the only action is `x` stop all (no per-row kill). `r` refreshes; the
* controller wires both to the gateway.
*
* NOTE: the contract spec'd `import { BackgroundProcess } from '../../logic/store.ts'`,
* but the type actually lives in `backgroundActivity.ts` (store.ts does not
* re-export it). Importing from the real source keeps `npm run check` green
* editing store.ts to add a re-export is out of fence (controller's job).
*/
import { type BoxRenderable } from '@opentui/core'
import { useKeyboard } from '@opentui/solid'
import { For, type JSXElement, onMount, Show } from 'solid-js'
import type { BackgroundProcess } from '../../logic/backgroundActivity.ts'
import { procIsRunning, type BackgroundProcess } from '../../logic/backgroundActivity.ts'
import { truncRight } from '../../logic/truncate.ts'
import { useDimensions } from '../dimensions.tsx'
import { useCloseLayer } from '../keymap.tsx'
import { useTheme } from '../theme.tsx'
/** Terminal statuses (case-insensitive); anything else is treated as running
* the same lenient rule the store uses (backgroundActivity `DONE_STATUSES`). */
const DONE_STATUSES = new Set(['exited', 'failed', 'complete', 'done', 'killed'])
const isRunning = (status: string): boolean => !DONE_STATUSES.has(status.toLowerCase())
function statusColor(status: string, theme: ReturnType<typeof useTheme>): string {
const c = theme().color
const s = status.toLowerCase()
if (s === 'failed' || s.includes('error')) return c.error
if (s === 'exited' || s === 'complete' || s === 'done') return c.ok
if (isRunning(status)) return c.accent
if (procIsRunning(status)) return c.accent
return c.muted
}
/** Keep the head of a string, ellipsizing when it must clip (one-line rows). */
function truncRight(s: string, max: number): string {
if (max <= 1) return s.length > max ? '…' : s
return s.length <= max ? s : s.slice(0, max - 1) + '…'
}
/** Compact inline uptime: <60 → 'Ns', <3600 → 'Nm', else 'Hh MMm'. */
function fmtUptime(seconds: number): string {
const s = Math.max(0, Math.floor(seconds))
@ -61,7 +46,7 @@ export function BackgroundPanel(props: {
const dims = useDimensions()
let rootRef: BoxRenderable | undefined
const running = () => props.processes.filter(p => isRunning(p.status)).length
const running = () => props.processes.filter(p => procIsRunning(p.status)).length
// Focus the root so the focus-within close layer is active; refresh once on
// open so the list is fresh.

View file

@ -17,9 +17,9 @@
* 1014 cells by terminal width (`ctxBarCells`), vs the old 5.
* - Responsive = drop, don't restack: as the terminal narrows, tail segments
* drop WHOLE in reverse priority (mcp bg profile cmp up cost
* ctx detail collapsing to a bare `ctx: 42%`) via the pure, table-tested
* `statusSegments` ladder. The health dot, model and ctx % are pinned.
* Nothing truncates mid-segment, so the row NEVER wraps or restacks.
* ctx detail collapsing to a bare `ctx: 42%`, then the `⚡ agents` chip) via
* the pure, table-tested `statusSegments` ladder. The health dot, model and
* ctx % are pinned. Nothing truncates mid-segment, so the row NEVER wraps.
*
* A pending update (`info.update_behind > 0`) BORROWS the whole line as a
* transient notice; it dismisses on Esc or after NOTICE_TTL_MS.
@ -28,10 +28,11 @@
* correct blue surface), `statusFg` for the model/profile/percent, muted
* metrics + labels, ok/warn dot, level-tinted ctx bar and cmp count.
*
* Background-activity chrome (glitch 2026-06-13): `⚡ N` = running subagents
* (folded here from the old agents-tray line), `bg: N` = running OS background
* processes (polled from `agents.list`). Both hidden at zero.
*
* Parity notes (data that does not reach this TUI yet reported, not faked):
* - `bg: N` (background tasks): the OpenTUI store has no background-task
* tracking (Ink counts `prompt.background` task_ids + `background.complete`
* locally); the segment slot exists in `statusSegments` but renders nothing.
* - `display.show_cost`: Ink reads it from its `config.get` polling loop,
* which this TUI doesn't have cost shows whenever `usage.cost_usd` is
* present instead.
@ -43,6 +44,7 @@ import { createEffect, createMemo, createSignal, onCleanup, Show } from 'solid-j
import { runningCount } from '../logic/backgroundActivity.ts'
import type { SessionStore } from '../logic/store.ts'
import { truncLeft, truncRight } from '../logic/truncate.ts'
import { isTrayAgent } from './agentsTray.tsx'
import { useDimensions } from './dimensions.tsx'
import { elapsedSeconds, useElapsedTick } from './elapsed.ts'
@ -67,6 +69,8 @@ const NOTICE_TTL_MS = 30_000
* Dot+model and the ctx % are pinned and never gated here; the cwd is gated
* by its own leftover-width budget instead. */
export interface StatusSegments {
/** Running-subagents `⚡ N` chip — survives narrowest (drops last). */
agents: boolean
/** Full `ctx: ███░░ 42% · 84k` read-out; false → compact `ctx: 42%`. */
ctxDetail: boolean
cost: boolean
@ -74,7 +78,7 @@ export interface StatusSegments {
up: boolean
compressions: boolean
profile: boolean
/** Background-tasks count — reserved; no store data feeds it yet (see header). */
/** Running OS background-processes count (`bg: N`). */
bg: boolean
mcp: boolean
}
@ -82,6 +86,7 @@ export interface StatusSegments {
export function statusSegments(cols: number): StatusSegments {
const w = Math.max(1, Math.floor(cols || 1))
return {
agents: w >= 60,
ctxDetail: w >= 72,
cost: w >= 80,
up: w >= 88,
@ -156,18 +161,6 @@ function shortCwd(cwd: string): string {
return segs.length <= 3 ? home : '…/' + segs.slice(-2).join('/')
}
/** Keep the TAIL of a string, prefixing with `…` when it must be clipped. */
function truncLeft(s: string, max: number): string {
if (max <= 1) return s.length > max ? '…' : s
return s.length <= max ? s : '…' + s.slice(s.length - max + 1)
}
/** Keep the HEAD of a string, suffixing with `…` when it must be clipped. */
function truncRight(s: string, max: number): string {
if (max <= 1) return s.length > max ? '…' : s
return s.length <= max ? s : s.slice(0, max - 1) + '…'
}
/** A unicode meter: `███░░` filled to `pct`% over `width` cells (Ink ctxBar). */
function ctxBar(pct: number, width: number): string {
const filled = Math.max(0, Math.min(width, Math.round((pct / 100) * width)))
@ -263,7 +256,7 @@ export function StatusBar(props: { store: SessionStore }) {
// `bg: N` — running OS background processes (polled into the store); the
// ambient half of the background-activity notifications (glitch 2026-06-13).
const bgText = createMemo(() => {
const n = runningCount([], props.store.state.backgroundProcesses)
const n = runningCount(props.store.state.backgroundProcesses)
return segs().bg && n > 0 ? `bg: ${n}` : ''
})
// `⚡ N` — running subagents. The ambient count lives HERE now (P4 input-density
@ -271,7 +264,7 @@ export function StatusBar(props: { store: SessionStore }) {
// composer — Down still expands it; this chip is the at-a-glance signal.
const agentsText = createMemo(() => {
const n = props.store.state.subagents.filter(isTrayAgent).length
return n > 0 && dims().width >= 60 ? `${n}` : ''
return segs().agents && n > 0 ? `${n}` : ''
})
// The cwd flows LAST on the same line (not right-pinned): its budget is the

View file

@ -20,7 +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 { notificationOsc } from '../logic/notificationDispatcher.ts'
import type { createSessionStore } from '../logic/store.ts'
import { promptNotification, TURN_COMPLETE_NOTIFICATION } from '../logic/termChrome.ts'
@ -66,7 +66,7 @@ export function TerminalChrome(props: { store: Store; chrome?: TerminalChromeSea
() => props.store.state.lastNotification,
n => {
if (!n) return
const osc = notificationChannels(n).osc
const osc = notificationOsc(n)
if (osc) chrome.notify(osc)
},
{ defer: true }