opentui(v6): fix /bg — it launches a background PROMPT, not the process panel

I conflated two "background" concepts. In hermes, /bg (aliases /background, /btw)
launches a background PROMPT (prompt.background → background.complete), and the
`bg: N` badge counts in-flight prompt tasks — but P3 hijacked /bg + bg: for the
OS-process registry and never handled background.complete (so completions were
silent). Corrected:

- /bg <prompt> now launches a background prompt (Ink parity): prompt.background,
  echoes "bg <id> started", tracks the task in store.bgTasks.
- background.complete → drops the task + renders a distinct inline completion
  CARD with the result (the missing completion notification; a completion-ish
  kind also fires the OSC desktop ping).
- `bg: N` badge counts store.bgTasks (in-flight background prompts), not OS procs.
- the OS-process panel moved off /bg to /processes (+ /procs); its header count
  uses runningCount. Dropped the ambient agents.list poll — the badge is now
  event-driven and the panel fetches on open.

Gate green; new store test for background.complete (card + badge decrement).
This commit is contained in:
alt-glitch 2026-06-13 22:44:02 +05:30
parent 76eab10b14
commit 8e853e3ff8
8 changed files with 92 additions and 36 deletions

View file

@ -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).

View file

@ -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 <prompt> — 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 <prompt>` (aliases /background, /btw) launch a background PROMPT via
* `prompt.background` (Ink parity): echo "bg <id> 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 <prompt> — 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<string, ClientHandler> = {
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<string, ClientHandler> = {
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,

View file

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

View file

@ -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', () => {

View file

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

View file

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

View file

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

View file

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