@pellux/goodvibes-tui 0.18.23 → 0.19.1

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 (80) hide show
  1. package/CHANGELOG.md +71 -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 +8 -6
  6. package/src/core/orchestrator.ts +1 -1
  7. package/src/daemon/cli.ts +54 -0
  8. package/src/input/commands/diff-runtime.ts +6 -5
  9. package/src/input/commands/guidance-runtime.ts +1 -1
  10. package/src/input/commands/health-runtime.ts +2 -2
  11. package/src/input/commands/local-setup-review.ts +1 -1
  12. package/src/input/commands/session-content.ts +1 -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/handler.ts +8 -10
  17. package/src/input/model-picker.ts +6 -2
  18. package/src/input/panel-integration-actions.ts +2 -1
  19. package/src/input/settings-modal-types.ts +60 -0
  20. package/src/input/settings-modal.ts +83 -65
  21. package/src/main.ts +52 -0
  22. package/src/panels/agent-inspector-panel.ts +10 -9
  23. package/src/panels/agent-logs-panel.ts +26 -6
  24. package/src/panels/approval-panel.ts +1 -0
  25. package/src/panels/automation-control-panel.ts +1 -0
  26. package/src/panels/base-panel.ts +108 -3
  27. package/src/panels/communication-panel.ts +1 -0
  28. package/src/panels/context-visualizer-panel.ts +2 -0
  29. package/src/panels/control-plane-panel.ts +1 -0
  30. package/src/panels/diff-panel.ts +2 -0
  31. package/src/panels/file-explorer-panel.ts +51 -31
  32. package/src/panels/file-preview-panel.ts +57 -35
  33. package/src/panels/git-panel.ts +12 -13
  34. package/src/panels/hooks-panel.ts +3 -1
  35. package/src/panels/incident-review-panel.ts +4 -2
  36. package/src/panels/knowledge-panel.ts +75 -107
  37. package/src/panels/local-auth-panel.ts +1 -0
  38. package/src/panels/marketplace-panel.ts +51 -69
  39. package/src/panels/mcp-panel.ts +3 -1
  40. package/src/panels/memory-panel.ts +90 -158
  41. package/src/panels/ops-control-panel.ts +1 -0
  42. package/src/panels/orchestration-panel.ts +70 -51
  43. package/src/panels/panel-list-panel.ts +5 -4
  44. package/src/panels/panel-manager.ts +3 -0
  45. package/src/panels/plan-dashboard-panel.ts +2 -0
  46. package/src/panels/plugins-panel.ts +1 -0
  47. package/src/panels/polish.ts +51 -2
  48. package/src/panels/provider-accounts-panel.ts +1 -0
  49. package/src/panels/provider-health-panel.ts +6 -8
  50. package/src/panels/routes-panel.ts +3 -1
  51. package/src/panels/schedule-panel.ts +7 -6
  52. package/src/panels/scrollable-list-panel.ts +19 -2
  53. package/src/panels/security-panel.ts +17 -15
  54. package/src/panels/services-panel.ts +6 -4
  55. package/src/panels/session-browser-panel.ts +19 -18
  56. package/src/panels/settings-sync-panel.ts +3 -1
  57. package/src/panels/skills-panel.ts +114 -230
  58. package/src/panels/subscription-panel.ts +1 -0
  59. package/src/panels/system-messages-panel.ts +147 -141
  60. package/src/panels/tasks-panel.ts +1 -0
  61. package/src/panels/token-budget-panel.ts +2 -0
  62. package/src/panels/watchers-panel.ts +1 -0
  63. package/src/panels/worktree-panel.ts +1 -0
  64. package/src/panels/wrfc-panel.ts +2 -0
  65. package/src/renderer/agent-detail-modal.ts +2 -2
  66. package/src/renderer/ansi-sanitize.ts +76 -0
  67. package/src/renderer/buffer.ts +12 -1
  68. package/src/renderer/help-overlay.ts +14 -3
  69. package/src/renderer/model-picker-overlay.ts +9 -2
  70. package/src/renderer/settings-modal-helpers.ts +27 -0
  71. package/src/renderer/settings-modal.ts +18 -1
  72. package/src/renderer/status-glyphs.ts +21 -0
  73. package/src/renderer/status-token.ts +4 -8
  74. package/src/renderer/tool-call.ts +4 -3
  75. package/src/runtime/bootstrap-core.ts +1 -1
  76. package/src/runtime/bootstrap-hook-bridge.ts +1 -1
  77. package/src/runtime/bootstrap.ts +7 -8
  78. package/src/runtime/diagnostics/panels/policy.ts +2 -1
  79. package/src/shell/ui-openers.ts +44 -3
  80. 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
  }
@@ -76,6 +76,7 @@ export class OpsControlPanel extends ScrollableListPanel<OpsAuditEntry> {
76
76
 
77
77
  public constructor(eventFeed: UiEventFeed<OpsEvent>) {
78
78
  super('ops-control', 'Ops Control', 'Q', 'agent');
79
+ this.showSelectionGutter = true; // I5: non-color selection affordance
79
80
  this._opsPanel = new OpsPanel(eventFeed);
80
81
  this._unsub = this._opsPanel.subscribe(() => this.markDirty());
81
82
  }
@@ -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
  }
@@ -216,6 +216,7 @@ export class PanelManager {
216
216
  p.activeIndex = (p.activeIndex - 1 + p.panels.length) % p.panels.length;
217
217
  const newPanel = p.panels[p.activeIndex];
218
218
  if (newPanel) newPanel.onActivate();
219
+ this._invalidateWorkspaceTabs();
219
220
  }
220
221
 
221
222
  activateByIndex(index: number): void {
@@ -259,6 +260,7 @@ export class PanelManager {
259
260
  togglePaneFocus(): void {
260
261
  if (!this._bottomPaneVisible || this.bottomPane.panels.length === 0) return;
261
262
  this._focusedPane = this._focusedPane === 'top' ? 'bottom' : 'top';
263
+ this._invalidateWorkspaceTabs();
262
264
  }
263
265
 
264
266
  // -------------------------------------------------------------------------
@@ -457,6 +459,7 @@ export class PanelManager {
457
459
  this._focusedPane = 'top';
458
460
  this._bottomPaneVisible = false;
459
461
  this._visible = false;
462
+ this._invalidateWorkspaceTabs();
460
463
  }
461
464
 
462
465
  // -------------------------------------------------------------------------
@@ -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
  // --------------------------------------------------------------------------
@@ -52,6 +52,7 @@ export class PluginsPanel extends ScrollableListPanel<PluginStatus> {
52
52
 
53
53
  public constructor(manager: PluginManagerObserver) {
54
54
  super('plugins', 'Plugins', 'P', 'monitoring');
55
+ this.showSelectionGutter = true; // I5: non-color selection affordance
55
56
  this.manager = manager;
56
57
  this.unsub = manager.subscribe(() => this.markDirty());
57
58
  }