@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
@@ -3,9 +3,16 @@ import { createEmptyLine } from '../types/grid.ts';
3
3
  import { ScrollableListPanel } from './scrollable-list-panel.ts';
4
4
  import type { UiControlPlaneSnapshot, UiReadModel } from '../runtime/ui-read-models.ts';
5
5
  import { truncateDisplay } from '../utils/terminal-width.ts';
6
+ import type { ApprovalBroker, ControlPlaneRecentEvent, SharedSessionBroker } from '@pellux/goodvibes-sdk/platform/control-plane';
7
+ import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
6
8
  import {
9
+ type ConfirmState,
10
+ handleConfirmInput,
11
+ renderConfirmLines,
12
+ } from './confirm-state.ts';
13
+ import {
14
+ buildDetailBlock,
7
15
  buildEmptyState,
8
- buildGuidanceLine,
9
16
  buildKeyValueLine,
10
17
  buildPanelLine,
11
18
  buildPanelWorkspace,
@@ -13,16 +20,9 @@ import {
13
20
  type PanelPalette,
14
21
  } from './polish.ts';
15
22
 
16
- const C = {
17
- ...DEFAULT_PANEL_PALETTE,
18
- header: '#94a3b8',
19
- headerBg: '#1e293b',
20
- ok: '#22c55e',
21
- warn: '#eab308',
22
- error: '#ef4444',
23
- info: '#38bdf8',
24
- selectBg: '#0f172a',
25
- } as const;
23
+ // Base chrome only — title band, state colors, and text tokens all come
24
+ // straight from DEFAULT_PANEL_PALETTE (WO-002).
25
+ const C = DEFAULT_PANEL_PALETTE;
26
26
 
27
27
  function formatTime(value?: number): string {
28
28
  if (!value) return 'n/a';
@@ -30,20 +30,73 @@ function formatTime(value?: number): string {
30
30
  }
31
31
 
32
32
  function connectionColor(state: string): string {
33
- if (state === 'connected' || state === 'healthy') return C.ok;
33
+ if (state === 'connected' || state === 'healthy') return C.good;
34
34
  if (state === 'degraded' || state === 'connecting' || state === 'initializing') return C.warn;
35
- if (state === 'terminal_failure') return C.error;
35
+ if (state === 'terminal_failure') return C.bad;
36
+ return C.dim;
37
+ }
38
+
39
+ function approvalStatusColor(status: string): string {
40
+ if (status === 'pending' || status === 'claimed') return C.warn;
41
+ if (status === 'approved') return C.good;
42
+ if (status === 'denied' || status === 'expired') return C.bad;
36
43
  return C.dim;
37
44
  }
38
45
 
39
46
  type ControlPlaneClient = UiControlPlaneSnapshot['clients'][number];
47
+ type ControlPlaneApproval = UiControlPlaneSnapshot['approvals'][number];
48
+ type ControlPlaneSession = UiControlPlaneSnapshot['sessions'][number];
49
+ type ControlPlaneEvent = UiControlPlaneSnapshot['recentEvents'][number];
50
+
51
+ type FocusSection = 'approvals' | 'clients' | 'sessions' | 'events';
52
+
53
+ const FOCUS_SECTIONS: readonly FocusSection[] = ['approvals', 'clients', 'sessions', 'events'];
54
+
55
+ type ControlPlaneItem =
56
+ | { readonly kind: 'client'; readonly client: ControlPlaneClient }
57
+ | { readonly kind: 'approval'; readonly approval: ControlPlaneApproval }
58
+ | { readonly kind: 'session'; readonly session: ControlPlaneSession }
59
+ | { readonly kind: 'event'; readonly event: ControlPlaneEvent };
40
60
 
41
- export class ControlPlanePanel extends ScrollableListPanel<ControlPlaneClient> {
61
+ type ApprovalConfirmSubject = { readonly approvalId: string; readonly approved: boolean };
62
+
63
+ /** Deps needed to drive real approve/deny and live session/event lookups (WO-121). */
64
+ export interface ControlPlanePanelDeps {
65
+ readonly approvalBroker?: ApprovalBroker;
66
+ readonly sessionBroker?: SharedSessionBroker;
67
+ readonly getControlPlaneRecentEvents?: (limit: number) => readonly ControlPlaneRecentEvent[];
68
+ }
69
+
70
+ /**
71
+ * ControlPlanePanel — daemon control-plane console (WO-121).
72
+ *
73
+ * Tab cycles focus across four selectable sections (approvals, clients,
74
+ * sessions, events); approvals is the primary/default section and supports
75
+ * a=approve / d=deny via ApprovalBroker.resolveApproval behind ConfirmState.
76
+ * Sessions and events pull from the live brokers when wired, falling back to
77
+ * the read-model snapshot when they are not.
78
+ */
79
+ export class ControlPlanePanel extends ScrollableListPanel<ControlPlaneItem> {
80
+ private readonly readModel?: UiReadModel<UiControlPlaneSnapshot>;
81
+ private readonly approvalBroker?: ApprovalBroker;
82
+ private readonly sessionBroker?: SharedSessionBroker;
83
+ private readonly getControlPlaneRecentEvents?: (limit: number) => readonly ControlPlaneRecentEvent[];
42
84
  private readonly unsub: (() => void) | null;
85
+ private focusSection: FocusSection = 'approvals';
86
+ private confirmAction: ConfirmState<ApprovalConfirmSubject> | null = null;
43
87
 
44
- public constructor(private readonly readModel?: UiReadModel<UiControlPlaneSnapshot>) {
45
- super('control-plane', 'Control Plane', 'C', 'monitoring');
88
+ public constructor(
89
+ readModel?: UiReadModel<UiControlPlaneSnapshot>,
90
+ deps: ControlPlanePanelDeps = {},
91
+ ) {
92
+ super('control-plane', 'Control Plane', 'C', 'runtime-ops');
46
93
  this.showSelectionGutter = true; // I5: non-color selection affordance
94
+ this.filterEnabled = true;
95
+ this.filterLabel = 'Filter approvals';
96
+ this.readModel = readModel;
97
+ this.approvalBroker = deps.approvalBroker;
98
+ this.sessionBroker = deps.sessionBroker;
99
+ this.getControlPlaneRecentEvents = deps.getControlPlaneRecentEvents;
47
100
  this.unsub = readModel ? readModel.subscribe(() => this.markDirty()) : null;
48
101
  }
49
102
 
@@ -55,12 +108,166 @@ export class ControlPlanePanel extends ScrollableListPanel<ControlPlaneClient> {
55
108
  return C;
56
109
  }
57
110
 
58
- protected getItems(): readonly ControlPlaneClient[] {
111
+ // -------------------------------------------------------------------------
112
+ // Data
113
+ // -------------------------------------------------------------------------
114
+
115
+ /**
116
+ * The read model's snapshot.approvals is capped upstream (SDK read model
117
+ * calls approvalBroker.listApprovals(6)). Sourcing directly from the
118
+ * broker when it's wired removes that cap so the panel can show and act
119
+ * on the full pending set, not just the first six.
120
+ */
121
+ private _approvals(snapshot: UiControlPlaneSnapshot): readonly ControlPlaneApproval[] {
122
+ const approvals = this.approvalBroker ? this.approvalBroker.listApprovals(50) : snapshot.approvals;
123
+ const pending: ControlPlaneApproval[] = [];
124
+ const rest: ControlPlaneApproval[] = [];
125
+ for (const approval of approvals) {
126
+ (approval.status === 'pending' ? pending : rest).push(approval);
127
+ }
128
+ return [...pending, ...rest];
129
+ }
130
+
131
+ private _sessions(snapshot: UiControlPlaneSnapshot): readonly ControlPlaneSession[] {
132
+ return this.sessionBroker ? this.sessionBroker.listSessions(50) : snapshot.sessions;
133
+ }
134
+
135
+ private _recentEvents(snapshot: UiControlPlaneSnapshot): readonly ControlPlaneEvent[] {
136
+ return this.getControlPlaneRecentEvents ? this.getControlPlaneRecentEvents(50) : snapshot.recentEvents;
137
+ }
138
+
139
+ protected getItems(): readonly ControlPlaneItem[] {
59
140
  if (!this.readModel) return [];
60
- return this.readModel.getSnapshot().clients;
141
+ const snapshot = this.readModel.getSnapshot();
142
+ switch (this.focusSection) {
143
+ case 'approvals':
144
+ return this._approvals(snapshot).map((approval): ControlPlaneItem => ({ kind: 'approval', approval }));
145
+ case 'sessions':
146
+ return this._sessions(snapshot).map((session): ControlPlaneItem => ({ kind: 'session', session }));
147
+ case 'events':
148
+ return this._recentEvents(snapshot).map((event): ControlPlaneItem => ({ kind: 'event', event }));
149
+ case 'clients':
150
+ default:
151
+ return snapshot.clients.map((client): ControlPlaneItem => ({ kind: 'client', client }));
152
+ }
153
+ }
154
+
155
+ protected override filterMatches(item: ControlPlaneItem, q: string): boolean {
156
+ switch (item.kind) {
157
+ case 'client':
158
+ return item.client.kind.toLowerCase().includes(q)
159
+ || item.client.label.toLowerCase().includes(q)
160
+ || item.client.transport.toLowerCase().includes(q);
161
+ case 'approval':
162
+ return item.approval.status.toLowerCase().includes(q)
163
+ || item.approval.request.tool.toLowerCase().includes(q)
164
+ || (item.approval.sessionId ?? '').toLowerCase().includes(q);
165
+ case 'session':
166
+ return item.session.status.toLowerCase().includes(q)
167
+ || item.session.title.toLowerCase().includes(q);
168
+ case 'event':
169
+ return item.event.event.toLowerCase().includes(q);
170
+ }
171
+ }
172
+
173
+ protected override getEmptyStateMessage(): string {
174
+ switch (this.focusSection) {
175
+ case 'approvals': return ' No approval pressure recorded.';
176
+ case 'sessions': return ' No shared sessions recorded.';
177
+ case 'events': return ' No recent control-plane events.';
178
+ default: return ' No control-plane clients connected.';
179
+ }
180
+ }
181
+
182
+ protected override getEmptyStateActions(): Array<{ command: string; summary: string }> {
183
+ return [
184
+ { command: '/cockpit', summary: 'watch operator posture from the terminal' },
185
+ { command: '/schedule list', summary: 'run automation that creates surface and daemon traffic' },
186
+ ];
187
+ }
188
+
189
+ // -------------------------------------------------------------------------
190
+ // Input
191
+ // -------------------------------------------------------------------------
192
+
193
+ handleInput(key: string): boolean {
194
+ if (this.lastError !== null) this.clearError();
195
+
196
+ if (this.confirmAction) {
197
+ const result = handleConfirmInput(this.confirmAction, key);
198
+ if (result === 'confirmed') {
199
+ this._executeConfirmed(this.confirmAction.subject);
200
+ this.confirmAction = null;
201
+ this.markDirty();
202
+ return true;
203
+ }
204
+ if (result === 'cancelled') {
205
+ this.confirmAction = null;
206
+ this.markDirty();
207
+ }
208
+ return true;
209
+ }
210
+
211
+ if (!this.filterActive) {
212
+ if (key === 'tab') {
213
+ this._cycleFocus();
214
+ return true;
215
+ }
216
+ if (this.focusSection === 'approvals' && this.approvalBroker && (key === 'a' || key === 'd')) {
217
+ this._beginApprovalDecision(key === 'a');
218
+ return true;
219
+ }
220
+ }
221
+
222
+ return super.handleInput(key);
223
+ }
224
+
225
+ private _cycleFocus(): void {
226
+ const idx = FOCUS_SECTIONS.indexOf(this.focusSection);
227
+ this.focusSection = FOCUS_SECTIONS[(idx + 1) % FOCUS_SECTIONS.length]!;
228
+ this.filterLabel = `Filter ${this.focusSection}`;
229
+ this.selectedIndex = 0;
230
+ this.markDirty();
231
+ }
232
+
233
+ private _beginApprovalDecision(approved: boolean): void {
234
+ const item = this.getSelectedItem();
235
+ if (!item || item.kind !== 'approval') return;
236
+ if (item.approval.status !== 'pending' && item.approval.status !== 'claimed') return;
237
+ this.confirmAction = {
238
+ subject: { approvalId: item.approval.id, approved },
239
+ label: `${item.approval.request.tool} · ${truncateDisplay(item.approval.sessionId ?? item.approval.id, 24)}`,
240
+ verb: approved ? 'Approve' : 'Deny',
241
+ };
242
+ this.markDirty();
243
+ }
244
+
245
+ private _executeConfirmed(subject: ApprovalConfirmSubject): void {
246
+ if (!this.approvalBroker) return;
247
+ void this.approvalBroker.resolveApproval(subject.approvalId, {
248
+ approved: subject.approved,
249
+ actor: 'operator',
250
+ actorSurface: 'tui',
251
+ }).catch((err) => {
252
+ this.setError(`${subject.approved ? 'Approve' : 'Deny'} failed: ${summarizeError(err)}`);
253
+ this.markDirty();
254
+ });
61
255
  }
62
256
 
63
- protected renderItem(client: ControlPlaneClient, _index: number, selected: boolean, width: number): Line {
257
+ // -------------------------------------------------------------------------
258
+ // Rendering — item rows
259
+ // -------------------------------------------------------------------------
260
+
261
+ protected renderItem(item: ControlPlaneItem, _index: number, selected: boolean, width: number): Line {
262
+ switch (item.kind) {
263
+ case 'client': return this._renderClientRow(item.client, selected, width);
264
+ case 'approval': return this._renderApprovalRow(item.approval, selected, width);
265
+ case 'session': return this._renderSessionRow(item.session, selected, width);
266
+ case 'event': return this._renderEventRow(item.event, selected, width);
267
+ }
268
+ }
269
+
270
+ private _renderClientRow(client: ControlPlaneClient, selected: boolean, width: number): Line {
64
271
  const bg = selected ? C.selectBg : undefined;
65
272
  return buildPanelLine(width, [
66
273
  [' ', C.label, bg],
@@ -71,17 +278,40 @@ export class ControlPlanePanel extends ScrollableListPanel<ControlPlaneClient> {
71
278
  ]);
72
279
  }
73
280
 
74
- protected override getEmptyStateMessage(): string {
75
- return ' No control-plane activity recorded.';
281
+ private _renderApprovalRow(approval: ControlPlaneApproval, selected: boolean, width: number): Line {
282
+ const bg = selected ? C.selectBg : undefined;
283
+ return buildPanelLine(width, [
284
+ [' ', C.label, bg],
285
+ [approval.status.padEnd(10), approvalStatusColor(approval.status), bg],
286
+ [` ${truncateDisplay(approval.request.tool, 16).padEnd(16)}`, C.value, bg],
287
+ [` ${truncateDisplay(approval.sessionId ?? approval.id, Math.max(0, width - 30))}`, C.dim, bg],
288
+ ]);
76
289
  }
77
290
 
78
- protected override getEmptyStateActions(): Array<{ command: string; summary: string }> {
79
- return [
80
- { command: '/cockpit', summary: 'watch operator posture from the terminal' },
81
- { command: '/schedule list', summary: 'run automation that creates surface and daemon traffic' },
82
- ];
291
+ private _renderSessionRow(session: ControlPlaneSession, selected: boolean, width: number): Line {
292
+ const bg = selected ? C.selectBg : undefined;
293
+ return buildPanelLine(width, [
294
+ [' ', C.label, bg],
295
+ [session.status.padEnd(10), session.status === 'active' ? C.good : C.dim, bg],
296
+ [` ${truncateDisplay(session.title, 20).padEnd(20)}`, C.value, bg],
297
+ [` ${truncateDisplay(session.activeAgentId ?? session.id, Math.max(0, width - 34))}`, C.dim, bg],
298
+ ]);
299
+ }
300
+
301
+ private _renderEventRow(event: ControlPlaneEvent, selected: boolean, width: number): Line {
302
+ const bg = selected ? C.selectBg : undefined;
303
+ const payload = typeof event.payload === 'string' ? event.payload : (JSON.stringify(event.payload) ?? '');
304
+ return buildPanelLine(width, [
305
+ [' ', C.label, bg],
306
+ [truncateDisplay(event.event, 16).padEnd(16), C.info, bg],
307
+ [` ${truncateDisplay(payload, Math.max(0, width - 19))}`, C.dim, bg],
308
+ ]);
83
309
  }
84
310
 
311
+ // -------------------------------------------------------------------------
312
+ // Rendering — workspace
313
+ // -------------------------------------------------------------------------
314
+
85
315
  public render(width: number, height: number): Line[] {
86
316
  const intro = 'Shared daemon control plane state, live clients, approval pressure, and recent omnichannel session posture.';
87
317
 
@@ -105,28 +335,29 @@ export class ControlPlanePanel extends ScrollableListPanel<ControlPlaneClient> {
105
335
  }
106
336
 
107
337
  const snapshot = this.readModel.getSnapshot();
108
- const approvals = snapshot.approvals;
109
- const sessions = snapshot.sessions;
110
- const recentEvents = snapshot.recentEvents;
111
- const clients = this.getItems();
338
+ const approvals = this._approvals(snapshot);
339
+ const sessions = this._sessions(snapshot);
340
+ const recentEvents = this._recentEvents(snapshot);
341
+ const clients = snapshot.clients;
342
+ const pendingApprovals = approvals.filter((entry) => entry.status === 'pending').length;
112
343
 
113
344
  const headerLines: Line[] = [
114
345
  buildKeyValueLine(width, [
115
346
  { label: 'state', value: snapshot.connectionState, valueColor: connectionColor(snapshot.connectionState) },
116
- { label: 'clients', value: String(snapshot.activeClientIds.length), valueColor: snapshot.activeClientIds.length > 0 ? C.ok : C.dim },
347
+ { label: 'clients', value: String(snapshot.activeClientIds.length), valueColor: snapshot.activeClientIds.length > 0 ? C.good : C.dim },
117
348
  { label: 'requests', value: String(snapshot.requestCount), valueColor: snapshot.requestCount > 0 ? C.info : C.dim },
118
- { label: 'errors', value: String(snapshot.errorCount), valueColor: snapshot.errorCount > 0 ? C.error : C.dim },
349
+ { label: 'errors', value: String(snapshot.errorCount), valueColor: snapshot.errorCount > 0 ? C.bad : C.dim },
119
350
  ], C),
120
351
  buildKeyValueLine(width, [
121
352
  { label: 'host', value: `${snapshot.host}:${snapshot.port}`, valueColor: C.value },
122
- { label: 'approvals', value: String(approvals.filter((entry) => entry.status === 'pending').length), valueColor: approvals.some((entry) => entry.status === 'pending') ? C.warn : C.dim },
353
+ { label: 'approvals', value: `${pendingApprovals} pending / ${approvals.length}`, valueColor: pendingApprovals > 0 ? C.warn : C.dim },
123
354
  { label: 'sessions', value: String(sessions.length), valueColor: sessions.length > 0 ? C.info : C.dim },
124
355
  { label: 'events', value: String(recentEvents.length), valueColor: recentEvents.length > 0 ? C.info : C.dim },
125
356
  ], C),
126
- buildGuidanceLine(width, '/cockpit', 'use the web operator surface or daemon APIs for direct interventions while this panel tracks overall posture', C),
357
+ this._buildFocusTabsLine(width),
127
358
  ];
128
359
 
129
- if (clients.length === 0 && approvals.length === 0 && sessions.length === 0) {
360
+ if (clients.length === 0 && approvals.length === 0 && sessions.length === 0 && recentEvents.length === 0) {
130
361
  return this.renderList(width, height, {
131
362
  title: 'Control Plane',
132
363
  header: headerLines,
@@ -135,77 +366,105 @@ export class ControlPlanePanel extends ScrollableListPanel<ControlPlaneClient> {
135
366
  }
136
367
 
137
368
  this.clampSelection();
138
- const selected = clients[this.selectedIndex];
369
+ const selected = this.getSelectedItem();
139
370
 
140
371
  const footerLines: Line[] = [];
141
372
  if (selected) {
142
- footerLines.push(
143
- buildPanelLine(width, [
144
- [' Client: ', C.label],
145
- [selected.label, C.value],
146
- [' Kind: ', C.label],
147
- [selected.kind, C.info],
148
- ]),
149
- buildPanelLine(width, [
150
- [' Transport: ', C.label],
151
- [selected.transport, C.value],
152
- [' Connected: ', C.label],
153
- [selected.connected ? 'yes' : 'no', selected.connected ? C.ok : C.warn],
154
- ]),
155
- buildPanelLine(width, [
156
- [' Route: ', C.label],
157
- [selected.routeId ?? 'n/a', C.dim],
158
- [' Session: ', C.label],
159
- [selected.sessionId ?? 'n/a', C.dim],
160
- ]),
161
- buildPanelLine(width, [
162
- [' Last seen: ', C.label],
163
- [formatTime(selected.lastSeenAt), C.dim],
164
- [' Remote: ', C.label],
165
- [truncateDisplay(selected.remoteAddress ?? 'n/a', Math.max(0, width - 36)), C.dim],
166
- ]),
167
- );
373
+ footerLines.push(...this._buildDetailLines(width, selected));
374
+ if (this.confirmAction) footerLines.push(...renderConfirmLines(width, this.confirmAction));
168
375
  } else {
169
- footerLines.push(buildPanelLine(width, [[' No connected client selected.', C.dim]]));
376
+ footerLines.push(buildPanelLine(width, [[' Nothing selected in this section.', C.dim]]));
377
+ }
378
+
379
+ return this.renderList(width, height, {
380
+ title: 'Control Plane',
381
+ header: headerLines,
382
+ footer: footerLines,
383
+ hints: this._buildHints(selected),
384
+ });
385
+ }
386
+
387
+ private _buildFocusTabsLine(width: number): Line {
388
+ const seg = (label: string, section: FocusSection): [string, string] => [
389
+ ` ${label} `,
390
+ this.focusSection === section ? C.value : C.dim,
391
+ ];
392
+ return buildPanelLine(width, [
393
+ seg('Approvals', 'approvals'),
394
+ seg('Clients', 'clients'),
395
+ seg('Sessions', 'sessions'),
396
+ seg('Events', 'events'),
397
+ [' (Tab to switch)', C.dim],
398
+ ]);
399
+ }
400
+
401
+ private _buildHints(selected: ControlPlaneItem | undefined): ReadonlyArray<{ keys: string; label: string }> {
402
+ if (this.filterActive) {
403
+ return [
404
+ { keys: 'type', label: `filter ${this.focusSection}` },
405
+ { keys: 'Enter', label: 'apply' },
406
+ { keys: 'Esc', label: 'clear' },
407
+ ];
170
408
  }
409
+ const hints: Array<{ keys: string; label: string }> = [
410
+ { keys: 'Up/Down', label: `select ${this.focusSection.slice(0, -1)}` },
411
+ { keys: 'Tab', label: 'section' },
412
+ ];
413
+ if (
414
+ this.focusSection === 'approvals'
415
+ && this.approvalBroker
416
+ && selected?.kind === 'approval'
417
+ && (selected.approval.status === 'pending' || selected.approval.status === 'claimed')
418
+ ) {
419
+ hints.push({ keys: 'a', label: 'approve' }, { keys: 'd', label: 'deny' });
420
+ }
421
+ hints.push({ keys: '/', label: 'filter' });
422
+ return hints;
423
+ }
171
424
 
172
- if (approvals.length > 0) {
173
- footerLines.push(
174
- ...approvals.slice(0, 6).map((approval) => buildPanelLine(width, [
175
- [' ', C.label],
176
- [approval.status.padEnd(10), approval.status === 'pending' ? C.warn : approval.status === 'approved' ? C.ok : approval.status === 'denied' ? C.error : C.dim],
177
- [` ${truncateDisplay(approval.request.tool, 16).padEnd(16)}`, C.value],
178
- [` ${truncateDisplay(approval.sessionId ?? approval.id, Math.max(0, width - 30))}`, C.dim],
179
- ])),
180
- );
425
+ private _buildDetailLines(width: number, item: ControlPlaneItem): Line[] {
426
+ if (item.kind === 'client') {
427
+ const client = item.client;
428
+ const rows: Line[] = [
429
+ buildPanelLine(width, [[' Client: ', C.label], [client.label, C.value], [' Kind: ', C.label], [client.kind, C.info]]),
430
+ buildPanelLine(width, [[' Transport: ', C.label], [client.transport, C.value], [' Connected: ', C.label], [client.connected ? 'yes' : 'no', client.connected ? C.good : C.warn]]),
431
+ buildPanelLine(width, [[' Route: ', C.label], [client.routeId ?? 'n/a', C.dim], [' Session: ', C.label], [client.sessionId ?? 'n/a', C.dim]]),
432
+ buildPanelLine(width, [[' Last seen: ', C.label], [formatTime(client.lastSeenAt), C.dim], [' Remote: ', C.label], [truncateDisplay(client.remoteAddress ?? 'n/a', Math.max(0, width - 36)), C.dim]]),
433
+ ];
434
+ return buildDetailBlock(width, `Client · ${client.label}`, rows, C);
181
435
  }
182
436
 
183
- if (sessions.length > 0) {
184
- footerLines.push(
185
- ...sessions.slice(0, 6).map((session) => buildPanelLine(width, [
186
- [' ', C.label],
187
- [session.status.padEnd(10), session.status === 'active' ? C.ok : C.dim],
188
- [` ${truncateDisplay(session.title, 20).padEnd(20)}`, C.value],
189
- [` ${truncateDisplay(session.activeAgentId ?? session.id, Math.max(0, width - 34))}`, C.dim],
190
- ])),
191
- );
437
+ if (item.kind === 'approval') {
438
+ const approval = item.approval;
439
+ const rows: Line[] = [
440
+ buildPanelLine(width, [[' Approval: ', C.label], [approval.id, C.value], [' Status: ', C.label], [approval.status, approvalStatusColor(approval.status)]]),
441
+ buildPanelLine(width, [[' Tool: ', C.label], [approval.request.tool, C.info], [' Risk: ', C.label], [approval.request.analysis.riskLevel, C.warn]]),
442
+ buildPanelLine(width, [[' Session: ', C.label], [approval.sessionId ?? 'n/a', C.dim], [' Route: ', C.label], [approval.routeId ?? 'n/a', C.dim]]),
443
+ buildPanelLine(width, [[' Requested: ', C.label], [formatTime(approval.createdAt), C.dim], [' Updated: ', C.label], [formatTime(approval.updatedAt), C.dim]]),
444
+ ];
445
+ if (approval.decision) {
446
+ rows.push(buildPanelLine(width, [[' Decision: ', C.label], [approval.decision.approved ? 'approved' : 'denied', approval.decision.approved ? C.good : C.bad]]));
447
+ }
448
+ return buildDetailBlock(width, `Approval · ${approval.request.tool}`, rows, C);
192
449
  }
193
450
 
194
- if (recentEvents.length > 0) {
195
- footerLines.push(
196
- ...recentEvents.slice(0, 6).map((event) => buildPanelLine(width, [
197
- [' ', C.label],
198
- [truncateDisplay(event.event, 16).padEnd(16), C.info],
199
- [` ${truncateDisplay(typeof event.payload === 'string' ? event.payload : JSON.stringify(event.payload) ?? '', Math.max(0, width - 19))}`, C.dim],
200
- ])),
201
- );
451
+ if (item.kind === 'session') {
452
+ const session = item.session;
453
+ const rows: Line[] = [
454
+ buildPanelLine(width, [[' Session: ', C.label], [session.title, C.value], [' Status: ', C.label], [session.status, session.status === 'active' ? C.good : C.dim]]),
455
+ buildPanelLine(width, [[' Kind: ', C.label], [session.kind, C.info], [' Messages: ', C.label], [String(session.messageCount), C.value]]),
456
+ buildPanelLine(width, [[' Active agent: ', C.label], [session.activeAgentId ?? 'none (unattached)', session.activeAgentId ? C.good : C.dim], [' Last agent: ', C.label], [session.lastAgentId ?? 'n/a', C.dim]]),
457
+ buildPanelLine(width, [[' Pending inputs: ', C.label], [String(session.pendingInputCount), session.pendingInputCount > 0 ? C.warn : C.dim], [' Last activity: ', C.label], [formatTime(session.lastActivityAt), C.dim]]),
458
+ ];
459
+ return buildDetailBlock(width, `Session · ${session.title}`, rows, C);
202
460
  }
203
- footerLines.push(buildPanelLine(width, [[' Up/Down move through connected clients', C.dim]]));
204
461
 
205
- return this.renderList(width, height, {
206
- title: 'Control Plane',
207
- header: headerLines,
208
- footer: footerLines,
209
- });
462
+ const event = item.event;
463
+ const payload = typeof event.payload === 'string' ? event.payload : (JSON.stringify(event.payload) ?? '');
464
+ const rows: Line[] = [
465
+ buildPanelLine(width, [[' Event: ', C.label], [event.event, C.info], [' At: ', C.label], [formatTime(event.createdAt), C.dim]]),
466
+ buildPanelLine(width, [[' Payload: ', C.label], [truncateDisplay(payload, Math.max(0, width - 12)), C.dim]]),
467
+ ];
468
+ return buildDetailBlock(width, `Event · ${event.event}`, rows, C);
210
469
  }
211
470
  }