import * as fs from "node:fs"; // Tier A (schedules pane): job reads go through the extension-layer provider // (G17 single source of truth) — this module NEVER touches the scheduler or // the scheduledJobs settings key directly. handle-schedule.ts is a light // module (crypto + scheduler + settings-store); the heavy team-tool.ts chain // is NOT pulled in. import { getScheduledJobs, getScheduledJobsHiddenCountView } from "../extension/team-tool/handle-schedule.ts"; import type { MetricRegistry } from "../observability/metric-registry.ts"; import { readCrewAgents } from "../runtime/crew-agent-records.ts"; import type { CrewAgentRecord } from "../runtime/crew-agent-runtime.ts"; import { getLiveAgentContextPercent } from "../runtime/live-session/live-agent-manager.ts"; import { isPlanApprovalPending } from "../runtime/plan-approval.ts"; import { isActiveRunStatus, isDisplayActiveRun, isLikelyOrphanedActiveRun } from "../runtime/process-status.ts"; import type { ScheduledJob } from "../runtime/scheduling/scheduler.ts"; import type { TeamRunManifest, TeamTaskState, UsageState } from "../state/types.ts"; import { aggregateUsage } from "../state/usage.ts"; import { readJsonFileCoalesced } from "../utils/file-coalescer.ts"; import { logInternalError } from "../utils/internal-error.ts"; import { resolveRealContainedPath } from "../utils/safe-paths.ts"; import { sanitizeLine, truncate, visibleWidth } from "../utils/visual.ts"; import type { InteractiveComponent } from "./component.ts"; import { renderAgentsPane } from "./dashboard-panes/agents-pane.ts"; import { summarizeTerminalReason } from "./dashboard-panes/cancellation-pane.ts"; import { renderHealthPane } from "./dashboard-panes/health-pane.ts"; import { renderMailboxPane } from "./dashboard-panes/mailbox-pane.ts"; import { renderMetricsPane } from "./dashboard-panes/metrics-pane.ts"; import { renderPlanPane } from "./dashboard-panes/plan-pane.ts"; import { renderProgressPane } from "./dashboard-panes/progress-pane.ts"; import { renderScheduleDetails, renderSchedulesPane } from "./dashboard-panes/schedules-pane.ts"; import { renderTranscriptPane } from "./dashboard-panes/transcript-pane.ts"; import { formatCount, formatDuration, formatTokens, teamWorkflowLabel } from "./format-helpers.ts"; import { goalFlagSuffix } from "./goal-flag.ts"; import { type DashboardKeyAction, dashboardActionForKey } from "./keybinding-map.ts"; import { HelpOverlay } from "./overlays/help-overlay.ts"; import { ACTIVE, CURSOR, canopyLine, formatHint, gaugeBar, overflowHint, RAIL, railLine, sectionLine } from "./rail.ts"; import type { OverlaySchedulerHandle } from "./shared-overlay-scheduler.ts"; import { registerOverlayScheduler } from "./shared-overlay-scheduler.ts"; import type { RunSnapshotCache, RunUiSnapshot } from "./snapshot-types.ts"; import { spinnerBucket, spinnerFrame } from "./spinner.ts"; import { applyStatusColor, colorizeStatusGlyphs, iconForStatus, type RunStatus } from "./status-colors.ts"; import type { CrewTheme } from "./theme-adapter.ts"; import { asCrewTheme, subscribeThemeChange } from "./theme-adapter.ts"; import { renderLines } from "./widget/widget-renderer.ts"; /** S05 — wrap a pane render in try/catch so a single pane crash does not bring down the whole dashboard. */ function safeRenderPane(name: string, fn: () => string[]): string[] { try { return fn(); } catch (error) { const message = error instanceof Error ? error.message : String(error); logInternalError("run-dashboard", new Error(`Dashboard pane '${name}' render failed: ${message}`)); return [``]; } } // PR-G3 (UI-3): extend the shared InteractiveComponent contract (render + // invalidate + handleInput). Structurally identical — no behavior change. interface DashboardComponent extends InteractiveComponent {} export interface RunDashboardOptions { showModel?: boolean; showTokens?: boolean; showTools?: boolean; snapshotCache?: RunSnapshotCache; runProvider?: () => TeamRunManifest[]; /** Pinned render clock (D6-T4) for pane renderers (agents pane durations). */ nowMs?: number; registry?: MetricRegistry; /** * Workspace/session ID for filtering runs and live agents. When provided, * only runs with matching ownerSessionId and live agents with matching * workspaceId are shown. This ensures session isolation in the UI. */ workspaceId?: string; /** Tier A (schedules pane): injectable clock source (D6-T4). Render paths * receive `now: Date` as a parameter and never read the wall clock * themselves; tests inject a fixed Date. When omitted the clock is read * ONCE per schedules-pane render pass, at this injection point only. */ now?: () => Date; /** * Poke the host TUI to repaint after a state change. Must be wired from * `commands.ts` (`() => requestRenderTarget(tui)`) so keypresses and event-bus * updates immediately refresh the overlay instead of waiting on the next * host tick. Without this the overlay can desync and base content (chat, * status line) can paint through stale cells. */ requestRender?: () => void; } /** * Persisted per-process so that pressing `r` (reload) or closing+reopening the * dashboard within the same Pi session keeps the user on the pane they were * looking at. Resetting to "agents" on every `new RunDashboard(...)` was a * UX regression. */ let lastActivePane: "agents" | "progress" | "mailbox" | "output" | "health" | "metrics" | "plan" | "schedules" = "agents"; /** * US-020 AC-2: the selected run SURVIVES dashboard close/reopen (the shared.ts * action loop re-creates RunDashboard after every done() selection — enter on * a run opens its detail, escape returns, and the cursor is where it was). * Persisted per-process like lastActivePane; restored by runId on construct. */ let lastSelectedRunId: string | undefined; /** @internal — test isolation hook. US-020 AC-2 persists the cursor in * module-level state (lastSelectedRunId) so it survives close/reopen cycles, * but that state ALSO leaks across RunDashboard instances within one process: * an E2E brute-force that constructs a fresh dashboard per iteration inherits * the previous iteration's cursor, and with wrap-navigation the driven * selection lands somewhere else entirely (measured: count=9 offset=8 restored * run-007 → 8 'j' wraps to run-006, not run-008). Tests that need a clean * cursor call this before constructing. */ export function __test__resetDashboardState(): void { lastSelectedRunId = undefined; lastActivePane = "agents"; } export type RunDashboardAction = | "status" | "summary" | "artifacts" | "api" | "events" | "agents" | "agent-events" | "agent-output" | "agent-transcript" | "agent-live" | "browser-open" | "mailbox" | "reload" | "mailbox-detail" | "health-recovery" | "health-kill-stale" | "health-diagnostic-export" | "plan-approve" | "plan-deny" | "schedule-enable" | "schedule-disable" | "schedule-run-now" | "schedule-remove" | "notifications-dismiss" | "cancel"; export interface RunDashboardSelection { runId: string; action: RunDashboardAction; /** Tier A: target scheduled-job id. Present only for schedule-* actions * (the extension layer routes them to handle-schedule.ts subActions). */ jobId?: string; } /** * Tier A: map dashboard schedule actions onto handle-schedule.ts subActions — * the ONLY mutation channel (the UI never calls the scheduler directly). * The extension layer consumes `done()` selections carrying schedule-* * actions and dispatches them as * `handleSchedule({ action: 'schedule', subAction, jobId })`. * Kept next to the action union so the two can never drift. */ export function scheduleDashboardActionToSubAction(action: RunDashboardAction): "enable" | "disable" | "run-now" | "remove" | undefined { switch (action) { case "schedule-enable": return "enable"; case "schedule-disable": return "disable"; case "schedule-run-now": return "run-now"; case "schedule-remove": return "remove"; default: return undefined; } } const TASK_READ_TTL_MS = 1000; /** Tier A: short TTL for the scheduled-jobs provider cache. getScheduledJobs() * is cheap when the scheduler singleton is registered (in-memory list) but * falls back to a settings-store disk read otherwise — render ticks must not * hit disk per frame (P0-6 discipline). Key `R` (schedule-refresh) drops the * cache for an immediate re-read. */ const SCHEDULE_JOBS_TTL_MS = 1000; /** Tier A: max schedules-pane body lines per render. Existing run-scoped * panes slice to 8; the schedules table spends 2 lines per job (main + sub), * so pane 8 gets a taller budget (6 jobs + header + key hint) — still well * inside the stable targetHeight clamp (12–36). */ const SCHEDULES_PANE_MAX_LINES = 14; /** Max run rows rendered in the dashboard run-list block (L-1 window budget). */ const RUN_LIST_MAX = 8; /** * 1.10 (UI-P1-2) — short TTL for the buildSignature cache. `buildSignature` * iterates every run and calls `snapshotFor → refreshIfStale`, which can * stat multiple files per run when the TTL expires. Rendering fires several * times per second (spinner + event-bus), so we cache the computed signature * for 100ms: enough to coalesce one render burst without holding the cache * across genuinely new data (>100ms idle means a fresh signature will be * computed on the next render anyway). */ const SIGNATURE_CACHE_TTL_MS = 100; /** * F-4 — a live run's snapshot is considered "possibly stale" once its * `fetchedAt` is this old. A progressing run rebuilds on every stamp change, * so an old `fetchedAt` means the manifest read has been repeatedly flaky * (the cache silently returned the previous entry). */ const STALE_SNAPSHOT_MS = 15_000; /** * L-1 — compute the run-list window (visible slots + which scroll indicators * render) for a given offset. Shared by `ensureRunListWindow()` and the * render loop so they ALWAYS agree and the selection can never land off-screen. */ interface RunListWindow { slots: number; hasTop: boolean; hasBottom: boolean; } function runListWindow(scrollOffset: number, count: number): RunListWindow { const hasTop = scrollOffset > 0; const availNoBottom = Math.max(1, RUN_LIST_MAX - (hasTop ? 1 : 0)); const hasBottom = scrollOffset + availNoBottom < count; const slots = Math.max(1, RUN_LIST_MAX - (hasTop ? 1 : 0) - (hasBottom ? 1 : 0)); return { slots, hasTop, hasBottom }; } /** L7 (real-test 2026-10-07): agent age in the shared formatDuration dialect * (`5m44s`), rendered bare — the retired `age=` prefix was wire format. * Sub-second ages read `now` (quieter than `123ms` on a rail row). */ function formatAge(iso: string | undefined): string | undefined { if (!iso) return undefined; const ms = Math.max(0, Date.now() - new Date(iso).getTime()); if (!Number.isFinite(ms)) return undefined; return ms < 1000 ? "now" : formatDuration(ms); } function readProgressPreview(run: TeamRunManifest, maxLines = 5, snapshotCache?: RunSnapshotCache, resolve?: SnapshotResolver): string[] { // P0-6: prefer the snapshot's `recentOutputLines` (no disk I/O) over reading the // progress artifact on every render. The progress artifact content is captured // into the snapshot's recent events / output pipeline upstream. const snapshot = resolve ? resolve(run) : snapshotFor(run, snapshotCache); if (snapshot?.recentOutputLines?.length) { return ["Progress:", ...snapshot.recentOutputLines.slice(0, maxLines)]; } if (snapshotCache) return ["Progress: (loading…)"]; // Legacy fallback: tests/dev paths without a snapshot cache still read the // progress artifact directly so existing assertions keep working. // RR-021 WI-4.3e: allocation-free reverse scan — the old // [...run.artifacts].reverse().find() copied the whole array per render. // (findLast needs an ES2023 lib bump, which breaks tsgo's RequestInfo // ambient resolution — see WI-4.3 evidence note.) let progress: (typeof run.artifacts)[number] | undefined; for (let i = run.artifacts.length - 1; i >= 0; i--) { if (run.artifacts[i]?.kind === "progress") { progress = run.artifacts[i]; break; } } if (!progress) return ["Progress: (none)"]; try { const progressPath = resolveRealContainedPath(run.artifactsRoot, progress.path); if (!fs.existsSync(progressPath)) return ["Progress: (none)"]; return ["Progress:", ...fs.readFileSync(progressPath, "utf-8").split(/\r?\n/).filter(Boolean).slice(0, maxLines)]; } catch (error) { const message = error instanceof Error ? error.message : String(error); return [`Progress: failed to read (${message})`]; } } /** * L7 (real-test 2026-10-07): TUI usage dialect — `↑1.0k ↓250 $0.012` — * mirroring the compactUsage pattern in live-run-sidebar.ts (kept local * because the sidebar's helper is module-private; do not edit that file). * Replaces the retired `tok=…/in=…/out=…/cache=…/$…` wire format, which * leaked log-line grammar onto rail rows and buried the numbers behind * key=value prefixes. cacheRead/cacheWrite stay hidden (sidebar parity). */ function compactUsage(usage: UsageState | undefined): string | undefined { if (!usage) return undefined; const parts: string[] = []; if (usage.input) parts.push(`↑${formatTokens(usage.input)}`); if (usage.output) parts.push(`↓${formatTokens(usage.output)}`); if (usage.cost) parts.push(`$${usage.cost.toFixed(3)}`); return parts.length ? parts.join(" ") : undefined; } function snapshotFor(run: TeamRunManifest, snapshotCache?: RunSnapshotCache): RunUiSnapshot | undefined { try { return snapshotCache?.refreshIfStale(run.runId); } catch { return snapshotCache?.get(run.runId); } } /** * L5 (real-test 2026-10-07): paint-path snapshot read for the frame-local * SnapshotResolver in `renderUnsafe`. Uses the cache's `readForRender` * accessor when present — cached snapshot ONLY; a lapsed TTL schedules the * coalesced async refresh off the paint path; a missing entry schedules one * refresh so the pre-load rows fill on a later frame (sidebar precedent, * live-run-sidebar.ts render()). Feature-detected because * `RunDashboardOptions.snapshotCache` keeps the BASE interface * (snapshot-types.ts): production wires the extended cache, while test mocks * and legacy callers may implement only get/refreshIfStale — for those a * plain get() keeps the render read-only. Keypress handlers and other * read-your-writes sites keep `snapshotFor()` (sync refreshIfStale) — Tier 11a. */ function snapshotForRender(run: TeamRunManifest, snapshotCache?: RunSnapshotCache): RunUiSnapshot | undefined { const cache = snapshotCache as | (RunSnapshotCache & { readForRender?: (runId: string) => RunUiSnapshot | undefined; scheduleRefresh?: (runId: string) => void; }) | undefined; if (typeof cache?.readForRender === "function") { try { const snapshot = cache.readForRender(run.runId); if (!snapshot && typeof cache.scheduleRefresh === "function") cache.scheduleRefresh(run.runId); return snapshot; } catch { return snapshotCache?.get(run.runId); } } return snapshotCache?.get(run.runId); } /** * PERF (2026-08-24, task 20) — per-frame snapshot resolver. The render path * used to resolve each run's snapshot 6-8 times per frame (refreshRuns, * buildSignature, groupedRuns, per-row, selected-run), and every * `snapshotFor → refreshIfStale` call can stat several files per run when its * own TTL expires. `renderUnsafe` now builds a frame-local Map keyed by runId * and threads this resolver through every render-path helper. Helpers keep * their plain direct-resolution behavior when no resolver is passed * (keypress handlers and other callers outside a render frame). */ type SnapshotResolver = (run: TeamRunManifest) => RunUiSnapshot | undefined; function readRunTasks(run: TeamRunManifest, snapshotCache?: RunSnapshotCache, resolve?: SnapshotResolver): TeamTaskState[] { const snapshot = resolve ? resolve(run) : snapshotFor(run, snapshotCache); if (snapshot) return snapshot.tasks; // P0-6: when a snapshot cache is provided but hasn't populated yet, return // empty (the render loop falls back to the empty pane placeholder) instead // of reading the tasks.json file synchronously every render tick. if (snapshotCache) return []; const parse = () => { if (!fs.existsSync(run.tasksPath)) return []; const parsed = JSON.parse(fs.readFileSync(run.tasksPath, "utf-8")); return Array.isArray(parsed) ? (parsed as TeamTaskState[]) : []; }; try { return readJsonFileCoalesced(run.tasksPath, TASK_READ_TTL_MS, parse); } catch { return []; } } function taskForAgent(tasks: TeamTaskState[], agent: CrewAgentRecord): TeamTaskState | undefined { return tasks.find((task) => task.id === agent.taskId); } function modelForTask(task: TeamTaskState | undefined): string | undefined { const attempts = task?.modelAttempts; if (!attempts?.length) return undefined; return attempts.find((attempt) => attempt.success)?.model ?? attempts.at(-1)?.model; } function modelForAgent(agent: CrewAgentRecord, task: TeamTaskState | undefined): string | undefined { return modelForTask(task) ?? agent.model; } function usageForAgent(agent: CrewAgentRecord, task: TeamTaskState | undefined): UsageState | undefined { return task?.usage ?? agent.usage; } function agentPreviewLine(agent: CrewAgentRecord, task: TeamTaskState | undefined, options: RunDashboardOptions): string { const stats = [ agent.progress?.activityState, options.showModel !== false ? modelForAgent(agent, task) : undefined, options.showTokens !== false ? (compactUsage(usageForAgent(agent, task)) ?? (agent.progress?.tokens !== undefined ? `↑${formatTokens(agent.progress.tokens)}` : undefined)) : undefined, options.showTools !== false && agent.progress?.currentTool ? `tool=${agent.progress.currentTool}` : undefined, options.showTools !== false && agent.toolUses !== undefined ? formatCount(agent.toolUses, "tool") : undefined, agent.progress?.turns !== undefined ? formatCount(agent.progress.turns, "turn") : undefined, agent.progress?.failedTool ? `failedTool=${agent.progress.failedTool}` : undefined, agent.startedAt ? formatAge(agent.completedAt ?? agent.startedAt) : undefined, ].filter((part): part is string => Boolean(part)); const recent = agent.progress?.recentOutput?.at(-1); const icon = iconForStatus(agent.status, { runningGlyph: spinnerFrame(agent.taskId), }); // GUARD (M4, §4): agents.json records are NOT schema-validated at read time, // so every interpolated field needs a fallback — `${agent.role}${ACTIVE}${agent.agent}` // printed `undefined->undefined` for a partially-written record. return sanitizeLine( `Agent: ${icon} ${agent.taskId ?? "?"} ${agent.role ?? "?"}${ACTIVE}${agent.agent ?? "?"}${stats.length ? ` · ${stats.join(" · ")}` : ""}${recent ? ` ⎿ ${recent}` : ""}`, ); } function readAgentPreview(run: TeamRunManifest, maxLines = 5, options: RunDashboardOptions = {}, resolve?: SnapshotResolver): string[] { try { const snapshot = resolve ? resolve(run) : snapshotFor(run, options.snapshotCache); // P0-6: when a snapshot cache is provided but hasn't populated yet, return // the empty-pane placeholder instead of calling `readCrewAgents` (disk I/O) // on every render tick. Legacy callers (no cache) keep the disk-read path // so existing unit tests continue to assert against concrete agent data. const agents = snapshot?.agents ?? (options.snapshotCache ? [] : readCrewAgents(run)); const tasks = snapshot?.tasks ?? readRunTasks(run, options.snapshotCache, resolve); if (!agents.length) return ["Agents: (none)"]; const totals = tasks.reduce( (acc, task) => { acc.input += task.usage?.input ?? 0; acc.output += task.usage?.output ?? 0; acc.cacheRead += task.usage?.cacheRead ?? 0; acc.cacheWrite += task.usage?.cacheWrite ?? 0; acc.cost += task.usage?.cost ?? 0; return acc; }, { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0 } as { input: number; output: number; cacheRead: number; cacheWrite: number; cost: number; }, ); const usage = compactUsage(totals); const header = usage ? `Agents: ${usage}` : "Agents:"; return [ header, ...agents.slice(0, maxLines).map((agent) => agentPreviewLine(agent, taskForAgent(tasks, agent), options)), ...(agents.length > maxLines ? [`Agents: +${agents.length - maxLines} more`] : []), ]; } catch (error) { const message = error instanceof Error ? error.message : String(error); return [`Agents: failed to read (${message})`]; } } function agentsFor(run: TeamRunManifest, snapshotCache?: RunSnapshotCache, resolve?: SnapshotResolver): CrewAgentRecord[] { const snapshot = resolve ? resolve(run) : snapshotFor(run, snapshotCache); if (snapshot) return snapshot.agents; // P0-6: when a snapshot cache is provided but hasn't populated yet, return // empty (callers handle the empty-state placeholder) instead of calling // `readCrewAgents` synchronously on every render tick. if (snapshotCache) return []; try { return readCrewAgents(run); } catch { return []; } } function runLabel( run: TeamRunManifest, selected: boolean, snapshotCache?: RunSnapshotCache, maxW?: number, resolve?: SnapshotResolver, ): string { const agents = agentsFor(run, snapshotCache, resolve); const stale = isLikelyOrphanedActiveRun(run, agents); const running = agents.find((agent) => agent.status === "running"); const queued = agents.find((agent) => agent.status === "queued"); const step = stale ? "orphaned queued run" : running ? `step ${running.taskId}` : queued ? `queued ${queued.taskId}` : `agents ${agents.length}`; const status: RunStatus = stale ? "stale" : (run.status as RunStatus); const marker = selected ? CURSOR : " "; const icon = iconForStatus(status, { runningGlyph: spinnerFrame(run.runId), }); // L-5: unified " · " separator (was "|"). // L-3: keep the GOAL (the human identifier) visible on narrow terminals — // when the full line overflows, sacrifice the meta prefix (runId/status // survive; team/workflow/step clip) rather than the goal. Optional `maxW` // enables the goal-aware truncation; legacy 3-arg callers (dev patch // scripts) get the untruncated full label. const head = `${marker} ${icon} ${run.runId.slice(-8)} ${status}${goalFlagSuffix(run)}`; const meta = `${teamWorkflowLabel(run.team, run.workflow)} · ${step}`; const goal = sanitizeLine(run.goal ?? ""); if (maxW === undefined) return sanitizeLine(`${head} · ${meta} · ${goal}`); const sepW = 3; // " · " if (visibleWidth(head) + sepW + visibleWidth(meta) + sepW + visibleWidth(goal) <= maxW) { return sanitizeLine(`${head} · ${meta} · ${goal}`); } // Not enough room. The STATUS token is the one that must survive — the run // list is how a failed/cancelled run gets spotted — so meta clips FIRST, // then the goal; the head itself is only clipped if it cannot fit alone. // (Live bug 2026-09-16: the old fallback truncated `head · meta` as ONE // string, so a 118-column render painted `› ✓ 122ab0dd complete…` — the // status cut in half while the meta it should have sacrificed stayed put.) const headW = visibleWidth(head); if (headW + sepW + 8 <= maxW) { const goalBudget = Math.max(8, maxW - headW - sepW); return sanitizeLine(`${head} · ${truncate(goal, goalBudget)}`); } return sanitizeLine(truncate(head, maxW)); } interface ResolvedRun { manifest: TeamRunManifest; snapshot: RunUiSnapshot | undefined; agents: CrewAgentRecord[]; status: RunStatus; } function resolveRuns(runs: TeamRunManifest[], snapshotCache?: RunSnapshotCache, resolve?: SnapshotResolver): Map { const map = new Map(); for (const run of runs) { const snapshot = resolve ? resolve(run) : snapshotFor(run, snapshotCache); const agents = snapshot?.agents ?? agentsFor(run, snapshotCache, resolve); const displayRun = snapshot?.manifest ?? run; const status: RunStatus = isLikelyOrphanedActiveRun(displayRun, agents) ? "stale" : (displayRun.status as RunStatus); map.set(run.runId, { manifest: run, snapshot, agents, status }); } return map; } function groupedRuns( runs: TeamRunManifest[], snapshotCache?: RunSnapshotCache, resolve?: SnapshotResolver, ): Array<{ label: string; run?: TeamRunManifest }> { const resolved = resolveRuns(runs, snapshotCache, resolve); const rows: Array<{ label: string; run?: TeamRunManifest }> = []; const active = runs.filter((run) => isDisplayActiveRun(resolved.get(run.runId)?.snapshot?.manifest ?? run, resolved.get(run.runId)?.agents ?? []), ); const rest = runs.filter( (run) => !isDisplayActiveRun(resolved.get(run.runId)?.snapshot?.manifest ?? run, resolved.get(run.runId)?.agents ?? []), ); if (active.length) rows.push({ label: "Active" }, ...active.map((run) => ({ label: run.runId, run }))); if (rest.length) rows.push({ label: "Recent" }, ...rest.map((run) => ({ label: run.runId, run }))); return rows; } function selectedRunFromGrouped( runs: TeamRunManifest[], selected: number, snapshotCache?: RunSnapshotCache, resolve?: SnapshotResolver, ): TeamRunManifest | undefined { return groupedRuns(runs, snapshotCache, resolve).filter((row) => row.run)[selected]?.run; } export class RunDashboard implements DashboardComponent { private selected = 0; private runScrollOffset = 0; private showHelp = false; private activePane: "agents" | "progress" | "mailbox" | "output" | "health" | "metrics" | "plan" | "schedules" = lastActivePane; /** WP-7 (R7): pane-scoped revision-diff toggle (X). */ private planDiff = false; /** Tier A (schedules pane): job-cursor, details toggle, 2-step delete * confirm-gate state, and the TTL'd provider cache (G17 reads only). */ private scheduleSelected = 0; private scheduleDetails = false; private scheduleDeleteArmed = false; /** US-020 (run cancel): 2-step confirm-gate + refusal status line — * mirrors the schedules delete gate (first x arms, second x dispatches, * any other key disarms; a non-running selection refuses with a line). */ private runCancelArmed = false; private runCancelRefused = false; private scheduleJobsCache: { at: number; jobs: ScheduledJob[]; hiddenCount: number } | undefined; private runs: TeamRunManifest[]; private readonly done: (selection: RunDashboardSelection | undefined) => void; private readonly theme: CrewTheme; private readonly options: RunDashboardOptions; private cachedWidth = 0; private cachedVersion = ""; private cachedLines: string[] = []; /** 1.10 (UI-P1-2) — short-TTL cache for `buildSignature()` so per-run * `snapshotFor → refreshIfStale` calls don't fire on every render tick. */ private cachedSignatureAt = 0; private cachedSignature = ""; private readonly unsubscribeTheme: () => void; private readonly schedulerHandle: OverlaySchedulerHandle | undefined; constructor( runs: TeamRunManifest[], done: (selection: RunDashboardSelection | undefined) => void, theme: unknown = {}, options: RunDashboardOptions = {}, ) { // Filter runs by workspaceId for session isolation // If workspaceId is provided, only show runs owned by that session or runs with no owner (legacy) const filteredRuns = options.workspaceId ? runs.filter((run) => !run.ownerSessionId || run.ownerSessionId === options.workspaceId) : runs; this.runs = filteredRuns; // US-020 AC-2: restore the cursor to the previously selected run (if it // is still in the list) so escape-return keeps the user's place. if (lastSelectedRunId) { // groupedRuns rows include GROUP LABELS ("Active"/"Recent") — index // into the SELECTABLE rows only (the same projection navigation uses). const selectable = groupedRuns(filteredRuns, options.snapshotCache).filter((rowItem) => rowItem.run); const idx = selectable.findIndex((rowItem) => rowItem.run?.runId === lastSelectedRunId); if (idx >= 0) this.selected = idx; } this.done = done; this.theme = asCrewTheme(theme); this.options = options; // 1.10 (UI-P1-1): route run:state / worker:lifecycle / ui:invalidate // through a RenderScheduler (debounce + fallback) instead of registering // 3 raw runEventBus subscriptions that each schedule an immediate // invalidate+requestRender. With 3 overlays subscribing independently, // a single event triggered up to 9 callbacks and ~150 invalidates/sec. // The scheduler keeps the per-channel subscriptions but collapses them // into one debounced render + one coalesced invalidate per runId. // // FIND-07: scheduler must be constructed BEFORE the theme subscription // and the input-handler paths so they can route their invalidate+render // calls through `this.scheduleRender()` (debounced + coalesced) instead // of bypassing it with direct `invalidateAndRender()` calls. Each direct // call would otherwise skip the scheduler's debounce window and force // a synchronous repaint, recreating the burst-storm that this scheduler // was introduced to eliminate. const renderTick = (): void => { this.invalidateAndRender(); }; this.schedulerHandle = registerOverlayScheduler(renderTick, () => { // Drop any cached signature — data underneath may have // changed so the next buildSignature() needs to recompute. this.cachedSignature = ""; this.cachedSignatureAt = 0; }); // FIND-07: route theme changes through the scheduler so they coalesce // with run:state / worker:lifecycle / ui:invalidate bursts instead of // each firing their own immediate invalidate+requestRender. this.unsubscribeTheme = subscribeThemeChange(theme, () => this.scheduleRender()); } /** * FIND-07: route an external invalidation through the RenderScheduler. * * Use this for any caller-driven invalidation (theme change, input * handler action) instead of calling `invalidateAndRender()` directly. * The scheduler debounces + coalesces these so a theme change arriving * in the middle of a run:state burst doesn't force a separate synchronous * repaint. Falls back to a direct render if the scheduler was not created * (defensive — currently always created in the constructor). */ private scheduleRender(): void { if (this.schedulerHandle) { this.schedulerHandle.schedule(); return; } this.invalidateAndRender(); } /** * Invalidate the layout cache AND poke the host TUI to repaint. Without * the explicit `requestRender` call the host only repaints on its own * tick / on keypress, so async events (subagent completed, mailbox * updates, theme change) would leave the overlay showing stale data * until the user pressed a key — which is exactly when the "cascading * dashboard" symptom surfaces because the diff renderer was comparing * against a stale `previousLines` snapshot. */ private invalidateAndRender(): void { this.invalidate(); try { this.options.requestRender?.(); } catch { /* host may not expose requestRender */ } } /** * Stable overlay height. The host pi-tui positions overlays based on the * number of lines `render()` returns; if that number fluctuates between * frames (empty state → full pane → fewer agents) the anchor row shifts * up/down and the differential renderer cannot reliably erase the * previous footprint, producing the "ghost dashboard below" bug. * * Locking the output to a single height per render eliminates that. */ private targetHeight(): number { const rows = Number.isFinite(process.stdout?.rows) ? Number(process.stdout?.rows) : 30; return Math.max(12, Math.min(36, rows - 2)); } private refreshRuns(resolve?: SnapshotResolver): void { if (!this.options.runProvider) return; const selectedRunId = this.selectedRunId(resolve); const next = this.options.runProvider(); // P3 (#8): re-apply the workspaceId filter on EVERY refresh, not just // the constructor. Without this, runs from other sessions leak back in // on frame 2+ once the runProvider returns the unfiltered manifest list. const unfiltered = Array.isArray(next) ? next : this.runs; this.runs = this.options.workspaceId ? unfiltered.filter((run) => !run.ownerSessionId || run.ownerSessionId === this.options.workspaceId) : unfiltered; if (selectedRunId) { const nextIndex = groupedRuns(this.runs, this.options.snapshotCache, resolve) .filter((row) => row.run) .findIndex((row) => row.run?.runId === selectedRunId); if (nextIndex >= 0) this.selected = nextIndex; else this.selected = 0; } } /** * L-1 — keep the run-list selection inside the rendered window so the `›` * marker can never scroll off-screen (and Enter can never act on an * invisible run). Uses the SAME `runListWindow()` the render loop uses and * iterates to a fixed point, because the slot count depends on whether the * ↑/↓ indicators render (which depends on the offset). This makes the * window correct on the SAME render that processed the keypress — there is * no one-frame lag where the marker is off-screen. */ private ensureRunListWindow(selectableCount: number): void { if (selectableCount <= RUN_LIST_MAX) { this.runScrollOffset = 0; return; } for (let i = 0; i < 4; i++) { const { slots } = runListWindow(this.runScrollOffset, selectableCount); const prev = this.runScrollOffset; if (this.selected < this.runScrollOffset) this.runScrollOffset = this.selected; else if (this.selected >= this.runScrollOffset + slots) this.runScrollOffset = this.selected - slots + 1; this.runScrollOffset = Math.max(0, Math.min(this.runScrollOffset, Math.max(0, selectableCount - 1))); if (this.runScrollOffset === prev) break; } } private buildSignature(resolve?: SnapshotResolver): string { // 1.10 (UI-P1-2) — short-TTL cache so we don't re-read every run's // snapshot on every render tick. `snapshotFor → refreshIfStale` can // stat multiple files per run when its own TTL expires, and the // dashboard's render is called several times per second (spinner + // event-bus). Cache the full computed signature for ~100ms: long // enough to coalesce one render burst, short enough that idle pauses // still pick up new data promptly. const now = Date.now(); if (this.cachedSignature && now - this.cachedSignatureAt < SIGNATURE_CACHE_TTL_MS) { return this.cachedSignature; } let hasRunning = false; const statuses = this.runs .map((run) => { const snapshot = resolve ? resolve(run) : snapshotFor(run, this.options.snapshotCache); const displayRun = snapshot?.manifest ?? run; const agents = snapshot?.agents ?? agentsFor(run, this.options.snapshotCache, resolve); const stale = isLikelyOrphanedActiveRun(displayRun, agents); const status: RunStatus = stale ? "stale" : (displayRun.status as RunStatus); if (status === "running" || agents.some((agent) => agent.status === "running")) hasRunning = true; return snapshot?.signature ?? `${displayRun.runId}:${displayRun.status}:${displayRun.updatedAt}:${status}`; }) .join("|"); const metricsSig = this.activePane === "metrics" ? `:metrics=${this.options.registry?.snapshot().length ?? 0}:${spinnerBucket()}` : ""; const schedulesSig = this.activePane === "schedules" ? `:sched=${this.schedulesSignatureFragment()}` : ""; const sig = `${this.selected}:${this.showHelp ? 1 : 0}:${this.activePane}:${statuses}${hasRunning ? `:spin=${spinnerBucket()}` : ""}${metricsSig}${schedulesSig}`; this.cachedSignature = sig; this.cachedSignatureAt = now; return sig; } invalidate(): void { this.cachedVersion = ""; this.cachedLines = []; // 1.10 (UI-P1-2): also drop the short-TTL signature cache so the next // render can't return a stale snapshot-derived signature that was // computed before the invalidating event arrived. this.cachedSignature = ""; this.cachedSignatureAt = 0; } dispose(): void { this.unsubscribeTheme(); this.schedulerHandle?.dispose(); } private selectedRunId(resolve?: SnapshotResolver): string | undefined { return selectedRunFromGrouped(this.runs, this.selected, this.options.snapshotCache, resolve)?.runId; } /** Tier A: scheduled jobs via the SINGLE source of truth (G17) — the * extension-layer provider, TTL-cached so render ticks never hit disk * per frame. `R` (schedule-refresh) drops the cache. The TTL timestamp * reads the SAME injected clock as the pane render (D6-T4 — no direct * Date.now() anywhere on the render path; review round 1 minor-1). * P2-1: the hidden project-tier count rides the SAME cached provider * read (jobs + count populate ONE cache entry) — no second uncached * disk-read path exists on render ticks. */ private scheduleData(): { jobs: ScheduledJob[]; hiddenCount: number } { const at = this.scheduleNow().getTime(); if (this.scheduleJobsCache && at - this.scheduleJobsCache.at < SCHEDULE_JOBS_TTL_MS) { return this.scheduleJobsCache; } let jobs: ScheduledJob[] = []; let hiddenCount = 0; try { jobs = getScheduledJobs(); } catch { jobs = []; } try { // Stash-first: in-memory while the scheduler singleton is registered; // a tiers read only in the no-scheduler fallback (TTL-bounded here). hiddenCount = getScheduledJobsHiddenCountView(); } catch { hiddenCount = 0; } this.scheduleJobsCache = { at, jobs, hiddenCount }; if (this.scheduleSelected > jobs.length - 1) this.scheduleSelected = Math.max(0, jobs.length - 1); return this.scheduleJobsCache; } private scheduleJobs(): ScheduledJob[] { return this.scheduleData().jobs; } /** P2-1: hidden project-tier jobs count — same cache entry as the jobs * read above (single provider read per TTL window). */ private scheduleHiddenCount(): number { return this.scheduleData().hiddenCount; } /** Job under the pane-8 cursor (up/down move it); undefined when the list * is empty — schedule actions are silent no-ops then (plan-approve * precedent for a gated action). */ private selectedScheduleJob(): ScheduledJob | undefined { const jobs = this.scheduleJobs(); return jobs[Math.min(this.scheduleSelected, jobs.length - 1)]; } /** Clock for the schedules pane (D6-T4): the render path receives the * resulting `now: Date` as a parameter and never reads the clock itself. * The SINGLE clock read point for everything pane-8: render, signature * pass, and the provider TTL timestamp (scheduleJobs) — so no second * wall-clock read exists on the render path. */ private scheduleNow(): Date { return this.options.now ? this.options.now() : new Date(); } /** Signature fragment for the schedules pane so job data, cursor, details, * confirm-gate state, and relative-time minute buckets invalidate the * layout cache (mirrors metricsSig). P2-1: the hidden count is part of the * fragment so a changed stash repaints the pane after the TTL. */ private schedulesSignatureFragment(): string { const { jobs, hiddenCount } = this.scheduleData(); const minuteBucket = Math.floor(this.scheduleNow().getTime() / 60_000); const jobSig = jobs .map((job) => `${job.enabled ? 1 : 0}:${job.runCount}:${job.nextRun ?? ""}:${job.lastStatus ?? ""}:${job.lastRun ?? ""}`) .join(","); return `${jobs.length}:${jobSig}:${hiddenCount}:${this.scheduleSelected}:${this.scheduleDetails ? 1 : 0}:${this.scheduleDeleteArmed ? 1 : 0}:${minuteBucket}`; } render(width: number): string[] { try { return this.renderUnsafe(width); } catch (error) { logInternalError("run-dashboard.render", error); return renderLines(["Dashboard error — see logs for details.", "Press r to reload · Esc to close."], width); } } private renderUnsafe(width: number): string[] { // PERF (2026-08-24): snapshot resolution stat'd 7-8 files per run 6-8 // times per frame. Resolve once per frame into a local map and thread // it through every render-path consumer below. // L5 (real-test 2026-10-07): the resolver reads the cache ONLY // (snapshotForRender → readForRender) — the sync refreshIfStale rebuild // moved off the paint path; refreshes are coalesced + async. const frameSnapshots = new Map(); const snapshotOnce: SnapshotResolver = (run) => { if (!frameSnapshots.has(run.runId)) frameSnapshots.set(run.runId, snapshotForRender(run, this.options.snapshotCache)); return frameSnapshots.get(run.runId); }; this.refreshRuns(snapshotOnce); const signature = this.buildSignature(snapshotOnce); if (signature !== this.cachedVersion || this.cachedWidth !== width) { // RAIL frame (M4, 2026-09-16) — the rounded box is retired. The frame // owner prefixes EVERY body row with `┃` (railLine owns the glyph + space // and pads/truncates to `budget`, so no row can spill past the terminal), // sections open with `┣` (sectionLine), the header hint rides the // canopy's right segment (dot-led) and the surface caps with `┗ `. const budget = Math.max(0, width - 2); const fg = (color: Parameters[0], text: string) => this.theme.fg(color, text); const row = (text: string) => railLine(RAIL.body, "border", text, this.theme, budget); const section = (name: string, subject?: string) => sectionLine({ name, subject, theme: this.theme, budget }); const lines: string[] = []; if (this.showHelp) { // K-1: help overlay replaces the dashboard body until dismissed. lines.push(...new HelpOverlay(this.theme).render(width)); } else { lines.push( canopyLine({ word: "DASHBOARD", subject: formatCount(this.runs.length, "run"), // US-020: the cancel hint joins the canopy only when the budget // has room — at narrow widths the canopy prefers the run-count // subject over hints, and the 5th hint ellipsized it (regression // caught by run-dashboard.test.ts's 80-col canonical canopy). right: formatHint([ ["1-8", "pane"], [["up", "down"], "move"], ["enter", "select"], ...(budget >= 96 ? ([["x", "cancel"]] as const) : []), ["?", "help"], ]), theme: this.theme, budget, }), ); // US-020: run-cancel confirm gate / refusal status line — // rendered right under the canopy so it is visible in EVERY pane // (schedule-delete's warning is pane-8-only by design; run cancel // is a root action). if (this.runCancelArmed) { lines.push(row(fg("warning", `⚠ x again to CANCEL '${this.selectedRunId() ?? "(none)"}' · any other key disarms`))); } else if (this.runCancelRefused) { lines.push(row(fg("dim", "nothing to cancel — the selected run is not active"))); } if (this.runs.length === 0) { // F-7: actionable empty state instead of a bare "No runs.". lines.push(row(fg("dim", "No runs yet."))); lines.push( row( fg( "dim", `Start one: team action='run' · ${formatHint([ ["r", "reload"], ["esc", "close"], ])}`, ), ), ); } else { // L-1: windowed run list so the selection can never scroll off-screen. const allGrouped = groupedRuns(this.runs, this.options.snapshotCache, snapshotOnce); const selectable = allGrouped.filter((rowItem) => rowItem.run); const selectableCount = selectable.length; if (this.selected > selectableCount - 1) this.selected = Math.max(0, selectableCount - 1); this.ensureRunListWindow(selectableCount); if (selectableCount <= RUN_LIST_MAX) { // Common case (≤8 runs): keep the Active/Recent group headers. for (const [gi, rowItem] of allGrouped.entries()) { if (!rowItem.run) { // `┣ ACTIVE ▸ 2` — the group label IS its run count. let groupCount = 0; for (let j = gi + 1; j < allGrouped.length && allGrouped[j]?.run; j++) groupCount++; lines.push(section(rowItem.label, `${groupCount}`)); continue; } const idx = selectable.findIndex((c) => c.run?.runId === rowItem.run?.runId); const snap = snapshotOnce(rowItem.run); const run = snap?.manifest ?? rowItem.run; const agents = snap?.agents ?? agentsFor(rowItem.run, this.options.snapshotCache, snapshotOnce); const status: RunStatus = isLikelyOrphanedActiveRun(run, agents) ? "stale" : (run.status as RunStatus); const label = runLabel(run, idx === this.selected, this.options.snapshotCache, budget, snapshotOnce); lines.push(row(applyStatusColor(this.theme, status, label))); } } else { // >8 runs: windowed list (group headers omitted to maximise run rows). const win = runListWindow(this.runScrollOffset, selectableCount); if (win.hasTop) lines.push(row(overflowHint(this.runScrollOffset, 0, this.theme))); for (let gi = this.runScrollOffset; gi < Math.min(this.runScrollOffset + win.slots, selectableCount); gi++) { const rowItem = selectable[gi]; if (!rowItem?.run) continue; const snap = snapshotOnce(rowItem.run); const run = snap?.manifest ?? rowItem.run; const agents = snap?.agents ?? agentsFor(rowItem.run, this.options.snapshotCache, snapshotOnce); const status: RunStatus = isLikelyOrphanedActiveRun(run, agents) ? "stale" : (run.status as RunStatus); const label = runLabel(run, gi === this.selected, this.options.snapshotCache, budget, snapshotOnce); lines.push(row(applyStatusColor(this.theme, status, label))); } if (win.hasBottom) lines.push(row(overflowHint(0, selectableCount - (this.runScrollOffset + win.slots), this.theme))); } // Selected run detail — compact. PERF (2026-08-24): reuse the // `selectable` rows already derived from the single groupedRuns() // computation above instead of recomputing grouping a third time // (`selectedRunFromGrouped` is exactly `selectable[selected].run`). // Tier A: the schedules pane REPLACES this run-scoped detail block — // it renders as a root-level section below (non-run-scoped). const selectedRun = selectable[Math.min(this.selected, selectable.length - 1)]?.run; if (selectedRun && this.activePane !== "schedules") { const snap = snapshotOnce(selectedRun); const r = snap?.manifest ?? selectedRun; const agents = snap?.agents ?? agentsFor(selectedRun, this.options.snapshotCache, snapshotOnce); const statusStr: RunStatus = isLikelyOrphanedActiveRun(r, agents) ? "stale" : (r.status as RunStatus); const selectedTasks = snap?.tasks ?? readRunTasks(r, this.options.snapshotCache, snapshotOnce); lines.push(section("run")); lines.push(row(`${fg("accent", "▸")} ${truncate(sanitizeLine(r.goal), budget - 4)}`)); // L-2: surface the failure/cancellation reason inline for terminal runs. const isTerminal = statusStr === "failed" || statusStr === "cancelled" || statusStr === "stopped"; const reason = isTerminal ? summarizeTerminalReason(r, selectedTasks, snap?.cancellationReason) : undefined; const reasonSuffix = reason ? ` · ${truncate(sanitizeLine(reason), 40)}` : ""; lines.push( row( fg( "dim", sanitizeLine( ` ${teamWorkflowLabel(r.team, r.workflow)} · ${statusStr} · ${r.runId.slice(-8)}${reasonSuffix}`, ), ), ), ); // Pane content (max 8 lines) — F-2: colorize embedded status glyphs. const paneLines = snap ? this.activePane === "agents" ? safeRenderPane("agents", () => renderAgentsPane(snap, this.options)) : this.activePane === "progress" ? safeRenderPane("progress", () => renderProgressPane(snap)) : this.activePane === "mailbox" ? safeRenderPane("mailbox", () => renderMailboxPane(snap)) : this.activePane === "health" ? safeRenderPane("health", () => renderHealthPane(snap, { isForeground: !r.async, }), ) : this.activePane === "metrics" ? safeRenderPane("metrics", () => renderMetricsPane(snap, { registry: this.options.registry, }), ) : this.activePane === "plan" ? safeRenderPane("plan", () => renderPlanPane(snap, { diff: this.planDiff })) : safeRenderPane("transcript", () => renderTranscriptPane(snap)) : [ ...readAgentPreview(r, 4, this.options, snapshotOnce), ...readProgressPreview(r, 2, this.options.snapshotCache, snapshotOnce), ]; const filteredPane = paneLines.filter((l) => l && !l.includes("(none)") && l.trim() !== ""); if (filteredPane.length > 0) { lines.push(section(this.activePane)); for (const line of filteredPane.slice(0, 8)) { lines.push(colorizeStatusGlyphs(row(truncate(sanitizeLine(line), budget)), this.theme)); } } // F-4: warn when a live run's snapshot is likely stale (flaky read). const isActiveRun = statusStr === "running" || statusStr === "queued" || statusStr === "waiting"; const staleData = isActiveRun && (snap ? Date.now() - snap.fetchedAt > STALE_SNAPSHOT_MS : true); if (staleData) lines.push(row(fg("warning", "⚠ data may be stale (manifest read flaky)"))); // One-line footer — V-1: width-padded so the right edge doesn't jitter. const usage = aggregateUsage(selectedTasks); const u = usage ?? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, }; const tok = (u.input ?? 0) + (u.output ?? 0) + (u.cacheRead ?? 0) + (u.cacheWrite ?? 0); const tokStr = tok > 0 ? (tok >= 1000 ? `${(tok / 1000).toFixed(1)}k tok` : `${tok} tok`) : ""; let ctxPct: number | undefined; for (const agent of agents) { if (agent.status === "running" && agent.runtime === "live-session") { const pct = getLiveAgentContextPercent(agent.taskId); if (pct != null) { ctxPct = pct; break; } } } // Footer: tokens + a RAIL gauge for the context window. The number // stays next to the bar — the bar reads the trend, the number is // what the user quotes. const footerFields: string[] = []; if (tokStr) footerFields.push(fg("dim", tokStr)); if (ctxPct != null && Number.isFinite(ctxPct)) { const ratio = Math.max(0, Math.min(1, ctxPct / 100)); footerFields.push(`${gaugeBar(ratio, 10, this.theme)} ${fg("muted", `${Math.round(ctxPct)}% ctx`)}`); } if (footerFields.length) lines.push(row(footerFields.join(fg("dim", " · ")))); } } // Tier A (schedules pane, key 8) — NON-RUN-SCOPED section: renders // with zero runs (below the empty-runs state) AND alongside the // run list, replacing the selected-run detail block (guard above). // Data via the G17 provider only; clock injected per D6-T4. if (this.activePane === "schedules") { const jobs = this.scheduleJobs(); const now = this.scheduleNow(); lines.push(section("schedules", `${jobs.length}`)); const paneLines = safeRenderPane("schedules", () => this.scheduleDetails && jobs.length > 0 ? renderScheduleDetails(jobs[Math.min(this.scheduleSelected, jobs.length - 1)], now) : renderSchedulesPane(jobs, now, { selectedIndex: this.scheduleSelected, hiddenCount: this.scheduleHiddenCount(), }), ); for (const line of paneLines.filter((l) => l && l.trim() !== "").slice(0, SCHEDULES_PANE_MAX_LINES)) { lines.push(colorizeStatusGlyphs(row(truncate(sanitizeLine(line), budget)), this.theme)); } if (this.scheduleDeleteArmed) { const victim = jobs[Math.min(this.scheduleSelected, jobs.length - 1)]; const name = victim ? truncate(sanitizeLine(victim.name), 24) : "?"; lines.push(row(fg("warning", `⚠ X again to DELETE '${name}' · any other key cancels`))); } } // Cap: the end of the surface + the close action (LAST, RAIL hint rule). lines.push( railLine( RAIL.close, "border", formatHint([ ["r", "reload"], ["esc", "close"], ]), this.theme, budget, ), ); } const target = this.targetHeight(); if (lines.length < target) { const blankRow = railLine(RAIL.body, "border", "", this.theme, budget); const bottom = lines.pop(); while (lines.length < target - 1) lines.push(blankRow); if (bottom) lines.push(bottom); } else if (lines.length > target) { const bottom = lines[lines.length - 1]; lines.length = target - 1; lines.push(bottom); } this.cachedLines = renderLines( lines.map((line) => truncate(line, width)), width, ); this.cachedVersion = signature; this.cachedWidth = width; } return this.cachedLines; } // Pi 0.81+ requires the Focusable contract: a string-indexable `focused` // marker that TUI toggles to track which component currently receives // input. Without it, isFocusable() returns false and downstream // dispatch may skip the component. Declared as an own property so // `"focused" in component` is true. public focused = false; handleInput(data: string): void { const action = dashboardActionForKey(data, this.activePane); // K-1: "?" toggles the help overlay; while it is shown, any other key // (including Esc) just dismisses it first instead of acting. if (action === "help") { this.showHelp = !this.showHelp; this.scheduleRender(); return; } if (this.showHelp) { this.showHelp = false; this.scheduleRender(); return; } // Tier A (schedules pane): 2-step confirm-gate — once X has armed a // pending delete, ANY other key (bound or not) disarms it before that // key's own processing continues. if (this.activePane === "schedules" && this.scheduleDeleteArmed && action !== "schedule-delete") { this.scheduleDeleteArmed = false; this.invalidate(); } // US-020: the run-cancel confirm gate disarms on any non-cancel key // (schedule-delete precedent), and a stale refusal line clears. if (this.runCancelArmed && action !== "cancel") { this.runCancelArmed = false; this.invalidate(); } if (this.runCancelRefused && action !== "cancel") this.runCancelRefused = false; const selectedRunId = this.selectedRunId(); // US-020 AC-2: keep the cursor across close/reopen cycles. if (selectedRunId) lastSelectedRunId = selectedRunId; if (action === "close") { this.done(undefined); return; } if (action === "select") { this.done(selectedRunId ? { runId: selectedRunId, action: "status" } : undefined); return; } // US-020: cancel the SELECTED run — dedicated branch (plan-approve // precedent) so a stray keystroke can never close the dashboard via // done(undefined). 2-step confirm gate: first x arms (render shows the // warning line), second x dispatches the existing cancel channel; a // non-running selection refuses with a status line (AC-3) — the guard // mutation target. if (action === "cancel") { const run = selectedRunFromGrouped(this.runs, this.selected, this.options.snapshotCache); const manifest = run ? (snapshotFor(run, this.options.snapshotCache)?.manifest ?? run) : undefined; if (this.runCancelArmed) { this.runCancelArmed = false; if (manifest && isActiveRunStatus(manifest.status)) { this.done({ runId: manifest.runId, action: "cancel" }); } else { this.runCancelRefused = true; this.invalidate(); } return; } this.runCancelRefused = manifest ? !isActiveRunStatus(manifest.status) : false; if (this.runCancelRefused) { this.invalidate(); return; } if (manifest) { this.runCancelArmed = true; this.invalidate(); } return; } // WP-3 (H4-subset): plan approval keys. Deliberately a DEDICATED branch // (not the generic union block below) because plan actions must gate on // the selected run actually being parked on a pending approval — a // stray keystroke on a non-pending run is a silent no-op (dashboard // stays open) instead of closing it via done(undefined). if (action === "plan-approve" || action === "plan-deny") { const run = selectedRunFromGrouped(this.runs, this.selected, this.options.snapshotCache); const manifest = run ? (snapshotFor(run, this.options.snapshotCache)?.manifest ?? run) : undefined; if (run && manifest && isPlanApprovalPending(manifest)) { this.done({ runId: run.runId, action }); } return; } // Tier A: schedules-pane actions (T/N/V/X/R — pane-scoped bindings) and // job-cursor navigation. Deliberately a DEDICATED branch (plan-approve // precedent): with no job under the cursor the mutation keys are silent // no-ops (dashboard stays open) instead of closing it via done(undefined). // Mutations ONLY ever leave via done() selections carrying schedule-* // actions — the extension layer routes them through handle-schedule.ts // subActions (see scheduleDashboardActionToSubAction); the UI never // touches the scheduler directly. if ( action === "schedule-toggle" || action === "schedule-run-now" || action === "schedule-delete" || action === "schedule-details" || action === "schedule-refresh" ) { this.handleSchedulePaneAction(action); return; } if (this.activePane === "schedules" && (action === "up" || action === "down")) { // Job cursor moves instead of the run-list selection while pane 8 owns input. const count = this.scheduleJobs().length; this.scheduleSelected = action === "up" ? Math.max(0, this.scheduleSelected - 1) : Math.min(Math.max(0, count - 1), this.scheduleSelected + 1); this.invalidate(); return; } if ( action === "summary" || action === "artifacts" || action === "api" || action === "agents" || action === "mailbox" || action === "reload" || action === "mailbox-detail" || action === "health-recovery" || action === "health-kill-stale" || action === "health-diagnostic-export" || action === "notifications-dismiss" ) { this.done(selectedRunId ? { runId: selectedRunId, action } : action === "reload" ? { runId: "", action } : undefined); return; } if (action === "events") { this.done(selectedRunId ? { runId: selectedRunId, action: "agent-events" } : undefined); return; } if (action === "output") { this.done(selectedRunId ? { runId: selectedRunId, action: "agent-output" } : undefined); return; } if (action === "transcript") { this.done(selectedRunId ? { runId: selectedRunId, action: "agent-transcript" } : undefined); return; } if (action === "live-conversation") { this.done(selectedRunId ? { runId: selectedRunId, action: "agent-live" } : undefined); return; } // Agents & Jobs browser (feat/agents-browser): one-keypress overlay, // mirrors the live-conversation branch. Unlike it, a missing run // selection does NOT dead-end — the browser lists live agents and // scheduled jobs independent of any single run, so runId "" keeps it // reachable with zero runs (the reload-action precedent). if (action === "browser") { this.done(selectedRunId ? { runId: selectedRunId, action: "browser-open" } : { runId: "", action: "browser-open" }); return; } if (action === "pane-agents") this.activePane = "agents"; else if (action === "pane-progress") this.activePane = "progress"; else if (action === "pane-mailbox") this.activePane = "mailbox"; else if (action === "pane-output") this.activePane = "output"; else if (action === "pane-health") this.activePane = "health"; else if (action === "pane-metrics") this.activePane = "metrics"; else if (action === "pane-plan") this.activePane = "plan"; else if (action === "pane-schedules") this.activePane = "schedules"; else if (action === "plan-diff") { this.planDiff = !this.planDiff; this.invalidate(); return; } else if (action === "up" || action === "down") { // US-020 AC-1: WRAP at the list edges (was: hard clamp). An empty list // must not divide by zero — stay at 0. const selectableCount = groupedRuns(this.runs, this.options.snapshotCache).filter((row) => row.run).length; if (selectableCount > 0) { this.selected = action === "up" ? (this.selected - 1 + selectableCount) % selectableCount : (this.selected + 1) % selectableCount; } } if (action) { lastActivePane = this.activePane; this.scheduleRender(); } } /** Tier A: dispatch a pane-8 action. Local state toggles (V details, R * refresh) mutate in place; mutations (T toggle, N run-now, X delete) * leave the component ONLY as done() selections carrying schedule-* * actions + jobId for the extension layer to route through * handle-schedule.ts subActions — the UI layer never mutates jobs itself. */ private handleSchedulePaneAction(action: DashboardKeyAction): void { // Pane-local state toggles follow the plan-diff precedent: // invalidate() so the next render (sync in tests, host-tick in the TUI) // recomputes immediately. if (action === "schedule-details") { this.scheduleDetails = !this.scheduleDetails; this.invalidate(); return; } if (action === "schedule-refresh") { this.scheduleJobsCache = undefined; this.invalidate(); return; } const job = this.selectedScheduleJob(); if (!job) return; // silent no-op — plan-approve precedent for gated actions if (action === "schedule-delete") { // 2-step destructive confirm-gate: first X arms (render shows the // warning line; any other key disarms — see handleInput), second X // confirms and emits the remove selection. if (this.scheduleDeleteArmed) { this.scheduleDeleteArmed = false; this.done({ runId: "", action: "schedule-remove", jobId: job.id }); } else { this.scheduleDeleteArmed = true; this.invalidate(); } return; } this.done({ runId: "", action: action === "schedule-toggle" ? (job.enabled ? "schedule-disable" : "schedule-enable") : "schedule-run-now", jobId: job.id, }); } }