@pellux/goodvibes-tui 0.21.0 → 0.23.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 (70) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/README.md +1 -1
  3. package/package.json +2 -1
  4. package/src/cli/completions/generate.ts +4 -8
  5. package/src/cli/entrypoint.ts +6 -0
  6. package/src/cli/management-commands.ts +1 -1
  7. package/src/cli/management-utils.ts +352 -0
  8. package/src/cli/management.ts +36 -334
  9. package/src/cli/parser.ts +17 -0
  10. package/src/cli/surface-command.ts +1 -1
  11. package/src/cli/types.ts +2 -0
  12. package/src/config/goodvibes-home-audit.ts +2 -0
  13. package/src/core/context-auto-compact.ts +110 -0
  14. package/src/core/conversation-rendering.ts +5 -2
  15. package/src/core/conversation-types.ts +24 -0
  16. package/src/core/conversation.ts +7 -12
  17. package/src/core/stream-event-wiring.ts +125 -7
  18. package/src/core/turn-event-wiring.ts +124 -0
  19. package/src/daemon/cli.ts +5 -0
  20. package/src/input/command-registry.ts +1 -0
  21. package/src/input/commands/channel-runtime.ts +139 -0
  22. package/src/input/commands/control-room-runtime.ts +5 -5
  23. package/src/input/commands/provider.ts +57 -3
  24. package/src/input/commands/runtime-services.ts +30 -1
  25. package/src/input/commands/session-workflow.ts +8 -16
  26. package/src/input/commands/session.ts +70 -20
  27. package/src/input/commands/share-runtime.ts +1 -1
  28. package/src/input/commands/shell-core.ts +54 -4
  29. package/src/input/commands.ts +2 -2
  30. package/src/input/handler-modal-routes.ts +37 -0
  31. package/src/input/handler-modal-token-routes.ts +19 -5
  32. package/src/input/handler-onboarding.ts +18 -0
  33. package/src/input/handler.ts +1 -0
  34. package/src/input/onboarding/onboarding-wizard-apply.ts +10 -0
  35. package/src/input/onboarding/onboarding-wizard-cloudflare-step.ts +14 -0
  36. package/src/input/onboarding/onboarding-wizard-steps.ts +6 -0
  37. package/src/input/onboarding/onboarding-wizard-validation.ts +77 -0
  38. package/src/input/settings-modal-behavior.ts +5 -0
  39. package/src/input/settings-modal-data.ts +77 -3
  40. package/src/input/settings-modal-mutations.ts +3 -0
  41. package/src/input/settings-modal-reset.ts +154 -0
  42. package/src/input/settings-modal.ts +55 -13
  43. package/src/main.ts +58 -50
  44. package/src/panels/agent-inspector-panel.ts +120 -18
  45. package/src/panels/agent-inspector-shared.ts +29 -0
  46. package/src/panels/builtin/development.ts +1 -0
  47. package/src/panels/builtin/knowledge.ts +14 -13
  48. package/src/panels/builtin/operations.ts +22 -1
  49. package/src/panels/builtin/shared.ts +7 -0
  50. package/src/panels/cockpit-panel.ts +123 -3
  51. package/src/panels/cockpit-read-model.ts +232 -0
  52. package/src/panels/index.ts +1 -1
  53. package/src/panels/knowledge-graph-panel.ts +84 -0
  54. package/src/panels/memory-panel.ts +370 -40
  55. package/src/panels/session-maintenance.ts +66 -15
  56. package/src/renderer/agent-detail-modal.ts +107 -3
  57. package/src/renderer/compaction-history-modal.ts +55 -0
  58. package/src/renderer/compaction-preview.ts +146 -0
  59. package/src/renderer/context-status-hint.ts +54 -0
  60. package/src/renderer/settings-modal-helpers.ts +2 -2
  61. package/src/renderer/settings-modal.ts +14 -3
  62. package/src/renderer/shell-surface.ts +10 -0
  63. package/src/runtime/bootstrap-command-parts.ts +4 -0
  64. package/src/runtime/bootstrap-core.ts +116 -0
  65. package/src/runtime/bootstrap-shell.ts +11 -0
  66. package/src/runtime/bootstrap.ts +7 -0
  67. package/src/runtime/services.ts +6 -1
  68. package/src/utils/browser.ts +29 -0
  69. package/src/version.ts +1 -1
  70. package/src/panels/knowledge-panel.ts +0 -343
@@ -21,6 +21,10 @@ import {
21
21
  resolveScrollablePanelSection,
22
22
  DEFAULT_PANEL_PALETTE,
23
23
  } from './polish.ts';
24
+ import {
25
+ type ConfirmState,
26
+ handleConfirmInput,
27
+ } from './confirm-state.ts';
24
28
  import { truncateDisplay } from '../utils/terminal-width.ts';
25
29
  import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
26
30
  import {
@@ -32,6 +36,8 @@ import {
32
36
  formatAgentDuration as formatMs,
33
37
  formatAgentTime as shortTime,
34
38
  jsonlToTimeline,
39
+ AGENT_TERMINAL_STATUSES,
40
+ AGENT_STALL_THRESHOLD_MS,
35
41
  } from './agent-inspector-shared.ts';
36
42
 
37
43
  // ---------------------------------------------------------------------------
@@ -77,10 +83,14 @@ const COLOR = {
77
83
  // AgentInspectorPanel
78
84
  // ---------------------------------------------------------------------------
79
85
 
86
+ // AGENT_TERMINAL_STATUSES and AGENT_STALL_THRESHOLD_MS imported from agent-inspector-shared.ts
87
+
80
88
  export interface AgentInspectorPanelDeps {
81
- readonly agentManager: Pick<AgentManager, 'list' | 'getStatus'>;
89
+ readonly agentManager: Pick<AgentManager, 'list' | 'getStatus' | 'cancel'>;
82
90
  readonly agentMessageBus: Pick<AgentMessageBus, 'getMessages'>;
83
91
  readonly workingDirectory: string;
92
+ /** Cancel the agent by id. Uses the same orphan-free path as WRFC. Returns true if cancelled. */
93
+ readonly cancelAgent: (agentId: string) => boolean;
84
94
  }
85
95
 
86
96
  export class AgentInspectorPanel extends BasePanel {
@@ -102,6 +112,9 @@ export class AgentInspectorPanel extends BasePanel {
102
112
  // Row cache — cleared on markDirty(), computed once per render cycle
103
113
  private _cachedRows: DisplayRow[] | null = null;
104
114
 
115
+ /** Pending cancel confirmation — subject is the agent id to cancel. */
116
+ private confirmCancel: ConfirmState<string> | null = null;
117
+
105
118
  constructor(private readonly deps: AgentInspectorPanelDeps) {
106
119
  super('inspector', 'Inspector', 'I', 'agent');
107
120
  }
@@ -157,13 +170,36 @@ export class AgentInspectorPanel extends BasePanel {
157
170
  // -------------------------------------------------------------------------
158
171
 
159
172
  handleInput(key: string): boolean {
173
+ // Confirm-cancel flow takes priority — same contract as WRFC panel.
174
+ if (this.confirmCancel) {
175
+ const result = handleConfirmInput(this.confirmCancel, key);
176
+ if (result === 'confirmed') {
177
+ const rec = this.selectedAgentId
178
+ ? this.deps.agentManager.getStatus(this.selectedAgentId)
179
+ : null;
180
+ if (rec && !AGENT_TERMINAL_STATUSES.has(rec.status)) {
181
+ this.deps.cancelAgent(rec.id);
182
+ }
183
+ this.confirmCancel = null;
184
+ this.markDirty();
185
+ return true;
186
+ }
187
+ if (result === 'cancelled') {
188
+ this.confirmCancel = null;
189
+ this.markDirty();
190
+ }
191
+ // absorbed: confirm stays pending
192
+ return true;
193
+ }
194
+
160
195
  switch (key) {
161
- case 'up': this._moveCursor(-1); return true;
162
- case 'down': this._moveCursor(1); return true;
163
- case 'pageup': this._scroll(-10); return true;
164
- case 'pagedown': this._scroll(10); return true;
165
- case 'return': this._toggleExpand(); return true;
166
- case 'tab': this._nextAgent(); return true;
196
+ case 'up': this._moveCursor(-1); return true;
197
+ case 'down': this._moveCursor(1); return true;
198
+ case 'pageup': this._scroll(-10); return true;
199
+ case 'pagedown': this._scroll(10); return true;
200
+ case 'return': this._toggleExpand(); return true;
201
+ case 'tab': this._nextAgent(); return true;
202
+ case 'c': this._beginCancelConfirm(); return true;
167
203
  default: return false;
168
204
  }
169
205
  }
@@ -217,6 +253,11 @@ export class AgentInspectorPanel extends BasePanel {
217
253
  }
218
254
 
219
255
  summaryLines.push(this._renderAgentInfoSummary(width, rec));
256
+ const now = Date.now();
257
+ const isStalled = this._isAgentStalled(rec, now);
258
+ if (isStalled) {
259
+ summaryLines.push(buildPanelLine(width, [[' STALLED', '#f59e0b'], [' — no activity for 5+ minutes', DEFAULT_PANEL_PALETTE.dim]]));
260
+ }
220
261
  const allRows = this._getCachedRows();
221
262
  if (allRows.length === 0) {
222
263
  return buildPanelWorkspace(width, height, {
@@ -243,13 +284,42 @@ export class AgentInspectorPanel extends BasePanel {
243
284
  }
244
285
 
245
286
  this.cursorIndex = Math.max(0, Math.min(this.cursorIndex, allRows.length - 1));
287
+ const selectedRec = this.selectedAgentId
288
+ ? this.deps.agentManager.getStatus(this.selectedAgentId)
289
+ : null;
290
+ const cancellable = selectedRec && !AGENT_TERMINAL_STATUSES.has(selectedRec.status);
246
291
  const summarySection = { title: 'Summary', lines: summaryLines } as const;
247
292
  const agentsSection = { title: 'Agents', lines: [selectorLine] } as const;
293
+
294
+ // Confirm-cancel overlay section.
295
+ const confirmSection = this.confirmCancel ? {
296
+ title: 'Confirm Cancel',
297
+ lines: [
298
+ buildPanelLine(width, [
299
+ [' Cancel agent "', DEFAULT_PANEL_PALETTE.warn],
300
+ [this.confirmCancel.label, DEFAULT_PANEL_PALETTE.value],
301
+ ['"?', DEFAULT_PANEL_PALETTE.warn],
302
+ ]),
303
+ buildPanelLine(width, [
304
+ [' y', DEFAULT_PANEL_PALETTE.info], [' confirm', DEFAULT_PANEL_PALETTE.dim],
305
+ [' Enter', DEFAULT_PANEL_PALETTE.info], [' confirm', DEFAULT_PANEL_PALETTE.dim],
306
+ [' n / Esc', DEFAULT_PANEL_PALETTE.info], [' cancel', DEFAULT_PANEL_PALETTE.dim],
307
+ ]),
308
+ ],
309
+ } : null;
310
+
311
+ const cancelHintFg = cancellable ? DEFAULT_PANEL_PALETTE.info : DEFAULT_PANEL_PALETTE.dim;
312
+ const footerLine = buildPanelLine(width, [
313
+ [` L${this.cursorIndex + 1}/${allRows.length}`, DEFAULT_PANEL_PALETTE.dim],
314
+ [' Tab', DEFAULT_PANEL_PALETTE.info], [' cycle agents', DEFAULT_PANEL_PALETTE.dim],
315
+ [' Up/Down', DEFAULT_PANEL_PALETTE.info], [' navigate', DEFAULT_PANEL_PALETTE.dim],
316
+ [' Enter', DEFAULT_PANEL_PALETTE.info], [' expand', DEFAULT_PANEL_PALETTE.dim],
317
+ [' c', cancelHintFg], [cancellable ? ' cancel' : ' cancel (n/a)', DEFAULT_PANEL_PALETTE.dim],
318
+ ]);
319
+
248
320
  const timelineSection = resolveScrollablePanelSection(width, height, {
249
321
  intro: 'Inspect a selected agent timeline, tool activity, expanded details, and live/historical message flow.',
250
- footerLines: [
251
- buildPanelLine(width, [[` L${this.cursorIndex + 1}/${allRows.length}`, DEFAULT_PANEL_PALETTE.dim], [' Tab', DEFAULT_PANEL_PALETTE.info], [' cycle agents', DEFAULT_PANEL_PALETTE.dim], [' Up/Down', DEFAULT_PANEL_PALETTE.info], [' navigate', DEFAULT_PANEL_PALETTE.dim], [' Enter', DEFAULT_PANEL_PALETTE.info], [' expand', DEFAULT_PANEL_PALETTE.dim]]),
252
- ],
322
+ footerLines: [footerLine],
253
323
  palette: DEFAULT_PANEL_PALETTE,
254
324
  beforeSections: [summarySection, agentsSection],
255
325
  section: {
@@ -259,20 +329,22 @@ export class AgentInspectorPanel extends BasePanel {
259
329
  scrollOffset: this.scrollOffset,
260
330
  minRows: 8,
261
331
  },
332
+ afterSections: confirmSection ? [confirmSection] : undefined,
262
333
  });
263
334
  this.scrollOffset = timelineSection.scrollOffset;
264
335
 
336
+ const sections = [
337
+ summarySection,
338
+ agentsSection,
339
+ timelineSection.section,
340
+ ...(confirmSection ? [confirmSection] : []),
341
+ ];
342
+
265
343
  return buildPanelWorkspace(width, height, {
266
344
  title: ` Inspector [${agents.length} agent${agents.length !== 1 ? 's' : ''}]`,
267
345
  intro: 'Inspect a selected agent timeline, tool activity, expanded details, and live/historical message flow.',
268
- sections: [
269
- summarySection,
270
- agentsSection,
271
- timelineSection.section,
272
- ],
273
- footerLines: [
274
- buildPanelLine(width, [[` L${this.cursorIndex + 1}/${allRows.length}`, DEFAULT_PANEL_PALETTE.dim], [' Tab', DEFAULT_PANEL_PALETTE.info], [' cycle agents', DEFAULT_PANEL_PALETTE.dim], [' Up/Down', DEFAULT_PANEL_PALETTE.info], [' navigate', DEFAULT_PANEL_PALETTE.dim], [' Enter', DEFAULT_PANEL_PALETTE.info], [' expand', DEFAULT_PANEL_PALETTE.dim]]),
275
- ],
346
+ sections,
347
+ footerLines: [footerLine],
276
348
  palette: DEFAULT_PANEL_PALETTE,
277
349
  });
278
350
  }
@@ -517,4 +589,34 @@ export class AgentInspectorPanel extends BasePanel {
517
589
  this.inspectAgent(next.id);
518
590
  }
519
591
  }
592
+
593
+ // -------------------------------------------------------------------------
594
+ // Private — cancel + stall
595
+ // -------------------------------------------------------------------------
596
+
597
+ /** Initiate cancel-confirm flow for the selected agent (noop if terminal or none selected). */
598
+ private _beginCancelConfirm(): void {
599
+ if (!this.selectedAgentId) return;
600
+ const rec = this.deps.agentManager.getStatus(this.selectedAgentId);
601
+ if (!rec || AGENT_TERMINAL_STATUSES.has(rec.status)) return;
602
+ const label = rec.task.split('\n')[0]?.slice(0, 40) ?? rec.id.slice(-8);
603
+ this.confirmCancel = { subject: rec.id, label };
604
+ this.markDirty();
605
+ }
606
+
607
+ /** Returns whether an agent is considered stalled (non-terminal, running past threshold). */
608
+ private _isAgentStalled(rec: AgentRecord, now: number): boolean {
609
+ if (AGENT_TERMINAL_STATUSES.has(rec.status)) return false;
610
+ return (now - rec.startedAt) >= AGENT_STALL_THRESHOLD_MS;
611
+ }
612
+
613
+ /**
614
+ * Count of all tracked agents that are stalled (non-terminal, no activity
615
+ * for AGENT_STALL_THRESHOLD_MS). Exposed so callers can aggregate a
616
+ * stalledAgentCount for cockpit / roster read-models.
617
+ */
618
+ getStalledAgentCount(): number {
619
+ const now = Date.now();
620
+ return this.deps.agentManager.list().filter(rec => this._isAgentStalled(rec, now)).length;
621
+ }
520
622
  }
@@ -1,5 +1,34 @@
1
1
  export type AgentInspectorEntryKind = 'user' | 'assistant' | 'tool_call' | 'tool_result' | 'session' | 'error';
2
2
 
3
+ // ---------------------------------------------------------------------------
4
+ // Shared agent status / stall constants
5
+ // Used by AgentInspectorPanel, AgentDetailModal, and cockpit read-model consumers.
6
+ // ---------------------------------------------------------------------------
7
+
8
+ /** Terminal statuses — cancel not offered; stall check skipped. */
9
+ export const AGENT_TERMINAL_STATUSES = new Set(['completed', 'failed', 'cancelled']);
10
+
11
+ /** Agents in a non-terminal state for longer than this are considered STALLED. */
12
+ export const AGENT_STALL_THRESHOLD_MS = 5 * 60 * 1000;
13
+
14
+ /**
15
+ * Count stalled agents from a raw record list.
16
+ * An agent is stalled when it is non-terminal and has been running for at
17
+ * least AGENT_STALL_THRESHOLD_MS without completing.
18
+ *
19
+ * Extracted as a standalone export so read-models and panels can share the
20
+ * canonical stall-count logic (TASK-046).
21
+ */
22
+ export function countStalledAgents(
23
+ records: ReadonlyArray<{ status: string; startedAt: number }>,
24
+ now: number = Date.now(),
25
+ ): number {
26
+ return records.filter(
27
+ (r) => !AGENT_TERMINAL_STATUSES.has(r.status) && (now - r.startedAt) >= AGENT_STALL_THRESHOLD_MS,
28
+ ).length;
29
+ }
30
+
31
+
3
32
  export interface AgentTimelineEntry {
4
33
  kind: AgentInspectorEntryKind;
5
34
  timestamp: number;
@@ -51,6 +51,7 @@ export function registerDevelopmentPanels(manager: PanelManager, deps: ResolvedB
51
51
  agentManager: ui.agents.agentManager,
52
52
  agentMessageBus: ui.agents.agentMessageBus,
53
53
  workingDirectory: ui.environment.workingDirectory,
54
+ cancelAgent: (agentId: string) => ui.agents.agentManager.cancel(agentId),
54
55
  });
55
56
  },
56
57
  });
@@ -1,26 +1,27 @@
1
1
  import type { PanelManager } from '../panel-manager.ts';
2
2
  import { MemoryPanel } from '../memory-panel.ts';
3
- import { KnowledgePanel } from '../knowledge-panel.ts';
3
+ import { KnowledgeGraphPanel } from '../knowledge-graph-panel.ts';
4
4
  import type { ResolvedBuiltinPanelDeps } from './shared.ts';
5
5
 
6
6
  export function registerKnowledgePanels(manager: PanelManager, deps: ResolvedBuiltinPanelDeps): void {
7
- if (!deps.memoryRegistry) return;
8
-
9
- const { memoryRegistry } = deps;
7
+ // KnowledgeGraphPanel is a no-arg panel — always register it regardless of memoryRegistry.
10
8
  manager.registerType({
11
9
  id: 'knowledge',
12
10
  name: 'Knowledge',
13
11
  icon: 'K',
14
12
  category: 'agent',
15
13
  description: 'Structured project knowledge: risks, runbooks, architecture notes, incidents, and durable facts',
16
- factory: () => new KnowledgePanel(memoryRegistry),
17
- });
18
- manager.registerType({
19
- id: 'memory',
20
- name: 'Memory',
21
- icon: 'M',
22
- category: 'agent',
23
- description: 'Project memory: decisions, constraints, incidents, and patterns with provenance links',
24
- factory: () => new MemoryPanel(memoryRegistry),
14
+ factory: () => new KnowledgeGraphPanel(),
25
15
  });
16
+ if (deps.memoryRegistry) {
17
+ const { memoryRegistry } = deps;
18
+ manager.registerType({
19
+ id: 'memory',
20
+ name: 'Memory',
21
+ icon: 'M',
22
+ category: 'agent',
23
+ description: 'Project memory: decisions, constraints, incidents, and patterns with provenance links',
24
+ factory: () => new MemoryPanel(memoryRegistry),
25
+ });
26
+ }
26
27
  }
@@ -37,6 +37,7 @@ import {
37
37
  import { createRuntimeProviderApi } from '@/runtime/index.ts';
38
38
  import type { ResolvedBuiltinPanelDeps } from './shared.ts';
39
39
  import { requireAutomationManager, requireControlPlanePanelDeps, requireHookPanelDeps, requirePluginManager, requireUiServices } from './shared.ts';
40
+ import { createCockpitRosterReadModel } from '../cockpit-read-model.ts';
40
41
 
41
42
  export function registerOperationsPanels(manager: PanelManager, deps: ResolvedBuiltinPanelDeps): void {
42
43
  const ui = requireUiServices(deps);
@@ -52,13 +53,33 @@ export function registerOperationsPanels(manager: PanelManager, deps: ResolvedBu
52
53
  environment: createEnvironmentVariableQuery(process.env),
53
54
  });
54
55
 
56
+ const rosterReadModel = createCockpitRosterReadModel(ui.agents.agentManager);
57
+ // Subscribe to agent lifecycle events so the roster re-renders on state changes.
58
+ // AGENT_RUNNING covers status transitions; AGENT_CANCELLED covers the cancellation
59
+ // terminal state not emitted by AGENT_FAILED. Noisy mid-run events (STREAM_DELTA,
60
+ // AWAITING_TOOL, etc.) are intentionally excluded — they don't affect roster fields.
61
+ // Note: stall detection is time-based, so stalled/stalledAgentCount will only refresh
62
+ // on the next lifecycle event; a periodic tick would be needed for real-time stall display.
63
+ ui.events.agents.on('AGENT_SPAWNING', () => rosterReadModel.markDirty());
64
+ ui.events.agents.on('AGENT_RUNNING', () => rosterReadModel.markDirty());
65
+ ui.events.agents.on('AGENT_COMPLETED', () => rosterReadModel.markDirty());
66
+ ui.events.agents.on('AGENT_FAILED', () => rosterReadModel.markDirty());
67
+ ui.events.agents.on('AGENT_CANCELLED', () => rosterReadModel.markDirty());
68
+
55
69
  manager.registerType({
56
70
  id: 'cockpit',
57
71
  name: 'Cockpit',
58
72
  icon: 'O',
59
73
  category: 'monitoring',
60
74
  description: 'Unified operator summary for orchestration, permissions, communication, MCP, plugins, and integrations',
61
- factory: () => new CockpitPanel(ui.readModels.cockpit),
75
+ factory: () => new CockpitPanel(
76
+ ui.readModels.cockpit,
77
+ rosterReadModel,
78
+ {
79
+ openAgentDetail: (agentId: string) => deps.openAgentDetail?.(agentId),
80
+ cancelAgent: (agentId: string) => ui.agents.agentManager.cancel(agentId),
81
+ },
82
+ ),
62
83
  });
63
84
 
64
85
  manager.registerType({
@@ -114,6 +114,13 @@ export interface BuiltinPanelDeps {
114
114
  hookActivityTracker?: Pick<HookActivityTracker, 'listRecent'>;
115
115
  /** Shared MCP registry for security panels and MCP workspace commands. */
116
116
  mcpRegistry?: McpRegistry;
117
+ /**
118
+ * Open the agent detail modal for the given agent id. Wired from
119
+ * InputHandler.agentDetailModal.open() at bootstrap — passed to the
120
+ * CockpitPanel factory so the agents workspace inspect key (i) works
121
+ * without the panel depending on the modal directly.
122
+ */
123
+ openAgentDetail?: (agentId: string) => void;
117
124
  }
118
125
 
119
126
  export type ResolvedBuiltinPanelDeps = Omit<
@@ -2,6 +2,7 @@ import type { Line } from '../types/grid.ts';
2
2
  import { createEmptyLine } from '../types/grid.ts';
3
3
  import { BasePanel } from './base-panel.ts';
4
4
  import type { UiCockpitSnapshot, UiReadModel } from '../runtime/ui-read-models.ts';
5
+ import type { CockpitRosterReadModel } from './cockpit-read-model.ts';
5
6
  import {
6
7
  buildGuidanceLine,
7
8
  buildKeyValueLine,
@@ -11,6 +12,7 @@ import {
11
12
  DEFAULT_PANEL_PALETTE,
12
13
  type PanelWorkspaceSection,
13
14
  } from './polish.ts';
15
+ import { agentStatusColor } from './agent-inspector-shared.ts';
14
16
 
15
17
  const C = {
16
18
  ...DEFAULT_PANEL_PALETTE,
@@ -24,45 +26,160 @@ function pickColor(value: number, warnAt = 1, badAt = 3): string {
24
26
  return C.good;
25
27
  }
26
28
 
27
- const WORKSPACE_IDS = ['flow', 'governance', 'health', 'domains'] as const;
29
+ export interface CockpitPanelActionCallbacks {
30
+ readonly openAgentDetail: (agentId: string) => void;
31
+ readonly cancelAgent: (agentId: string) => boolean;
32
+ }
33
+
34
+ const WORKSPACE_IDS = ['flow', 'governance', 'health', 'domains', 'agents'] as const;
35
+ type WorkspaceId = (typeof WORKSPACE_IDS)[number];
36
+
37
+ function formatCost(cost: number | null): string {
38
+ if (cost === null) return 'n/a';
39
+ return cost < 0.01 ? '<$0.01' : `$${cost.toFixed(2)}`;
40
+ }
28
41
 
29
42
  export class CockpitPanel extends BasePanel {
30
43
  private readonly unsub: (() => void) | null;
44
+ private readonly rosterUnsub: (() => void) | null;
45
+ private readonly actionCallbacks: CockpitPanelActionCallbacks;
31
46
  private selectedWorkspaceIndex = 0;
47
+ private agentCursorIndex = 0;
48
+ private pendingCancelId: string | null = null;
32
49
 
33
- public constructor(private readonly readModel?: UiReadModel<UiCockpitSnapshot>) {
50
+ public constructor(
51
+ private readonly readModel?: UiReadModel<UiCockpitSnapshot>,
52
+ private readonly rosterReadModel?: CockpitRosterReadModel,
53
+ actionCallbacks?: Partial<CockpitPanelActionCallbacks>,
54
+ ) {
34
55
  super('cockpit', 'Cockpit', 'O', 'monitoring');
35
56
  this.unsub = readModel ? readModel.subscribe(() => this.markDirty()) : null;
57
+ this.rosterUnsub = rosterReadModel ? rosterReadModel.subscribe(() => this.markDirty()) : null;
58
+ this.actionCallbacks = {
59
+ openAgentDetail: actionCallbacks?.openAgentDetail ?? ((_id: string) => { /* noop */ }),
60
+ cancelAgent: actionCallbacks?.cancelAgent ?? ((_id: string) => false),
61
+ };
36
62
  }
37
63
 
38
64
  public override onDestroy(): void {
39
65
  this.unsub?.();
66
+ this.rosterUnsub?.();
67
+ }
68
+
69
+ private get selectedWorkspace(): WorkspaceId {
70
+ return WORKSPACE_IDS[this.selectedWorkspaceIndex] ?? 'flow';
40
71
  }
41
72
 
42
73
  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;
79
+ this.markDirty();
80
+ } else if (key === 'escape' || key === 'n') {
81
+ this.pendingCancelId = null;
82
+ this.markDirty();
83
+ }
84
+ // All other keys are consumed while confirm is pending
85
+ return true;
86
+ }
43
87
  if (key === 'left' || key === 'h') {
44
88
  this.selectedWorkspaceIndex = Math.max(0, this.selectedWorkspaceIndex - 1);
89
+ this.agentCursorIndex = 0;
45
90
  this.markDirty();
46
91
  return true;
47
92
  }
48
93
  if (key === 'right' || key === 'l') {
49
94
  this.selectedWorkspaceIndex = Math.min(WORKSPACE_IDS.length - 1, this.selectedWorkspaceIndex + 1);
95
+ this.agentCursorIndex = 0;
50
96
  this.markDirty();
51
97
  return true;
52
98
  }
53
99
  if (key === 'home') {
54
100
  this.selectedWorkspaceIndex = 0;
101
+ this.agentCursorIndex = 0;
55
102
  this.markDirty();
56
103
  return true;
57
104
  }
58
105
  if (key === 'end') {
59
106
  this.selectedWorkspaceIndex = WORKSPACE_IDS.length - 1;
107
+ this.agentCursorIndex = 0;
60
108
  this.markDirty();
61
109
  return true;
62
110
  }
111
+ // Agents workspace: cursor movement, inspect, and cancel-initiation
112
+ if (this.selectedWorkspace === 'agents') {
113
+ const roster = this.rosterReadModel?.getSnapshot().roster ?? [];
114
+ if (key === 'up' || key === 'k') {
115
+ this.agentCursorIndex = Math.max(0, this.agentCursorIndex - 1);
116
+ this.markDirty();
117
+ return true;
118
+ }
119
+ if (key === 'down' || key === 'j') {
120
+ this.agentCursorIndex = Math.min(Math.max(0, roster.length - 1), this.agentCursorIndex + 1);
121
+ this.markDirty();
122
+ return true;
123
+ }
124
+ if (key === 'i' || key === 'enter' || key === 'return') {
125
+ const entry = roster[this.agentCursorIndex];
126
+ if (entry) {
127
+ this.actionCallbacks.openAgentDetail(entry.id);
128
+ }
129
+ return true;
130
+ }
131
+ if (key === 'c') {
132
+ const entry = roster[this.agentCursorIndex];
133
+ if (entry && !this.isTerminal(entry.status)) {
134
+ this.pendingCancelId = entry.id;
135
+ this.markDirty();
136
+ }
137
+ return true;
138
+ }
139
+ }
63
140
  return false;
64
141
  }
65
142
 
143
+ private isTerminal(status: string): boolean {
144
+ return status === 'completed' || status === 'failed' || status === 'cancelled';
145
+ }
146
+
147
+ private renderAgentsWorkspace(width: number): Line[] {
148
+ const lines: Line[] = [];
149
+ if (!this.rosterReadModel) {
150
+ lines.push(buildPanelLine(width, [[' Agent roster read model not wired.', C.empty]]));
151
+ return lines;
152
+ }
153
+ const { roster, stalledAgentCount, totalCost } = this.rosterReadModel.getSnapshot();
154
+ lines.push(buildPanelLine(width, [
155
+ [` ${roster.length} agent${roster.length !== 1 ? 's' : ''}`, C.label],
156
+ stalledAgentCount > 0 ? [` ${stalledAgentCount} stalled`, C.bad] : ['', C.dim],
157
+ [` cost: ${formatCost(totalCost)}`, C.dim],
158
+ ]));
159
+ const colors: Record<string, string> = {
160
+ pending: C.dim, running: C.value, completed: C.good, failed: C.bad, cancelled: C.dim, system: C.dim,
161
+ };
162
+ for (let i = 0; i < roster.length; i++) {
163
+ const entry = roster[i]!;
164
+ const cursor = i === this.agentCursorIndex ? '>' : ' ';
165
+ const shortId = entry.id.length > 8 ? entry.id.slice(-8) : entry.id;
166
+ 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
+ ]));
176
+ }
177
+ if (roster.length === 0) {
178
+ lines.push(buildPanelLine(width, [[' No agents tracked yet.', C.empty]]));
179
+ }
180
+ return lines;
181
+ }
182
+
66
183
  public render(width: number, height: number): Line[] {
67
184
  this.needsRender = false;
68
185
 
@@ -153,7 +270,7 @@ export class CockpitPanel extends BasePanel {
153
270
  ], C));
154
271
  workspaceLines.push(buildGuidanceLine(width, '/incident latest', 'inspect the latest incident bundle and replay fallout', C));
155
272
  workspaceLines.push(buildGuidanceLine(width, '/plugins', 'review errored plugins and provenance posture', C));
156
- } else {
273
+ } else if (selectedWorkspace === 'domains') {
157
274
  workspaceLines.push(buildKeyValueLine(width, [
158
275
  { label: 'tasks', value: String(snapshot.taskCount), valueColor: C.value },
159
276
  { label: 'comms', value: String(snapshot.communicationCount), valueColor: C.value },
@@ -161,6 +278,9 @@ export class CockpitPanel extends BasePanel {
161
278
  ], C));
162
279
  workspaceLines.push(buildGuidanceLine(width, '/mcp', 'inspect trust, quarantine, and risky server posture', C));
163
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));
164
284
  }
165
285
 
166
286
  const sections: PanelWorkspaceSection[] = [