opentui(v6): background-activity P3 — /bg process panel + ambient bg badge (no core)

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).
This commit is contained in:
alt-glitch 2026-06-13 22:09:11 +05:30
parent 74cb03423e
commit 7016fa4902
9 changed files with 289 additions and 3 deletions

View file

@ -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}
/>
</ThemeProvider>
</KeymapProvider>

View file

@ -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<string, ClientHandler> = {
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<string, ClientHandler> = {
details: detailsCmd,
exit: (_arg, ctx) => ctx.quit(),
heapdump: heapdumpCmd,
jobs: (_arg, ctx) => ctx.openBackgroundPanel(),
mem: memCmd,
model: modelCmd,
replay: replayCmd,

View file

@ -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,

View file

@ -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<typeof createSessionStore>) {
return () => (
<ThemeProvider theme={() => store.state.theme}>
<App store={store} />
</ThemeProvider>
)
}
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.')
})
})

View file

@ -226,6 +226,7 @@ function makeCtx(request: (method: string, params: Record<string, unknown>) => 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),

View file

@ -69,6 +69,7 @@ function makeCtx(request: (method: string, params: Record<string, unknown>) => P
modelItems: () => undefined,
setModelItems: () => {},
openDashboard: () => {},
openBackgroundPanel: () => {},
openPager: (title, text) => paged.push({ text, title }),
openPicker: () => {},
openSessionPicker: () => {},

View file

@ -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<BackgroundProcess[]>
stopAll: () => Promise<void>
}
}
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}
/>
</Match>
<Match when={backgroundPanel()}>
<BackgroundPanel
processes={props.store.state.backgroundProcesses}
onRefresh={refreshBackground}
onStopAll={() => {
void props.backgroundOps
?.stopAll()
.then(refreshBackground)
.catch(() => {})
}}
onClose={closeBackgroundPanel}
/>
</Match>
</Switch>
</box>
</SessionInfoProvider>

View file

@ -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<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
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 (
<box
ref={el => (rootRef = el)}
focusable
style={{ borderColor: theme().color.accent, flexDirection: 'column', flexGrow: 1, minHeight: 0 }}
border
>
<box style={{ flexShrink: 0, paddingLeft: 1 }}>
<text fg={theme().color.accent} selectable={false}>
<b> Background processes · {running()} running</b>
</text>
</box>
<box style={{ flexGrow: 1, minHeight: 0, paddingLeft: 1, flexDirection: 'column' }}>
<Show
when={props.processes.length > 0}
fallback={
<text fg={theme().color.muted} selectable={false}>
No background processes running.
</text>
}
>
<scrollbox style={{ flexGrow: 1, minHeight: 0 }} stickyScroll stickyStart="top">
<For each={props.processes}>
{proc => {
// Budget the command into the leftover row width so it never
// wraps: total border/pad glyph(2) ` · <uptime> <status>` tail.
const tail = () => ` · ${fmtUptime(proc.uptimeSeconds)} ${proc.status}`
const cmdMax = () => Math.max(8, dims().width - 4 - 2 - tail().length)
return (
<text selectable={false}>
<span style={{ fg: statusColor(proc.status, theme) }}> </span>
<span style={{ fg: theme().color.text }}>{truncRight(proc.command, cmdMax())}</span>
<span style={{ fg: theme().color.muted }}>{` · ${fmtUptime(proc.uptimeSeconds)} `}</span>
<span style={{ fg: statusColor(proc.status, theme) }}>{proc.status}</span>
</text>
)
}}
</For>
</scrollbox>
</Show>
</box>
<box style={{ flexShrink: 0, paddingLeft: 1 }}>
<text fg={theme().color.muted} selectable={false}>
Esc/q close · r refresh · x stop all
</text>
</box>
</box>
)
}

View file

@ -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. */}
<Seg text={profileText()} fg={theme().color.statusFg} />
{/* `bg: N` would slot here (segs().bg) — no store data feeds it yet (see header). */}
<Seg text={bgText()} fg={theme().color.statusWarn} />
<Seg text={mcpText()} />
</text>
{/* the cwd is RIGHT-PINNED (F10): a flex spacer eats the slack so the