diff --git a/ui-opentui/src/entry/main.tsx b/ui-opentui/src/entry/main.tsx index bd5dc7869d0..b08d6ffade9 100644 --- a/ui-opentui/src/entry/main.tsx +++ b/ui-opentui/src/entry/main.tsx @@ -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() diff --git a/ui-opentui/src/logic/backgroundActivity.ts b/ui-opentui/src/logic/backgroundActivity.ts index 09d401aae94..8091ea7c54b 100644 --- a/ui-opentui/src/logic/backgroundActivity.ts +++ b/ui-opentui/src/logic/backgroundActivity.ts @@ -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 } diff --git a/ui-opentui/src/logic/notificationDispatcher.ts b/ui-opentui/src/logic/notificationDispatcher.ts index 7060f64441e..348055f522a 100644 --- a/ui-opentui/src/logic/notificationDispatcher.ts +++ b/ui-opentui/src/logic/notificationDispatcher.ts @@ -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 } diff --git a/ui-opentui/src/logic/truncate.ts b/ui-opentui/src/logic/truncate.ts new file mode 100644 index 00000000000..bbb9745e7e1 --- /dev/null +++ b/ui-opentui/src/logic/truncate.ts @@ -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) +} diff --git a/ui-opentui/src/test/backgroundActivity.test.ts b/ui-opentui/src/test/backgroundActivity.test.ts index a973f2e9e8d..c5bef69033b 100644 --- a/ui-opentui/src/test/backgroundActivity.test.ts +++ b/ui-opentui/src/test/backgroundActivity.test.ts @@ -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) }) }) diff --git a/ui-opentui/src/test/notificationDispatcher.test.ts b/ui-opentui/src/test/notificationDispatcher.test.ts index 81db216a7d7..39bbd42e024 100644 --- a/ui-opentui/src/test/notificationDispatcher.test.ts +++ b/ui-opentui/src/test/notificationDispatcher.test.ts @@ -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 { 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' + }) }) }) diff --git a/ui-opentui/src/test/statusChrome.test.tsx b/ui-opentui/src/test/statusChrome.test.tsx index 09a0fb1ed8e..0d98448da99 100644 --- a/ui-opentui/src/test/statusChrome.test.tsx +++ b/ui-opentui/src/test/statusChrome.test.tsx @@ -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, diff --git a/ui-opentui/src/view/overlays/agentsDashboard.tsx b/ui-opentui/src/view/overlays/agentsDashboard.tsx index af00cbd3bd3..5e327289ace 100644 --- a/ui-opentui/src/view/overlays/agentsDashboard.tsx +++ b/ui-opentui/src/view/overlays/agentsDashboard.tsx @@ -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): 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 { diff --git a/ui-opentui/src/view/overlays/backgroundPanel.tsx b/ui-opentui/src/view/overlays/backgroundPanel.tsx index 53aa711e15b..6bcd456c7c2 100644 --- a/ui-opentui/src/view/overlays/backgroundPanel.tsx +++ b/ui-opentui/src/view/overlays/backgroundPanel.tsx @@ -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): 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. diff --git a/ui-opentui/src/view/statusBar.tsx b/ui-opentui/src/view/statusBar.tsx index f76febb4ac3..c0ae3d7e3bd 100644 --- a/ui-opentui/src/view/statusBar.tsx +++ b/ui-opentui/src/view/statusBar.tsx @@ -17,9 +17,9 @@ * 10–14 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 diff --git a/ui-opentui/src/view/terminalChrome.tsx b/ui-opentui/src/view/terminalChrome.tsx index c82ff855224..c8c834efa1a 100644 --- a/ui-opentui/src/view/terminalChrome.tsx +++ b/ui-opentui/src/view/terminalChrome.tsx @@ -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 }