@pellux/goodvibes-tui 0.18.20 → 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 (34) hide show
  1. package/CHANGELOG.md +120 -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.ts +44 -6
  8. package/src/input/handler-shortcuts.ts +138 -125
  9. package/src/input/handler.ts +121 -119
  10. package/src/input/keybindings.ts +30 -0
  11. package/src/panels/approval-panel.ts +54 -82
  12. package/src/panels/automation-control-panel.ts +119 -161
  13. package/src/panels/communication-panel.ts +68 -107
  14. package/src/panels/control-plane-panel.ts +116 -172
  15. package/src/panels/hooks-panel.ts +101 -138
  16. package/src/panels/incident-review-panel.ts +55 -107
  17. package/src/panels/local-auth-panel.ts +76 -93
  18. package/src/panels/mcp-panel.ts +108 -155
  19. package/src/panels/ops-control-panel.ts +50 -85
  20. package/src/panels/panel-manager.ts +22 -2
  21. package/src/panels/plugins-panel.ts +36 -60
  22. package/src/panels/routes-panel.ts +89 -141
  23. package/src/panels/scrollable-list-panel.ts +45 -14
  24. package/src/panels/security-panel.ts +101 -137
  25. package/src/panels/services-panel.ts +58 -102
  26. package/src/panels/settings-sync-panel.ts +76 -122
  27. package/src/panels/subscription-panel.ts +63 -86
  28. package/src/panels/tasks-panel.ts +129 -179
  29. package/src/panels/watchers-panel.ts +88 -137
  30. package/src/renderer/buffer.ts +11 -0
  31. package/src/renderer/diff.ts +8 -0
  32. package/src/renderer/help-overlay.ts +37 -28
  33. package/src/renderer/markdown.ts +3 -145
  34. package/src/version.ts +1 -1
@@ -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,10 +70,9 @@ 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');
@@ -87,13 +82,7 @@ export class OpsControlPanel extends BasePanel {
87
82
 
88
83
  public override onActivate(): void {
89
84
  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;
85
+ this.selectedIndex = 0;
97
86
  }
98
87
 
99
88
  public override onDestroy(): void {
@@ -104,81 +93,57 @@ export class OpsControlPanel extends BasePanel {
104
93
  this._opsPanel.dispose();
105
94
  }
106
95
 
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
- }
96
+ protected override getPalette(): PanelPalette {
97
+ return C;
98
+ }
99
+
100
+ protected getItems(): readonly OpsAuditEntry[] {
101
+ // Return reversed so newest entries appear at top
102
+ return [...this._opsPanel.getSnapshot()].reverse();
103
+ }
104
+
105
+ protected renderItem(entry: OpsAuditEntry, _index: number, _selected: boolean, width: number): Line {
106
+ const seqStr = String(entry.seq).padStart(4, ' ');
107
+ const timeStr = fmtTime(entry.ts);
108
+ const action = entry.action.slice(0, 15).padEnd(15, ' ');
109
+ const kindTag = entry.targetKind === 'task' ? 'T:' : 'A:';
110
+ // Truncation is intentional: TUI column width limits target ID display to 14 chars
111
+ const shortId = entry.targetId.slice(-10);
112
+ const target = (kindTag + shortId).slice(0, 14).padEnd(14, ' ');
113
+ const outLabel = outcomeLabel(entry.outcome);
114
+ const noteRaw = (entry.note ?? entry.errorMessage ?? '').slice(0, Math.max(0, width - 63));
115
+
116
+ const segs: Array<[string, string, string?]> = [
117
+ [` ${seqStr} `, C.seq],
118
+ [`${timeStr} `, C.dim],
119
+ [`${action} `, C.value],
120
+ [`${target} `, targetColor(entry.targetKind)],
121
+ [outLabel, outcomeColor(entry.outcome)],
122
+ ];
123
+ if (noteRaw) segs.push([` ${noteRaw}`, C.note]);
124
+ return buildPanelLine(width, segs);
125
+ }
126
+
127
+ protected override getEmptyStateMessage(): string {
128
+ return ' No operator interventions recorded.';
129
+ }
130
+
131
+ protected override getEmptyStateActions(): Array<{ command: string; summary: string }> {
132
+ return [{ command: '/cockpit', summary: 'open the cockpit and drive runtime interventions from the control rooms' }];
133
+ }
130
134
 
131
- const reversed = [...entries].reverse();
132
- const entryRows: Line[] = [
135
+ public render(width: number, height: number): Line[] {
136
+ const headerLines: Line[] = [
133
137
  buildPanelLine(width, [[' SEQ TIME ACTION TARGET OUT NOTE', C.label]]),
134
138
  ];
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;
139
+ const footerLines: Line[] = [
140
+ buildPanelLine(width, [[' Up/Down scroll the intervention log', C.dim]]),
141
+ ];
172
142
 
173
- const sections: PanelWorkspaceSection[] = [logSection.section];
174
- const lines = buildPanelWorkspace(width, height, {
143
+ return this.renderList(width, height, {
175
144
  title: 'Operator Control Plane',
176
- intro,
177
- sections,
178
- footerLines: [buildPanelLine(width, [[' Up/Down scroll the intervention log', C.dim]])],
179
- palette: C,
145
+ header: headerLines,
146
+ footer: footerLines,
180
147
  });
181
- while (lines.length < height) lines.push(createEmptyLine(width));
182
- return lines;
183
148
  }
184
149
  }
@@ -39,6 +39,9 @@ export class PanelManager {
39
39
  private _verticalSplitRatio: number = 0.5; // top gets 50% of panel height
40
40
  private _bottomPaneVisible: boolean = false;
41
41
 
42
+ // Cache for getWorkspaceTabs() — invalidated on every panel lifecycle event
43
+ private _cachedWorkspaceTabs: readonly WorkspaceTab[] | null = null;
44
+
42
45
  // -------------------------------------------------------------------------
43
46
  // Registration
44
47
  // -------------------------------------------------------------------------
@@ -79,6 +82,11 @@ export class PanelManager {
79
82
  // Panel lifecycle — operates on a specific pane (defaults to focused)
80
83
  // -------------------------------------------------------------------------
81
84
 
85
+ /** Invalidate the workspace tab cache. Call on every panel lifecycle mutation. */
86
+ private _invalidateWorkspaceTabs(): void {
87
+ this._cachedWorkspaceTabs = null;
88
+ }
89
+
82
90
  open(panelId: string, pane?: 'top' | 'bottom'): Panel {
83
91
  const existingPane = this._findPaneOf(panelId);
84
92
  if (existingPane) {
@@ -107,6 +115,7 @@ export class PanelManager {
107
115
  this._focusedPane = 'top';
108
116
  }
109
117
  panel.onActivate();
118
+ this._invalidateWorkspaceTabs();
110
119
  return panel;
111
120
  }
112
121
 
@@ -146,6 +155,7 @@ export class PanelManager {
146
155
  if (this.topPane.panels.length === 0 && this.bottomPane.panels.length === 0) {
147
156
  this._visible = false;
148
157
  }
158
+ this._invalidateWorkspaceTabs();
149
159
  return;
150
160
  }
151
161
  }
@@ -187,6 +197,7 @@ export class PanelManager {
187
197
  p.activeIndex = (p.activeIndex + 1) % p.panels.length;
188
198
  const newPanel = p.panels[p.activeIndex];
189
199
  if (newPanel) newPanel.onActivate();
200
+ this._invalidateWorkspaceTabs();
190
201
  }
191
202
 
192
203
  nextWorkspaceTab(): void {
@@ -216,6 +227,7 @@ export class PanelManager {
216
227
  p.activeIndex = index;
217
228
  const newPanel = p.panels[p.activeIndex];
218
229
  if (newPanel) newPanel.onActivate();
230
+ this._invalidateWorkspaceTabs();
219
231
  }
220
232
 
221
233
  activateById(panelId: string): void {
@@ -231,6 +243,7 @@ export class PanelManager {
231
243
  focusPane(pane: 'top' | 'bottom'): void {
232
244
  if (pane === 'bottom' && !this._bottomPaneVisible) return;
233
245
  this._focusedPane = pane;
246
+ this._invalidateWorkspaceTabs();
234
247
  }
235
248
 
236
249
  getFocusedPane(): 'top' | 'bottom' {
@@ -253,6 +266,7 @@ export class PanelManager {
253
266
  // -------------------------------------------------------------------------
254
267
 
255
268
  toggleBottomPane(): void {
269
+ this._invalidateWorkspaceTabs();
256
270
  if (this._bottomPaneVisible) {
257
271
  this._bottomPaneVisible = false;
258
272
  if (this._focusedPane === 'bottom') this._focusedPane = 'top';
@@ -329,7 +343,8 @@ export class PanelManager {
329
343
  return this._findPaneOf(panelId);
330
344
  }
331
345
 
332
- getWorkspaceTabs(): WorkspaceTab[] {
346
+ getWorkspaceTabs(): readonly WorkspaceTab[] {
347
+ if (this._cachedWorkspaceTabs !== null) return this._cachedWorkspaceTabs;
333
348
  const focusedPanelId = this.getActivePanel()?.id;
334
349
  const topTabs = this.topPane.panels.map((panel) => ({
335
350
  id: panel.id,
@@ -347,7 +362,9 @@ export class PanelManager {
347
362
  active: panel.id === focusedPanelId,
348
363
  focused: panel.id === focusedPanelId,
349
364
  }));
350
- return [...topTabs, ...bottomTabs];
365
+ const tabs = [...topTabs, ...bottomTabs] as WorkspaceTab[];
366
+ this._cachedWorkspaceTabs = tabs;
367
+ return tabs;
351
368
  }
352
369
 
353
370
  activateWorkspaceIndex(index: number): void {
@@ -357,6 +374,7 @@ export class PanelManager {
357
374
  this._focusedPane = tab.pane;
358
375
  if (tab.pane === 'bottom') this._bottomPaneVisible = true;
359
376
  this._activateByIdInPane(tab.id, tab.pane);
377
+ this._invalidateWorkspaceTabs();
360
378
  }
361
379
 
362
380
  // -------------------------------------------------------------------------
@@ -491,6 +509,7 @@ export class PanelManager {
491
509
  this._bottomPaneVisible = true;
492
510
  }
493
511
  this._focusedPane = dstPaneName;
512
+ this._invalidateWorkspaceTabs();
494
513
  }
495
514
 
496
515
  private _cycleWorkspaceTab(direction: 1 | -1): void {
@@ -533,6 +552,7 @@ export class PanelManager {
533
552
  p.activeIndex = index;
534
553
  const newPanel = p.panels[p.activeIndex];
535
554
  if (newPanel) newPanel.onActivate();
555
+ this._invalidateWorkspaceTabs();
536
556
  }
537
557
  }
538
558
  }
@@ -1,14 +1,13 @@
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 { PluginManagerObserver, PluginStatus } from '@pellux/goodvibes-sdk/platform/plugins/manager';
5
5
  import {
6
6
  buildEmptyState,
7
7
  buildPanelLine,
8
8
  buildPanelWorkspace,
9
9
  DEFAULT_PANEL_PALETTE,
10
- resolvePrimaryScrollableSection,
11
- type PanelWorkspaceSection,
10
+ type PanelPalette,
12
11
  } from './polish.ts';
13
12
 
14
13
  const C = {
@@ -47,11 +46,9 @@ function statusLabel(status: PluginStatus): string {
47
46
  return 'DISABLED';
48
47
  }
49
48
 
50
- export class PluginsPanel extends BasePanel {
49
+ export class PluginsPanel extends ScrollableListPanel<PluginStatus> {
51
50
  private readonly manager: PluginManagerObserver;
52
51
  private readonly unsub: (() => void) | null;
53
- private selectedIndex = 0;
54
- private scrollOffset = 0;
55
52
 
56
53
  public constructor(manager: PluginManagerObserver) {
57
54
  super('plugins', 'Plugins', 'P', 'monitoring');
@@ -68,26 +65,39 @@ export class PluginsPanel extends BasePanel {
68
65
  this.unsub?.();
69
66
  }
70
67
 
71
- public handleInput(key: string): boolean {
72
- const plugins = this.manager.list();
73
- if (plugins.length === 0) return false;
74
- if (key === 'up' || key === 'k') {
75
- this.selectedIndex = Math.max(0, this.selectedIndex - 1);
76
- this.markDirty();
77
- return true;
78
- }
79
- if (key === 'down' || key === 'j') {
80
- this.selectedIndex = Math.min(plugins.length - 1, this.selectedIndex + 1);
81
- this.markDirty();
82
- return true;
83
- }
84
- return false;
68
+ protected override getPalette(): PanelPalette {
69
+ return C;
70
+ }
71
+
72
+ protected getItems(): readonly PluginStatus[] {
73
+ return this.manager.list();
74
+ }
75
+
76
+ protected renderItem(plugin: PluginStatus, _index: number, selected: boolean, width: number): Line {
77
+ const bg = selected ? C.selectBg : undefined;
78
+ return buildPanelLine(width, [
79
+ [' ', C.label, bg],
80
+ [plugin.name.padEnd(22), C.value, bg],
81
+ [` ${statusLabel(plugin).padEnd(11)}`, statusColor(plugin), bg],
82
+ [` ${plugin.trustTier.toUpperCase().padEnd(10)}`, trustColor(plugin.trustTier), bg],
83
+ [` ${plugin.version}`, C.dim, bg],
84
+ ]);
85
+ }
86
+
87
+ protected override getEmptyStateMessage(): string {
88
+ return ' No plugins discovered.';
89
+ }
90
+
91
+ protected override getEmptyStateActions(): Array<{ command: string; summary: string }> {
92
+ return [
93
+ { command: '/plugin list', summary: 'inspect plugin discovery paths and current registry state' },
94
+ { command: '/marketplace', summary: 'review curated ecosystem entries and provenance posture' },
95
+ ];
85
96
  }
86
97
 
87
98
  public render(width: number, height: number): Line[] {
88
- this.needsRender = false;
89
99
  const intro = 'Plugin trust, capabilities, signatures, and quarantine posture for the active ecosystem surface.';
90
- const plugins = this.manager.list();
100
+ const plugins = this.getItems();
91
101
 
92
102
  if (plugins.length === 0) {
93
103
  const workspace = buildPanelWorkspace(width, height, {
@@ -111,7 +121,7 @@ export class PluginsPanel extends BasePanel {
111
121
  return workspace;
112
122
  }
113
123
 
114
- this.selectedIndex = Math.min(this.selectedIndex, plugins.length - 1);
124
+ this.clampSelection();
115
125
  const selected = plugins[this.selectedIndex]!;
116
126
  const selectedCaps = this.manager.capabilities(selected.name);
117
127
  const trustRecord = this.manager.getTrustRecord(selected.name);
@@ -157,45 +167,11 @@ export class PluginsPanel extends BasePanel {
157
167
  }
158
168
 
159
169
  detailLines.push(buildPanelLine(width, [[' Inspect trust and capability state here, then use /plugin to take action.', C.dim]]));
160
- const detailSection: PanelWorkspaceSection = { title: 'Selected Plugin', lines: detailLines };
161
- const resolvedPluginsSection = resolvePrimaryScrollableSection(width, height, {
162
- intro,
163
- footerLines: [buildPanelLine(width, [[' Up/Down move through discovered plugins', C.dim]])],
164
- palette: C,
165
- section: {
166
- title: 'Plugins',
167
- scrollableLines: plugins.map((plugin, absolute) => {
168
- const bg = absolute === this.selectedIndex ? C.selectBg : undefined;
169
- return buildPanelLine(width, [
170
- [' ', C.label, bg],
171
- [plugin.name.padEnd(22), C.value, bg],
172
- [` ${statusLabel(plugin).padEnd(11)}`, statusColor(plugin), bg],
173
- [` ${plugin.trustTier.toUpperCase().padEnd(10)}`, trustColor(plugin.trustTier), bg],
174
- [` ${plugin.version}`, C.dim, bg],
175
- ]);
176
- }),
177
- selectedIndex: this.selectedIndex,
178
- scrollOffset: this.scrollOffset,
179
- guardRows: 1,
180
- minRows: 4,
181
- appendWindowSummary: { dimColor: C.dim },
182
- },
183
- afterSections: [detailSection],
184
- });
185
- this.scrollOffset = resolvedPluginsSection.scrollOffset;
170
+ detailLines.push(buildPanelLine(width, [[' Up/Down move through discovered plugins', C.dim]]));
186
171
 
187
- const sections: PanelWorkspaceSection[] = [
188
- resolvedPluginsSection.section,
189
- detailSection,
190
- ];
191
- const lines = buildPanelWorkspace(width, height, {
172
+ return this.renderList(width, height, {
192
173
  title: 'Plugin Control Room',
193
- intro,
194
- sections,
195
- footerLines: [buildPanelLine(width, [[' Up/Down move through discovered plugins', C.dim]])],
196
- palette: C,
174
+ footer: detailLines,
197
175
  });
198
- while (lines.length < height) lines.push(createEmptyLine(width));
199
- return lines.slice(0, height);
200
176
  }
201
177
  }