@pellux/goodvibes-tui 0.18.20 → 0.19.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 (83) hide show
  1. package/CHANGELOG.md +154 -0
  2. package/README.md +1 -1
  3. package/docs/foundation-artifacts/operator-contract.json +1 -1
  4. package/package.json +7 -3
  5. package/src/core/conversation-rendering.ts +22 -6
  6. package/src/core/orchestrator.ts +1 -1
  7. package/src/input/commands/diff-runtime.ts +6 -5
  8. package/src/input/commands/guidance-runtime.ts +1 -1
  9. package/src/input/commands/health-runtime.ts +2 -2
  10. package/src/input/commands/local-setup-review.ts +1 -1
  11. package/src/input/commands/session-content.ts +1 -1
  12. package/src/input/commands/session.ts +0 -1
  13. package/src/input/commands/shell-core.ts +3 -2
  14. package/src/input/commands/skills-runtime.ts +2 -2
  15. package/src/input/commands/subscription-runtime.ts +4 -4
  16. package/src/input/feed-context-factory.ts +236 -0
  17. package/src/input/handler-feed.ts +44 -6
  18. package/src/input/handler-shortcuts.ts +138 -125
  19. package/src/input/handler.ts +119 -119
  20. package/src/input/keybindings.ts +30 -0
  21. package/src/input/panel-integration-actions.ts +2 -1
  22. package/src/input/settings-modal-types.ts +60 -0
  23. package/src/input/settings-modal.ts +83 -65
  24. package/src/panels/agent-inspector-panel.ts +10 -9
  25. package/src/panels/agent-logs-panel.ts +26 -6
  26. package/src/panels/approval-panel.ts +55 -82
  27. package/src/panels/automation-control-panel.ts +120 -161
  28. package/src/panels/base-panel.ts +108 -3
  29. package/src/panels/communication-panel.ts +69 -107
  30. package/src/panels/context-visualizer-panel.ts +2 -0
  31. package/src/panels/control-plane-panel.ts +117 -172
  32. package/src/panels/diff-panel.ts +2 -0
  33. package/src/panels/file-explorer-panel.ts +51 -31
  34. package/src/panels/file-preview-panel.ts +57 -35
  35. package/src/panels/git-panel.ts +12 -13
  36. package/src/panels/hooks-panel.ts +103 -138
  37. package/src/panels/incident-review-panel.ts +59 -109
  38. package/src/panels/knowledge-panel.ts +75 -107
  39. package/src/panels/local-auth-panel.ts +77 -93
  40. package/src/panels/marketplace-panel.ts +51 -69
  41. package/src/panels/mcp-panel.ts +110 -155
  42. package/src/panels/memory-panel.ts +90 -158
  43. package/src/panels/ops-control-panel.ts +51 -85
  44. package/src/panels/orchestration-panel.ts +70 -51
  45. package/src/panels/panel-list-panel.ts +5 -4
  46. package/src/panels/panel-manager.ts +25 -2
  47. package/src/panels/plan-dashboard-panel.ts +2 -0
  48. package/src/panels/plugins-panel.ts +37 -60
  49. package/src/panels/polish.ts +51 -2
  50. package/src/panels/provider-accounts-panel.ts +1 -0
  51. package/src/panels/provider-health-panel.ts +6 -8
  52. package/src/panels/routes-panel.ts +91 -141
  53. package/src/panels/schedule-panel.ts +7 -6
  54. package/src/panels/scrollable-list-panel.ts +64 -16
  55. package/src/panels/security-panel.ts +118 -152
  56. package/src/panels/services-panel.ts +63 -105
  57. package/src/panels/session-browser-panel.ts +19 -18
  58. package/src/panels/settings-sync-panel.ts +79 -123
  59. package/src/panels/skills-panel.ts +114 -230
  60. package/src/panels/subscription-panel.ts +64 -86
  61. package/src/panels/system-messages-panel.ts +147 -141
  62. package/src/panels/tasks-panel.ts +130 -179
  63. package/src/panels/token-budget-panel.ts +2 -0
  64. package/src/panels/watchers-panel.ts +89 -137
  65. package/src/panels/worktree-panel.ts +1 -0
  66. package/src/panels/wrfc-panel.ts +2 -0
  67. package/src/renderer/agent-detail-modal.ts +2 -2
  68. package/src/renderer/ansi-sanitize.ts +76 -0
  69. package/src/renderer/buffer.ts +23 -1
  70. package/src/renderer/diff.ts +8 -0
  71. package/src/renderer/help-overlay.ts +48 -28
  72. package/src/renderer/markdown.ts +3 -145
  73. package/src/renderer/settings-modal-helpers.ts +27 -0
  74. package/src/renderer/settings-modal.ts +18 -1
  75. package/src/renderer/status-glyphs.ts +21 -0
  76. package/src/renderer/status-token.ts +4 -8
  77. package/src/renderer/tool-call.ts +4 -3
  78. package/src/runtime/bootstrap-core.ts +1 -1
  79. package/src/runtime/bootstrap-hook-bridge.ts +1 -1
  80. package/src/runtime/bootstrap.ts +7 -8
  81. package/src/runtime/diagnostics/panels/policy.ts +2 -1
  82. package/src/shell/ui-openers.ts +1 -1
  83. package/src/version.ts +1 -1
@@ -1,33 +1,27 @@
1
1
  /**
2
2
  * MemoryPanel — project memory substrate TUI panel.
3
+ *
4
+ * Migrated to SearchableListPanel<MemoryRecord> (Wave B1).
3
5
  */
4
6
 
5
7
  import type { Line } from '../types/grid.ts';
6
8
  import type { MemoryRegistry } from '@pellux/goodvibes-sdk/platform/state/memory-store';
7
9
  import type { MemoryRecord, MemoryClass } from '@pellux/goodvibes-sdk/platform/state/memory-store';
8
- import { BasePanel } from './base-panel.ts';
10
+ import { SearchableListPanel } from './scrollable-list-panel.ts';
9
11
  import {
10
12
  buildBodyText,
11
- buildEmptyState,
12
13
  buildGuidanceLine,
13
14
  buildKeyValueLine,
14
15
  buildPanelLine,
15
- buildSearchInputLine,
16
- buildPanelWorkspace,
17
- resolveScrollablePanelSection,
16
+ extendPalette,
18
17
  DEFAULT_PANEL_PALETTE,
19
- type PanelWorkspaceSection,
20
18
  } from './polish.ts';
21
19
  import {
22
20
  getPanelSearchFocusTransition,
23
- isPanelSearchBackspace,
24
21
  isPanelSearchCancel,
25
- isPanelSearchCommit,
26
- isPanelSearchPrintable,
27
22
  } from './search-focus.ts';
28
23
 
29
- const C = {
30
- ...DEFAULT_PANEL_PALETTE,
24
+ const C = extendPalette(DEFAULT_PANEL_PALETTE, {
31
25
  header: '#94a3b8',
32
26
  headerBg: '#1e293b',
33
27
  decision: '#38bdf8',
@@ -42,7 +36,7 @@ const C = {
42
36
  selected: '#1e3a5f',
43
37
  searchBg: '#0f172a',
44
38
  searchFg: '#e2e8f0',
45
- } as const;
39
+ });
46
40
 
47
41
  function fmtTime(ts: number): string {
48
42
  const d = new Date(ts);
@@ -63,13 +57,9 @@ function classColor(cls: MemoryClass): string {
63
57
  }
64
58
  }
65
59
 
66
- export class MemoryPanel extends BasePanel {
60
+ export class MemoryPanel extends SearchableListPanel<MemoryRecord> {
67
61
  private registry: MemoryRegistry;
68
- private records: MemoryRecord[] = [];
69
- private selectedIdx = 0;
70
- private scrollOffset = 0;
71
- private searchMode = false;
72
- private searchQuery = '';
62
+ private filterFocused = false;
73
63
  private unsubscribe?: () => void;
74
64
 
75
65
  constructor(registry: MemoryRegistry) {
@@ -79,9 +69,11 @@ export class MemoryPanel extends BasePanel {
79
69
 
80
70
  onActivate(): void {
81
71
  super.onActivate();
82
- this.reload();
72
+ this.searchQuery = '';
73
+ this.invalidateFilter();
74
+ this.filterFocused = false;
83
75
  this.unsubscribe = this.registry.subscribe(() => {
84
- this.reload();
76
+ this.invalidateFilter();
85
77
  this.markDirty();
86
78
  });
87
79
  }
@@ -95,134 +87,112 @@ export class MemoryPanel extends BasePanel {
95
87
  this.unsubscribe = undefined;
96
88
  }
97
89
 
98
- private reload(): void {
99
- const filter = this.searchQuery.trim()
100
- ? { query: this.searchQuery.trim(), limit: 100 }
101
- : { limit: 100 };
102
- this.records = this.registry.search(filter);
103
- this.selectedIdx = Math.min(this.selectedIdx, Math.max(0, this.records.length - 1));
90
+ // ---------------------------------------------------------------------------
91
+ // SearchableListPanel implementation
92
+ // ---------------------------------------------------------------------------
93
+
94
+ protected getAllItems(): readonly MemoryRecord[] {
95
+ return this.registry.search({ limit: 100 });
104
96
  }
105
97
 
106
- handleInput(key: string): boolean {
107
- if (this.searchMode) return this.handleSearchInput(key);
98
+ protected matchesSearch(record: MemoryRecord, query: string): boolean {
99
+ const q = query.trim().toLowerCase();
100
+ if (!q) return true;
101
+ const haystack = [
102
+ record.summary,
103
+ record.detail ?? '',
104
+ record.cls,
105
+ record.scope,
106
+ record.tags.join(' '),
107
+ ].join(' ').toLowerCase();
108
+ return haystack.includes(q);
109
+ }
108
110
 
109
- const transition = getPanelSearchFocusTransition(key, { selectedIndex: this.selectedIdx, itemCount: this.records.length });
110
- if (transition === 'focus-search') {
111
- this.searchMode = true;
112
- this.markDirty();
113
- return true;
114
- }
111
+ protected renderItem(record: MemoryRecord, index: number, selected: boolean, width: number): Line {
112
+ const bg = selected ? C.selected : undefined;
113
+ return buildPanelLine(width, [
114
+ [' ', C.label, bg],
115
+ [`[${record.scope.slice(0, 1).toUpperCase()}/${record.cls.slice(0, 3).toUpperCase()}] `, classColor(record.cls), bg],
116
+ [record.id.slice(-8), C.dim, bg],
117
+ [' ', C.label, bg],
118
+ [fmtTime(record.createdAt), C.dim, bg],
119
+ [' ', C.label, bg],
120
+ [record.summary.slice(0, Math.max(0, width - 33)), C.value, bg],
121
+ ]);
122
+ }
115
123
 
116
- switch (key) {
117
- case 'ArrowUp':
118
- case 'k':
119
- if (this.selectedIdx > 0) {
120
- this.selectedIdx--;
121
- this.markDirty();
122
- }
123
- return true;
124
- case 'ArrowDown':
125
- case 'j':
126
- if (this.selectedIdx < this.records.length - 1) {
127
- this.selectedIdx++;
128
- this.markDirty();
129
- }
130
- return true;
131
- case 'Escape':
132
- if (this.searchQuery) {
133
- this.searchQuery = '';
134
- this.reload();
135
- this.markDirty();
136
- }
137
- return true;
138
- case 'r':
139
- this.reload();
124
+ protected override getPalette() { return C; }
125
+ protected override getEmptyStateMessage() {
126
+ return this.searchQuery
127
+ ? ` No records matching "${this.searchQuery}"`
128
+ : ' No memory records. Use /recall add <class> <summary> to create one.';
129
+ }
130
+ protected override getEmptyStateActions() {
131
+ return [
132
+ { command: '/recall add fact <summary>', summary: 'capture a durable fact directly' },
133
+ { command: '/recall capture incident latest', summary: 'promote the latest incident into memory' },
134
+ ];
135
+ }
136
+
137
+ handleInput(key: string): boolean {
138
+ // Filter-focus mode: typing goes into the search query
139
+ if (this.filterFocused) {
140
+ const items = this.getItems();
141
+ const transition = getPanelSearchFocusTransition(key, { selectedIndex: this.selectedIndex, itemCount: items.length });
142
+ if (transition === 'focus-list') {
143
+ this.filterFocused = false;
140
144
  this.markDirty();
141
145
  return true;
146
+ }
147
+ if (isPanelSearchCancel(key)) {
148
+ this.filterFocused = false;
149
+ return super.handleInput(key);
150
+ }
151
+ return super.handleInput(key);
142
152
  }
143
- return false;
144
- }
145
153
 
146
- private handleSearchInput(key: string): boolean {
147
- const transition = getPanelSearchFocusTransition(key, { selectedIndex: this.selectedIdx, itemCount: this.records.length });
148
- if (transition === 'focus-list') {
149
- this.searchMode = false;
150
- this.selectedIdx = 0;
151
- this.markDirty();
152
- return true;
153
- }
154
- if (isPanelSearchCommit(key) || isPanelSearchCancel(key)) {
155
- this.searchMode = false;
156
- this.reload();
157
- this.markDirty();
158
- return true;
159
- }
160
- if (isPanelSearchBackspace(key)) {
161
- this.searchQuery = this.searchQuery.slice(0, -1);
154
+ const items = this.getItems();
155
+ const transition = getPanelSearchFocusTransition(key, { selectedIndex: this.selectedIndex, itemCount: items.length });
156
+ if (transition === 'focus-search') {
157
+ this.filterFocused = true;
162
158
  this.markDirty();
163
159
  return true;
164
160
  }
165
- if (isPanelSearchPrintable(key)) {
166
- this.searchQuery += key;
161
+
162
+ if (key === 'r') {
163
+ this.invalidateFilter();
167
164
  this.markDirty();
168
165
  return true;
169
166
  }
170
- return false;
167
+
168
+ return super.handleInput(key);
171
169
  }
172
170
 
173
171
  render(width: number, height: number): Line[] {
172
+ this.clampSelection();
174
173
  const intro = 'Durable project memory across decisions, constraints, incidents, patterns, risks, runbooks, and related provenance.';
175
174
 
176
- if (!this.records.length && !this.searchQuery) {
177
- this.reload();
178
- }
179
-
180
- if (!this.records.length) {
181
- const message = this.searchQuery
182
- ? `No records matching "${this.searchQuery}"`
183
- : 'No memory records. Use /recall add <class> <summary> to create one.';
184
- return buildPanelWorkspace(width, height, {
185
- title: 'Memory',
186
- intro,
187
- sections: [{
188
- lines: buildEmptyState(
189
- width,
190
- ` ${message}`,
191
- 'Memory becomes useful once durable facts, incidents, and decisions are promoted into the project substrate.',
192
- [
193
- { command: '/recall add fact <summary>', summary: 'capture a durable fact directly' },
194
- { command: '/recall capture incident latest', summary: 'promote the latest incident into memory' },
195
- ],
196
- C,
197
- ),
198
- }],
199
- footerLines: [
200
- buildPanelLine(width, [[' / search j/k or Up/Down move r reload', C.dim]]),
201
- ],
202
- palette: C,
203
- });
204
- }
205
-
175
+ const records = this.getItems();
206
176
  const byClass = new Map<MemoryClass, number>();
207
- for (const record of this.records) {
177
+ for (const record of records) {
208
178
  byClass.set(record.cls, (byClass.get(record.cls) ?? 0) + 1);
209
179
  }
210
180
 
211
- const summaryLines = [
181
+ const filterLine = this.buildFilterInputLine(width, 'Filter', this.filterFocused);
182
+
183
+ const summaryLines: Line[] = [
212
184
  buildKeyValueLine(width, [
213
- { label: 'records', value: String(this.records.length), valueColor: C.value },
185
+ { label: 'records', value: String(records.length), valueColor: C.value },
214
186
  { label: 'facts', value: String(byClass.get('fact') ?? 0), valueColor: C.fact },
215
187
  { label: 'decisions', value: String(byClass.get('decision') ?? 0), valueColor: C.decision },
216
188
  { label: 'incidents', value: String(byClass.get('incident') ?? 0), valueColor: C.incident },
217
189
  { label: 'runbooks', value: String(byClass.get('runbook') ?? 0), valueColor: C.runbook },
218
190
  ], C),
219
- ...(this.searchMode || this.searchQuery
220
- ? [buildSearchInputLine(width, '', `${this.searchMode ? '/ ' : '~ '}${this.searchQuery}${this.searchMode ? '_' : ''}`, C, { active: this.searchMode, bg: C.searchBg, valueColor: C.searchFg })]
221
- : []),
191
+ filterLine,
222
192
  buildGuidanceLine(width, '/recall review', 'review durable knowledge and queue posture from the command surface', C),
223
193
  ];
224
194
 
225
- const selected = this.records[this.selectedIdx];
195
+ const selected = records[this.selectedIndex];
226
196
  const selectedLines: Line[] = [];
227
197
  if (selected) {
228
198
  selectedLines.push(buildKeyValueLine(width, [
@@ -243,51 +213,13 @@ export class MemoryPanel extends BasePanel {
243
213
  }
244
214
  }
245
215
 
246
- const summarySection: PanelWorkspaceSection = { title: 'Summary', lines: summaryLines };
247
- const selectedSection: PanelWorkspaceSection = selectedLines.length > 0 ? { title: 'Selected', lines: selectedLines } : { title: 'Selected', lines: [] };
248
- const recordsSection = resolveScrollablePanelSection(width, height, {
249
- intro,
250
- footerLines: [
251
- buildPanelLine(width, [[' / search j/k or Up/Down move r reload Esc clear search', C.dim]]),
252
- ],
253
- palette: C,
254
- beforeSections: [summarySection],
255
- section: {
256
- title: 'Records',
257
- scrollableLines: this.records.map((record, globalIndex) => {
258
- const bg = globalIndex === this.selectedIdx ? C.selected : undefined;
259
- return buildPanelLine(width, [
260
- [' ', C.label, bg],
261
- [`[${record.scope.slice(0, 1).toUpperCase()}/${record.cls.slice(0, 3).toUpperCase()}] `, classColor(record.cls), bg],
262
- [record.id.slice(-8), C.dim, bg],
263
- [' ', C.label, bg],
264
- [fmtTime(record.createdAt), C.dim, bg],
265
- [' ', C.label, bg],
266
- [record.summary.slice(0, Math.max(0, width - 33)), C.value, bg],
267
- ]);
268
- }),
269
- selectedIndex: this.selectedIdx,
270
- scrollOffset: this.scrollOffset,
271
- minRows: 4,
272
- appendWindowSummary: { dimColor: C.dim },
273
- },
274
- afterSections: selectedLines.length > 0 ? [selectedSection] : [],
275
- });
276
- this.scrollOffset = recordsSection.scrollOffset;
277
- const sections: PanelWorkspaceSection[] = [
278
- summarySection,
279
- recordsSection.section,
280
- ];
281
- if (selectedLines.length > 0) sections.push(selectedSection);
282
-
283
- return buildPanelWorkspace(width, height, {
216
+ return this.renderList(width, height, {
284
217
  title: 'Memory',
285
- intro,
286
- sections,
287
- footerLines: [
218
+ header: summaryLines,
219
+ footer: [
220
+ ...selectedLines,
288
221
  buildPanelLine(width, [[' / search j/k or Up/Down move r reload Esc clear search', C.dim]]),
289
222
  ],
290
- palette: C,
291
223
  });
292
224
  }
293
225
  }
@@ -12,15 +12,11 @@ import type { OpsEvent } from '@pellux/goodvibes-sdk/platform/runtime/events/ind
12
12
  import type { UiEventFeed } from '../runtime/ui-events.ts';
13
13
  import type { OpsAuditEntry } from '../runtime/diagnostics/panels/ops.ts';
14
14
  import { OpsPanel } from '../runtime/diagnostics/panels/ops.ts';
15
- import { BasePanel } from './base-panel.ts';
16
- import { createEmptyLine } from '../types/grid.ts';
15
+ import { ScrollableListPanel } from './scrollable-list-panel.ts';
17
16
  import {
18
- buildEmptyState,
19
17
  buildPanelLine,
20
- buildPanelWorkspace,
21
- resolveScrollablePanelSection,
22
18
  DEFAULT_PANEL_PALETTE,
23
- type PanelWorkspaceSection,
19
+ type PanelPalette,
24
20
  } from './polish.ts';
25
21
 
26
22
  // ── Colour palette ──────────────────────────────────────────────────────────
@@ -74,26 +70,20 @@ function targetColor(kind: OpsAuditEntry['targetKind']): string {
74
70
 
75
71
  // ── OpsControlPanel ──────────────────────────────────────────────────────────
76
72
 
77
- export class OpsControlPanel extends BasePanel {
73
+ export class OpsControlPanel extends ScrollableListPanel<OpsAuditEntry> {
78
74
  private readonly _opsPanel: OpsPanel;
79
75
  private _unsub: (() => void) | null = null;
80
- private _scrollOffset = 0;
81
76
 
82
77
  public constructor(eventFeed: UiEventFeed<OpsEvent>) {
83
78
  super('ops-control', 'Ops Control', 'Q', 'agent');
79
+ this.showSelectionGutter = true; // I5: non-color selection affordance
84
80
  this._opsPanel = new OpsPanel(eventFeed);
85
81
  this._unsub = this._opsPanel.subscribe(() => this.markDirty());
86
82
  }
87
83
 
88
84
  public override onActivate(): void {
89
85
  super.onActivate();
90
- this._scrollOffset = 0;
91
- }
92
-
93
- public handleInput(key: string): boolean {
94
- if (key === 'up' || key === 'k') { this._scrollOffset = Math.max(0, this._scrollOffset - 1); return true; }
95
- if (key === 'down' || key === 'j') { this._scrollOffset++; return true; }
96
- return false;
86
+ this.selectedIndex = 0;
97
87
  }
98
88
 
99
89
  public override onDestroy(): void {
@@ -104,81 +94,57 @@ export class OpsControlPanel extends BasePanel {
104
94
  this._opsPanel.dispose();
105
95
  }
106
96
 
107
- public render(width: number, height: number): Line[] {
108
- this.needsRender = false;
109
- const entries = this._opsPanel.getSnapshot();
110
- const intro = 'Operator interventions, outcomes, and task or agent targets across the active control plane.';
111
-
112
- if (entries.length === 0) {
113
- const workspace = buildPanelWorkspace(width, height, {
114
- title: 'Operator Control Plane',
115
- intro,
116
- sections: [{
117
- lines: buildEmptyState(
118
- width,
119
- ' No operator interventions recorded.',
120
- 'Actions like pause, retry, cancel, move, and approval decisions will appear here once the operator starts intervening in runtime workflows.',
121
- [{ command: '/cockpit', summary: 'open the cockpit and drive runtime interventions from the control rooms' }],
122
- C,
123
- ),
124
- }],
125
- palette: C,
126
- });
127
- while (workspace.length < height) workspace.push(createEmptyLine(width));
128
- return workspace;
129
- }
97
+ protected override getPalette(): PanelPalette {
98
+ return C;
99
+ }
100
+
101
+ protected getItems(): readonly OpsAuditEntry[] {
102
+ // Return reversed so newest entries appear at top
103
+ return [...this._opsPanel.getSnapshot()].reverse();
104
+ }
105
+
106
+ protected renderItem(entry: OpsAuditEntry, _index: number, _selected: boolean, width: number): Line {
107
+ const seqStr = String(entry.seq).padStart(4, ' ');
108
+ const timeStr = fmtTime(entry.ts);
109
+ const action = entry.action.slice(0, 15).padEnd(15, ' ');
110
+ const kindTag = entry.targetKind === 'task' ? 'T:' : 'A:';
111
+ // Truncation is intentional: TUI column width limits target ID display to 14 chars
112
+ const shortId = entry.targetId.slice(-10);
113
+ const target = (kindTag + shortId).slice(0, 14).padEnd(14, ' ');
114
+ const outLabel = outcomeLabel(entry.outcome);
115
+ const noteRaw = (entry.note ?? entry.errorMessage ?? '').slice(0, Math.max(0, width - 63));
116
+
117
+ const segs: Array<[string, string, string?]> = [
118
+ [` ${seqStr} `, C.seq],
119
+ [`${timeStr} `, C.dim],
120
+ [`${action} `, C.value],
121
+ [`${target} `, targetColor(entry.targetKind)],
122
+ [outLabel, outcomeColor(entry.outcome)],
123
+ ];
124
+ if (noteRaw) segs.push([` ${noteRaw}`, C.note]);
125
+ return buildPanelLine(width, segs);
126
+ }
127
+
128
+ protected override getEmptyStateMessage(): string {
129
+ return ' No operator interventions recorded.';
130
+ }
131
+
132
+ protected override getEmptyStateActions(): Array<{ command: string; summary: string }> {
133
+ return [{ command: '/cockpit', summary: 'open the cockpit and drive runtime interventions from the control rooms' }];
134
+ }
130
135
 
131
- const reversed = [...entries].reverse();
132
- const entryRows: Line[] = [
136
+ public render(width: number, height: number): Line[] {
137
+ const headerLines: Line[] = [
133
138
  buildPanelLine(width, [[' SEQ TIME ACTION TARGET OUT NOTE', C.label]]),
134
139
  ];
135
- for (const entry of reversed) {
136
- const seqStr = String(entry.seq).padStart(4, ' ');
137
- const timeStr = fmtTime(entry.ts);
138
- const action = entry.action.slice(0, 15).padEnd(15, ' ');
139
- const kindTag = entry.targetKind === 'task' ? 'T:' : 'A:';
140
- // Truncation is intentional: TUI column width limits target ID display to 14 chars
141
- const shortId = entry.targetId.slice(-10);
142
- const target = (kindTag + shortId).slice(0, 14).padEnd(14, ' ');
143
- const outLabel = outcomeLabel(entry.outcome);
144
- const noteRaw = (entry.note ?? entry.errorMessage ?? '').slice(0, Math.max(0, width - 63));
145
-
146
- const segs: Array<[string, string, string?]> = [
147
- [` ${seqStr} `, C.seq],
148
- [`${timeStr} `, C.dim],
149
- [`${action} `, C.value],
150
- [`${target} `, targetColor(entry.targetKind)],
151
- [outLabel, outcomeColor(entry.outcome)],
152
- ];
153
- if (noteRaw) segs.push([` ${noteRaw}`, C.note]);
154
- entryRows.push(buildPanelLine(width, segs));
155
- }
156
- const logSection = resolveScrollablePanelSection(width, height, {
157
- intro,
158
- footerLines: [buildPanelLine(width, [[' Up/Down scroll the intervention log', C.dim]])],
159
- palette: C,
160
- section: {
161
- title: 'Audit Log',
162
- scrollableLines: entryRows,
163
- scrollOffset: this._scrollOffset,
164
- minRows: 4,
165
- appendWindowSummary: {
166
- dimColor: C.label,
167
- formatter: (window) => buildPanelLine(width, [[` [${window.start + 1}-${window.end}/${window.total}] Up/Down to scroll`.slice(0, width), C.label]]),
168
- },
169
- },
170
- });
171
- this._scrollOffset = logSection.scrollOffset;
140
+ const footerLines: Line[] = [
141
+ buildPanelLine(width, [[' Up/Down scroll the intervention log', C.dim]]),
142
+ ];
172
143
 
173
- const sections: PanelWorkspaceSection[] = [logSection.section];
174
- const lines = buildPanelWorkspace(width, height, {
144
+ return this.renderList(width, height, {
175
145
  title: 'Operator Control Plane',
176
- intro,
177
- sections,
178
- footerLines: [buildPanelLine(width, [[' Up/Down scroll the intervention log', C.dim]])],
179
- palette: C,
146
+ header: headerLines,
147
+ footer: footerLines,
180
148
  });
181
- while (lines.length < height) lines.push(createEmptyLine(width));
182
- return lines;
183
149
  }
184
150
  }