@pellux/goodvibes-tui 0.28.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (144) hide show
  1. package/CHANGELOG.md +133 -0
  2. package/README.md +1 -1
  3. package/package.json +1 -1
  4. package/src/core/conversation-line-cache.ts +432 -0
  5. package/src/core/conversation-rendering.ts +11 -3
  6. package/src/core/conversation.ts +50 -4
  7. package/src/input/commands/cost-runtime.ts +49 -0
  8. package/src/input/commands/operator-runtime.ts +5 -1
  9. package/src/input/commands.ts +2 -0
  10. package/src/input/handler-content-actions.ts +8 -3
  11. package/src/input/handler-feed-routes.ts +12 -35
  12. package/src/input/handler-feed.ts +4 -8
  13. package/src/input/handler-shortcuts.ts +51 -0
  14. package/src/input/handler.ts +43 -6
  15. package/src/input/input-history.ts +17 -4
  16. package/src/input/keybindings.ts +48 -8
  17. package/src/input/panel-integration-actions.ts +107 -1
  18. package/src/main.ts +19 -11
  19. package/src/panels/agent-inspector-panel.ts +125 -36
  20. package/src/panels/agent-inspector-shared.ts +144 -0
  21. package/src/panels/approval-panel.ts +67 -16
  22. package/src/panels/automation-control-panel.ts +368 -124
  23. package/src/panels/base-panel.ts +1 -1
  24. package/src/panels/builtin/agent.ts +28 -37
  25. package/src/panels/builtin/development.ts +40 -32
  26. package/src/panels/builtin/knowledge.ts +15 -6
  27. package/src/panels/builtin/operations.ts +178 -117
  28. package/src/panels/builtin/session.ts +35 -9
  29. package/src/panels/builtin/shared.ts +98 -6
  30. package/src/panels/cockpit-panel.ts +232 -73
  31. package/src/panels/communication-panel.ts +58 -20
  32. package/src/panels/confirm-state.ts +8 -1
  33. package/src/panels/control-plane-panel.ts +351 -118
  34. package/src/panels/cost-tracker-panel.ts +165 -7
  35. package/src/panels/debug-panel.ts +312 -159
  36. package/src/panels/diff-panel.ts +209 -57
  37. package/src/panels/docs-panel.ts +152 -66
  38. package/src/panels/eval-panel.ts +220 -42
  39. package/src/panels/file-explorer-panel.ts +202 -122
  40. package/src/panels/file-preview-panel.ts +132 -57
  41. package/src/panels/git-panel.ts +310 -129
  42. package/src/panels/hooks-panel.ts +150 -22
  43. package/src/panels/incident-review-panel.ts +223 -35
  44. package/src/panels/index.ts +0 -4
  45. package/src/panels/intelligence-panel.ts +212 -86
  46. package/src/panels/knowledge-graph-panel.ts +461 -101
  47. package/src/panels/local-auth-panel.ts +240 -28
  48. package/src/panels/marketplace-panel.ts +193 -26
  49. package/src/panels/memory-panel.ts +78 -77
  50. package/src/panels/ops-control-panel.ts +146 -29
  51. package/src/panels/ops-strategy-panel.ts +72 -4
  52. package/src/panels/orchestration-panel.ts +231 -69
  53. package/src/panels/panel-list-panel.ts +154 -131
  54. package/src/panels/panel-manager.ts +97 -9
  55. package/src/panels/plan-dashboard-panel.ts +333 -17
  56. package/src/panels/plugins-panel.ts +184 -29
  57. package/src/panels/policy-panel.ts +210 -38
  58. package/src/panels/polish-core.ts +7 -2
  59. package/src/panels/polish.ts +23 -4
  60. package/src/panels/project-planning-answer-actions.ts +134 -0
  61. package/src/panels/project-planning-panel.ts +62 -113
  62. package/src/panels/provider-health-panel.ts +434 -518
  63. package/src/panels/provider-health-routes.ts +203 -0
  64. package/src/panels/provider-health-tracker.ts +194 -6
  65. package/src/panels/provider-health-views.ts +560 -0
  66. package/src/panels/qr-panel.ts +116 -30
  67. package/src/panels/remote-panel.ts +114 -36
  68. package/src/panels/routes-panel.ts +63 -22
  69. package/src/panels/sandbox-panel.ts +174 -42
  70. package/src/panels/scrollable-list-panel.ts +19 -135
  71. package/src/panels/security-panel.ts +133 -33
  72. package/src/panels/services-panel.ts +116 -64
  73. package/src/panels/session-browser-panel.ts +73 -2
  74. package/src/panels/session-maintenance.ts +4 -122
  75. package/src/panels/settings-sync-panel.ts +335 -72
  76. package/src/panels/skills-panel.ts +157 -89
  77. package/src/panels/subscription-panel.ts +86 -33
  78. package/src/panels/symbol-outline-panel.ts +248 -108
  79. package/src/panels/system-messages-panel.ts +114 -13
  80. package/src/panels/tasks-panel.ts +326 -139
  81. package/src/panels/thinking-panel.ts +43 -10
  82. package/src/panels/token-budget-panel.ts +194 -18
  83. package/src/panels/tool-inspector-panel.ts +144 -34
  84. package/src/panels/types.ts +40 -4
  85. package/src/panels/work-plan-panel.ts +280 -17
  86. package/src/panels/worktree-panel.ts +175 -42
  87. package/src/panels/wrfc-panel.ts +116 -24
  88. package/src/renderer/agent-detail-modal.ts +37 -8
  89. package/src/renderer/code-block.ts +58 -28
  90. package/src/renderer/context-inspector.ts +3 -6
  91. package/src/renderer/conversation-overlays.ts +25 -11
  92. package/src/renderer/conversation-surface.ts +1 -21
  93. package/src/renderer/diff-view.ts +6 -4
  94. package/src/renderer/footer-tips.ts +41 -0
  95. package/src/renderer/fullscreen-primitives.ts +23 -17
  96. package/src/renderer/fullscreen-workspace.ts +2 -7
  97. package/src/renderer/help-overlay.ts +91 -14
  98. package/src/renderer/hint-grammar.ts +52 -0
  99. package/src/renderer/history-search-overlay.ts +10 -16
  100. package/src/renderer/layout.ts +7 -7
  101. package/src/renderer/markdown.ts +9 -1
  102. package/src/renderer/modal-factory.ts +23 -15
  103. package/src/renderer/model-picker-overlay.ts +8 -485
  104. package/src/renderer/model-workspace.ts +9 -24
  105. package/src/renderer/overlay-box.ts +23 -19
  106. package/src/renderer/process-indicator.ts +15 -25
  107. package/src/renderer/profile-picker-modal.ts +13 -14
  108. package/src/renderer/prompt-content-width.ts +16 -0
  109. package/src/renderer/search-overlay.ts +27 -6
  110. package/src/renderer/selection-modal-overlay.ts +3 -1
  111. package/src/renderer/semantic-diff.ts +1 -1
  112. package/src/renderer/session-picker-modal.ts +6 -4
  113. package/src/renderer/settings-modal-helpers.ts +10 -34
  114. package/src/renderer/settings-modal.ts +70 -10
  115. package/src/renderer/shell-surface.ts +54 -15
  116. package/src/renderer/status-glyphs.ts +11 -9
  117. package/src/renderer/surface-layout.ts +0 -12
  118. package/src/renderer/term-caps.ts +1 -1
  119. package/src/renderer/theme.ts +60 -3
  120. package/src/renderer/tool-call.ts +6 -20
  121. package/src/renderer/ui-factory.ts +48 -37
  122. package/src/renderer/ui-primitives.ts +41 -3
  123. package/src/runtime/bootstrap-shell.ts +35 -18
  124. package/src/runtime/diagnostics/panels/index.ts +0 -3
  125. package/src/runtime/render-scheduler.ts +80 -0
  126. package/src/shell/ui-openers.ts +14 -22
  127. package/src/utils/format-duration.ts +2 -2
  128. package/src/utils/splash-lines.ts +54 -5
  129. package/src/version.ts +1 -1
  130. package/src/work-plans/work-plan-store.ts +13 -0
  131. package/src/panels/agent-logs-panel.ts +0 -635
  132. package/src/panels/agent-logs-shared.ts +0 -129
  133. package/src/panels/context-visualizer-panel.ts +0 -238
  134. package/src/panels/forensics-panel.ts +0 -378
  135. package/src/panels/provider-account-snapshot.ts +0 -259
  136. package/src/panels/provider-accounts-panel.ts +0 -255
  137. package/src/panels/provider-stats-panel.ts +0 -391
  138. package/src/panels/schedule-panel.ts +0 -365
  139. package/src/panels/watchers-panel.ts +0 -213
  140. package/src/renderer/file-tree.ts +0 -153
  141. package/src/renderer/progress.ts +0 -99
  142. package/src/renderer/status-token.ts +0 -67
  143. package/src/runtime/diagnostics/panels/panel-resources.ts +0 -118
  144. package/src/runtime/diagnostics/panels/policy.ts +0 -177
@@ -8,7 +8,7 @@ import type { ApprovalBroker, SharedSessionBroker } from '@pellux/goodvibes-sdk/
8
8
  import type { AutomationManager } from '@pellux/goodvibes-sdk/platform/automation';
9
9
  import type { ControlPlaneRecentEvent } from '@pellux/goodvibes-sdk/platform/control-plane';
10
10
  import type { UiRuntimeServices } from '../../runtime/ui-services.ts';
11
- import type { PluginManagerObserver } from '@pellux/goodvibes-sdk/platform/plugins';
11
+ import type { PluginManagerControls } from '../plugins-panel.ts';
12
12
  import type { HookWorkbench } from '@pellux/goodvibes-sdk/platform/hooks';
13
13
  import type { HookDispatcher } from '@pellux/goodvibes-sdk/platform/hooks';
14
14
  import type { HookActivityTracker } from '@pellux/goodvibes-sdk/platform/hooks';
@@ -25,6 +25,15 @@ import type { ApiTokenAuditor } from '@pellux/goodvibes-sdk/platform/security';
25
25
  import type { ComponentHealthMonitor } from '../../runtime/perf/panel-health-monitor.ts';
26
26
  import type { WorktreeRegistry } from '@/runtime/index.ts';
27
27
  import type { SandboxSessionRegistry } from '@/runtime/index.ts';
28
+ import type { OpsApi, PlanRuntimeService } from '@/runtime/index.ts';
29
+ import type { WatcherRegistry } from '@pellux/goodvibes-sdk/platform/watchers';
30
+ import type { RuntimeStore } from '../../runtime/store/index.ts';
31
+ import type { KnowledgeApi } from '@pellux/goodvibes-sdk/platform/knowledge';
32
+ import type { SessionChangeTracker } from '@pellux/goodvibes-sdk/platform/sessions';
33
+ import type { Line } from '../../types/grid.ts';
34
+ import type { Panel, PanelCategory } from '../types.ts';
35
+ import { BasePanel } from '../base-panel.ts';
36
+ import { buildEmptyState, buildPanelWorkspace, DEFAULT_PANEL_PALETTE } from '../polish.ts';
28
37
 
29
38
  export interface BuiltinPanelDeps {
30
39
  /** Config manager for settings-sync and other config-backed panels. */
@@ -45,7 +54,7 @@ export interface BuiltinPanelDeps {
45
54
  subscriptionManager?: SubscriptionManager;
46
55
  /** Shared service registry for services-backed panels. */
47
56
  serviceRegistry?: ServiceRegistry;
48
- /** Context window size in tokens (for ContextVisualizerPanel). */
57
+ /** Context window size in tokens. Unused since WO-113 folded ContextVisualizerPanel into TokenBudgetPanel (which reads getCtxWindow instead); kept for source compatibility. */
49
58
  contextWindow?: number;
50
59
  /** Main Orchestrator instance for TokenBudgetPanel.wire(). */
51
60
  orchestrator?: Orchestrator;
@@ -104,8 +113,8 @@ export interface BuiltinPanelDeps {
104
113
  systemMessagesPanel?: import('../system-messages-panel.ts').SystemMessagesPanel;
105
114
  /** Explicit UI-facing runtime services for agent/process/WRFC/remote panels and modals. */
106
115
  uiServices?: UiRuntimeServices;
107
- /** Shared plugin manager observer for plugin and security panels. */
108
- pluginManager?: PluginManagerObserver;
116
+ /** Shared plugin manager for plugin and security panels (widened past the read-only observer surface — WO-134 — so PluginsPanel can drive enable/disable/verify/lift-quarantine). */
117
+ pluginManager?: PluginManagerControls;
109
118
  /** Shared hook dispatcher for the hooks control-room panel. */
110
119
  hookDispatcher?: Pick<HookDispatcher, 'listHooks' | 'getChains'>;
111
120
  /** Shared hook workbench for the hooks control-room panel. */
@@ -114,6 +123,24 @@ export interface BuiltinPanelDeps {
114
123
  hookActivityTracker?: Pick<HookActivityTracker, 'listRecent'>;
115
124
  /** Shared MCP registry for security panels and MCP workspace commands. */
116
125
  mcpRegistry?: McpRegistry;
126
+ /** Ops control-plane API (cancel/pause/resume/retry) for operator/ops panels to drive real actions. */
127
+ opsApi?: OpsApi;
128
+ /** Plan runtime service for plan/ops-strategy panels to drive adaptive-planner actions. */
129
+ planRuntime?: PlanRuntimeService;
130
+ /** Watcher registry for the watchers panel to drive watcher lifecycle actions. */
131
+ watcherRegistry?: WatcherRegistry;
132
+ /** Root runtime store for panels that need direct selector access to runtime state (see `src/runtime/store/selectors/index.ts`). */
133
+ runtimeStore?: RuntimeStore;
134
+ /** Knowledge API surface (graph nodes/sources/issues, search, schedules) for the Knowledge panel. */
135
+ knowledgeApi?: KnowledgeApi;
136
+ /** Optional session change tracker for the Git panel's session-changed file highlights. */
137
+ sessionChangeTracker?: Pick<SessionChangeTracker, 'getChangedFiles'>;
138
+ /**
139
+ * Open (or focus) a panel by id, wrapping `PanelManager.open`. Use for direct
140
+ * cross-panel navigation instead of printing a "/panel open …" signpost
141
+ * (mirrors the openAgentDetail callback below).
142
+ */
143
+ openPanel?: (panelId: string) => void;
117
144
  /**
118
145
  * Open the agent detail modal for the given agent id. Wired from
119
146
  * InputHandler.agentDetailModal.open() at bootstrap — passed to the
@@ -239,7 +266,7 @@ export function requireControlPlanePanelDeps(deps: BuiltinPanelDeps): ControlPla
239
266
 
240
267
  export function requireAutomationManager(deps: BuiltinPanelDeps): AutomationManager {
241
268
  if (!deps.automationManager) {
242
- throw new Error('SchedulePanel requires an automation manager to be wired at bootstrap.');
269
+ throw new Error('AutomationControlPanel requires an automation manager to be wired at bootstrap.');
243
270
  }
244
271
  return deps.automationManager;
245
272
  }
@@ -251,7 +278,7 @@ export function requireUiServices(deps: BuiltinPanelDeps): UiRuntimeServices {
251
278
  return deps.uiServices;
252
279
  }
253
280
 
254
- export function requirePluginManager(deps: BuiltinPanelDeps): PluginManagerObserver {
281
+ export function requirePluginManager(deps: BuiltinPanelDeps): PluginManagerControls {
255
282
  if (!deps.pluginManager) {
256
283
  throw new Error('Plugin manager must be wired at bootstrap for plugin and security panels.');
257
284
  }
@@ -279,3 +306,68 @@ export function requireMcpRegistry(deps: BuiltinPanelDeps): McpRegistry {
279
306
  }
280
307
  return deps.mcpRegistry;
281
308
  }
309
+
310
+ export function requireKnowledgeApi(deps: BuiltinPanelDeps): KnowledgeApi {
311
+ if (!deps.knowledgeApi) {
312
+ throw new Error('Knowledge API must be wired at bootstrap for the Knowledge panel.');
313
+ }
314
+ return deps.knowledgeApi;
315
+ }
316
+
317
+ // ---------------------------------------------------------------------------
318
+ // WO-152: always-register conditional panels with a "dependency not
319
+ // configured" empty state instead of skipping registration entirely.
320
+ // ---------------------------------------------------------------------------
321
+
322
+ /**
323
+ * Minimal placeholder Panel used when a builtin panel's runtime dependency
324
+ * (e.g. an orchestrator usage getter, a memory registry, an eval registry)
325
+ * was not wired at bootstrap for this build/session. Renders a single
326
+ * "dependency not configured" empty state via `buildEmptyState` so opening
327
+ * the panel id (`/panel open <id>`, a saved layout, a cross-panel jump)
328
+ * always resolves to a real panel instead of "Unknown panel" — the panel
329
+ * type is always registered; only its data source is sometimes absent.
330
+ */
331
+ class UnconfiguredDependencyPanel extends BasePanel {
332
+ constructor(
333
+ id: string,
334
+ name: string,
335
+ icon: string,
336
+ category: PanelCategory,
337
+ private readonly emptyTitle: string,
338
+ private readonly emptyBody: string,
339
+ ) {
340
+ super(id, name, icon, category);
341
+ }
342
+
343
+ render(width: number, height: number): Line[] {
344
+ if (width <= 0 || height <= 0) return [];
345
+ return buildPanelWorkspace(width, height, {
346
+ title: `${this.name} Workspace`,
347
+ sections: [{
348
+ lines: buildEmptyState(width, this.emptyTitle, this.emptyBody, [], DEFAULT_PANEL_PALETTE),
349
+ }],
350
+ palette: DEFAULT_PANEL_PALETTE,
351
+ });
352
+ }
353
+ }
354
+
355
+ /**
356
+ * Build a factory that instantiates `configured()` when `dependencyPresent`
357
+ * is true, otherwise a placeholder Panel rendering `emptyTitle`/`emptyBody`
358
+ * via `buildEmptyState`. Use for builtin panels whose registration used to
359
+ * be gated behind an `if (deps.xyz)` check (cost/memory/incident/eval).
360
+ */
361
+ export function withUnconfiguredFallback(
362
+ dependencyPresent: boolean,
363
+ id: string,
364
+ name: string,
365
+ icon: string,
366
+ category: PanelCategory,
367
+ emptyTitle: string,
368
+ emptyBody: string,
369
+ configured: () => Panel,
370
+ ): () => Panel {
371
+ if (dependencyPresent) return configured;
372
+ return () => new UnconfiguredDependencyPanel(id, name, icon, category, emptyTitle, emptyBody);
373
+ }
@@ -5,21 +5,25 @@ import { BasePanel } from './base-panel.ts';
5
5
  import type { UiCockpitSnapshot, UiReadModel } from '../runtime/ui-read-models.ts';
6
6
  import type { CockpitRosterReadModel } from './cockpit-read-model.ts';
7
7
  import {
8
- buildGuidanceLine,
9
- buildKeyValueLine,
8
+ buildAlignedRow,
10
9
  buildPanelLine,
11
10
  buildPanelWorkspace,
12
11
  buildStatPill,
13
12
  DEFAULT_PANEL_PALETTE,
13
+ type ColumnSpec,
14
14
  type PanelWorkspaceSection,
15
15
  } from './polish.ts';
16
+ import { type ConfirmState, handleConfirmInput, renderConfirmLines } from './confirm-state.ts';
16
17
  import { agentStatusColor } from './agent-inspector-shared.ts';
17
18
 
18
- const C = {
19
- ...DEFAULT_PANEL_PALETTE,
20
- header: '#cbd5e1',
21
- headerBg: '#0f172a',
22
- } as const;
19
+ // Base chrome only — title band and text tokens come straight from
20
+ // DEFAULT_PANEL_PALETTE (WO-002).
21
+ const C = DEFAULT_PANEL_PALETTE;
22
+
23
+ // How often the panel force-refreshes while active so time-based state (the
24
+ // roster's stalled badges, which derive from wall-clock elapsed time rather
25
+ // than an event) stays current even when no agent lifecycle event has fired.
26
+ const STALL_TICK_MS = 5_000;
23
27
 
24
28
  function pickColor(value: number, warnAt = 1, badAt = 3): string {
25
29
  if (value >= badAt) return C.bad;
@@ -28,41 +32,143 @@ function pickColor(value: number, warnAt = 1, badAt = 3): string {
28
32
  }
29
33
 
30
34
  export interface CockpitPanelActionCallbacks {
35
+ /** Open the quick agent-detail modal (bootstrap-shell openAgentDetail wiring). */
31
36
  readonly openAgentDetail: (agentId: string) => void;
37
+ /** Deep-link into the Inspector panel focused on this agent (WO-110 inspectAgent route). */
38
+ readonly inspectAgent: (agentId: string) => void;
32
39
  readonly cancelAgent: (agentId: string) => boolean;
40
+ /** Cross-panel jump for a domain mini-summary card (wraps PanelManager.open via deps.openPanel). */
41
+ readonly openPanel: (panelId: string) => void;
33
42
  }
34
43
 
35
44
  const WORKSPACE_IDS = ['flow', 'governance', 'health', 'domains', 'agents'] as const;
36
45
  type WorkspaceId = (typeof WORKSPACE_IDS)[number];
46
+ type DomainWorkspaceId = Exclude<WorkspaceId, 'agents'>;
47
+
48
+ /** A single live domain mini-summary card. Enter on the focused card jumps to `target` via deps.openPanel. */
49
+ interface DomainCard {
50
+ readonly label: string;
51
+ readonly target: string;
52
+ }
53
+
54
+ // Replaces the old buildGuidanceLine ('/orchestration', '/policy', …) signposts:
55
+ // each non-agents workspace is now a short list of live, jumpable domain cards.
56
+ const DOMAIN_CARDS: Record<DomainWorkspaceId, readonly DomainCard[]> = {
57
+ flow: [
58
+ { label: 'Orchestration', target: 'orchestration' },
59
+ { label: 'Tasks', target: 'tasks' },
60
+ ],
61
+ governance: [
62
+ { label: 'Policy', target: 'policy' },
63
+ { label: 'Security', target: 'security' },
64
+ ],
65
+ health: [
66
+ { label: 'Incidents', target: 'incident' },
67
+ ],
68
+ domains: [
69
+ { label: 'Communication', target: 'communication' },
70
+ ],
71
+ };
72
+
73
+ /** Live stat triplet for a domain card, computed from the current cockpit snapshot. */
74
+ function domainCardStats(target: string, snapshot: UiCockpitSnapshot): ReadonlyArray<readonly [string, string, string]> {
75
+ switch (target) {
76
+ case 'orchestration':
77
+ return [
78
+ ['graphs', String(snapshot.activeGraphs), pickColor(snapshot.activeGraphs, 1, 4)],
79
+ ['running', String(snapshot.runningTasks), C.value],
80
+ ['guards', String(snapshot.guardTrips), pickColor(snapshot.guardTrips)],
81
+ ];
82
+ case 'tasks':
83
+ return [
84
+ ['running', String(snapshot.runningTasks), C.value],
85
+ ['blocked', String(snapshot.blockedTasks), pickColor(snapshot.blockedTasks)],
86
+ ['failed', String(snapshot.failedTasks), pickColor(snapshot.failedTasks)],
87
+ ];
88
+ case 'policy':
89
+ return [
90
+ ['preflight', snapshot.preflightStatus.toUpperCase(), snapshot.preflightStatus === 'block' ? C.bad : snapshot.preflightStatus === 'warn' ? C.warn : snapshot.preflightStatus === 'pass' ? C.good : C.dim],
91
+ ['issues', String(snapshot.preflightIssueCount), pickColor(snapshot.preflightIssueCount)],
92
+ ['lint', String(snapshot.lintFindingCount), pickColor(snapshot.lintFindingCount)],
93
+ ];
94
+ case 'security':
95
+ return [
96
+ ['token blocked', String(snapshot.tokenBlockedCount), pickColor(snapshot.tokenBlockedCount)],
97
+ ['overdue', String(snapshot.tokenRotationOverdueCount), pickColor(snapshot.tokenRotationOverdueCount)],
98
+ ['scope violations', String(snapshot.tokenScopeViolationCount), pickColor(snapshot.tokenScopeViolationCount)],
99
+ ];
100
+ case 'incident':
101
+ return [
102
+ ['count', String(snapshot.incidentCount), pickColor(snapshot.incidentCount)],
103
+ ['latest', snapshot.latestIncident ? snapshot.latestIncident.classification : 'none', snapshot.latestIncident ? C.bad : C.dim],
104
+ ];
105
+ case 'communication':
106
+ return [
107
+ ['messages', String(snapshot.communicationCount), C.value],
108
+ ['blocked', String(snapshot.blockedMessages), pickColor(snapshot.blockedMessages)],
109
+ ];
110
+ default:
111
+ return [];
112
+ }
113
+ }
37
114
 
38
115
  function formatCost(cost: number | null): string {
39
116
  if (cost === null) return 'n/a';
40
117
  return cost < 0.01 ? '<$0.01' : `$${cost.toFixed(2)}`;
41
118
  }
42
119
 
120
+ function formatTokens(inputTokens: number | null, outputTokens: number | null): string {
121
+ if (inputTokens === null && outputTokens === null) return 'n/a';
122
+ const fmt = (n: number | null) => (n === null ? '-' : n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n));
123
+ return `${fmt(inputTokens)}/${fmt(outputTokens)}`;
124
+ }
125
+
43
126
  export class CockpitPanel extends BasePanel {
44
127
  private readonly unsub: (() => void) | null;
45
128
  private readonly rosterUnsub: (() => void) | null;
46
129
  private readonly actionCallbacks: CockpitPanelActionCallbacks;
47
130
  private selectedWorkspaceIndex = 0;
48
131
  private agentCursorIndex = 0;
49
- private pendingCancelId: string | null = null;
132
+ private domainCursorIndex = 0;
133
+ private confirm: ConfirmState<string> | null = null;
134
+ private stallTickTimer: ReturnType<typeof setInterval> | null = null;
50
135
 
51
136
  public constructor(
52
137
  private readonly readModel?: UiReadModel<UiCockpitSnapshot>,
53
138
  private readonly rosterReadModel?: CockpitRosterReadModel,
54
139
  actionCallbacks?: Partial<CockpitPanelActionCallbacks>,
55
140
  ) {
56
- super('cockpit', 'Cockpit', 'O', 'monitoring');
141
+ super('cockpit', 'Cockpit', 'O', 'runtime-ops');
57
142
  this.unsub = readModel ? readModel.subscribe(() => this.markDirty()) : null;
58
143
  this.rosterUnsub = rosterReadModel ? rosterReadModel.subscribe(() => this.markDirty()) : null;
59
144
  this.actionCallbacks = {
60
145
  openAgentDetail: actionCallbacks?.openAgentDetail ?? ((_id: string) => { /* noop */ }),
146
+ inspectAgent: actionCallbacks?.inspectAgent ?? ((_id: string) => { /* noop */ }),
61
147
  cancelAgent: actionCallbacks?.cancelAgent ?? ((_id: string) => false),
148
+ openPanel: actionCallbacks?.openPanel ?? ((_id: string) => { /* noop */ }),
62
149
  };
63
150
  }
64
151
 
152
+ public override onActivate(): void {
153
+ super.onActivate();
154
+ // Periodic tick so the roster's time-derived stalled badges (see
155
+ // cockpit-read-model's AGENT_STALL_THRESHOLD_MS) refresh even when no
156
+ // agent lifecycle event fires while this panel is on screen.
157
+ if (this.stallTickTimer === null) {
158
+ this.stallTickTimer = this.registerTimer(setInterval(() => this.markDirty(), STALL_TICK_MS));
159
+ }
160
+ }
161
+
162
+ public override onDeactivate(): void {
163
+ super.onDeactivate();
164
+ if (this.stallTickTimer !== null) {
165
+ this.clearTimer(this.stallTickTimer);
166
+ this.stallTickTimer = null;
167
+ }
168
+ }
169
+
65
170
  public override onDestroy(): void {
171
+ super.onDestroy();
66
172
  this.unsub?.();
67
173
  this.rosterUnsub?.();
68
174
  }
@@ -72,45 +178,51 @@ export class CockpitPanel extends BasePanel {
72
178
  }
73
179
 
74
180
  public handleInput(key: string): boolean {
75
- // Confirm-cancel absorb: when a cancel is pending, only y/enter confirm, escape/n dismiss, everything else is consumed
76
- if (this.pendingCancelId !== null) {
77
- if (key === 'y' || key === 'enter' || key === 'return') {
78
- this.actionCallbacks.cancelAgent(this.pendingCancelId);
79
- this.pendingCancelId = null;
181
+ // Confirm-cancel absorb: when a cancel is pending, only y/enter/return confirm,
182
+ // escape/n dismiss, everything else is consumed (canonical ConfirmState contract).
183
+ if (this.confirm) {
184
+ const result = handleConfirmInput(this.confirm, key);
185
+ if (result === 'confirmed') {
186
+ this.actionCallbacks.cancelAgent(this.confirm.subject);
187
+ this.confirm = null;
80
188
  this.markDirty();
81
- } else if (key === 'escape' || key === 'n') {
82
- this.pendingCancelId = null;
189
+ } else if (result === 'cancelled') {
190
+ this.confirm = null;
83
191
  this.markDirty();
84
192
  }
85
- // All other keys are consumed while confirm is pending
193
+ // 'absorbed' (and the confirmed/cancelled cases above) all consume the key.
86
194
  return true;
87
195
  }
88
196
  if (key === 'left' || key === 'h') {
89
197
  this.selectedWorkspaceIndex = Math.max(0, this.selectedWorkspaceIndex - 1);
90
198
  this.agentCursorIndex = 0;
199
+ this.domainCursorIndex = 0;
91
200
  this.markDirty();
92
201
  return true;
93
202
  }
94
203
  if (key === 'right' || key === 'l') {
95
204
  this.selectedWorkspaceIndex = Math.min(WORKSPACE_IDS.length - 1, this.selectedWorkspaceIndex + 1);
96
205
  this.agentCursorIndex = 0;
206
+ this.domainCursorIndex = 0;
97
207
  this.markDirty();
98
208
  return true;
99
209
  }
100
210
  if (key === 'home') {
101
211
  this.selectedWorkspaceIndex = 0;
102
212
  this.agentCursorIndex = 0;
213
+ this.domainCursorIndex = 0;
103
214
  this.markDirty();
104
215
  return true;
105
216
  }
106
217
  if (key === 'end') {
107
218
  this.selectedWorkspaceIndex = WORKSPACE_IDS.length - 1;
108
219
  this.agentCursorIndex = 0;
220
+ this.domainCursorIndex = 0;
109
221
  this.markDirty();
110
222
  return true;
111
223
  }
112
- // Agents workspace: cursor movement, inspect, and cancel-initiation
113
224
  if (this.selectedWorkspace === 'agents') {
225
+ // Agents workspace: roster cursor movement, inspect, and cancel-initiation.
114
226
  const roster = this.rosterReadModel?.getSnapshot().roster ?? [];
115
227
  if (key === 'up' || key === 'k') {
116
228
  this.agentCursorIndex = Math.max(0, this.agentCursorIndex - 1);
@@ -122,18 +234,50 @@ export class CockpitPanel extends BasePanel {
122
234
  this.markDirty();
123
235
  return true;
124
236
  }
125
- if (key === 'i' || key === 'enter' || key === 'return') {
237
+ if (key === 'i') {
238
+ // Quick view is only meaningful when the cursor is on a real roster
239
+ // entry — otherwise leave the key unconsumed instead of absorbing it
240
+ // as a silent no-op.
241
+ const entry = roster[this.agentCursorIndex];
242
+ if (!entry) return false;
243
+ this.actionCallbacks.openAgentDetail(entry.id);
244
+ return true;
245
+ }
246
+ if (key === 'enter' || key === 'return') {
126
247
  const entry = roster[this.agentCursorIndex];
127
248
  if (entry) {
128
- this.actionCallbacks.openAgentDetail(entry.id);
249
+ this.actionCallbacks.inspectAgent(entry.id);
129
250
  }
130
251
  return true;
131
252
  }
132
253
  if (key === 'c') {
254
+ // Cancel is only meaningful on a real, non-terminal roster entry —
255
+ // otherwise leave the key unconsumed instead of absorbing it as a
256
+ // silent no-op.
133
257
  const entry = roster[this.agentCursorIndex];
134
- if (entry && !this.isTerminal(entry.status)) {
135
- this.pendingCancelId = entry.id;
136
- this.markDirty();
258
+ if (!entry || this.isTerminal(entry.status)) return false;
259
+ const shortId = entry.id.length > 8 ? entry.id.slice(-8) : entry.id;
260
+ this.confirm = { subject: entry.id, label: `agent ${shortId}`, verb: 'Cancel' };
261
+ this.markDirty();
262
+ return true;
263
+ }
264
+ } else {
265
+ // Domain-summary workspaces: card cursor movement + Enter jump.
266
+ const cards = DOMAIN_CARDS[this.selectedWorkspace];
267
+ if (key === 'up' || key === 'k') {
268
+ this.domainCursorIndex = Math.max(0, this.domainCursorIndex - 1);
269
+ this.markDirty();
270
+ return true;
271
+ }
272
+ if (key === 'down' || key === 'j') {
273
+ this.domainCursorIndex = Math.min(cards.length - 1, this.domainCursorIndex + 1);
274
+ this.markDirty();
275
+ return true;
276
+ }
277
+ if (key === 'enter' || key === 'return') {
278
+ const card = cards[this.domainCursorIndex];
279
+ if (card) {
280
+ this.actionCallbacks.openPanel(card.target);
137
281
  }
138
282
  return true;
139
283
  }
@@ -151,29 +295,45 @@ export class CockpitPanel extends BasePanel {
151
295
  lines.push(buildPanelLine(width, [[' Agent roster read model not wired.', C.empty]]));
152
296
  return lines;
153
297
  }
154
- const { roster, stalledAgentCount, totalCost } = this.rosterReadModel.getSnapshot();
298
+ const { roster, stalledAgentCount, totalCost, totalInputTokens, totalOutputTokens } = this.rosterReadModel.getSnapshot();
155
299
  lines.push(buildPanelLine(width, [
156
300
  [` ${roster.length} agent${roster.length !== 1 ? 's' : ''}`, C.label],
157
301
  stalledAgentCount > 0 ? [` ${stalledAgentCount} stalled`, C.bad] : ['', C.dim],
158
302
  [` cost: ${formatCost(totalCost)}`, C.dim],
303
+ [` tokens: ${formatTokens(totalInputTokens, totalOutputTokens)}`, C.dim],
159
304
  ]));
160
305
  const colors: Record<string, string> = {
161
306
  pending: C.dim, running: C.value, completed: C.good, failed: C.bad, cancelled: C.dim, system: C.dim,
162
307
  };
308
+ // Fixed-width columns (id/status/stalled/model/tokens/cost) + task takes the remainder.
309
+ const columns: ColumnSpec[] = [
310
+ { width: 9 },
311
+ { width: 10 },
312
+ { width: 8 },
313
+ { width: 14 },
314
+ { width: 13, align: 'right' },
315
+ { width: 8, align: 'right' },
316
+ { width: Math.max(8, width - 68) },
317
+ ];
163
318
  for (let i = 0; i < roster.length; i++) {
164
319
  const entry = roster[i]!;
165
- const cursor = i === this.agentCursorIndex ? '>' : ' ';
320
+ const selected = i === this.agentCursorIndex;
166
321
  const shortId = entry.id.length > 8 ? entry.id.slice(-8) : entry.id;
167
322
  const statusColor = agentStatusColor(entry.status, colors);
168
- const stalledBadge: [string, string] = entry.stalled ? [' STALLED', C.bad] : ['', C.dim];
169
- lines.push(buildPanelLine(width, [
170
- [cursor, i === this.agentCursorIndex ? C.value : C.dim],
171
- [` ${shortId} `, C.dim],
172
- [entry.status, statusColor],
173
- stalledBadge,
174
- [' ', C.dim],
175
- [truncateDisplay(entry.task, Math.max(0, width - 30)), C.label],
176
- ]));
323
+ lines.push(buildAlignedRow(
324
+ width,
325
+ [
326
+ { text: shortId, fg: C.dim },
327
+ { text: entry.status, fg: statusColor },
328
+ { text: entry.stalled ? 'STALLED' : '', fg: C.bad },
329
+ { text: entry.model, fg: C.info },
330
+ { text: formatTokens(entry.inputTokens, entry.outputTokens), fg: C.dim },
331
+ { text: formatCost(entry.cost), fg: C.value },
332
+ { text: entry.task, fg: C.label },
333
+ ],
334
+ columns,
335
+ { selected, selectedBg: C.headerBg, marker: '▸' },
336
+ ));
177
337
  }
178
338
  if (roster.length === 0) {
179
339
  lines.push(buildPanelLine(width, [[' No agents tracked yet.', C.empty]]));
@@ -181,9 +341,42 @@ export class CockpitPanel extends BasePanel {
181
341
  return lines;
182
342
  }
183
343
 
344
+ /** Live domain mini-summary cards for a non-agents workspace (replaces the old signpost pair). */
345
+ private renderDomainWorkspace(width: number, workspace: DomainWorkspaceId, snapshot: UiCockpitSnapshot): Line[] {
346
+ const cards = DOMAIN_CARDS[workspace];
347
+ const lines: Line[] = [];
348
+ for (let i = 0; i < cards.length; i++) {
349
+ const card = cards[i]!;
350
+ const focused = i === this.domainCursorIndex;
351
+ const stats = domainCardStats(card.target, snapshot);
352
+ lines.push(buildPanelLine(width, [
353
+ [focused ? '▸ ' : ' ', focused ? C.value : C.dim],
354
+ [card.label, focused ? C.header : C.label, focused ? C.headerBg : undefined],
355
+ [' ', C.dim],
356
+ ...stats.flatMap(([label, value, color]): Array<[string, string, string?]> => [
357
+ [`${label} `, C.dim],
358
+ [`${value} `, color],
359
+ ]),
360
+ ]));
361
+ }
362
+ lines.push(buildPanelLine(width, [[' ↑/↓ select domain Enter opens panel', C.dim]]));
363
+ return lines;
364
+ }
365
+
184
366
  public render(width: number, height: number): Line[] {
185
367
  this.needsRender = false;
186
368
 
369
+ if (this.confirm) {
370
+ const lines = buildPanelWorkspace(width, height, {
371
+ title: 'Operator Cockpit',
372
+ sections: [{ title: 'Confirmation', lines: renderConfirmLines(width, this.confirm) }],
373
+ footerLines: [buildPanelLine(width, [[' y / Enter confirm n / Esc cancel', C.dim]])],
374
+ palette: C,
375
+ });
376
+ while (lines.length < height) lines.push(createEmptyLine(width));
377
+ return lines.slice(0, height);
378
+ }
379
+
187
380
  if (!this.readModel) {
188
381
  const lines: Line[] = [buildPanelLine(width, [[' Runtime read model not wired into this panel yet.', C.empty]])];
189
382
  const workspace = buildPanelWorkspace(width, height, {
@@ -246,43 +439,9 @@ export class CockpitPanel extends BasePanel {
246
439
  `tasks:${snapshot.taskCount} agents:${snapshot.agentCount} graphs:${snapshot.totalGraphs} comms:${snapshot.communicationCount} mcp:${snapshot.mcpServerCount} plugins:${snapshot.pluginCount}`,
247
440
  C.dim,
248
441
  ]])];
249
- const workspaceLines: Line[] = [];
250
- if (selectedWorkspace === 'flow') {
251
- workspaceLines.push(buildKeyValueLine(width, [
252
- { label: 'running', value: String(snapshot.runningTasks), valueColor: C.value },
253
- { label: 'blocked', value: String(snapshot.blockedTasks), valueColor: pickColor(snapshot.blockedTasks) },
254
- { label: 'graphs', value: String(snapshot.activeGraphs), valueColor: pickColor(snapshot.activeGraphs, 1, 4) },
255
- ], C));
256
- workspaceLines.push(buildGuidanceLine(width, '/orchestration', 'inspect graph state, retries, and subtree controls', C));
257
- workspaceLines.push(buildGuidanceLine(width, '/tasks', 'review active task pressure and task-specific output', C));
258
- } else if (selectedWorkspace === 'governance') {
259
- workspaceLines.push(buildKeyValueLine(width, [
260
- { label: 'preflight', value: snapshot.preflightStatus.toUpperCase(), valueColor: snapshot.preflightStatus === 'block' ? C.bad : snapshot.preflightStatus === 'warn' ? C.warn : snapshot.preflightStatus === 'pass' ? C.good : C.dim },
261
- { label: 'lint', value: String(snapshot.lintFindingCount), valueColor: pickColor(snapshot.lintFindingCount) },
262
- { label: 'allow-all mcp', value: String(snapshot.elevatedMcp), valueColor: pickColor(snapshot.elevatedMcp) },
263
- ], C));
264
- workspaceLines.push(buildGuidanceLine(width, '/policy', 'run simulation, preflight, and bundle review', C));
265
- workspaceLines.push(buildGuidanceLine(width, '/security', 'inspect trust, tokens, quarantines, and incident pressure', C));
266
- } else if (selectedWorkspace === 'health') {
267
- workspaceLines.push(buildKeyValueLine(width, [
268
- { label: 'incidents', value: String(snapshot.incidentCount), valueColor: pickColor(snapshot.incidentCount) },
269
- { label: 'plugins', value: String(snapshot.erroredPlugins), valueColor: pickColor(snapshot.erroredPlugins) },
270
- { label: 'integrations', value: String(snapshot.failingIntegrations), valueColor: pickColor(snapshot.failingIntegrations) },
271
- ], C));
272
- workspaceLines.push(buildGuidanceLine(width, '/incident latest', 'inspect the latest incident bundle and replay fallout', C));
273
- workspaceLines.push(buildGuidanceLine(width, '/plugins', 'review errored plugins and provenance posture', C));
274
- } else if (selectedWorkspace === 'domains') {
275
- workspaceLines.push(buildKeyValueLine(width, [
276
- { label: 'tasks', value: String(snapshot.taskCount), valueColor: C.value },
277
- { label: 'comms', value: String(snapshot.communicationCount), valueColor: C.value },
278
- { label: 'mcp', value: String(snapshot.mcpServerCount), valueColor: C.value },
279
- ], C));
280
- workspaceLines.push(buildGuidanceLine(width, '/mcp', 'inspect trust, quarantine, and risky server posture', C));
281
- workspaceLines.push(buildGuidanceLine(width, '/communication', 'review blocked lanes and agent message flow', C));
282
- } else {
283
- // 'agents' workspace — roster from rosterReadModel
284
- workspaceLines.push(...this.renderAgentsWorkspace(width));
285
- }
442
+ const workspaceLines: Line[] = selectedWorkspace === 'agents'
443
+ ? this.renderAgentsWorkspace(width)
444
+ : this.renderDomainWorkspace(width, selectedWorkspace, snapshot);
286
445
 
287
446
  // Visible workspace selector so focus and the available workspaces are
288
447
  // obvious at a glance (not buried in a footer string). Rendered as a
@@ -308,8 +467,8 @@ export class CockpitPanel extends BasePanel {
308
467
  ];
309
468
  // Context-aware footer: surface the keys that actually work in this view.
310
469
  const footerHint = selectedWorkspace === 'agents'
311
- ? ' ←/→ workspace ↑/↓ select agent i inspect c cancel Home/End jump'
312
- : ' ←/→ workspace Home/End jump → agents for per-agent inspect/cancel';
470
+ ? ' ←/→ workspace ↑/↓ select agent i quick view enter inspector c cancel Home/End jump'
471
+ : ' ←/→ workspace ↑/↓ select domain enter opens panel Home/End jump';
313
472
  const lines = buildPanelWorkspace(width, height, {
314
473
  title: 'Operator Cockpit',
315
474
  sections,