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:
@@ -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)
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user