@pellux/goodvibes-tui 0.27.0 → 0.29.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 (143) hide show
  1. package/CHANGELOG.md +124 -0
  2. package/README.md +1 -1
  3. package/package.json +1 -1
  4. package/src/core/context-usage.ts +53 -0
  5. package/src/core/conversation.ts +4 -6
  6. package/src/core/session-recovery.ts +2 -0
  7. package/src/core/turn-event-wiring.ts +1 -0
  8. package/src/input/commands/cost-runtime.ts +49 -0
  9. package/src/input/commands/eval.ts +4 -3
  10. package/src/input/commands/operator-runtime.ts +5 -1
  11. package/src/input/commands.ts +2 -0
  12. package/src/input/handler-feed-routes.ts +60 -26
  13. package/src/input/handler-feed.ts +17 -8
  14. package/src/input/handler-picker-routes.ts +18 -13
  15. package/src/input/handler-shortcuts.ts +51 -0
  16. package/src/input/handler-ui-state.ts +4 -0
  17. package/src/input/handler.ts +43 -6
  18. package/src/input/keybindings.ts +53 -8
  19. package/src/input/model-picker.ts +5 -0
  20. package/src/input/panel-integration-actions.ts +117 -1
  21. package/src/main.ts +5 -2
  22. package/src/panels/agent-inspector-panel.ts +139 -45
  23. package/src/panels/agent-inspector-shared.ts +147 -3
  24. package/src/panels/approval-panel.ts +204 -102
  25. package/src/panels/automation-control-panel.ts +370 -103
  26. package/src/panels/base-panel.ts +26 -2
  27. package/src/panels/builtin/agent.ts +28 -37
  28. package/src/panels/builtin/development.ts +40 -32
  29. package/src/panels/builtin/knowledge.ts +15 -6
  30. package/src/panels/builtin/operations.ts +178 -117
  31. package/src/panels/builtin/session.ts +35 -9
  32. package/src/panels/builtin/shared.ts +98 -6
  33. package/src/panels/cockpit-panel.ts +253 -75
  34. package/src/panels/cockpit-read-model.ts +2 -1
  35. package/src/panels/communication-panel.ts +159 -56
  36. package/src/panels/confirm-state.ts +8 -1
  37. package/src/panels/control-plane-panel.ts +354 -95
  38. package/src/panels/cost-tracker-panel.ts +243 -57
  39. package/src/panels/debug-panel.ts +347 -159
  40. package/src/panels/diff-panel.ts +257 -82
  41. package/src/panels/docs-panel.ts +166 -64
  42. package/src/panels/eval-panel.ts +336 -109
  43. package/src/panels/expandable-list-panel.ts +189 -0
  44. package/src/panels/file-explorer-panel.ts +238 -159
  45. package/src/panels/file-preview-panel.ts +141 -59
  46. package/src/panels/git-panel.ts +360 -191
  47. package/src/panels/hooks-panel.ts +181 -19
  48. package/src/panels/incident-review-panel.ts +252 -43
  49. package/src/panels/index.ts +0 -4
  50. package/src/panels/intelligence-panel.ts +310 -103
  51. package/src/panels/knowledge-graph-panel.ts +480 -58
  52. package/src/panels/local-auth-panel.ts +251 -33
  53. package/src/panels/marketplace-panel.ts +219 -33
  54. package/src/panels/memory-panel.ts +83 -81
  55. package/src/panels/ops-control-panel.ts +211 -32
  56. package/src/panels/ops-strategy-panel.ts +131 -45
  57. package/src/panels/orchestration-panel.ts +259 -67
  58. package/src/panels/panel-list-panel.ts +184 -136
  59. package/src/panels/panel-manager.ts +120 -13
  60. package/src/panels/plan-dashboard-panel.ts +507 -74
  61. package/src/panels/plugins-panel.ts +227 -34
  62. package/src/panels/policy-panel.ts +264 -55
  63. package/src/panels/polish-core.ts +162 -0
  64. package/src/panels/polish-tables.ts +258 -0
  65. package/src/panels/polish.ts +67 -149
  66. package/src/panels/project-planning-answer-actions.ts +134 -0
  67. package/src/panels/project-planning-panel.ts +84 -113
  68. package/src/panels/provider-health-panel.ts +438 -480
  69. package/src/panels/provider-health-routes.ts +203 -0
  70. package/src/panels/provider-health-tracker.ts +194 -6
  71. package/src/panels/provider-health-views.ts +560 -0
  72. package/src/panels/qr-panel.ts +136 -38
  73. package/src/panels/remote-panel.ts +123 -38
  74. package/src/panels/routes-panel.ts +87 -24
  75. package/src/panels/sandbox-panel.ts +232 -65
  76. package/src/panels/scrollable-list-panel.ts +143 -145
  77. package/src/panels/security-panel.ts +204 -52
  78. package/src/panels/services-panel.ts +174 -76
  79. package/src/panels/session-browser-panel.ts +101 -6
  80. package/src/panels/session-maintenance.ts +4 -122
  81. package/src/panels/settings-sync-panel.ts +346 -68
  82. package/src/panels/skills-panel.ts +157 -89
  83. package/src/panels/subscription-panel.ts +105 -34
  84. package/src/panels/symbol-outline-panel.ts +287 -154
  85. package/src/panels/system-messages-panel.ts +172 -38
  86. package/src/panels/tasks-panel.ts +348 -134
  87. package/src/panels/thinking-panel.ts +66 -32
  88. package/src/panels/token-budget-panel.ts +233 -46
  89. package/src/panels/tool-inspector-panel.ts +170 -54
  90. package/src/panels/types.ts +65 -4
  91. package/src/panels/work-plan-panel.ts +393 -39
  92. package/src/panels/worktree-panel.ts +239 -61
  93. package/src/panels/wrfc-panel-format.ts +133 -0
  94. package/src/panels/wrfc-panel.ts +146 -150
  95. package/src/renderer/compaction-preview.ts +2 -1
  96. package/src/renderer/compositor.ts +46 -43
  97. package/src/renderer/conversation-overlays.ts +25 -11
  98. package/src/renderer/footer-tips.ts +41 -0
  99. package/src/renderer/fullscreen-primitives.ts +22 -16
  100. package/src/renderer/help-overlay.ts +91 -14
  101. package/src/renderer/hint-grammar.ts +52 -0
  102. package/src/renderer/layout.ts +7 -7
  103. package/src/renderer/modal-factory.ts +23 -15
  104. package/src/renderer/model-picker-overlay.ts +30 -11
  105. package/src/renderer/model-workspace.ts +2 -3
  106. package/src/renderer/overlay-box.ts +16 -10
  107. package/src/renderer/panel-composite.ts +7 -20
  108. package/src/renderer/panel-workspace-bar.ts +14 -8
  109. package/src/renderer/process-indicator.ts +3 -1
  110. package/src/renderer/progress.ts +8 -6
  111. package/src/renderer/search-overlay.ts +27 -6
  112. package/src/renderer/session-picker-modal.ts +6 -4
  113. package/src/renderer/settings-modal.ts +70 -10
  114. package/src/renderer/shell-surface.ts +41 -15
  115. package/src/renderer/status-glyphs.ts +11 -9
  116. package/src/renderer/tab-strip.ts +148 -34
  117. package/src/renderer/theme.ts +60 -3
  118. package/src/renderer/ui-factory.ts +45 -36
  119. package/src/renderer/ui-primitives.ts +23 -2
  120. package/src/runtime/bootstrap-shell.ts +35 -18
  121. package/src/runtime/diagnostics/panels/index.ts +0 -3
  122. package/src/runtime/ui/model-picker/health-enrichment.ts +3 -0
  123. package/src/runtime/ui/model-picker/types.ts +4 -0
  124. package/src/shell/ui-openers.ts +14 -22
  125. package/src/utils/format-duration.ts +55 -0
  126. package/src/utils/format-number.ts +71 -0
  127. package/src/utils/splash-lines.ts +44 -3
  128. package/src/version.ts +1 -1
  129. package/src/work-plans/work-plan-store.ts +13 -0
  130. package/src/panels/agent-logs-panel.ts +0 -666
  131. package/src/panels/agent-logs-shared.ts +0 -129
  132. package/src/panels/context-visualizer-panel.ts +0 -214
  133. package/src/panels/forensics-panel.ts +0 -364
  134. package/src/panels/panel-picker.ts +0 -106
  135. package/src/panels/provider-account-snapshot.ts +0 -259
  136. package/src/panels/provider-accounts-panel.ts +0 -218
  137. package/src/panels/provider-stats-panel.ts +0 -366
  138. package/src/panels/schedule-panel.ts +0 -342
  139. package/src/panels/watchers-panel.ts +0 -193
  140. package/src/renderer/panel-picker-overlay.ts +0 -202
  141. package/src/renderer/panel-tab-bar.ts +0 -69
  142. package/src/runtime/diagnostics/panels/panel-resources.ts +0 -118
  143. 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
+ }
@@ -1,24 +1,29 @@
1
1
  import type { Line } from '../types/grid.ts';
2
2
  import { createEmptyLine } from '../types/grid.ts';
3
+ import { truncateDisplay } from '../utils/terminal-width.ts';
3
4
  import { BasePanel } from './base-panel.ts';
4
5
  import type { UiCockpitSnapshot, UiReadModel } from '../runtime/ui-read-models.ts';
5
6
  import type { CockpitRosterReadModel } from './cockpit-read-model.ts';
6
7
  import {
7
- buildGuidanceLine,
8
- buildKeyValueLine,
8
+ buildAlignedRow,
9
9
  buildPanelLine,
10
10
  buildPanelWorkspace,
11
11
  buildStatPill,
12
12
  DEFAULT_PANEL_PALETTE,
13
+ type ColumnSpec,
13
14
  type PanelWorkspaceSection,
14
15
  } from './polish.ts';
16
+ import { type ConfirmState, handleConfirmInput, renderConfirmLines } from './confirm-state.ts';
15
17
  import { agentStatusColor } from './agent-inspector-shared.ts';
16
18
 
17
- const C = {
18
- ...DEFAULT_PANEL_PALETTE,
19
- header: '#cbd5e1',
20
- headerBg: '#0f172a',
21
- } 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;
22
27
 
23
28
  function pickColor(value: number, warnAt = 1, badAt = 3): string {
24
29
  if (value >= badAt) return C.bad;
@@ -27,41 +32,143 @@ function pickColor(value: number, warnAt = 1, badAt = 3): string {
27
32
  }
28
33
 
29
34
  export interface CockpitPanelActionCallbacks {
35
+ /** Open the quick agent-detail modal (bootstrap-shell openAgentDetail wiring). */
30
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;
31
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;
32
42
  }
33
43
 
34
44
  const WORKSPACE_IDS = ['flow', 'governance', 'health', 'domains', 'agents'] as const;
35
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
+ }
36
114
 
37
115
  function formatCost(cost: number | null): string {
38
116
  if (cost === null) return 'n/a';
39
117
  return cost < 0.01 ? '<$0.01' : `$${cost.toFixed(2)}`;
40
118
  }
41
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
+
42
126
  export class CockpitPanel extends BasePanel {
43
127
  private readonly unsub: (() => void) | null;
44
128
  private readonly rosterUnsub: (() => void) | null;
45
129
  private readonly actionCallbacks: CockpitPanelActionCallbacks;
46
130
  private selectedWorkspaceIndex = 0;
47
131
  private agentCursorIndex = 0;
48
- private pendingCancelId: string | null = null;
132
+ private domainCursorIndex = 0;
133
+ private confirm: ConfirmState<string> | null = null;
134
+ private stallTickTimer: ReturnType<typeof setInterval> | null = null;
49
135
 
50
136
  public constructor(
51
137
  private readonly readModel?: UiReadModel<UiCockpitSnapshot>,
52
138
  private readonly rosterReadModel?: CockpitRosterReadModel,
53
139
  actionCallbacks?: Partial<CockpitPanelActionCallbacks>,
54
140
  ) {
55
- super('cockpit', 'Cockpit', 'O', 'monitoring');
141
+ super('cockpit', 'Cockpit', 'O', 'runtime-ops');
56
142
  this.unsub = readModel ? readModel.subscribe(() => this.markDirty()) : null;
57
143
  this.rosterUnsub = rosterReadModel ? rosterReadModel.subscribe(() => this.markDirty()) : null;
58
144
  this.actionCallbacks = {
59
145
  openAgentDetail: actionCallbacks?.openAgentDetail ?? ((_id: string) => { /* noop */ }),
146
+ inspectAgent: actionCallbacks?.inspectAgent ?? ((_id: string) => { /* noop */ }),
60
147
  cancelAgent: actionCallbacks?.cancelAgent ?? ((_id: string) => false),
148
+ openPanel: actionCallbacks?.openPanel ?? ((_id: string) => { /* noop */ }),
61
149
  };
62
150
  }
63
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
+
64
170
  public override onDestroy(): void {
171
+ super.onDestroy();
65
172
  this.unsub?.();
66
173
  this.rosterUnsub?.();
67
174
  }
@@ -71,45 +178,51 @@ export class CockpitPanel extends BasePanel {
71
178
  }
72
179
 
73
180
  public handleInput(key: string): boolean {
74
- // Confirm-cancel absorb: when a cancel is pending, only y/enter confirm, escape/n dismiss, everything else is consumed
75
- if (this.pendingCancelId !== null) {
76
- if (key === 'y' || key === 'enter' || key === 'return') {
77
- this.actionCallbacks.cancelAgent(this.pendingCancelId);
78
- 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;
79
188
  this.markDirty();
80
- } else if (key === 'escape' || key === 'n') {
81
- this.pendingCancelId = null;
189
+ } else if (result === 'cancelled') {
190
+ this.confirm = null;
82
191
  this.markDirty();
83
192
  }
84
- // All other keys are consumed while confirm is pending
193
+ // 'absorbed' (and the confirmed/cancelled cases above) all consume the key.
85
194
  return true;
86
195
  }
87
196
  if (key === 'left' || key === 'h') {
88
197
  this.selectedWorkspaceIndex = Math.max(0, this.selectedWorkspaceIndex - 1);
89
198
  this.agentCursorIndex = 0;
199
+ this.domainCursorIndex = 0;
90
200
  this.markDirty();
91
201
  return true;
92
202
  }
93
203
  if (key === 'right' || key === 'l') {
94
204
  this.selectedWorkspaceIndex = Math.min(WORKSPACE_IDS.length - 1, this.selectedWorkspaceIndex + 1);
95
205
  this.agentCursorIndex = 0;
206
+ this.domainCursorIndex = 0;
96
207
  this.markDirty();
97
208
  return true;
98
209
  }
99
210
  if (key === 'home') {
100
211
  this.selectedWorkspaceIndex = 0;
101
212
  this.agentCursorIndex = 0;
213
+ this.domainCursorIndex = 0;
102
214
  this.markDirty();
103
215
  return true;
104
216
  }
105
217
  if (key === 'end') {
106
218
  this.selectedWorkspaceIndex = WORKSPACE_IDS.length - 1;
107
219
  this.agentCursorIndex = 0;
220
+ this.domainCursorIndex = 0;
108
221
  this.markDirty();
109
222
  return true;
110
223
  }
111
- // Agents workspace: cursor movement, inspect, and cancel-initiation
112
224
  if (this.selectedWorkspace === 'agents') {
225
+ // Agents workspace: roster cursor movement, inspect, and cancel-initiation.
113
226
  const roster = this.rosterReadModel?.getSnapshot().roster ?? [];
114
227
  if (key === 'up' || key === 'k') {
115
228
  this.agentCursorIndex = Math.max(0, this.agentCursorIndex - 1);
@@ -121,18 +234,50 @@ export class CockpitPanel extends BasePanel {
121
234
  this.markDirty();
122
235
  return true;
123
236
  }
124
- 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') {
125
247
  const entry = roster[this.agentCursorIndex];
126
248
  if (entry) {
127
- this.actionCallbacks.openAgentDetail(entry.id);
249
+ this.actionCallbacks.inspectAgent(entry.id);
128
250
  }
129
251
  return true;
130
252
  }
131
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.
132
257
  const entry = roster[this.agentCursorIndex];
133
- if (entry && !this.isTerminal(entry.status)) {
134
- this.pendingCancelId = entry.id;
135
- 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);
136
281
  }
137
282
  return true;
138
283
  }
@@ -150,29 +295,45 @@ export class CockpitPanel extends BasePanel {
150
295
  lines.push(buildPanelLine(width, [[' Agent roster read model not wired.', C.empty]]));
151
296
  return lines;
152
297
  }
153
- const { roster, stalledAgentCount, totalCost } = this.rosterReadModel.getSnapshot();
298
+ const { roster, stalledAgentCount, totalCost, totalInputTokens, totalOutputTokens } = this.rosterReadModel.getSnapshot();
154
299
  lines.push(buildPanelLine(width, [
155
300
  [` ${roster.length} agent${roster.length !== 1 ? 's' : ''}`, C.label],
156
301
  stalledAgentCount > 0 ? [` ${stalledAgentCount} stalled`, C.bad] : ['', C.dim],
157
302
  [` cost: ${formatCost(totalCost)}`, C.dim],
303
+ [` tokens: ${formatTokens(totalInputTokens, totalOutputTokens)}`, C.dim],
158
304
  ]));
159
305
  const colors: Record<string, string> = {
160
306
  pending: C.dim, running: C.value, completed: C.good, failed: C.bad, cancelled: C.dim, system: C.dim,
161
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
+ ];
162
318
  for (let i = 0; i < roster.length; i++) {
163
319
  const entry = roster[i]!;
164
- const cursor = i === this.agentCursorIndex ? '>' : ' ';
320
+ const selected = i === this.agentCursorIndex;
165
321
  const shortId = entry.id.length > 8 ? entry.id.slice(-8) : entry.id;
166
322
  const statusColor = agentStatusColor(entry.status, colors);
167
- const stalledBadge: [string, string] = entry.stalled ? [' STALLED', C.bad] : ['', C.dim];
168
- lines.push(buildPanelLine(width, [
169
- [cursor, i === this.agentCursorIndex ? C.value : C.dim],
170
- [` ${shortId} `, C.dim],
171
- [entry.status, statusColor],
172
- stalledBadge,
173
- [' ', C.dim],
174
- [entry.task.slice(0, Math.max(0, width - 30)), C.label],
175
- ]));
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
+ ));
176
337
  }
177
338
  if (roster.length === 0) {
178
339
  lines.push(buildPanelLine(width, [[' No agents tracked yet.', C.empty]]));
@@ -180,9 +341,42 @@ export class CockpitPanel extends BasePanel {
180
341
  return lines;
181
342
  }
182
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
+
183
366
  public render(width: number, height: number): Line[] {
184
367
  this.needsRender = false;
185
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
+
186
380
  if (!this.readModel) {
187
381
  const lines: Line[] = [buildPanelLine(width, [[' Runtime read model not wired into this panel yet.', C.empty]])];
188
382
  const workspace = buildPanelWorkspace(width, height, {
@@ -238,63 +432,47 @@ export class CockpitPanel extends BasePanel {
238
432
  [' latest incident ', C.label],
239
433
  [snapshot.latestIncident.classification, C.bad],
240
434
  [' ', C.label],
241
- [snapshot.latestIncident.summary.slice(0, Math.max(0, width - 19 - snapshot.latestIncident.classification.length)), C.dim],
435
+ [truncateDisplay(snapshot.latestIncident.summary, Math.max(0, width - 19 - snapshot.latestIncident.classification.length)), C.dim],
242
436
  ]));
243
437
  }
244
438
  const domainLines: Line[] = [buildPanelLine(width, [[
245
439
  `tasks:${snapshot.taskCount} agents:${snapshot.agentCount} graphs:${snapshot.totalGraphs} comms:${snapshot.communicationCount} mcp:${snapshot.mcpServerCount} plugins:${snapshot.pluginCount}`,
246
440
  C.dim,
247
441
  ]])];
248
- const workspaceLines: Line[] = [];
249
- if (selectedWorkspace === 'flow') {
250
- workspaceLines.push(buildKeyValueLine(width, [
251
- { label: 'running', value: String(snapshot.runningTasks), valueColor: C.value },
252
- { label: 'blocked', value: String(snapshot.blockedTasks), valueColor: pickColor(snapshot.blockedTasks) },
253
- { label: 'graphs', value: String(snapshot.activeGraphs), valueColor: pickColor(snapshot.activeGraphs, 1, 4) },
254
- ], C));
255
- workspaceLines.push(buildGuidanceLine(width, '/orchestration', 'inspect graph state, retries, and subtree controls', C));
256
- workspaceLines.push(buildGuidanceLine(width, '/tasks', 'review active task pressure and task-specific output', C));
257
- } else if (selectedWorkspace === 'governance') {
258
- workspaceLines.push(buildKeyValueLine(width, [
259
- { label: 'preflight', value: snapshot.preflightStatus.toUpperCase(), valueColor: snapshot.preflightStatus === 'block' ? C.bad : snapshot.preflightStatus === 'warn' ? C.warn : snapshot.preflightStatus === 'pass' ? C.good : C.dim },
260
- { label: 'lint', value: String(snapshot.lintFindingCount), valueColor: pickColor(snapshot.lintFindingCount) },
261
- { label: 'allow-all mcp', value: String(snapshot.elevatedMcp), valueColor: pickColor(snapshot.elevatedMcp) },
262
- ], C));
263
- workspaceLines.push(buildGuidanceLine(width, '/policy', 'run simulation, preflight, and bundle review', C));
264
- workspaceLines.push(buildGuidanceLine(width, '/security', 'inspect trust, tokens, quarantines, and incident pressure', C));
265
- } else if (selectedWorkspace === 'health') {
266
- workspaceLines.push(buildKeyValueLine(width, [
267
- { label: 'incidents', value: String(snapshot.incidentCount), valueColor: pickColor(snapshot.incidentCount) },
268
- { label: 'plugins', value: String(snapshot.erroredPlugins), valueColor: pickColor(snapshot.erroredPlugins) },
269
- { label: 'integrations', value: String(snapshot.failingIntegrations), valueColor: pickColor(snapshot.failingIntegrations) },
270
- ], C));
271
- workspaceLines.push(buildGuidanceLine(width, '/incident latest', 'inspect the latest incident bundle and replay fallout', C));
272
- workspaceLines.push(buildGuidanceLine(width, '/plugins', 'review errored plugins and provenance posture', C));
273
- } else if (selectedWorkspace === 'domains') {
274
- workspaceLines.push(buildKeyValueLine(width, [
275
- { label: 'tasks', value: String(snapshot.taskCount), valueColor: C.value },
276
- { label: 'comms', value: String(snapshot.communicationCount), valueColor: C.value },
277
- { label: 'mcp', value: String(snapshot.mcpServerCount), valueColor: C.value },
278
- ], C));
279
- workspaceLines.push(buildGuidanceLine(width, '/mcp', 'inspect trust, quarantine, and risky server posture', C));
280
- workspaceLines.push(buildGuidanceLine(width, '/communication', 'review blocked lanes and agent message flow', C));
281
- } else {
282
- // 'agents' workspace — roster from rosterReadModel
283
- workspaceLines.push(...this.renderAgentsWorkspace(width));
284
- }
442
+ const workspaceLines: Line[] = selectedWorkspace === 'agents'
443
+ ? this.renderAgentsWorkspace(width)
444
+ : this.renderDomainWorkspace(width, selectedWorkspace, snapshot);
445
+
446
+ // Visible workspace selector so focus and the available workspaces are
447
+ // obvious at a glance (not buried in a footer string). Rendered as a
448
+ // title-less row to keep it compact; the ▸ marker + accent show focus.
449
+ const selectorLine = buildPanelLine(width, [
450
+ [' switch ', C.label],
451
+ ...WORKSPACE_IDS.flatMap((id, i): Array<[string, string, string?]> => {
452
+ const focused = i === this.selectedWorkspaceIndex;
453
+ return [
454
+ [focused ? '▸' : ' ', focused ? C.value : C.dim],
455
+ [`${id} `, focused ? C.header : C.dim, focused ? C.headerBg : undefined],
456
+ ];
457
+ }),
458
+ ]);
285
459
 
286
460
  const sections: PanelWorkspaceSection[] = [
461
+ { lines: [selectorLine] },
287
462
  { title: 'Flow', lines: flowLines },
288
463
  { title: 'Governance', lines: governanceLines },
289
464
  { title: 'Health', lines: healthLines },
290
465
  { title: 'Domains', lines: domainLines },
291
- { title: 'Selected Workspace', lines: workspaceLines },
466
+ { title: `Workspace · ${selectedWorkspace}`, lines: workspaceLines },
292
467
  ];
468
+ // Context-aware footer: surface the keys that actually work in this view.
469
+ const footerHint = selectedWorkspace === 'agents'
470
+ ? ' ←/→ workspace ↑/↓ select agent i quick view enter inspector c cancel Home/End jump'
471
+ : ' ←/→ workspace ↑/↓ select domain enter opens panel Home/End jump';
293
472
  const lines = buildPanelWorkspace(width, height, {
294
473
  title: 'Operator Cockpit',
295
- intro: 'Live runtime pressure across orchestration, approvals, governance, integrations, and provider trust posture.',
296
474
  sections,
297
- footerLines: [buildPanelLine(width, [[` Left/Right move workspace focus Home/End jump focus=${selectedWorkspace}`, C.dim]])],
475
+ footerLines: [buildPanelLine(width, [[footerHint, C.dim]])],
298
476
  palette: C,
299
477
  });
300
478
  while (lines.length < height) lines.push(createEmptyLine(width));
@@ -19,6 +19,7 @@
19
19
  // ---------------------------------------------------------------------------
20
20
 
21
21
  import type { AgentRecord } from '@pellux/goodvibes-sdk/platform/tools';
22
+ import { truncateDisplay } from '../utils/terminal-width.ts';
22
23
  import { calcSessionCost } from '../export/cost-utils.ts';
23
24
  import {
24
25
  AGENT_TERMINAL_STATUSES,
@@ -137,7 +138,7 @@ export function buildCockpitRosterSnapshot(
137
138
  totalCost += agentCost;
138
139
  }
139
140
 
140
- const task = rec.task.length > 50 ? rec.task.slice(0, 47) + '...' : rec.task;
141
+ const task = truncateDisplay(rec.task, 50);
141
142
 
142
143
  return {
143
144
  id: rec.id,