From 1d30aa5571a06cde9772f7debe1c8255655b5a39 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Sun, 20 Sep 2026 12:32:19 -0700 Subject: [PATCH] feat(tui): Processes block in the live-work dock and /agents overlay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- ui-tui/src/app/useMainApp.ts | 12 +++ ui-tui/src/components/agentsOverlay.tsx | 26 ++++++ ui-tui/src/components/agentsPanel.tsx | 114 +++++++++++++++++++++--- 3 files changed, 140 insertions(+), 12 deletions(-) diff --git a/ui-tui/src/app/useMainApp.ts b/ui-tui/src/app/useMainApp.ts index b3c6600dcb..248a659d4c 100644 --- a/ui-tui/src/app/useMainApp.ts +++ b/ui-tui/src/app/useMainApp.ts @@ -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('session.active_list', { current_session_id: getUiState().sid }) .then(raw => { const result = asRpcResult(raw) diff --git a/ui-tui/src/components/agentsOverlay.tsx b/ui-tui/src/components/agentsOverlay.tsx index f7221bf450..55873d92c3 100644 --- a/ui-tui/src/components/agentsOverlay.tsx +++ b/ui-tui/src/components/agentsOverlay.tsx @@ -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 ( + + + {`Processes · ${processSummary(block)}`} + + {block.rows.map(row => ( + + ))} + + ) +} + function Field({ name, t, value }: { name: string; t: Theme; value: ReactNode }) { return ( @@ -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) const prevLiveCountRef = useRef(liveSubagents.length) @@ -938,6 +962,7 @@ export function AgentsOverlay({ gw, initialHistoryIndex = 0, onClose, t }: Agent ) : rows.length === 0 ? ( No subagents this turn. Trigger delegate_task to populate the tree. + ) : mode === 'list' ? ( @@ -958,6 +983,7 @@ export function AgentsOverlay({ gw, initialHistoryIndex = 0, onClose, t }: Agent /> ))} + ) : ( diff --git a/ui-tui/src/components/agentsPanel.tsx b/ui-tui/src/components/agentsPanel.tsx index 71dcea81a5..07e937cc3d 100644 --- a/ui-tui/src/components/agentsPanel.tsx +++ b/ui-tui/src/components/agentsPanel.tsx @@ -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 ( + + {glyph.glyph} + {compactPreview(row.command, commandWidth)} + · {activity} + + ) +} + 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 ( - - {collapsed - ? summary + activity + hints - : `▾ ${running} live agents${hidden ? ` · +${hidden} more` : ''} · Ctrl+T expand · F7 collapse`} - + {collapsed ? ( + + {summary + activity + hints} + + ) : null} + {!collapsed && running ? ( + + {`▾ ${running} live agents${hidden ? ` · +${hidden} more` : ''} · Ctrl+T expand · F7 collapse`} + + ) : null} {!collapsed && rows.map(row => ( @@ -51,6 +127,14 @@ export function AgentsPanelView({ {` ↳ ${compactPreview(row.detail, cols - 4)}`} ))} + {!collapsed && processes.total ? ( + + {`▾ Processes · ${processSummary(processes)}${processes.hidden ? ` · +${processes.hidden} more` : ''}${ + running ? '' : ' · Ctrl+T expand · F7 collapse' + }`} + + ) : null} + {!collapsed && processes.rows.map(row => )} ) } @@ -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 ( )