diff --git a/ui-opentui/src/entry/main.tsx b/ui-opentui/src/entry/main.tsx index b08d6ffade9..579d8440fa0 100644 --- a/ui-opentui/src/entry/main.tsx +++ b/ui-opentui/src/entry/main.tsx @@ -538,6 +538,7 @@ export const run = Effect.fn('Tui.run')(function* (input: TuiInput) { .map(e => `${e.scope}: ${e.msg}`), openDashboard: () => store.openDashboard(), openBackgroundPanel: () => store.openBackgroundPanel(), + addBgTask: id => store.addBgTask(id), openPager: (title, text) => store.openPager(title, text), openPicker: picker => store.openPicker(picker), openSessionPicker: tab => store.openSessionPicker(tab), @@ -594,26 +595,9 @@ 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 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) { - const idle = store.state.backgroundProcesses.length === 0 - yield* Effect.sleep(idle ? '30 seconds' : '8 seconds') - yield* Effect.promise(() => - backgroundOps - .list() - .then(procs => store.setBackgroundProcesses(procs)) - .catch(() => {}) - ) - } - }) - ) + // (No ambient OS-process poll: the `bg:` badge now counts in-flight + // background-PROMPT tasks from the event stream, and the /processes panel + // fetches `agents.list` on open. Nothing to poll for.) // Contact point #1: the single render bridge. After this, the screen is Solid's. // The theme is sourced reactively from the store (skin events update it). diff --git a/ui-opentui/src/logic/slash.ts b/ui-opentui/src/logic/slash.ts index 8e0c89293a9..4217ac4ea22 100644 --- a/ui-opentui/src/logic/slash.ts +++ b/ui-opentui/src/logic/slash.ts @@ -73,8 +73,10 @@ export interface SlashContext { readonly openPicker: (picker: PickerState) => void /** Open the agents dashboard (/agents, /tasks). */ readonly openDashboard: () => void - /** Open the background-process panel (/bg). */ + /** Open the OS background-process panel (/processes). */ readonly openBackgroundPanel: () => void + /** Track an in-flight background-prompt task id (`/bg` → prompt.background). */ + readonly addBgTask: (id: string) => void /** Cached `/model` picker rows (Epic 7 instant open); undefined until prefetched. */ readonly modelItems: () => PickerItem[] | undefined /** Update the cached `/model` picker rows. */ @@ -212,7 +214,8 @@ const CLIENT_HELP_LINES = [ '/clear, /new — clear the transcript (confirm)', '/compact [on|off|toggle] — compact transcript spacing', '/details [hidden|collapsed|expanded|cycle] — tool/reasoning detail', - '/bg — background processes (list + stop all)', + '/bg — launch a background prompt', + '/processes — OS background processes (list + stop all)', '/replay [n|path] — inspect an archived spawn tree', '/mem — live memory stats (diag)', '/heapdump — write a V8 heap snapshot (diag)', @@ -663,11 +666,36 @@ const toolsCmd: ClientHandler = async (arg, ctx) => { } } +/** `/bg ` (aliases /background, /btw) — launch a background PROMPT via + * `prompt.background` (Ink parity): echo "bg started" and track the task so + * the `bg: N` badge counts it until `background.complete` clears it. NOT the OS + * process panel (that's /processes). */ +const backgroundCmd: ClientHandler = async (arg, ctx) => { + const text = arg.trim() + if (!text) { + ctx.pushSystem('/bg — launch a background prompt') + return + } + try { + const r = await ctx.request('prompt.background', { session_id: ctx.sessionId(), text }) + const taskId = readStr(r, 'task_id') + if (taskId) { + ctx.addBgTask(taskId) + ctx.pushSystem(`bg ${taskId} started`) + } else { + ctx.pushSystem('/bg: no task id returned') + } + } catch (error) { + ctx.pushSystem(`/bg: ${error instanceof Error ? error.message : 'failed'}`) + } +} + /** The TUI-only client commands (run in-process, never hit the gateway). */ const CLIENT: Record = { agents: (_arg, ctx) => ctx.openDashboard(), - background: (_arg, ctx) => ctx.openBackgroundPanel(), - bg: (_arg, ctx) => ctx.openBackgroundPanel(), + background: backgroundCmd, + bg: backgroundCmd, + btw: backgroundCmd, clear: (_arg, ctx) => ctx.confirm('Clear the transcript?', ctx.clearTranscript), compact: compactCmd, copy: (arg, ctx) => { @@ -678,8 +706,9 @@ const CLIENT: Record = { details: detailsCmd, exit: (_arg, ctx) => ctx.quit(), heapdump: heapdumpCmd, - jobs: (_arg, ctx) => ctx.openBackgroundPanel(), mem: memCmd, + processes: (_arg, ctx) => ctx.openBackgroundPanel(), + procs: (_arg, ctx) => ctx.openBackgroundPanel(), model: modelCmd, replay: replayCmd, resume: resumeCmd, diff --git a/ui-opentui/src/logic/store.ts b/ui-opentui/src/logic/store.ts index ff65eeb98ee..acec3305655 100644 --- a/ui-opentui/src/logic/store.ts +++ b/ui-opentui/src/logic/store.ts @@ -257,10 +257,13 @@ export interface StoreState { dashboard: boolean /** Subagent id the dashboard should preselect on open (tray Enter — Epic 2.7). */ dashboardAgent: string | undefined - /** Whether the background-process panel overlay is open (/bg). */ + /** Whether the OS background-process panel overlay is open (/processes). */ backgroundPanel: boolean - /** OS background processes (polled from `agents.list`) — the panel + the `bg:` badge. */ + /** OS background processes (from `agents.list`) — shown in the /processes panel. */ backgroundProcesses: BackgroundProcess[] + /** In-flight background-PROMPT task ids (`/bg` → `prompt.background`, cleared on + * `background.complete`) — drives the `bg: N` status-bar badge. */ + bgTasks: string[] /** 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 @@ -474,6 +477,7 @@ export function createSessionStore(options?: SessionStoreOptions) { dashboardAgent: undefined, backgroundPanel: false, backgroundProcesses: [], + bgTasks: [], lastNotification: undefined, status: undefined, // startedAt is set ONCE here (store creation ≈ session start) — the status @@ -649,10 +653,20 @@ export function createSessionStore(options?: SessionStoreOptions) { function closeBackgroundPanel() { setState('backgroundPanel', false) } - /** Replace the polled OS-process snapshot (drives the panel + the `bg:` badge). */ + /** Replace the OS-process snapshot (drives the /processes panel). */ function setBackgroundProcesses(procs: BackgroundProcess[]) { setState('backgroundProcesses', procs) } + /** Track an in-flight background-prompt task (`/bg` start) — drives the `bg:` badge. */ + function addBgTask(id: string) { + if (!state.bgTasks.includes(id)) setState('bgTasks', [...state.bgTasks, id]) + } + function removeBgTask(id: string) { + setState( + 'bgTasks', + state.bgTasks.filter(t => t !== id) + ) + } /** Open a local Y/N confirm dialog (non-gateway; e.g. /clear). */ function setConfirm(message: string, onConfirm: () => void) { @@ -825,6 +839,19 @@ export function createSessionStore(options?: SessionStoreOptions) { if (key) clearNotificationCards(key) break } + // A background PROMPT (`/bg`) finished: drop it from the in-flight set (the + // `bg:` badge) and surface its result as a distinct inline completion card + // (a completion-ish kind → also fires the OSC desktop ping). + case 'background.complete': { + removeBgTask(event.payload.task_id) + pushNotification({ + id: `bg:${event.payload.task_id}`, + kind: 'background task complete', + level: 'info', + text: `bg ${event.payload.task_id} → ${event.payload.text}` + }) + break + } // reasoning.delta is the model's actual reasoning — a (dim) transcript part. case 'reasoning.delta': { const text = event.payload?.text ?? '' @@ -1142,6 +1169,7 @@ export function createSessionStore(options?: SessionStoreOptions) { openBackgroundPanel, closeBackgroundPanel, setBackgroundProcesses, + addBgTask, hydrate, beginBuffer, commitSnapshot, diff --git a/ui-opentui/src/test/notificationCard.test.tsx b/ui-opentui/src/test/notificationCard.test.tsx index a834252148f..1036ee14e75 100644 --- a/ui-opentui/src/test/notificationCard.test.tsx +++ b/ui-opentui/src/test/notificationCard.test.tsx @@ -43,6 +43,19 @@ describe('notification store wiring', () => { store.apply({ type: 'notification.show', payload: { level: 'warn', kind: 'x' } }) expect(store.state.messages.some(m => m.role === 'notification')).toBe(false) }) + + test('background.complete → a completion card + drops the bg task from the badge count', () => { + const store = createSessionStore() + store.apply({ type: 'gateway.ready' }) + store.addBgTask('bg_abc') + expect(store.state.bgTasks).toEqual(['bg_abc']) + store.apply({ type: 'background.complete', payload: { task_id: 'bg_abc', text: 'all done' } }) + expect(store.state.bgTasks).toEqual([]) // untracked → bg: badge decrements + const last = store.state.messages.at(-1) + expect(last?.role).toBe('notification') + expect(last?.notification?.text).toContain('bg_abc') + expect(last?.notification?.text).toContain('all done') + }) }) describe('NotificationCard frame', () => { diff --git a/ui-opentui/src/test/slash.test.ts b/ui-opentui/src/test/slash.test.ts index e63566a8904..3053c609cf3 100644 --- a/ui-opentui/src/test/slash.test.ts +++ b/ui-opentui/src/test/slash.test.ts @@ -227,6 +227,7 @@ function makeCtx(request: (method: string, params: Record) => P setModelItems: items => (modelCache.value = items), openDashboard: () => (dashboard.value = true), openBackgroundPanel: () => {}, + addBgTask: () => {}, openPager: (title, text) => paged.push({ text, title }), openPicker: p => pickers.push(p), openSessionPicker: tab => sessionPickers.push(tab), diff --git a/ui-opentui/src/test/utilityCommands.test.ts b/ui-opentui/src/test/utilityCommands.test.ts index 2872c5b5a53..eb334213b9d 100644 --- a/ui-opentui/src/test/utilityCommands.test.ts +++ b/ui-opentui/src/test/utilityCommands.test.ts @@ -70,6 +70,7 @@ function makeCtx(request: (method: string, params: Record) => P setModelItems: () => {}, openDashboard: () => {}, openBackgroundPanel: () => {}, + addBgTask: () => {}, openPager: (title, text) => paged.push({ text, title }), openPicker: () => {}, openSessionPicker: () => {}, diff --git a/ui-opentui/src/view/overlays/backgroundPanel.tsx b/ui-opentui/src/view/overlays/backgroundPanel.tsx index 6bcd456c7c2..e9799d03f79 100644 --- a/ui-opentui/src/view/overlays/backgroundPanel.tsx +++ b/ui-opentui/src/view/overlays/backgroundPanel.tsx @@ -11,7 +11,7 @@ import { type BoxRenderable } from '@opentui/core' import { useKeyboard } from '@opentui/solid' import { For, type JSXElement, onMount, Show } from 'solid-js' -import { procIsRunning, type BackgroundProcess } from '../../logic/backgroundActivity.ts' +import { procIsRunning, runningCount, type BackgroundProcess } from '../../logic/backgroundActivity.ts' import { truncRight } from '../../logic/truncate.ts' import { useDimensions } from '../dimensions.tsx' import { useCloseLayer } from '../keymap.tsx' @@ -46,7 +46,7 @@ export function BackgroundPanel(props: { const dims = useDimensions() let rootRef: BoxRenderable | undefined - const running = () => props.processes.filter(p => procIsRunning(p.status)).length + const running = () => runningCount(props.processes) // 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 c0ae3d7e3bd..ef33b559895 100644 --- a/ui-opentui/src/view/statusBar.tsx +++ b/ui-opentui/src/view/statusBar.tsx @@ -29,8 +29,9 @@ * 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. + * (folded here from the old agents-tray line), `bg: N` = in-flight background + * PROMPTS (`/bg` → prompt.background, cleared on background.complete). Both + * hidden at zero. (OS background processes live in the /processes panel.) * * Parity notes (data that does not reach this TUI yet — reported, not faked): * - `display.show_cost`: Ink reads it from its `config.get` polling loop, @@ -42,7 +43,6 @@ import { useKeyboard } from '@opentui/solid' import { createEffect, createMemo, createSignal, onCleanup, Show } from 'solid-js' -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' @@ -253,10 +253,10 @@ export function StatusBar(props: { store: SessionStore }) { const n = info().mcpServers ?? 0 return segs().mcp && n > 0 ? `mcp: ${n}` : '' }) - // `bg: N` — running OS background processes (polled into the store); the - // ambient half of the background-activity notifications (glitch 2026-06-13). + // `bg: N` — in-flight background-PROMPT tasks (`/bg` → prompt.background, + // cleared on background.complete), mirroring Ink's bgTasks count. const bgText = createMemo(() => { - const n = runningCount(props.store.state.backgroundProcesses) + const n = props.store.state.bgTasks.length return segs().bg && n > 0 ? `bg: ${n}` : '' }) // `⚡ N` — running subagents. The ambient count lives HERE now (P4 input-density