@pellux/goodvibes-tui 0.18.19 → 0.18.23

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 (42) hide show
  1. package/CHANGELOG.md +170 -0
  2. package/README.md +1 -1
  3. package/package.json +1 -1
  4. package/src/core/conversation-rendering.ts +20 -6
  5. package/src/input/commands/session.ts +0 -1
  6. package/src/input/feed-context-factory.ts +236 -0
  7. package/src/input/handler-feed-routes.ts +10 -0
  8. package/src/input/handler-feed.ts +44 -6
  9. package/src/input/handler-shortcuts.ts +138 -125
  10. package/src/input/handler.ts +121 -119
  11. package/src/input/keybindings.ts +30 -0
  12. package/src/panels/approval-panel.ts +54 -74
  13. package/src/panels/automation-control-panel.ts +119 -161
  14. package/src/panels/base-panel.ts +71 -0
  15. package/src/panels/communication-panel.ts +68 -107
  16. package/src/panels/confirm-state.ts +61 -0
  17. package/src/panels/control-plane-panel.ts +116 -172
  18. package/src/panels/git-panel.ts +9 -0
  19. package/src/panels/hooks-panel.ts +101 -138
  20. package/src/panels/incident-review-panel.ts +55 -107
  21. package/src/panels/knowledge-panel.ts +63 -14
  22. package/src/panels/local-auth-panel.ts +76 -93
  23. package/src/panels/marketplace-panel.ts +19 -12
  24. package/src/panels/mcp-panel.ts +108 -155
  25. package/src/panels/ops-control-panel.ts +50 -85
  26. package/src/panels/panel-manager.ts +22 -2
  27. package/src/panels/plugins-panel.ts +36 -60
  28. package/src/panels/routes-panel.ts +89 -141
  29. package/src/panels/scrollable-list-panel.ts +71 -16
  30. package/src/panels/security-panel.ts +101 -137
  31. package/src/panels/services-panel.ts +58 -102
  32. package/src/panels/settings-sync-panel.ts +76 -122
  33. package/src/panels/skills-panel.ts +44 -0
  34. package/src/panels/subscription-panel.ts +69 -80
  35. package/src/panels/tasks-panel.ts +129 -179
  36. package/src/panels/watchers-panel.ts +88 -137
  37. package/src/renderer/buffer.ts +11 -0
  38. package/src/renderer/diff.ts +8 -0
  39. package/src/renderer/help-overlay.ts +37 -28
  40. package/src/renderer/markdown.ts +3 -145
  41. package/src/renderer/status-token.ts +71 -0
  42. package/src/version.ts +1 -1
@@ -1,6 +1,5 @@
1
1
  import type { Line } from '../types/grid.ts';
2
- import { createEmptyLine } from '../types/grid.ts';
3
- import { BasePanel } from './base-panel.ts';
2
+ import { ScrollableListPanel } from './scrollable-list-panel.ts';
4
3
  import type { UiCommunicationSnapshot, UiReadModel } from '../runtime/ui-read-models.ts';
5
4
  import { truncateDisplay } from '../utils/terminal-width.ts';
6
5
  import {
@@ -11,10 +10,9 @@ import {
11
10
  buildPanelLine,
12
11
  buildPanelWorkspace,
13
12
  DEFAULT_PANEL_PALETTE,
14
- resolvePrimaryScrollableSection,
15
- type PanelWorkspaceSection,
13
+ type PanelPalette,
16
14
  } from './polish.ts';
17
- import { getTrackedVisibleWindow } from '../renderer/surface-layout.ts';
15
+ import { createEmptyLine } from '../types/grid.ts';
18
16
 
19
17
  const C = {
20
18
  ...DEFAULT_PANEL_PALETTE,
@@ -26,11 +24,11 @@ const C = {
26
24
  selectBg: '#0f172a',
27
25
  } as const;
28
26
 
29
- export class CommunicationPanel extends BasePanel {
27
+ type CommunicationRecord = UiCommunicationSnapshot['records'][number];
28
+
29
+ export class CommunicationPanel extends ScrollableListPanel<CommunicationRecord> {
30
30
  private readonly readModel?: UiReadModel<UiCommunicationSnapshot>;
31
31
  private readonly unsub: (() => void) | null;
32
- private selectedIndex = 0;
33
- private scrollOffset = 0;
34
32
 
35
33
  public constructor(readModel?: UiReadModel<UiCommunicationSnapshot>) {
36
34
  super('communication', 'Communication', 'Y', 'monitoring');
@@ -42,31 +40,40 @@ export class CommunicationPanel extends BasePanel {
42
40
  this.unsub?.();
43
41
  }
44
42
 
45
- public handleInput(key: string): boolean {
46
- const records = this.records();
47
- if (records.length === 0) return false;
48
- if (key === 'up' || key === 'k') {
49
- this.selectedIndex = Math.max(0, this.selectedIndex - 1);
50
- this.markDirty();
51
- return true;
52
- }
53
- if (key === 'down' || key === 'j') {
54
- this.selectedIndex = Math.min(records.length - 1, this.selectedIndex + 1);
55
- this.markDirty();
56
- return true;
57
- }
58
- return false;
43
+ protected override getPalette(): PanelPalette {
44
+ return C;
59
45
  }
60
46
 
61
- private records() {
47
+ protected getItems(): readonly CommunicationRecord[] {
62
48
  if (!this.readModel) return [];
63
- return [...this.readModel.getSnapshot().records];
49
+ return this.readModel.getSnapshot().records;
50
+ }
51
+
52
+ protected renderItem(record: CommunicationRecord, index: number, selected: boolean, width: number): Line {
53
+ const bg = selected ? C.selectBg : undefined;
54
+ const color = record.status === 'blocked' ? C.error : record.status === 'delivered' ? C.ok : C.info;
55
+ return buildPanelLine(width, [
56
+ [' ', C.label, bg],
57
+ [record.status.padEnd(10), color, bg],
58
+ [` ${record.kind.padEnd(10)}`, C.info, bg],
59
+ [` ${truncateDisplay(`${record.fromId} -> ${record.toId}`, 28).padEnd(28)}`, C.value, bg],
60
+ [` ${truncateDisplay(record.content, Math.max(0, width - 53))}`, C.dim, bg],
61
+ ]);
62
+ }
63
+
64
+ protected override getEmptyStateMessage(): string {
65
+ return ' No structured communication recorded yet.';
66
+ }
67
+
68
+ protected override getEmptyStateActions(): Array<{ command: string; summary: string }> {
69
+ return [
70
+ { command: '/orchestration', summary: 'review graphs and recursive agent activity' },
71
+ { command: '/communication', summary: 'reopen this workspace once the runtime emits message traffic' },
72
+ ];
64
73
  }
65
74
 
66
75
  public render(width: number, height: number): Line[] {
67
- this.needsRender = false;
68
76
  const intro = 'Structured agent communication, routing policy outcomes, and delivery status across orchestration trees.';
69
- const footerLines = [buildPanelLine(width, [[' Up/Down move through messages', C.dim]])];
70
77
 
71
78
  if (!this.readModel) {
72
79
  const workspace = buildPanelWorkspace(width, height, {
@@ -88,104 +95,58 @@ export class CommunicationPanel extends BasePanel {
88
95
  }
89
96
 
90
97
  const snapshot = this.readModel.getSnapshot();
91
- const records = this.records();
98
+ const records = this.getItems();
99
+
100
+ const postureLines: Line[] = [
101
+ buildPanelLine(width, [[' Communication posture', C.label]]),
102
+ buildKeyValueLine(width, [
103
+ { label: 'sent', value: String(snapshot.totalSent), valueColor: snapshot.totalSent > 0 ? C.info : C.dim },
104
+ { label: 'delivered', value: String(snapshot.totalDelivered), valueColor: snapshot.totalDelivered > 0 ? C.ok : C.dim },
105
+ { label: 'blocked', value: String(snapshot.totalBlocked), valueColor: snapshot.totalBlocked > 0 ? C.error : C.dim },
106
+ ], C),
107
+ buildGuidanceLine(width, '/orchestration', 'inspect recursive routing, message handoff, and blocked broadcast posture', C),
108
+ ];
92
109
 
93
110
  if (records.length === 0) {
94
- const workspace = buildPanelWorkspace(width, height, {
111
+ return this.renderList(width, height, {
95
112
  title: 'Communication Control Room',
96
- intro,
97
- sections: [{
98
- title: 'Communication posture',
99
- lines: [
100
- buildKeyValueLine(width, [
101
- { label: 'sent', value: String(snapshot.totalSent), valueColor: snapshot.totalSent > 0 ? C.info : C.dim },
102
- { label: 'delivered', value: String(snapshot.totalDelivered), valueColor: snapshot.totalDelivered > 0 ? C.ok : C.dim },
103
- { label: 'blocked', value: String(snapshot.totalBlocked), valueColor: snapshot.totalBlocked > 0 ? C.error : C.dim },
104
- ], C),
105
- buildGuidanceLine(width, '/communication', 'review structured message flow, delivery posture, and blocked routing decisions', C),
106
- ...buildEmptyState(
107
- width,
108
- ' No structured communication recorded yet.',
109
- 'Messages, escalations, findings, and handoffs will appear here once orchestration starts routing them through the communication policy.',
110
- [
111
- { command: '/orchestration', summary: 'review graphs and recursive agent activity' },
112
- { command: '/communication', summary: 'reopen this workspace once the runtime emits message traffic' },
113
- ],
114
- C,
115
- ),
116
- ],
117
- }],
118
- palette: C,
113
+ header: postureLines,
119
114
  });
120
- while (workspace.length < height) workspace.push(createEmptyLine(width));
121
- return workspace;
122
115
  }
123
116
 
124
- this.selectedIndex = Math.min(this.selectedIndex, records.length - 1);
125
- const postureLines: Line[] = [
117
+ this.clampSelection();
118
+ const selected = records[this.selectedIndex];
119
+
120
+ // Update posture with selected info
121
+ const postureWithSelected: Line[] = [
122
+ buildPanelLine(width, [[' Communication posture', C.label]]),
126
123
  buildKeyValueLine(width, [
127
124
  { label: 'sent', value: String(snapshot.totalSent), valueColor: snapshot.totalSent > 0 ? C.info : C.dim },
128
125
  { label: 'delivered', value: String(snapshot.totalDelivered), valueColor: snapshot.totalDelivered > 0 ? C.ok : C.dim },
129
126
  { label: 'blocked', value: String(snapshot.totalBlocked), valueColor: snapshot.totalBlocked > 0 ? C.error : C.dim },
130
- { label: 'selected', value: `${records[this.selectedIndex]?.fromId ?? 'n/a'} -> ${records[this.selectedIndex]?.toId ?? 'n/a'}`, valueColor: C.value },
127
+ { label: 'selected', value: `${selected?.fromId ?? 'n/a'} -> ${selected?.toId ?? 'n/a'}`, valueColor: C.value },
131
128
  ], C),
132
129
  buildGuidanceLine(width, '/orchestration', 'inspect recursive routing, message handoff, and blocked broadcast posture', C),
133
130
  ];
134
131
 
135
- const selected = records[this.selectedIndex]!;
136
- const detailLines: Line[] = [
137
- buildPanelLine(width, [[' Route: ', C.label], [`${selected.scope} / ${selected.kind}`, C.value], [' Status: ', C.label], [selected.status, selected.status === 'blocked' ? C.error : C.ok]]),
138
- buildPanelLine(width, [[' From: ', C.label], [selected.fromId, C.value], [' To: ', C.label], [selected.toId, C.value]]),
139
- buildPanelLine(width, [[' Roles: ', C.label], [`${selected.fromRole ?? 'unknown'} -> ${selected.toRole ?? 'unknown'}`, C.dim]]),
140
- ];
141
- if (selected.reason) {
142
- detailLines.push(buildPanelLine(width, [[' Reason: ', C.label], [truncateDisplay(selected.reason, Math.max(0, width - 11)), C.warn]]));
132
+ const footerLines: Line[] = [];
133
+ if (selected) {
134
+ footerLines.push(
135
+ buildPanelLine(width, [[' Route: ', C.label], [`${selected.scope} / ${selected.kind}`, C.value], [' Status: ', C.label], [selected.status, selected.status === 'blocked' ? C.error : C.ok]]),
136
+ buildPanelLine(width, [[' From: ', C.label], [selected.fromId, C.value], [' To: ', C.label], [selected.toId, C.value]]),
137
+ buildPanelLine(width, [[' Roles: ', C.label], [`${selected.fromRole ?? 'unknown'} -> ${selected.toRole ?? 'unknown'}`, C.dim]]),
138
+ );
139
+ if (selected.reason) {
140
+ footerLines.push(buildPanelLine(width, [[' Reason: ', C.label], [truncateDisplay(selected.reason, Math.max(0, width - 11)), C.warn]]));
141
+ }
142
+ footerLines.push(...buildBodyText(width, ` Content: ${selected.content}`, C));
143
143
  }
144
- detailLines.push(...buildBodyText(width, ` Content: ${selected.content}`, C));
145
- const postureSection: PanelWorkspaceSection = { title: 'Communication posture', lines: postureLines };
146
- const detailSection: PanelWorkspaceSection = { title: 'Selected Message', lines: detailLines };
147
- const rawOverviewLines: Line[] = records.map((record, absolute) => {
148
- const bg = absolute === this.selectedIndex ? C.selectBg : undefined;
149
- const color = record.status === 'blocked' ? C.error : record.status === 'delivered' ? C.ok : C.info;
150
- return buildPanelLine(width, [
151
- [' ', C.label, bg],
152
- [record.status.padEnd(10), color, bg],
153
- [` ${record.kind.padEnd(10)}`, C.info, bg],
154
- [` ${truncateDisplay(`${record.fromId} -> ${record.toId}`, 28).padEnd(28)}`, C.value, bg],
155
- [` ${truncateDisplay(record.content, Math.max(0, width - 53))}`, C.dim, bg],
156
- ]);
157
- });
158
- const resolvedMessagesSection = resolvePrimaryScrollableSection(width, height, {
159
- intro,
160
- footerLines,
161
- palette: C,
162
- beforeSections: [postureSection],
163
- section: {
164
- title: 'Recent Messages',
165
- scrollableLines: rawOverviewLines,
166
- selectedIndex: this.selectedIndex,
167
- scrollOffset: this.scrollOffset,
168
- guardRows: 1,
169
- minRows: 4,
170
- appendWindowSummary: { dimColor: C.dim },
171
- },
172
- afterSections: [detailSection],
173
- });
174
- this.scrollOffset = resolvedMessagesSection.scrollOffset;
144
+ footerLines.push(buildPanelLine(width, [[' Up/Down move through messages', C.dim]]));
175
145
 
176
- const sections: PanelWorkspaceSection[] = [
177
- postureSection,
178
- resolvedMessagesSection.section,
179
- detailSection,
180
- ];
181
- const lines = buildPanelWorkspace(width, height, {
146
+ return this.renderList(width, height, {
182
147
  title: 'Communication Control Room',
183
- intro,
184
- sections,
185
- footerLines,
186
- palette: C,
148
+ header: postureWithSelected,
149
+ footer: footerLines,
187
150
  });
188
- while (lines.length < height) lines.push(createEmptyLine(width));
189
- return lines.slice(0, height);
190
151
  }
191
152
  }
@@ -0,0 +1,61 @@
1
+ // ---------------------------------------------------------------------------
2
+ // useConfirmState<T> — reusable inline y/n confirmation helper
3
+ //
4
+ // Pattern (chosen over ConfirmableListPanel base class):
5
+ // - Composable: any panel holds a ConfirmState field, not a new base class
6
+ // - Identical y/n UX everywhere: y confirms, n/Esc cancels, any other key
7
+ // is absorbed (does nothing) while confirm is active
8
+ // - Render: caller calls renderConfirmLines(width, state) to get the two
9
+ // lines that replace the normal content area when confirming
10
+ // ---------------------------------------------------------------------------
11
+
12
+ import type { Line } from '../types/grid.ts';
13
+ import { buildPanelLine } from './polish.ts';
14
+ import { DEFAULT_PANEL_PALETTE } from './polish.ts';
15
+
16
+ export interface ConfirmState<T = string> {
17
+ /** The subject of the confirmation (e.g. item name or id). */
18
+ readonly subject: T;
19
+ /** Human-readable label for the item being destroyed. */
20
+ readonly label: string;
21
+ }
22
+
23
+ /**
24
+ * Call this from a panel's handleInput() BEFORE any other key handling.
25
+ *
26
+ * Returns:
27
+ * - `'confirmed'` — user pressed y; caller must execute the action and
28
+ * clear state (set confirm to null)
29
+ * - `'cancelled'` — user pressed n or Esc; caller must clear state
30
+ * - `'absorbed'` — any other key while confirm is active; caller returns true
31
+ * - `'inactive'` — no confirm pending; caller continues normal dispatch
32
+ */
33
+ export function handleConfirmInput<T = string>(
34
+ confirm: ConfirmState<T> | null,
35
+ key: string,
36
+ ): 'confirmed' | 'cancelled' | 'absorbed' | 'inactive' {
37
+ if (!confirm) return 'inactive';
38
+ if (key === 'y') return 'confirmed';
39
+ if (key === 'n' || key === 'escape') return 'cancelled';
40
+ return 'absorbed';
41
+ }
42
+
43
+ /**
44
+ * Build the two confirmation lines to show in place of the normal list body.
45
+ * Callers embed these lines in a workspace section titled 'Confirmation'.
46
+ */
47
+ export function renderConfirmLines<T = string>(width: number, state: ConfirmState<T>): Line[] {
48
+ const palette = DEFAULT_PANEL_PALETTE;
49
+ return [
50
+ buildPanelLine(width, [[
51
+ ` Delete "${state.label}"?`,
52
+ palette.warn,
53
+ ]]),
54
+ buildPanelLine(width, [
55
+ [' y', palette.info],
56
+ [' confirm delete', palette.dim],
57
+ [' n / Esc', palette.info],
58
+ [' cancel', palette.dim],
59
+ ]),
60
+ ];
61
+ }
@@ -1,6 +1,6 @@
1
1
  import type { Line } from '../types/grid.ts';
2
2
  import { createEmptyLine } from '../types/grid.ts';
3
- import { BasePanel } from './base-panel.ts';
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
6
  import {
@@ -10,8 +10,7 @@ import {
10
10
  buildPanelLine,
11
11
  buildPanelWorkspace,
12
12
  DEFAULT_PANEL_PALETTE,
13
- resolvePrimaryScrollableSection,
14
- type PanelWorkspaceSection,
13
+ type PanelPalette,
15
14
  } from './polish.ts';
16
15
 
17
16
  const C = {
@@ -37,10 +36,10 @@ function connectionColor(state: string): string {
37
36
  return C.dim;
38
37
  }
39
38
 
40
- export class ControlPlanePanel extends BasePanel {
39
+ type ControlPlaneClient = UiControlPlaneSnapshot['clients'][number];
40
+
41
+ export class ControlPlanePanel extends ScrollableListPanel<ControlPlaneClient> {
41
42
  private readonly unsub: (() => void) | null;
42
- private selectedIndex = 0;
43
- private scrollOffset = 0;
44
43
 
45
44
  public constructor(private readonly readModel?: UiReadModel<UiControlPlaneSnapshot>) {
46
45
  super('control-plane', 'Control Plane', 'C', 'monitoring');
@@ -51,29 +50,38 @@ export class ControlPlanePanel extends BasePanel {
51
50
  this.unsub?.();
52
51
  }
53
52
 
54
- public handleInput(key: string): boolean {
55
- const clients = this.clients();
56
- if (clients.length === 0) return false;
57
- if (key === 'up' || key === 'k') {
58
- this.selectedIndex = Math.max(0, this.selectedIndex - 1);
59
- this.markDirty();
60
- return true;
61
- }
62
- if (key === 'down' || key === 'j') {
63
- this.selectedIndex = Math.min(clients.length - 1, this.selectedIndex + 1);
64
- this.markDirty();
65
- return true;
66
- }
67
- return false;
53
+ protected override getPalette(): PanelPalette {
54
+ return C;
68
55
  }
69
56
 
70
- private clients() {
57
+ protected getItems(): readonly ControlPlaneClient[] {
71
58
  if (!this.readModel) return [];
72
- return [...this.readModel.getSnapshot().clients];
59
+ return this.readModel.getSnapshot().clients;
60
+ }
61
+
62
+ protected renderItem(client: ControlPlaneClient, _index: number, selected: boolean, width: number): Line {
63
+ const bg = selected ? C.selectBg : undefined;
64
+ return buildPanelLine(width, [
65
+ [' ', C.label, bg],
66
+ [client.kind.padEnd(10), C.info, bg],
67
+ [` ${truncateDisplay(client.label, 20).padEnd(20)}`, C.value, bg],
68
+ [` ${client.transport.padEnd(12)}`, C.dim, bg],
69
+ [` ${truncateDisplay(formatTime(client.lastSeenAt), Math.max(0, width - 46))}`, C.dim, bg],
70
+ ]);
71
+ }
72
+
73
+ protected override getEmptyStateMessage(): string {
74
+ return ' No control-plane activity recorded.';
75
+ }
76
+
77
+ protected override getEmptyStateActions(): Array<{ command: string; summary: string }> {
78
+ return [
79
+ { command: '/cockpit', summary: 'watch operator posture from the terminal' },
80
+ { command: '/schedule list', summary: 'run automation that creates surface and daemon traffic' },
81
+ ];
73
82
  }
74
83
 
75
84
  public render(width: number, height: number): Line[] {
76
- this.needsRender = false;
77
85
  const intro = 'Shared daemon control plane state, live clients, approval pressure, and recent omnichannel session posture.';
78
86
 
79
87
  if (!this.readModel) {
@@ -99,168 +107,104 @@ export class ControlPlanePanel extends BasePanel {
99
107
  const approvals = snapshot.approvals;
100
108
  const sessions = snapshot.sessions;
101
109
  const recentEvents = snapshot.recentEvents;
102
- const clients = this.clients();
103
-
104
- const summarySection: PanelWorkspaceSection = {
105
- title: 'Posture',
106
- lines: [
107
- buildKeyValueLine(width, [
108
- { label: 'state', value: snapshot.connectionState, valueColor: connectionColor(snapshot.connectionState) },
109
- { label: 'clients', value: String(snapshot.activeClientIds.length), valueColor: snapshot.activeClientIds.length > 0 ? C.ok : C.dim },
110
- { label: 'requests', value: String(snapshot.requestCount), valueColor: snapshot.requestCount > 0 ? C.info : C.dim },
111
- { label: 'errors', value: String(snapshot.errorCount), valueColor: snapshot.errorCount > 0 ? C.error : C.dim },
112
- ], C),
113
- buildKeyValueLine(width, [
114
- { label: 'host', value: `${snapshot.host}:${snapshot.port}`, valueColor: C.value },
115
- { label: 'approvals', value: String(approvals.filter((entry) => entry.status === 'pending').length), valueColor: approvals.some((entry) => entry.status === 'pending') ? C.warn : C.dim },
116
- { label: 'sessions', value: String(sessions.length), valueColor: sessions.length > 0 ? C.info : C.dim },
117
- { label: 'events', value: String(recentEvents.length), valueColor: recentEvents.length > 0 ? C.info : C.dim },
118
- ], C),
119
- buildGuidanceLine(width, '/cockpit', 'use the web operator surface or daemon APIs for direct interventions while this panel tracks overall posture', C),
120
- ],
121
- };
110
+ const clients = this.getItems();
111
+
112
+ const headerLines: Line[] = [
113
+ buildKeyValueLine(width, [
114
+ { label: 'state', value: snapshot.connectionState, valueColor: connectionColor(snapshot.connectionState) },
115
+ { label: 'clients', value: String(snapshot.activeClientIds.length), valueColor: snapshot.activeClientIds.length > 0 ? C.ok : C.dim },
116
+ { label: 'requests', value: String(snapshot.requestCount), valueColor: snapshot.requestCount > 0 ? C.info : C.dim },
117
+ { label: 'errors', value: String(snapshot.errorCount), valueColor: snapshot.errorCount > 0 ? C.error : C.dim },
118
+ ], C),
119
+ buildKeyValueLine(width, [
120
+ { label: 'host', value: `${snapshot.host}:${snapshot.port}`, valueColor: C.value },
121
+ { label: 'approvals', value: String(approvals.filter((entry) => entry.status === 'pending').length), valueColor: approvals.some((entry) => entry.status === 'pending') ? C.warn : C.dim },
122
+ { label: 'sessions', value: String(sessions.length), valueColor: sessions.length > 0 ? C.info : C.dim },
123
+ { label: 'events', value: String(recentEvents.length), valueColor: recentEvents.length > 0 ? C.info : C.dim },
124
+ ], C),
125
+ buildGuidanceLine(width, '/cockpit', 'use the web operator surface or daemon APIs for direct interventions while this panel tracks overall posture', C),
126
+ ];
122
127
 
123
128
  if (clients.length === 0 && approvals.length === 0 && sessions.length === 0) {
124
- const workspace = buildPanelWorkspace(width, height, {
129
+ return this.renderList(width, height, {
125
130
  title: 'Control Plane',
126
- intro,
127
- sections: [
128
- summarySection,
129
- {
130
- lines: buildEmptyState(
131
- width,
132
- ' No control-plane activity recorded.',
133
- 'Start the daemon, connect a surface, or trigger an approval to populate this operator panel.',
134
- [
135
- { command: '/cockpit', summary: 'watch operator posture from the terminal' },
136
- { command: '/schedule list', summary: 'run automation that creates surface and daemon traffic' },
137
- ],
138
- C,
139
- ),
140
- },
141
- ],
142
- palette: C,
131
+ header: headerLines,
132
+ emptyMessage: ' No control-plane activity recorded.',
143
133
  });
144
- while (workspace.length < height) workspace.push(createEmptyLine(width));
145
- return workspace;
146
134
  }
147
135
 
148
- this.selectedIndex = Math.min(this.selectedIndex, Math.max(0, clients.length - 1));
136
+ this.clampSelection();
149
137
  const selected = clients[this.selectedIndex];
150
138
 
151
- const detailSection: PanelWorkspaceSection = selected
152
- ? {
153
- title: 'Selected Client',
154
- lines: [
155
- buildPanelLine(width, [
156
- [' Client: ', C.label],
157
- [selected.label, C.value],
158
- [' Kind: ', C.label],
159
- [selected.kind, C.info],
160
- ]),
161
- buildPanelLine(width, [
162
- [' Transport: ', C.label],
163
- [selected.transport, C.value],
164
- [' Connected: ', C.label],
165
- [selected.connected ? 'yes' : 'no', selected.connected ? C.ok : C.warn],
166
- ]),
167
- buildPanelLine(width, [
168
- [' Route: ', C.label],
169
- [selected.routeId ?? 'n/a', C.dim],
170
- [' Session: ', C.label],
171
- [selected.sessionId ?? 'n/a', C.dim],
172
- ]),
173
- buildPanelLine(width, [
174
- [' Last seen: ', C.label],
175
- [formatTime(selected.lastSeenAt), C.dim],
176
- [' Remote: ', C.label],
177
- [truncateDisplay(selected.remoteAddress ?? 'n/a', Math.max(0, width - 36)), C.dim],
178
- ]),
179
- ],
180
- }
181
- : {
182
- title: 'Selected Client',
183
- lines: [buildPanelLine(width, [[' No connected client selected.', C.dim]])],
184
- };
185
-
186
- const approvalsSection: PanelWorkspaceSection = {
187
- title: 'Approvals',
188
- lines: approvals.length > 0
189
- ? approvals.slice(0, 6).map((approval) => buildPanelLine(width, [
190
- [' ', C.label],
191
- [approval.status.padEnd(10), approval.status === 'pending' ? C.warn : approval.status === 'approved' ? C.ok : approval.status === 'denied' ? C.error : C.dim],
192
- [` ${truncateDisplay(approval.request.tool, 16).padEnd(16)}`, C.value],
193
- [` ${truncateDisplay(approval.sessionId ?? approval.id, Math.max(0, width - 30))}`, C.dim],
194
- ]))
195
- : [buildPanelLine(width, [[' No recent approvals.', C.dim]])],
196
- };
139
+ const footerLines: Line[] = [];
140
+ if (selected) {
141
+ footerLines.push(
142
+ buildPanelLine(width, [
143
+ [' Client: ', C.label],
144
+ [selected.label, C.value],
145
+ [' Kind: ', C.label],
146
+ [selected.kind, C.info],
147
+ ]),
148
+ buildPanelLine(width, [
149
+ [' Transport: ', C.label],
150
+ [selected.transport, C.value],
151
+ [' Connected: ', C.label],
152
+ [selected.connected ? 'yes' : 'no', selected.connected ? C.ok : C.warn],
153
+ ]),
154
+ buildPanelLine(width, [
155
+ [' Route: ', C.label],
156
+ [selected.routeId ?? 'n/a', C.dim],
157
+ [' Session: ', C.label],
158
+ [selected.sessionId ?? 'n/a', C.dim],
159
+ ]),
160
+ buildPanelLine(width, [
161
+ [' Last seen: ', C.label],
162
+ [formatTime(selected.lastSeenAt), C.dim],
163
+ [' Remote: ', C.label],
164
+ [truncateDisplay(selected.remoteAddress ?? 'n/a', Math.max(0, width - 36)), C.dim],
165
+ ]),
166
+ );
167
+ } else {
168
+ footerLines.push(buildPanelLine(width, [[' No connected client selected.', C.dim]]));
169
+ }
197
170
 
198
- const sessionsSection: PanelWorkspaceSection = {
199
- title: 'Sessions',
200
- lines: sessions.length > 0
201
- ? sessions.slice(0, 6).map((session) => buildPanelLine(width, [
202
- [' ', C.label],
203
- [session.status.padEnd(10), session.status === 'active' ? C.ok : C.dim],
204
- [` ${truncateDisplay(session.title, 20).padEnd(20)}`, C.value],
205
- [` ${truncateDisplay(session.activeAgentId ?? session.id, Math.max(0, width - 34))}`, C.dim],
206
- ]))
207
- : [buildPanelLine(width, [[' No shared sessions recorded.', C.dim]])],
208
- };
171
+ if (approvals.length > 0) {
172
+ footerLines.push(
173
+ ...approvals.slice(0, 6).map((approval) => buildPanelLine(width, [
174
+ [' ', C.label],
175
+ [approval.status.padEnd(10), approval.status === 'pending' ? C.warn : approval.status === 'approved' ? C.ok : approval.status === 'denied' ? C.error : C.dim],
176
+ [` ${truncateDisplay(approval.request.tool, 16).padEnd(16)}`, C.value],
177
+ [` ${truncateDisplay(approval.sessionId ?? approval.id, Math.max(0, width - 30))}`, C.dim],
178
+ ])),
179
+ );
180
+ }
209
181
 
210
- const eventsSection: PanelWorkspaceSection = {
211
- title: 'Recent Events',
212
- lines: recentEvents.length > 0
213
- ? recentEvents.slice(0, 6).map((event) => buildPanelLine(width, [
214
- [' ', C.label],
215
- [truncateDisplay(event.event, 16).padEnd(16), C.info],
216
- [` ${truncateDisplay(typeof event.payload === 'string' ? event.payload : JSON.stringify(event.payload) ?? '', Math.max(0, width - 19))}`, C.dim],
217
- ]))
218
- : [buildPanelLine(width, [[' No recent control-plane events.', C.dim]])],
219
- };
182
+ if (sessions.length > 0) {
183
+ footerLines.push(
184
+ ...sessions.slice(0, 6).map((session) => buildPanelLine(width, [
185
+ [' ', C.label],
186
+ [session.status.padEnd(10), session.status === 'active' ? C.ok : C.dim],
187
+ [` ${truncateDisplay(session.title, 20).padEnd(20)}`, C.value],
188
+ [` ${truncateDisplay(session.activeAgentId ?? session.id, Math.max(0, width - 34))}`, C.dim],
189
+ ])),
190
+ );
191
+ }
220
192
 
221
- const resolvedClients = resolvePrimaryScrollableSection(width, height, {
222
- intro,
223
- footerLines: [buildPanelLine(width, [[' Up/Down move through connected clients', C.dim]])],
224
- palette: C,
225
- beforeSections: [summarySection],
226
- section: {
227
- title: 'Clients',
228
- scrollableLines: clients.map((client, absolute) => {
229
- const bg = absolute === this.selectedIndex ? C.selectBg : undefined;
230
- return buildPanelLine(width, [
231
- [' ', C.label, bg],
232
- [client.kind.padEnd(10), C.info, bg],
233
- [` ${truncateDisplay(client.label, 20).padEnd(20)}`, C.value, bg],
234
- [` ${client.transport.padEnd(12)}`, C.dim, bg],
235
- [` ${truncateDisplay(formatTime(client.lastSeenAt), Math.max(0, width - 46))}`, C.dim, bg],
236
- ]);
237
- }),
238
- selectedIndex: this.selectedIndex,
239
- scrollOffset: this.scrollOffset,
240
- guardRows: 1,
241
- minRows: 4,
242
- appendWindowSummary: { dimColor: C.dim },
243
- },
244
- afterSections: [detailSection, approvalsSection, sessionsSection, eventsSection],
245
- });
246
- this.scrollOffset = resolvedClients.scrollOffset;
193
+ if (recentEvents.length > 0) {
194
+ footerLines.push(
195
+ ...recentEvents.slice(0, 6).map((event) => buildPanelLine(width, [
196
+ [' ', C.label],
197
+ [truncateDisplay(event.event, 16).padEnd(16), C.info],
198
+ [` ${truncateDisplay(typeof event.payload === 'string' ? event.payload : JSON.stringify(event.payload) ?? '', Math.max(0, width - 19))}`, C.dim],
199
+ ])),
200
+ );
201
+ }
202
+ footerLines.push(buildPanelLine(width, [[' Up/Down move through connected clients', C.dim]]));
247
203
 
248
- const sections: PanelWorkspaceSection[] = [
249
- summarySection,
250
- resolvedClients.section,
251
- detailSection,
252
- approvalsSection,
253
- sessionsSection,
254
- eventsSection,
255
- ];
256
- const lines = buildPanelWorkspace(width, height, {
204
+ return this.renderList(width, height, {
257
205
  title: 'Control Plane',
258
- intro,
259
- sections,
260
- footerLines: [buildPanelLine(width, [[' Up/Down move through connected clients', C.dim]])],
261
- palette: C,
206
+ header: headerLines,
207
+ footer: footerLines,
262
208
  });
263
- while (lines.length < height) lines.push(createEmptyLine(width));
264
- return lines.slice(0, height);
265
209
  }
266
210
  }