feat(tui): Processes block in the live-work dock and /agents overlay

The Ink dock and /agents overlay only merged subagent.* events with
subagent.list. They now poll process.list for the owning session on the
same cadence and paint a Processes block under the agents (⚙ command ·
elapsed · last output; exit verdict for 60 s after exit). The dock budget
is split so neither block hides the other; processes alone still surface
the dock. Glyphs live in lib/processGlyph.ts so panel and overlay match.
This commit is contained in:
teknium1
2026-09-20 12:32:19 -07:00
committed by Teknium
parent caa2e2dd99
commit 1d30aa5571
3 changed files with 140 additions and 12 deletions

View File

@@ -57,6 +57,7 @@ import { getInputSelection } from './inputSelectionStore.js'
import { type GatewayRpc, type StateSetter, type TranscriptRow } from './interfaces.js'
import { $overlayState, patchOverlayState } from './overlayStore.js'
import { $goodVibesTick } from './petFlashStore.js'
import { applyProcessSnapshot, type ProcessEntry } from './processRoster.js'
import { scrollWithSelectionBy } from './scroll.js'
import { respondToServerRequest } from './serverRequestStore.js'
import { turnController } from './turnController.js'
@@ -608,6 +609,7 @@ export function useMainApp(gw: GatewayClient) {
let stopped = false
applyAgentSnapshot(ui.sid)
applyProcessSnapshot(ui.sid)
const refresh = () => {
const sid = ui.sid
@@ -620,6 +622,16 @@ export function useMainApp(gw: GatewayClient) {
}
})
.catch(() => {})
// Background processes share the dock with the subagents (Processes block).
gw.request<{ processes: ProcessEntry[] }>('process.list', { session_id: sid })
.then(raw => {
const result = asRpcResult<{ processes: ProcessEntry[] }>(raw)
if (!stopped && result && getUiState().sid === sid) {
applyProcessSnapshot(sid, result.processes ?? [])
}
})
.catch(() => {})
gw.request<SessionActiveListResponse>('session.active_list', { current_session_id: getUiState().sid })
.then(raw => {
const result = asRpcResult<SessionActiveListResponse>(raw)

View File

@@ -10,6 +10,7 @@ import {
toggleOverlaySection
} from '../app/delegationStore.js'
import { patchOverlayState } from '../app/overlayStore.js'
import { type ProcessRow, useProcessRows } from '../app/processRoster.js'
import { $spawnDiff, $spawnHistory, clearDiffPair, type SpawnSnapshot } from '../app/spawnHistoryStore.js'
import { $uiState } from '../app/uiStore.js'
import type { GatewayClient } from '../gatewayClient.js'
@@ -35,6 +36,7 @@ import type { Theme } from '../theme.js'
import type { SubagentNode, SubagentProgress } from '../types.js'
import { AgentLiveTail, AgentSteerForm, rosterViewport } from './agentControls.js'
import { buildProcessBlock, ProcessRowLine, processSummary } from './agentsPanel.js'
import { listRowStyle } from './overlayPrimitives.js'
import { OverlayScrollbar } from './overlayScrollbar.js'
@@ -295,6 +297,27 @@ function OverlaySection({
)
}
/** Background processes owned by this session, listed under the spawn tree. They are
* not part of the cursor roster (no per-row steer/tail); `/stop` ends them all. */
function ProcessesSection({ cols, rows, t }: { cols: number; rows: readonly ProcessRow[]; t: Theme }) {
if (rows.length === 0) {
return null
}
const block = buildProcessBlock(rows, 0)
return (
<Box flexDirection="column" flexShrink={0} marginTop={1}>
<Text bold color={t.color.accent} wrap="truncate-end">
{`Processes · ${processSummary(block)}`}
</Text>
{block.rows.map(row => (
<ProcessRowLine cols={cols} key={row.id} row={row} t={t} />
))}
</Box>
)
}
function Field({ name, t, value }: { name: string; t: Theme; value: ReactNode }) {
return (
<Text wrap="truncate-end">
@@ -604,6 +627,7 @@ export function AgentsOverlay({ gw, initialHistoryIndex = 0, onClose, t }: Agent
// scrollable pane. Two panes side-by-side in Ink fought Yoga flex.
const [mode, setMode] = useState<'detail' | 'list' | 'steer' | 'tail'>('list')
const { sid } = useStore($uiState)
const processRows = useProcessRows(now)
const detailScrollRef = useRef<null | ScrollBoxHandle>(null)
const prevLiveCountRef = useRef(liveSubagents.length)
@@ -938,6 +962,7 @@ export function AgentsOverlay({ gw, initialHistoryIndex = 0, onClose, t }: Agent
) : rows.length === 0 ? (
<Box flexDirection="column" flexGrow={1}>
<Text color={t.color.muted}>No subagents this turn. Trigger delegate_task to populate the tree.</Text>
<ProcessesSection cols={cols - 2} rows={processRows} t={t} />
</Box>
) : mode === 'list' ? (
<Box flexDirection="column" flexGrow={1} flexShrink={1} minHeight={0}>
@@ -958,6 +983,7 @@ export function AgentsOverlay({ gw, initialHistoryIndex = 0, onClose, t }: Agent
/>
))}
</Box>
<ProcessesSection cols={cols - 2} rows={processRows} t={t} />
</Box>
) : (
<Box flexDirection="row" flexGrow={1} flexShrink={1} minHeight={0}>

View File

@@ -4,29 +4,100 @@ import { useStore } from '@nanostores/react'
import { useEffect, useState } from 'react'
import { $agentDockCollapsed, useAgentRoster } from '../app/agentRoster.js'
import { type ProcessRow, useProcessRows } from '../app/processRoster.js'
import { $uiState } from '../app/uiStore.js'
import { type AgentRows, buildAgentRows, dockRowLimit } from '../lib/agentRows.js'
import { processGlyph } from '../lib/processGlyph.js'
import { statusGlyph } from '../lib/subagentGlyph.js'
import { fmtDuration } from '../lib/subagentTree.js'
import { compactPreview } from '../lib/text.js'
import type { Theme } from '../theme.js'
export interface ProcessBlock {
/** Rows that exist but were cut by the height bound. */
hidden: number
rows: ProcessRow[]
running: number
total: number
}
export const EMPTY_PROCESSES: ProcessBlock = { hidden: 0, rows: [], running: 0, total: 0 }
/** Height-bound the process rows the way `buildAgentRows` bounds agents; callers
* split one dock budget between the two blocks. */
export const buildProcessBlock = (rows: readonly ProcessRow[], maxRows: number): ProcessBlock => {
const shown = maxRows > 0 ? rows.slice(0, maxRows) : [...rows]
return {
hidden: rows.length - shown.length,
rows: shown,
running: rows.filter(r => r.status === 'running').length,
total: rows.length
}
}
/** Split a dock row budget when both blocks are present so neither hides the other. */
export const splitDockBudget = (
limit: number,
agents: number,
processes: number
): { agents: number; processes: number } => {
if (!processes) {
return { agents: limit, processes: 0 }
}
const agentBudget = Math.min(agents, Math.max(1, limit - Math.max(1, Math.floor(limit / 2))))
return { agents: agentBudget, processes: Math.max(1, limit - agentBudget) }
}
export const processSummary = (block: ProcessBlock): string => {
const done = block.total - block.running
return [block.running ? `${block.running} running` : '', done ? `${done} done` : ''].filter(Boolean).join(' · ')
}
/** One `⚙ command · 42s · last: …` line per process; the exit verdict replaces the
* activity once the process has finished. */
export function ProcessRowLine({ cols, row, t }: { cols: number; row: ProcessRow; t: Theme }) {
const glyph = processGlyph(row.status, t)
const activity = row.status === 'running' ? `${fmtDuration(row.elapsedSeconds)} · ${row.detail}` : row.detail
const commandWidth = Math.max(8, cols - stringWidth(activity) - 6)
return (
<Text wrap="truncate-end">
<Text color={glyph.color}>{glyph.glyph} </Text>
<Text color={t.color.text}>{compactPreview(row.command, commandWidth)}</Text>
<Text color={t.color.muted}> · {activity}</Text>
</Text>
)
}
export function AgentsPanelView({
collapsed = false,
cols,
hidden,
processes = EMPTY_PROCESSES,
rows,
running,
t
}: AgentRows & { collapsed?: boolean; cols: number; t: Theme }) {
if (!running) {
}: AgentRows & { collapsed?: boolean; cols: number; processes?: ProcessBlock; t: Theme }) {
if (!running && !processes.total) {
return null
}
const summary = `▸ ${running} live agents`
const counts = [
running ? `${running} live agents` : '',
processes.total ? (processes.running ? `${processes.running} procs` : `${processes.total} done`) : ''
]
.filter(Boolean)
.join(' · ')
const summary = `▸ ${counts}`
const hints = ' · Ctrl+T expand · F7 restore'
const activityWidth = cols - stringWidth(summary + hints) - 3
const activity = rows[0]?.detail && activityWidth >= 12 ? ` · ${compactPreview(rows[0].detail, activityWidth)}` : ''
const firstDetail = rows[0]?.detail ?? processes.rows[0]?.detail ?? ''
const activity = firstDetail && activityWidth >= 12 ? ` · ${compactPreview(firstDetail, activityWidth)}` : ''
return (
<Box
@@ -35,11 +106,16 @@ export function AgentsPanelView({
flexShrink={0}
width={cols}
>
<Text bold color={t.color.accent} wrap="truncate-end">
{collapsed
? summary + activity + hints
: `▾ ${running} live agents${hidden ? ` · +${hidden} more` : ''} · Ctrl+T expand · F7 collapse`}
</Text>
{collapsed ? (
<Text bold color={t.color.accent} wrap="truncate-end">
{summary + activity + hints}
</Text>
) : null}
{!collapsed && running ? (
<Text bold color={t.color.accent} wrap="truncate-end">
{`▾ ${running} live agents${hidden ? ` · +${hidden} more` : ''} · Ctrl+T expand · F7 collapse`}
</Text>
) : null}
{!collapsed &&
rows.map(row => (
<Box flexDirection="column" key={row.key}>
@@ -51,6 +127,14 @@ export function AgentsPanelView({
<Text color={t.color.muted} wrap="truncate-end">{` ↳ ${compactPreview(row.detail, cols - 4)}`}</Text>
</Box>
))}
{!collapsed && processes.total ? (
<Text bold color={t.color.accent} wrap="truncate-end">
{`▾ Processes · ${processSummary(processes)}${processes.hidden ? ` · +${processes.hidden} more` : ''}${
running ? '' : ' · Ctrl+T expand · F7 collapse'
}`}
</Text>
) : null}
{!collapsed && processes.rows.map(row => <ProcessRowLine cols={cols} key={row.id} row={row} t={t} />)}
</Box>
)
}
@@ -62,21 +146,27 @@ export function LiveAgentsPanel({ cols }: { cols: number }) {
const subagents = useAgentRoster()
const live = subagents.some(s => s.status === 'running' || s.status === 'queued')
const [now, setNow] = useState(Date.now)
const processRows = useProcessRows(now)
// Process rows carry `Ns ago` / elapsed text, so the clock ticks while any are shown.
const ticking = live || processRows.length > 0
useEffect(() => {
if (!live) {
if (!ticking) {
return
}
const timer = setInterval(() => setNow(Date.now()), 1000)
return () => clearInterval(timer)
}, [live])
}, [ticking])
const budget = splitDockBudget(dockRowLimit(stdout?.rows ?? 24), subagents.length, processRows.length)
return (
<AgentsPanelView
collapsed={collapsed}
cols={cols}
{...buildAgentRows(subagents, [], now, dockRowLimit(stdout?.rows ?? 24))}
{...buildAgentRows(subagents, [], now, budget.agents)}
processes={buildProcessBlock(processRows, budget.processes)}
t={theme}
/>
)