@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,7 +1,16 @@
1
+ /**
2
+ * OrchestrationPanel — displays task graphs, node contracts, recursion guards,
3
+ * and WRFC-visible orchestration state.
4
+ *
5
+ * Migrated (Wave B2): extends ScrollableListPanel<OrchestrationGraphRecord>.
6
+ * Navigation (up/down/j/k) is handled by the base class.
7
+ */
8
+
1
9
  import type { Line } from '../types/grid.ts';
2
10
  import { createEmptyLine } from '../types/grid.ts';
3
- import { BasePanel } from './base-panel.ts';
11
+ import { ScrollableListPanel } from './scrollable-list-panel.ts';
4
12
  import type { UiOrchestrationSnapshot, UiReadModel } from '../runtime/ui-read-models.ts';
13
+ import type { OrchestrationGraphRecord } from '@pellux/goodvibes-sdk/platform/runtime/store/domains/orchestration';
5
14
  import {
6
15
  buildEmptyState,
7
16
  buildGuidanceLine,
@@ -10,11 +19,12 @@ import {
10
19
  buildPanelWorkspace,
11
20
  resolveScrollablePanelSection,
12
21
  DEFAULT_PANEL_PALETTE,
22
+ extendPalette,
23
+ type PanelPalette,
13
24
  type PanelWorkspaceSection,
14
25
  } from './polish.ts';
15
26
 
16
- const C = {
17
- ...DEFAULT_PANEL_PALETTE,
27
+ const C = extendPalette(DEFAULT_PANEL_PALETTE, {
18
28
  header: '#94a3b8',
19
29
  headerBg: '#1e293b',
20
30
  running: '#22c55e',
@@ -23,30 +33,22 @@ const C = {
23
33
  failed: '#ef4444',
24
34
  completed: '#a78bfa',
25
35
  selectBg: '#0f172a',
26
- } as const;
36
+ } as const);
27
37
 
28
38
  function statusColor(status: string): string {
29
39
  switch (status) {
30
- case 'ready':
31
- return C.ready;
32
- case 'running':
33
- return C.running;
34
- case 'blocked':
35
- return C.blocked;
36
- case 'failed':
37
- return C.failed;
38
- case 'completed':
39
- return C.completed;
40
- default:
41
- return C.dim;
40
+ case 'ready': return C.ready;
41
+ case 'running': return C.running;
42
+ case 'blocked': return C.blocked;
43
+ case 'failed': return C.failed;
44
+ case 'completed': return C.completed;
45
+ default: return C.dim;
42
46
  }
43
47
  }
44
48
 
45
- export class OrchestrationPanel extends BasePanel {
49
+ export class OrchestrationPanel extends ScrollableListPanel<OrchestrationGraphRecord> {
46
50
  private readonly readModel?: UiReadModel<UiOrchestrationSnapshot>;
47
51
  private readonly unsub: (() => void) | null;
48
- private selectedIndex = 0;
49
- private scrollOffset = 0;
50
52
 
51
53
  public constructor(readModel?: UiReadModel<UiOrchestrationSnapshot>) {
52
54
  super('orchestration', 'Orchestration', 'Q', 'monitoring');
@@ -58,32 +60,52 @@ export class OrchestrationPanel extends BasePanel {
58
60
  this.unsub?.();
59
61
  }
60
62
 
61
- public handleInput(key: string): boolean {
62
- const graphs = this._graphs();
63
- if (graphs.length === 0) return false;
64
- if (key === 'up' || key === 'k') {
65
- this.selectedIndex = Math.max(0, this.selectedIndex - 1);
66
- this.markDirty();
67
- return true;
68
- }
69
- if (key === 'down' || key === 'j') {
70
- this.selectedIndex = Math.min(graphs.length - 1, this.selectedIndex + 1);
71
- this.markDirty();
72
- return true;
73
- }
74
- return false;
75
- }
63
+ // ---------------------------------------------------------------------------
64
+ // ScrollableListPanel contract
65
+ // ---------------------------------------------------------------------------
76
66
 
77
- private _graphs() {
67
+ protected getItems(): readonly OrchestrationGraphRecord[] {
78
68
  if (!this.readModel) return [];
79
69
  return [...this.readModel.getSnapshot().graphs].sort((a, b) => b.createdAt - a.createdAt);
80
70
  }
81
71
 
72
+ protected renderItem(
73
+ graph: OrchestrationGraphRecord,
74
+ index: number,
75
+ selected: boolean,
76
+ width: number,
77
+ ): Line {
78
+ const bg = selected ? C.selectBg : undefined;
79
+ return buildPanelLine(width, [
80
+ [' ', C.label, bg],
81
+ [graph.status.padEnd(10), statusColor(graph.status), bg],
82
+ [` ${graph.mode.padEnd(17)}`, C.value, bg],
83
+ [` ${graph.id.slice(0, 8)} `, C.dim, bg],
84
+ [graph.title.slice(0, Math.max(0, width - 39)), C.value, bg],
85
+ ]);
86
+ }
87
+
88
+ protected override getPalette(): PanelPalette {
89
+ return C;
90
+ }
91
+
92
+ protected override getEmptyStateMessage(): string {
93
+ return ' No orchestration graphs recorded yet.';
94
+ }
95
+
96
+ // ---------------------------------------------------------------------------
97
+ // Input — base class handles all navigation (up/down/j/k/pageup/pagedown/g/G)
98
+ // ---------------------------------------------------------------------------
99
+
100
+ // ---------------------------------------------------------------------------
101
+ // Render — multi-section layout (posture + scrollable graphs + detail + nodes)
102
+ // ---------------------------------------------------------------------------
103
+
82
104
  public render(width: number, height: number): Line[] {
83
- this.needsRender = false;
84
105
  const intro = 'Task graphs, node contracts, recursion guards, and WRFC-visible orchestration state.';
85
106
 
86
107
  if (!this.readModel) {
108
+ this.needsRender = false;
87
109
  const workspace = buildPanelWorkspace(width, height, {
88
110
  title: 'Orchestration Control Room',
89
111
  intro,
@@ -103,7 +125,7 @@ export class OrchestrationPanel extends BasePanel {
103
125
  }
104
126
 
105
127
  const snapshot = this.readModel.getSnapshot();
106
- const graphs = this._graphs();
128
+ const graphs = this.getItems();
107
129
  const postureLines = [
108
130
  buildKeyValueLine(width, [
109
131
  { label: 'graphs', value: String(snapshot.totalGraphs), valueColor: snapshot.totalGraphs > 0 ? C.value : C.dim },
@@ -115,6 +137,7 @@ export class OrchestrationPanel extends BasePanel {
115
137
  buildGuidanceLine(width, '/orchestration', 'inspect recursive execution posture, graph health, and node contract flow', C),
116
138
  ];
117
139
  if (graphs.length === 0) {
140
+ this.needsRender = false;
118
141
  const workspace = buildPanelWorkspace(width, height, {
119
142
  title: 'Orchestration Control Room',
120
143
  intro,
@@ -124,7 +147,7 @@ export class OrchestrationPanel extends BasePanel {
124
147
  ...postureLines,
125
148
  ...buildEmptyState(
126
149
  width,
127
- ' No orchestration graphs recorded yet.',
150
+ this.getEmptyStateMessage(),
128
151
  'Graphs, nodes, child contracts, and recursion guard trips will appear here as orchestration starts.',
129
152
  [
130
153
  { command: '/tasks', summary: 'create or inspect task flows that feed orchestration graphs' },
@@ -140,7 +163,7 @@ export class OrchestrationPanel extends BasePanel {
140
163
  return workspace;
141
164
  }
142
165
 
143
- this.selectedIndex = Math.min(this.selectedIndex, graphs.length - 1);
166
+ this.clampSelection();
144
167
  const selected = graphs[this.selectedIndex]!;
145
168
  const detailLines: Line[] = [
146
169
  buildPanelLine(width, [
@@ -208,6 +231,10 @@ export class OrchestrationPanel extends BasePanel {
208
231
  ]);
209
232
  });
210
233
 
234
+ const scrollableLines: Line[] = graphs.map((graph, index) =>
235
+ this.renderItem(graph, index, index === this.selectedIndex, width),
236
+ );
237
+
211
238
  const postureSection: PanelWorkspaceSection = { title: 'Orchestration posture', lines: postureLines };
212
239
  const selectedGraphSection: PanelWorkspaceSection = { title: 'Selected Graph', lines: detailLines };
213
240
  const nodesSection: PanelWorkspaceSection = { title: 'Nodes', lines: nodeLines };
@@ -217,24 +244,15 @@ export class OrchestrationPanel extends BasePanel {
217
244
  beforeSections: [postureSection],
218
245
  section: {
219
246
  title: 'Graphs',
220
- scrollableLines: graphs.map((graph, absolute) => {
221
- const bg = absolute === this.selectedIndex ? C.selectBg : undefined;
222
- return buildPanelLine(width, [
223
- [' ', C.label, bg],
224
- [graph.status.padEnd(10), statusColor(graph.status), bg],
225
- [` ${graph.mode.padEnd(17)}`, C.value, bg],
226
- [` ${graph.id.slice(0, 8)} `, C.dim, bg],
227
- [graph.title.slice(0, Math.max(0, width - 39)), C.value, bg],
228
- ]);
229
- }),
247
+ scrollableLines,
230
248
  selectedIndex: this.selectedIndex,
231
- scrollOffset: this.scrollOffset,
249
+ scrollOffset: this.scrollStart,
232
250
  minRows: 4,
233
251
  appendWindowSummary: { dimColor: C.dim },
234
252
  },
235
253
  afterSections: [selectedGraphSection, nodesSection],
236
254
  });
237
- this.scrollOffset = graphsSection.scrollOffset;
255
+ this.scrollStart = graphsSection.scrollOffset;
238
256
 
239
257
  const sections: PanelWorkspaceSection[] = [
240
258
  postureSection,
@@ -242,6 +260,7 @@ export class OrchestrationPanel extends BasePanel {
242
260
  selectedGraphSection,
243
261
  nodesSection,
244
262
  ];
263
+ this.needsRender = false;
245
264
  const lines = buildPanelWorkspace(width, height, {
246
265
  title: 'Orchestration Control Room',
247
266
  intro,
@@ -39,6 +39,7 @@ import {
39
39
  isPanelSearchCommit,
40
40
  isPanelSearchPrintable,
41
41
  } from './search-focus.ts';
42
+ import { logger } from '@pellux/goodvibes-sdk/platform/utils/logger';
42
43
 
43
44
  // ── Colour palette ────────────────────────────────────────────────────────────
44
45
  const C = {
@@ -163,7 +164,7 @@ export class PanelListPanel extends BasePanel {
163
164
  try {
164
165
  this.panelManager.open(selectedPanel.reg.id);
165
166
  } catch (err) {
166
- console.debug('[panel-list] failed to open panel:', err);
167
+ logger.warn(`[panel-list] failed to open panel: ${err}`);
167
168
  }
168
169
  }
169
170
  this.markDirty();
@@ -205,7 +206,7 @@ export class PanelListPanel extends BasePanel {
205
206
  try {
206
207
  this.panelManager.open(selectedPanel.reg.id);
207
208
  } catch (err) {
208
- console.debug('[panel-list] failed to open panel:', err);
209
+ logger.warn(`[panel-list] failed to open panel: ${err}`);
209
210
  }
210
211
  this.markDirty();
211
212
  }
@@ -221,7 +222,7 @@ export class PanelListPanel extends BasePanel {
221
222
  pm.open(selectedPanel.reg.id, pane);
222
223
  pm.show();
223
224
  } catch (err) {
224
- console.debug('[panel-list] failed to place panel:', err);
225
+ logger.warn(`[panel-list] failed to place panel: ${err}`);
225
226
  }
226
227
  this.markDirty();
227
228
  }
@@ -234,7 +235,7 @@ export class PanelListPanel extends BasePanel {
234
235
  try {
235
236
  this.panelManager.moveToOtherPane(selectedPanel.reg.id);
236
237
  } catch (err) {
237
- console.debug('[panel-list] failed to move panel:', err);
238
+ logger.warn(`[panel-list] failed to move panel: ${err}`);
238
239
  }
239
240
  this.markDirty();
240
241
  }
@@ -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 {
@@ -205,6 +216,7 @@ export class PanelManager {
205
216
  p.activeIndex = (p.activeIndex - 1 + p.panels.length) % p.panels.length;
206
217
  const newPanel = p.panels[p.activeIndex];
207
218
  if (newPanel) newPanel.onActivate();
219
+ this._invalidateWorkspaceTabs();
208
220
  }
209
221
 
210
222
  activateByIndex(index: number): void {
@@ -216,6 +228,7 @@ export class PanelManager {
216
228
  p.activeIndex = index;
217
229
  const newPanel = p.panels[p.activeIndex];
218
230
  if (newPanel) newPanel.onActivate();
231
+ this._invalidateWorkspaceTabs();
219
232
  }
220
233
 
221
234
  activateById(panelId: string): void {
@@ -231,6 +244,7 @@ export class PanelManager {
231
244
  focusPane(pane: 'top' | 'bottom'): void {
232
245
  if (pane === 'bottom' && !this._bottomPaneVisible) return;
233
246
  this._focusedPane = pane;
247
+ this._invalidateWorkspaceTabs();
234
248
  }
235
249
 
236
250
  getFocusedPane(): 'top' | 'bottom' {
@@ -246,6 +260,7 @@ export class PanelManager {
246
260
  togglePaneFocus(): void {
247
261
  if (!this._bottomPaneVisible || this.bottomPane.panels.length === 0) return;
248
262
  this._focusedPane = this._focusedPane === 'top' ? 'bottom' : 'top';
263
+ this._invalidateWorkspaceTabs();
249
264
  }
250
265
 
251
266
  // -------------------------------------------------------------------------
@@ -253,6 +268,7 @@ export class PanelManager {
253
268
  // -------------------------------------------------------------------------
254
269
 
255
270
  toggleBottomPane(): void {
271
+ this._invalidateWorkspaceTabs();
256
272
  if (this._bottomPaneVisible) {
257
273
  this._bottomPaneVisible = false;
258
274
  if (this._focusedPane === 'bottom') this._focusedPane = 'top';
@@ -329,7 +345,8 @@ export class PanelManager {
329
345
  return this._findPaneOf(panelId);
330
346
  }
331
347
 
332
- getWorkspaceTabs(): WorkspaceTab[] {
348
+ getWorkspaceTabs(): readonly WorkspaceTab[] {
349
+ if (this._cachedWorkspaceTabs !== null) return this._cachedWorkspaceTabs;
333
350
  const focusedPanelId = this.getActivePanel()?.id;
334
351
  const topTabs = this.topPane.panels.map((panel) => ({
335
352
  id: panel.id,
@@ -347,7 +364,9 @@ export class PanelManager {
347
364
  active: panel.id === focusedPanelId,
348
365
  focused: panel.id === focusedPanelId,
349
366
  }));
350
- return [...topTabs, ...bottomTabs];
367
+ const tabs = [...topTabs, ...bottomTabs] as WorkspaceTab[];
368
+ this._cachedWorkspaceTabs = tabs;
369
+ return tabs;
351
370
  }
352
371
 
353
372
  activateWorkspaceIndex(index: number): void {
@@ -357,6 +376,7 @@ export class PanelManager {
357
376
  this._focusedPane = tab.pane;
358
377
  if (tab.pane === 'bottom') this._bottomPaneVisible = true;
359
378
  this._activateByIdInPane(tab.id, tab.pane);
379
+ this._invalidateWorkspaceTabs();
360
380
  }
361
381
 
362
382
  // -------------------------------------------------------------------------
@@ -439,6 +459,7 @@ export class PanelManager {
439
459
  this._focusedPane = 'top';
440
460
  this._bottomPaneVisible = false;
441
461
  this._visible = false;
462
+ this._invalidateWorkspaceTabs();
442
463
  }
443
464
 
444
465
  // -------------------------------------------------------------------------
@@ -491,6 +512,7 @@ export class PanelManager {
491
512
  this._bottomPaneVisible = true;
492
513
  }
493
514
  this._focusedPane = dstPaneName;
515
+ this._invalidateWorkspaceTabs();
494
516
  }
495
517
 
496
518
  private _cycleWorkspaceTab(direction: 1 | -1): void {
@@ -533,6 +555,7 @@ export class PanelManager {
533
555
  p.activeIndex = index;
534
556
  const newPanel = p.panels[p.activeIndex];
535
557
  if (newPanel) newPanel.onActivate();
558
+ this._invalidateWorkspaceTabs();
536
559
  }
537
560
  }
538
561
  }
@@ -80,6 +80,7 @@ export class PlanDashboardPanel extends BasePanel {
80
80
  // --------------------------------------------------------------------------
81
81
 
82
82
  render(width: number, height: number): Line[] {
83
+ return this.trackedRender(() => {
83
84
  const plan = this.planManager.getActive();
84
85
  if (!plan) {
85
86
  return buildPanelWorkspace(width, height, {
@@ -100,6 +101,7 @@ export class PlanDashboardPanel extends BasePanel {
100
101
  });
101
102
  }
102
103
  return this.renderPlan(plan, width, height);
104
+ });
103
105
  }
104
106
 
105
107
  // --------------------------------------------------------------------------
@@ -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,14 +46,13 @@ 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');
55
+ this.showSelectionGutter = true; // I5: non-color selection affordance
58
56
  this.manager = manager;
59
57
  this.unsub = manager.subscribe(() => this.markDirty());
60
58
  }
@@ -68,26 +66,39 @@ export class PluginsPanel extends BasePanel {
68
66
  this.unsub?.();
69
67
  }
70
68
 
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;
69
+ protected override getPalette(): PanelPalette {
70
+ return C;
71
+ }
72
+
73
+ protected getItems(): readonly PluginStatus[] {
74
+ return this.manager.list();
75
+ }
76
+
77
+ protected renderItem(plugin: PluginStatus, _index: number, selected: boolean, width: number): Line {
78
+ const bg = selected ? C.selectBg : undefined;
79
+ return buildPanelLine(width, [
80
+ [' ', C.label, bg],
81
+ [plugin.name.padEnd(22), C.value, bg],
82
+ [` ${statusLabel(plugin).padEnd(11)}`, statusColor(plugin), bg],
83
+ [` ${plugin.trustTier.toUpperCase().padEnd(10)}`, trustColor(plugin.trustTier), bg],
84
+ [` ${plugin.version}`, C.dim, bg],
85
+ ]);
86
+ }
87
+
88
+ protected override getEmptyStateMessage(): string {
89
+ return ' No plugins discovered.';
90
+ }
91
+
92
+ protected override getEmptyStateActions(): Array<{ command: string; summary: string }> {
93
+ return [
94
+ { command: '/plugin list', summary: 'inspect plugin discovery paths and current registry state' },
95
+ { command: '/marketplace', summary: 'review curated ecosystem entries and provenance posture' },
96
+ ];
85
97
  }
86
98
 
87
99
  public render(width: number, height: number): Line[] {
88
- this.needsRender = false;
89
100
  const intro = 'Plugin trust, capabilities, signatures, and quarantine posture for the active ecosystem surface.';
90
- const plugins = this.manager.list();
101
+ const plugins = this.getItems();
91
102
 
92
103
  if (plugins.length === 0) {
93
104
  const workspace = buildPanelWorkspace(width, height, {
@@ -111,7 +122,7 @@ export class PluginsPanel extends BasePanel {
111
122
  return workspace;
112
123
  }
113
124
 
114
- this.selectedIndex = Math.min(this.selectedIndex, plugins.length - 1);
125
+ this.clampSelection();
115
126
  const selected = plugins[this.selectedIndex]!;
116
127
  const selectedCaps = this.manager.capabilities(selected.name);
117
128
  const trustRecord = this.manager.getTrustRecord(selected.name);
@@ -157,45 +168,11 @@ export class PluginsPanel extends BasePanel {
157
168
  }
158
169
 
159
170
  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;
171
+ detailLines.push(buildPanelLine(width, [[' Up/Down move through discovered plugins', C.dim]]));
186
172
 
187
- const sections: PanelWorkspaceSection[] = [
188
- resolvedPluginsSection.section,
189
- detailSection,
190
- ];
191
- const lines = buildPanelWorkspace(width, height, {
173
+ return this.renderList(width, height, {
192
174
  title: 'Plugin Control Room',
193
- intro,
194
- sections,
195
- footerLines: [buildPanelLine(width, [[' Up/Down move through discovered plugins', C.dim]])],
196
- palette: C,
175
+ footer: detailLines,
197
176
  });
198
- while (lines.length < height) lines.push(createEmptyLine(width));
199
- return lines.slice(0, height);
200
177
  }
201
178
  }
@@ -3,6 +3,7 @@ import { createEmptyLine, createStyledCell } from '../types/grid.ts';
3
3
  import { getDisplayWidth, wrapText } from '../utils/terminal-width.ts';
4
4
  import { getSurfaceContentRows, getTrackedVisibleWindow, getVisibleWindow, type VisibleWindow } from '../renderer/surface-layout.ts';
5
5
  import { GLYPHS, UI_TONES } from '../renderer/ui-primitives.ts';
6
+ import { type StatusState, STATE_GLYPHS } from '../renderer/status-glyphs.ts';
6
7
 
7
8
  export interface PanelPalette {
8
9
  readonly label: string;
@@ -42,13 +43,36 @@ export const DEFAULT_PANEL_PALETTE: Readonly<Required<PanelPalette>> = {
42
43
  selectBg: UI_TONES.bg.selected,
43
44
  } as const;
44
45
 
46
+ /**
47
+ * Extend the base panel palette with domain-specific colors.
48
+ *
49
+ * Convention: raw hex colors may only live inside a palette constant declared
50
+ * at the top of a panel file, not inline in render calls.
51
+ *
52
+ * @example
53
+ * ```ts
54
+ * const C = extendPalette(DEFAULT_PANEL_PALETTE, {
55
+ * decision: '#38bdf8',
56
+ * incident: '#ef4444',
57
+ * });
58
+ * ```
59
+ */
60
+ export function extendPalette<T extends Record<string, string>>(
61
+ base: typeof DEFAULT_PANEL_PALETTE,
62
+ extras: T,
63
+ ): typeof DEFAULT_PANEL_PALETTE & T {
64
+ return { ...base, ...extras };
65
+ }
66
+
45
67
  export function buildPanelLine(
46
68
  width: number,
47
- segments: Array<[string, string, string?]>,
69
+ segments: Array<StyledPanelSegment | [string, string, string?]>,
48
70
  ): Line {
49
71
  return buildStyledPanelLine(
50
72
  width,
51
- segments.map(([text, fg, bg]) => ({ text, fg, bg })),
73
+ segments.map((seg) =>
74
+ Array.isArray(seg) ? { text: seg[0], fg: seg[1], bg: seg[2] } : seg,
75
+ ),
52
76
  );
53
77
  }
54
78
 
@@ -256,6 +280,31 @@ export function buildSearchInputLine(
256
280
  ], { fillBg: bg });
257
281
  }
258
282
 
283
+ /**
284
+ * Build a status pill segment (glyph + label) for use in buildPanelLine.
285
+ *
286
+ * Returns StyledPanelSegment[] — spread directly into a buildPanelLine segments
287
+ * array:
288
+ * buildPanelLine(width, [[' count ', C.label], ...buildStatusPill('bad', '3')])
289
+ *
290
+ * Convention: raw hex colors may only live inside a palette constant declared at
291
+ * the top of a panel file; buildStatusPill derives its color from the palette.
292
+ */
293
+ export function buildStatusPill(
294
+ state: StatusState,
295
+ label: string,
296
+ opts?: { glyph?: string; bg?: string; count?: number },
297
+ ): StyledPanelSegment[] {
298
+ const glyph = opts?.glyph ?? STATE_GLYPHS[state];
299
+ const color = state === 'good' ? DEFAULT_PANEL_PALETTE.good
300
+ : state === 'warn' ? DEFAULT_PANEL_PALETTE.warn
301
+ : state === 'bad' ? DEFAULT_PANEL_PALETTE.bad
302
+ : DEFAULT_PANEL_PALETTE.info;
303
+ const bg = opts?.bg;
304
+ const text = opts?.count !== undefined ? `${glyph} ${label} (${opts.count})` : `${glyph} ${label}`;
305
+ return [{ text, fg: color, bg }];
306
+ }
307
+
259
308
  export function buildStatPill(
260
309
  label: string,
261
310
  value: string,
@@ -36,6 +36,7 @@ export class ProviderAccountsPanel extends ScrollableListPanel<ProviderAccountRe
36
36
 
37
37
  public constructor(deps: ProviderAccountsPanelDeps) {
38
38
  super('accounts', 'Accounts', 'Q', 'monitoring');
39
+ this.showSelectionGutter = true; // I5: non-color selection affordance
39
40
  this.providerAccounts = deps.providerAccounts;
40
41
  void this.refresh();
41
42
  }