mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-12 13:52:15 +00:00
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:
parent
74cb03423e
commit
7016fa4902
9 changed files with 289 additions and 3 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
59
ui-opentui/src/test/backgroundPanel.test.tsx
Normal file
59
ui-opentui/src/test/backgroundPanel.test.tsx
Normal 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.')
|
||||
})
|
||||
})
|
||||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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: () => {},
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
134
ui-opentui/src/view/overlays/backgroundPanel.tsx
Normal file
134
ui-opentui/src/view/overlays/backgroundPanel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue