From 7016fa4902049cccd16c239626b858d8c7865f45 Mon Sep 17 00:00:00 2001 From: alt-glitch Date: Sat, 13 Jun 2026 22:09:11 +0530 Subject: [PATCH] =?UTF-8?q?opentui(v6):=20background-activity=20P3=20?= =?UTF-8?q?=E2=80=94=20/bg=20process=20panel=20+=20ambient=20bg=20badge=20?= =?UTF-8?q?(no=20core)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The OS process registry (the qxpe "Claude Code background process" gap) had NO surface in the TUI. Now: - /bg (aliases /background, /jobs) opens a Background Processes panel listing the registry from agents.list — per-process command + uptime + status, running count, and a single STOP-ALL action (x → process.stop; the gateway exposes kill_all only, so there's no per-row kill — noted in the panel). - the reserved status-bar `bg: N` badge (A) now shows the running-process count, fed by a slow (8s) scoped poll of agents.list so it stays live with the panel closed; hidden at zero. Background *runs* are intentionally NOT duplicated here — they're already the resume picker's active-sessions tab; this panel targets the process registry, the actual gap. All TUI-only (agents.list + process.stop already exist). Gate green; new backgroundPanel.test (parse + list + running-count + empty state). --- ui-opentui/src/entry/main.tsx | 29 ++++ ui-opentui/src/logic/slash.ts | 6 + ui-opentui/src/logic/store.ts | 22 ++- ui-opentui/src/test/backgroundPanel.test.tsx | 59 ++++++++ ui-opentui/src/test/slash.test.ts | 1 + ui-opentui/src/test/utilityCommands.test.ts | 1 + ui-opentui/src/view/App.tsx | 29 ++++ .../src/view/overlays/backgroundPanel.tsx | 134 ++++++++++++++++++ ui-opentui/src/view/statusBar.tsx | 11 +- 9 files changed, 289 insertions(+), 3 deletions(-) create mode 100644 ui-opentui/src/test/backgroundPanel.test.tsx create mode 100644 ui-opentui/src/view/overlays/backgroundPanel.tsx diff --git a/ui-opentui/src/entry/main.tsx b/ui-opentui/src/entry/main.tsx index e2d18c746e9..bd5dc7869d0 100644 --- a/ui-opentui/src/entry/main.tsx +++ b/ui-opentui/src/entry/main.tsx @@ -36,6 +36,7 @@ import { makeAppLayer } from '../boundary/runtime.ts' import { nthAssistantResponse } from '../logic/copy.ts' import { envFlag, launchCwd } from '../logic/env.ts' import { createPromptHistory, dirHistoryPersister, loadDirHistory } from '../logic/history.ts' +import { parseProcessList } from '../logic/backgroundActivity.ts' import { createPasteStore } from '../logic/pastes.ts' import { mapResumeHistory } from '../logic/resume.ts' import { @@ -484,6 +485,14 @@ export const run = Effect.fn('Tui.run')(function* (input: TuiInput) { Effect.runPromise(gateway.request('session.title', { session_id: sessionId, title })).then(() => undefined) } + // The background-process panel's gateway calls (view/overlays/backgroundPanel.tsx): + // `agents.list` lists the OS process registry; `process.stop` kills ALL of them + // (the gateway exposes kill-all only — no per-process RPC, hence no per-row kill). + const backgroundOps = { + list: () => Effect.runPromise(gateway.request('agents.list', {})).then(parseProcessList), + stopAll: () => Effect.runPromise(gateway.request('process.stop', {})).then(() => undefined) + } + // Boot-picker Esc fallback: the picker closed without a pick and no // session exists yet (bare `--resume` launch) — create a fresh one so // the composer has somewhere to send prompts. @@ -528,6 +537,7 @@ export const run = Effect.fn('Tui.run')(function* (input: TuiInput) { .tail(200) .map(e => `${e.scope}: ${e.msg}`), openDashboard: () => store.openDashboard(), + openBackgroundPanel: () => store.openBackgroundPanel(), openPager: (title, text) => store.openPager(title, text), openPicker: picker => store.openPicker(picker), openSessionPicker: tab => store.openSessionPicker(tab), @@ -584,6 +594,24 @@ 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. + if (!input.fake) + yield* Effect.forkScoped( + Effect.gen(function* () { + while (true) { + yield* Effect.sleep('8 seconds') + yield* Effect.promise(() => + backgroundOps + .list() + .then(procs => store.setBackgroundProcesses(procs)) + .catch(() => {}) + ) + } + }) + ) + // 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). yield* Effect.promise(() => @@ -604,6 +632,7 @@ export const run = Effect.fn('Tui.run')(function* (input: TuiInput) { history={history} onImagePaste={onImagePaste} pasteStore={pasteStore} + backgroundOps={backgroundOps} /> diff --git a/ui-opentui/src/logic/slash.ts b/ui-opentui/src/logic/slash.ts index b481160d766..8e0c89293a9 100644 --- a/ui-opentui/src/logic/slash.ts +++ b/ui-opentui/src/logic/slash.ts @@ -73,6 +73,8 @@ export interface SlashContext { readonly openPicker: (picker: PickerState) => void /** Open the agents dashboard (/agents, /tasks). */ readonly openDashboard: () => void + /** Open the background-process panel (/bg). */ + readonly openBackgroundPanel: () => void /** Cached `/model` picker rows (Epic 7 instant open); undefined until prefetched. */ readonly modelItems: () => PickerItem[] | undefined /** Update the cached `/model` picker rows. */ @@ -210,6 +212,7 @@ 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)', '/replay [n|path] — inspect an archived spawn tree', '/mem — live memory stats (diag)', '/heapdump — write a V8 heap snapshot (diag)', @@ -663,6 +666,8 @@ const toolsCmd: ClientHandler = async (arg, ctx) => { /** 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(), clear: (_arg, ctx) => ctx.confirm('Clear the transcript?', ctx.clearTranscript), compact: compactCmd, copy: (arg, ctx) => { @@ -673,6 +678,7 @@ const CLIENT: Record = { details: detailsCmd, exit: (_arg, ctx) => ctx.quit(), heapdump: heapdumpCmd, + jobs: (_arg, ctx) => ctx.openBackgroundPanel(), mem: memCmd, model: modelCmd, replay: replayCmd, diff --git a/ui-opentui/src/logic/store.ts b/ui-opentui/src/logic/store.ts index e49c02d9df3..ff65eeb98ee 100644 --- a/ui-opentui/src/logic/store.ts +++ b/ui-opentui/src/logic/store.ts @@ -26,7 +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 { parseNotification, type ActivityNotification, type BackgroundProcess } from './backgroundActivity.ts' import { stripAnsi, stripOmittedNote, stripToolEnvelope } from './toolOutput.ts' import { DEFAULT_THEME, type Theme, themeFromSkin } from './theme.ts' @@ -257,6 +257,10 @@ 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). */ + backgroundPanel: boolean + /** OS background processes (polled from `agents.list`) — the panel + the `bg:` badge. */ + backgroundProcesses: BackgroundProcess[] /** 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 @@ -468,6 +472,8 @@ export function createSessionStore(options?: SessionStoreOptions) { subagents: [], dashboard: false, dashboardAgent: undefined, + backgroundPanel: false, + backgroundProcesses: [], lastNotification: undefined, status: undefined, // startedAt is set ONCE here (store creation ≈ session start) — the status @@ -637,6 +643,17 @@ export function createSessionStore(options?: SessionStoreOptions) { setState('dashboardAgent', undefined) } + function openBackgroundPanel() { + setState('backgroundPanel', true) + } + function closeBackgroundPanel() { + setState('backgroundPanel', false) + } + /** Replace the polled OS-process snapshot (drives the panel + the `bg:` badge). */ + function setBackgroundProcesses(procs: BackgroundProcess[]) { + setState('backgroundProcesses', procs) + } + /** Open a local Y/N confirm dialog (non-gateway; e.g. /clear). */ function setConfirm(message: string, onConfirm: () => void) { setState('prompt', { kind: 'confirm', message, onConfirm }) @@ -1122,6 +1139,9 @@ export function createSessionStore(options?: SessionStoreOptions) { setDetails, openDashboard, closeDashboard, + openBackgroundPanel, + closeBackgroundPanel, + setBackgroundProcesses, hydrate, beginBuffer, commitSnapshot, diff --git a/ui-opentui/src/test/backgroundPanel.test.tsx b/ui-opentui/src/test/backgroundPanel.test.tsx new file mode 100644 index 00000000000..7eac50826a7 --- /dev/null +++ b/ui-opentui/src/test/backgroundPanel.test.tsx @@ -0,0 +1,59 @@ +/** + * Background-process panel (P3) — /bg opens it; it lists the polled OS processes + * with a running count + stop-all affordance, and shows an empty state. + */ +import { describe, expect, test } from 'vitest' + +import { parseProcessList } from '../logic/backgroundActivity.ts' +import { createSessionStore } from '../logic/store.ts' +import { App } from '../view/App.tsx' +import { ThemeProvider } from '../view/theme.tsx' +import { captureFrame } from './lib/render.ts' + +function appWith(store: ReturnType) { + return () => ( + store.state.theme}> + + + ) +} + +describe('background-process panel (P3)', () => { + test('parseProcessList maps an agents.list result (snake_case → camel, skips junk)', () => { + const procs = parseProcessList({ + processes: [ + { session_id: 's1', command: 'vite dev', status: 'running', uptime_seconds: 42 }, + { command: 'no session id — dropped' }, + { session_id: 's2', command: 'claude --bg', status: 'exited', uptime_seconds: 5 } + ] + }) + expect(procs).toEqual([ + { sessionId: 's1', command: 'vite dev', status: 'running', uptimeSeconds: 42 }, + { sessionId: 's2', command: 'claude --bg', status: 'exited', uptimeSeconds: 5 } + ]) + }) + + test('the panel lists processes with a running count + stop-all hint', async () => { + const store = createSessionStore() + store.apply({ type: 'gateway.ready' }) + store.setBackgroundProcesses([ + { sessionId: 's1', command: 'vite dev --host 0.0.0.0 --port 3000', status: 'running', uptimeSeconds: 125 }, + { sessionId: 's2', command: 'pytest -x --watch', status: 'running', uptimeSeconds: 8 }, + { sessionId: 's3', command: 'claude-code background job', status: 'exited', uptimeSeconds: 4 } + ]) + store.openBackgroundPanel() + const frame = await captureFrame(appWith(store), { until: 'Background processes', width: 110, height: 24 }) + expect(frame).toContain('Background processes · 2 running') // exited one excluded + expect(frame).toContain('vite dev') + expect(frame).toContain('pytest') + expect(frame).toContain('x stop all') // footer affordance + }) + + test('empty state when nothing is running', async () => { + const store = createSessionStore() + store.apply({ type: 'gateway.ready' }) + store.openBackgroundPanel() + const frame = await captureFrame(appWith(store), { until: 'Background processes', width: 110, height: 24 }) + expect(frame).toContain('No background processes running.') + }) +}) diff --git a/ui-opentui/src/test/slash.test.ts b/ui-opentui/src/test/slash.test.ts index cc387972667..e63566a8904 100644 --- a/ui-opentui/src/test/slash.test.ts +++ b/ui-opentui/src/test/slash.test.ts @@ -226,6 +226,7 @@ function makeCtx(request: (method: string, params: Record) => P modelItems: () => modelCache.value, setModelItems: items => (modelCache.value = items), openDashboard: () => (dashboard.value = true), + openBackgroundPanel: () => {}, 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 93c4edf38d3..2872c5b5a53 100644 --- a/ui-opentui/src/test/utilityCommands.test.ts +++ b/ui-opentui/src/test/utilityCommands.test.ts @@ -69,6 +69,7 @@ function makeCtx(request: (method: string, params: Record) => P modelItems: () => undefined, setModelItems: () => {}, openDashboard: () => {}, + openBackgroundPanel: () => {}, openPager: (title, text) => paged.push({ text, title }), openPicker: () => {}, openSessionPicker: () => {}, diff --git a/ui-opentui/src/view/App.tsx b/ui-opentui/src/view/App.tsx index 7c4053095e8..f5f74842ffe 100644 --- a/ui-opentui/src/view/App.tsx +++ b/ui-opentui/src/view/App.tsx @@ -20,12 +20,14 @@ import { deferClose } from '../logic/defer.ts' import type { PromptHistory as ComposerHistory } from '../logic/history.ts' import type { PasteStore } from '../logic/pastes.ts' import { actionCommand, promptHistoryEntries } from '../logic/promptHistory.ts' +import type { BackgroundProcess } from '../logic/backgroundActivity.ts' import type { SessionStore } from '../logic/store.ts' import { AgentsTray, type AgentsTrayApi } from './agentsTray.tsx' import { Composer } from './composer.tsx' import { DimensionsProvider } from './dimensions.tsx' import { Header } from './header.tsx' import { AgentsDashboard } from './overlays/agentsDashboard.tsx' +import { BackgroundPanel } from './overlays/backgroundPanel.tsx' import { Pager } from './overlays/pager.tsx' import { Picker } from './overlays/picker.tsx' import { PromptHistory } from './overlays/promptHistory.tsx' @@ -52,6 +54,11 @@ export interface AppProps { readonly history?: ComposerHistory readonly onImagePaste?: () => void readonly pasteStore?: PasteStore + /** Gateway calls for the background-process panel (agents.list + process.stop). */ + readonly backgroundOps?: { + list: () => Promise + stopAll: () => Promise + } } const NOOP = () => {} @@ -75,6 +82,7 @@ export function App(props: AppProps) { const blocked = () => props.store.state.prompt !== undefined const pager = () => props.store.state.pager const dashboard = () => props.store.state.dashboard + const backgroundPanel = () => props.store.state.backgroundPanel const sessionPicker = () => props.store.state.sessionPicker const picker = () => props.store.state.picker const promptHistory = () => props.store.state.promptHistory @@ -82,6 +90,14 @@ export function App(props: AppProps) { // the freshly-remounted composer (see deferClose). const closePager = () => deferClose(() => props.store.closePager()) const closeDashboard = () => deferClose(() => props.store.closeDashboard()) + const closeBackgroundPanel = () => deferClose(() => props.store.closeBackgroundPanel()) + // Fetch the current OS-process snapshot into the store (panel refresh + the bg badge). + const refreshBackground = () => { + void props.backgroundOps + ?.list() + .then(procs => props.store.setBackgroundProcesses(procs)) + .catch(() => {}) + } // close WITHOUT a pick — the boot path may create a fresh session here. const closeSessionPicker = () => deferClose(() => { @@ -204,6 +220,19 @@ export function App(props: AppProps) { preselect={props.store.state.dashboardAgent} /> + + { + void props.backgroundOps + ?.stopAll() + .then(refreshBackground) + .catch(() => {}) + }} + onClose={closeBackgroundPanel} + /> + diff --git a/ui-opentui/src/view/overlays/backgroundPanel.tsx b/ui-opentui/src/view/overlays/backgroundPanel.tsx new file mode 100644 index 00000000000..53aa711e15b --- /dev/null +++ b/ui-opentui/src/view/overlays/backgroundPanel.tsx @@ -0,0 +1,134 @@ +/** + * BackgroundPanel — the OS-level "background processes" overlay. Mirrors + * `agentsDashboard.tsx`'s shell (bordered full-height root, header, scrollable + * list, footer hint, `useCloseLayer` + `useKeyboard` + onMount focus). + * + * 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 { 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 + 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)) + if (s < 60) return `${s}s` + if (s < 3600) return `${Math.floor(s / 60)}m` + const h = Math.floor(s / 3600) + const m = Math.floor((s % 3600) / 60) + return `${h}h ${String(m).padStart(2, '0')}m` +} + +export function BackgroundPanel(props: { + processes: BackgroundProcess[] + onRefresh: () => void + onStopAll: () => void + onClose: () => void +}): JSXElement { + const theme = useTheme() + const dims = useDimensions() + let rootRef: BoxRenderable | undefined + + const running = () => props.processes.filter(p => isRunning(p.status)).length + + // Focus the root so the focus-within close layer is active; refresh once on + // open so the list is fresh. + onMount(() => { + rootRef?.focus() + props.onRefresh() + }) + useCloseLayer( + () => rootRef, + () => props.onClose() + ) + + useKeyboard(key => { + // Esc/Ctrl+C close via the keymap (useCloseLayer); the rest here. + if (key.name === 'q') return props.onClose() + if (key.name === 'r') return props.onRefresh() + if (key.name === 'x') return props.onStopAll() + }) + + return ( + (rootRef = el)} + focusable + style={{ borderColor: theme().color.accent, flexDirection: 'column', flexGrow: 1, minHeight: 0 }} + border + > + + + ▦ Background processes · {running()} running + + + + + 0} + fallback={ + + No background processes running. + + } + > + + + {proc => { + // Budget the command into the leftover row width so it never + // wraps: total − border/pad − glyph(2) − ` · ` tail. + const tail = () => ` · ${fmtUptime(proc.uptimeSeconds)} ${proc.status}` + const cmdMax = () => Math.max(8, dims().width - 4 - 2 - tail().length) + return ( + + + {truncRight(proc.command, cmdMax())} + {` · ${fmtUptime(proc.uptimeSeconds)} `} + {proc.status} + + ) + }} + + + + + + + + Esc/q close · r refresh · x stop all + + + + ) +} diff --git a/ui-opentui/src/view/statusBar.tsx b/ui-opentui/src/view/statusBar.tsx index 74bc9ce4544..f00f9b31b50 100644 --- a/ui-opentui/src/view/statusBar.tsx +++ b/ui-opentui/src/view/statusBar.tsx @@ -41,6 +41,7 @@ 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 { useDimensions } from './dimensions.tsx' import { elapsedSeconds, useElapsedTick } from './elapsed.ts' @@ -258,6 +259,12 @@ 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). + const bgText = createMemo(() => { + const n = runningCount([], props.store.state.backgroundProcesses) + return segs().bg && n > 0 ? `bg: ${n}` : '' + }) // The cwd flows LAST on the same line (not right-pinned): its budget is the // row width minus every segment before it; it tail-truncates into that, and @@ -265,7 +272,7 @@ export function StatusBar(props: { store: SessionStore }) { const leftLen = createMemo(() => { let len = 1 // dot if (model()) len += 1 + model().length + effort().length - for (const seg of [ctxText(), costText(), upText(), cmpText(), profileText(), mcpText()]) { + for (const seg of [ctxText(), costText(), upText(), cmpText(), profileText(), bgText(), mcpText()]) { if (seg) len += SEP.length + seg.length } return len @@ -340,7 +347,7 @@ export function StatusBar(props: { store: SessionStore }) { {/* statusFg, not accent — persistent chrome spends no warm ink (design pass); the navy fill is the bar's one blue surface. */} - {/* `bg: N` would slot here (segs().bg) — no store data feeds it yet (see header). */} + {/* the cwd is RIGHT-PINNED (F10): a flex spacer eats the slack so the