@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
@@ -23,67 +23,23 @@ import type { FeatureFlag, FlagState } from '@pellux/goodvibes-sdk/platform/runt
23
23
  import type { McpRegistry } from '@pellux/goodvibes-sdk/platform/mcp/registry';
24
24
  import { logger } from '@pellux/goodvibes-sdk/platform/utils/logger';
25
25
  import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils/error-display';
26
-
27
- // ---------------------------------------------------------------------------
28
- // Types
29
- // ---------------------------------------------------------------------------
30
-
31
- export type SettingsCategory = 'display' | 'ui' | 'provider' | 'subscriptions' | 'behavior' | 'storage' | 'permissions' | 'mcp' | 'sandbox' | 'danger' | 'tools' | 'flags';
32
-
33
- export const SETTINGS_CATEGORIES: SettingsCategory[] = [
34
- 'display',
35
- 'ui',
36
- 'provider',
37
- 'subscriptions',
38
- 'behavior',
39
- 'storage',
40
- 'permissions',
41
- 'mcp',
42
- 'sandbox',
43
- 'danger',
44
- 'tools',
45
- 'flags',
46
- ];
47
-
48
- export interface SettingEntry {
49
- setting: ConfigSetting;
50
- currentValue: unknown;
51
- isDefault: boolean;
52
- effectiveSource?: 'default' | 'local' | 'synced' | 'managed';
53
- locked?: boolean;
54
- conflict?: boolean;
55
- sourceLabel?: string;
56
- lockReason?: string;
57
- }
58
-
59
- /** A single feature flag entry for the flags tab. */
60
- export interface FlagEntry {
61
- flag: FeatureFlag;
62
- state: FlagState;
63
- }
64
-
65
- export interface McpEntry {
66
- name: string;
67
- connected: boolean;
68
- role: 'general' | 'docs' | 'filesystem' | 'git' | 'database' | 'browser' | 'automation' | 'ops' | 'remote';
69
- trustMode: 'constrained' | 'ask-on-risk' | 'allow-all' | 'blocked';
70
- allowedPaths: string[];
71
- allowedHosts: string[];
72
- }
73
-
74
- export interface SubscriptionEntry {
75
- provider: string;
76
- state: 'active' | 'pending' | 'available';
77
- tokenType?: string;
78
- expiresAt?: number;
79
- oauthConfigured: boolean;
80
- activeRoute?: ProviderAuthRoute;
81
- preferredRoute?: ProviderAuthRoute;
82
- authFreshness?: ProviderAuthFreshness;
83
- routeReason?: string;
84
- issues?: string[];
85
- nextActions?: string[];
86
- }
26
+ import {
27
+ SETTINGS_CATEGORIES,
28
+ type FlagEntry,
29
+ type McpEntry,
30
+ type SettingEntry,
31
+ type SettingsCategory,
32
+ type SubscriptionEntry,
33
+ } from './settings-modal-types.ts';
34
+
35
+ export {
36
+ SETTINGS_CATEGORIES,
37
+ type FlagEntry,
38
+ type McpEntry,
39
+ type SettingEntry,
40
+ type SettingsCategory,
41
+ type SubscriptionEntry,
42
+ } from './settings-modal-types.ts';
87
43
 
88
44
  /**
89
45
  * Map a config key to the model picker target it should open, or null if the
@@ -151,6 +107,13 @@ export class SettingsModal {
151
107
  /** Provider subscription entries (populated when subscriptions tab is active). */
152
108
  public subscriptionEntries: SubscriptionEntry[] = [];
153
109
 
110
+ /**
111
+ * Set after a network-category save that touches controlPlane or httpListener
112
+ * config keys. Renderer reads this to display a transient restart notice.
113
+ * Cleared on next open() or close().
114
+ */
115
+ public lastSaveTriggeredRestart: 'control-plane' | 'http-listener' | 'web' | null = null;
116
+
154
117
  private configManager: ConfigManager | null = null;
155
118
  private featureFlagManager: FeatureFlagManager | null = null;
156
119
  private mcpRegistry: McpRegistry | null = null;
@@ -185,6 +148,7 @@ export class SettingsModal {
185
148
  this.editBuffer = '';
186
149
  this.mcpAllowAllConfirmationTarget = null;
187
150
  this.subscriptionLogoutConfirmationTarget = null;
151
+ this.lastSaveTriggeredRestart = null;
188
152
  this.active = true;
189
153
  }
190
154
 
@@ -194,6 +158,7 @@ export class SettingsModal {
194
158
  this.editBuffer = '';
195
159
  this.mcpAllowAllConfirmationTarget = null;
196
160
  this.subscriptionLogoutConfirmationTarget = null;
161
+ this.lastSaveTriggeredRestart = null;
197
162
  this.serviceRegistry = null;
198
163
  }
199
164
 
@@ -548,7 +513,15 @@ export class SettingsModal {
548
513
  for (const setting of CONFIG_SCHEMA) {
549
514
  const rawCat = setting.key.split('.')[0] as string;
550
515
  // Route helper.* settings into the tools group for unified display
551
- const cat = (rawCat === 'helper' ? 'tools' : rawCat) as SettingsCategory;
516
+ // Route controlPlane.* and httpListener.* into the network group
517
+ let cat: SettingsCategory;
518
+ if (rawCat === 'helper') {
519
+ cat = 'tools';
520
+ } else if (rawCat === 'controlPlane' || rawCat === 'httpListener' || rawCat === 'web') {
521
+ cat = 'network';
522
+ } else {
523
+ cat = rawCat as SettingsCategory;
524
+ }
552
525
  if (!this.groups.has(cat)) continue;
553
526
  const currentValue = configManager.get(setting.key as ConfigKey);
554
527
  const resolved = getResolvedSettingLookup(configManager, setting.key as ConfigKey)?.entry;
@@ -720,17 +693,62 @@ export class SettingsModal {
720
693
  /** Returns [] for the flags category (flags use flagEntries instead). */
721
694
  private _currentItems(): SettingEntry[] {
722
695
  if (this.currentCategory === 'flags' || this.currentCategory === 'mcp' || this.currentCategory === 'subscriptions') return [];
723
- return this.groups.get(this.currentCategory) ?? [];
696
+ const items = this.groups.get(this.currentCategory) ?? [];
697
+ if (this.currentCategory === 'network') {
698
+ // Hide host fields when the corresponding hostMode is not 'custom'
699
+ return items.filter(entry => {
700
+ if (entry.setting.key === 'controlPlane.host') {
701
+ const hostMode = this.configManager?.get('controlPlane.hostMode');
702
+ return hostMode === 'custom';
703
+ }
704
+ if (entry.setting.key === 'httpListener.host') {
705
+ const hostMode = this.configManager?.get('httpListener.hostMode');
706
+ return hostMode === 'custom';
707
+ }
708
+ if (entry.setting.key === 'web.host') {
709
+ const hostMode = this.configManager?.get('web.hostMode');
710
+ return hostMode === 'custom';
711
+ }
712
+ return true;
713
+ });
714
+ }
715
+ return items;
724
716
  }
725
717
 
726
718
  private _setValue(key: ConfigKey, value: unknown): void {
727
719
  if (!this.configManager) return;
720
+ // Diff previous value before writing — avoids false restart notices on no-op saves
721
+ const previousValue = this.configManager.get(key);
722
+ const isRestartKey = ['host', 'port', 'hostMode', 'enabled'].includes(key.split('.')[1] ?? '');
728
723
  try {
729
724
  this.configManager.setDynamic(key, value);
730
725
  // Update the cached entry in-place — avoids full schema re-scan on each edit
731
726
  const rawCat = key.split('.')[0] as string;
732
- // helper.* entries are stored in the tools group
733
- const cat = (rawCat === 'helper' ? 'tools' : rawCat) as SettingsCategory;
727
+ // Resolve the display category from the key prefix
728
+ let cat: SettingsCategory;
729
+ if (rawCat === 'helper') {
730
+ cat = 'tools';
731
+ } else if (rawCat === 'controlPlane') {
732
+ cat = 'network';
733
+ // SDK auto-restarts the daemon server on controlPlane binding changes
734
+ if (isRestartKey && previousValue !== value) {
735
+ this.lastSaveTriggeredRestart = 'control-plane';
736
+ }
737
+ } else if (rawCat === 'httpListener') {
738
+ cat = 'network';
739
+ // SDK auto-restarts the HTTP listener on binding changes
740
+ if (isRestartKey && previousValue !== value) {
741
+ this.lastSaveTriggeredRestart = 'http-listener';
742
+ }
743
+ } else if (rawCat === 'web') {
744
+ cat = 'network';
745
+ // SDK auto-restarts the web server on binding changes
746
+ if (isRestartKey && previousValue !== value) {
747
+ this.lastSaveTriggeredRestart = 'web';
748
+ }
749
+ } else {
750
+ cat = rawCat as SettingsCategory;
751
+ }
734
752
  const entries = this.groups.get(cat);
735
753
  if (entries) {
736
754
  const entry = entries.find(e => e.setting.key === key);
package/src/main.ts CHANGED
@@ -80,6 +80,49 @@ function resolveShellEntrypointOwnership(): ShellEntrypointOwnership {
80
80
  };
81
81
  }
82
82
 
83
+ // ---------------------------------------------------------------------------
84
+ // CLI flag parsing (TUI shell entry point)
85
+ // ---------------------------------------------------------------------------
86
+
87
+ type TuiCliFlags = {
88
+ readonly provider: string | undefined;
89
+ readonly model: string | undefined;
90
+ };
91
+
92
+ function parseTuiCliFlags(argv: readonly string[]): TuiCliFlags {
93
+ let provider: string | undefined;
94
+ let model: string | undefined;
95
+
96
+ for (let i = 0; i < argv.length; i++) {
97
+ const arg = argv[i];
98
+ if (arg === '--help' || arg === '-h') {
99
+ // eslint-disable-next-line no-console
100
+ console.log([
101
+ 'Usage: goodvibes [options]',
102
+ '',
103
+ 'Options:',
104
+ ' --provider <id> Override the provider from settings.json at startup',
105
+ ' --model <registryKey> Override the model from settings.json at startup',
106
+ ' Format: provider:modelId (e.g. inception:mercury-2)',
107
+ ' If provider:modelId format is used, --provider is inferred',
108
+ ' --help, -h Show this help message',
109
+ ].join('\n'));
110
+ process.exit(0);
111
+ }
112
+ if (arg === '--provider' && argv[i + 1] !== undefined) {
113
+ provider = argv[++i];
114
+ } else if (arg === '--model' && argv[i + 1] !== undefined) {
115
+ model = argv[++i];
116
+ // Infer provider from registryKey format (provider:modelId) if --provider not given
117
+ if (typeof model === 'string' && model.includes(':') && provider === undefined) {
118
+ provider = model.split(':')[0];
119
+ }
120
+ }
121
+ }
122
+
123
+ return { provider, model };
124
+ }
125
+
83
126
  async function main() {
84
127
  const stdout = process.stdout;
85
128
  const stdin = process.stdin;
@@ -95,6 +138,15 @@ async function main() {
95
138
  });
96
139
  new GlobalNetworkTransportInstaller().install(configManager);
97
140
 
141
+ // Apply CLI flags — override settings.json before the provider registry is constructed
142
+ const cliFlags = parseTuiCliFlags(process.argv.slice(2));
143
+ if (cliFlags.provider !== undefined) {
144
+ configManager.set('provider.provider', cliFlags.provider);
145
+ }
146
+ if (cliFlags.model !== undefined) {
147
+ configManager.set('provider.model', cliFlags.model);
148
+ }
149
+
98
150
  // ── Bootstrap all runtime subsystems ─────────────────────────────────────
99
151
  // bootstrapRuntime initializes all subsystems in dependency order and returns
100
152
  // a fully-wired BootstrapContext. main.ts owns terminal setup, the render loop,
@@ -97,7 +97,7 @@ export class AgentInspectorPanel extends BasePanel {
97
97
  private cursorIndex = 0;
98
98
 
99
99
  // Refresh timer (active only while panel is active)
100
- private refreshTimer: ReturnType<typeof setInterval> | null = null;
100
+ private refreshTimerId: ReturnType<typeof setInterval> | null = null;
101
101
 
102
102
  // Row cache — cleared on markDirty(), computed once per render cycle
103
103
  private _cachedRows: DisplayRow[] | null = null;
@@ -131,7 +131,7 @@ export class AgentInspectorPanel extends BasePanel {
131
131
  this.cursorIndex = 0;
132
132
  this.timeline = [];
133
133
  this.markDirty();
134
- this._refreshTimeline().catch(() => {});
134
+ this._refreshTimeline().catch((err) => { logger.debug('agent inspector timeline refresh failed', { err }); });
135
135
  }
136
136
 
137
137
  // -------------------------------------------------------------------------
@@ -149,6 +149,7 @@ export class AgentInspectorPanel extends BasePanel {
149
149
 
150
150
  override onDestroy(): void {
151
151
  this._stopRefresh();
152
+ super.onDestroy();
152
153
  }
153
154
 
154
155
  // -------------------------------------------------------------------------
@@ -461,16 +462,16 @@ export class AgentInspectorPanel extends BasePanel {
461
462
  }
462
463
 
463
464
  private _startRefresh(): void {
464
- if (this.refreshTimer) return;
465
- this.refreshTimer = setInterval(() => {
466
- this._refreshTimeline().catch(() => {});
467
- }, REFRESH_MS);
465
+ if (this.refreshTimerId) return;
466
+ this.refreshTimerId = this.registerTimer(setInterval(() => {
467
+ this._refreshTimeline().catch((err) => { logger.debug('agent inspector timeline refresh tick failed', { err }); });
468
+ }, REFRESH_MS));
468
469
  }
469
470
 
470
471
  private _stopRefresh(): void {
471
- if (this.refreshTimer) {
472
- clearInterval(this.refreshTimer);
473
- this.refreshTimer = null;
472
+ if (this.refreshTimerId) {
473
+ this.clearTimer(this.refreshTimerId);
474
+ this.refreshTimerId = null;
474
475
  }
475
476
  }
476
477
 
@@ -1,4 +1,4 @@
1
- import { readFileSync, existsSync, watch, type FSWatcher } from 'fs';
1
+ import { promises as fsPromises, watch, type FSWatcher } from 'fs';
2
2
  import type { Line } from '../types/grid.ts';
3
3
  import { createEmptyLine, createStyledCell } from '../types/grid.ts';
4
4
  import { ScrollableListPanel } from './scrollable-list-panel.ts';
@@ -61,6 +61,7 @@ export class AgentLogsPanel extends ScrollableListPanel<LogEntry> {
61
61
 
62
62
  constructor(agentEvents: UiEventFeed<AgentEvent>, private readonly deps: AgentLogsPanelDeps) {
63
63
  super('agent-logs', 'Agents', 'A', 'agent');
64
+ this.showSelectionGutter = true; // I5: non-color selection affordance
64
65
  this.agentEvents = agentEvents;
65
66
  this._refreshAgents();
66
67
  this._startPolling();
@@ -256,14 +257,22 @@ export class AgentLogsPanel extends ScrollableListPanel<LogEntry> {
256
257
  }
257
258
 
258
259
  private _pollCurrentAgent(): void {
260
+ void this._pollCurrentAgentAsync();
261
+ }
262
+
263
+ private async _pollCurrentAgentAsync(): Promise<void> {
259
264
  const agent = this._selectedAgent();
260
265
  if (!agent) return;
261
266
 
262
267
  const sessionFile = this._sessionFilePath(agent.id);
263
- if (!existsSync(sessionFile)) return;
268
+ try {
269
+ await fsPromises.access(sessionFile);
270
+ } catch {
271
+ return;
272
+ }
264
273
 
265
274
  try {
266
- const content = readFileSync(sessionFile, 'utf-8');
275
+ const content = await fsPromises.readFile(sessionFile, 'utf-8');
267
276
  if (content.length === this.lastFileSize) return;
268
277
  this.lastFileSize = content.length;
269
278
 
@@ -284,7 +293,9 @@ export class AgentLogsPanel extends ScrollableListPanel<LogEntry> {
284
293
  private _watchAgent(agentId: string): void {
285
294
  this._stopWatcher();
286
295
  const sessionFile = this._sessionFilePath(agentId);
287
- if (!existsSync(sessionFile)) return;
296
+ // Start watching immediately; the watcher setup itself is synchronous,
297
+ // the file-existence check is skipped to avoid blocking — if the file
298
+ // does not yet exist watch() will throw and we catch it below.
288
299
  try {
289
300
  this.fsWatcher = watch(sessionFile, () => {
290
301
  if (!this.paused) {
@@ -392,24 +403,33 @@ export class AgentLogsPanel extends ScrollableListPanel<LogEntry> {
392
403
  }
393
404
 
394
405
  private _reloadAgent(agent: AgentRecord): void {
406
+ void this._reloadAgentAsync(agent);
407
+ }
408
+
409
+ private async _reloadAgentAsync(agent: AgentRecord): Promise<void> {
395
410
  const sessionFile = this._sessionFilePath(agent.id);
396
- if (!existsSync(sessionFile)) {
411
+ try {
412
+ await fsPromises.access(sessionFile);
413
+ } catch {
397
414
  this.allEntries = [];
398
415
  this.filteredEntries = [];
399
416
  this.lastFileSize = 0;
417
+ this.markDirty();
400
418
  return;
401
419
  }
402
420
  try {
403
- const content = readFileSync(sessionFile, 'utf-8');
421
+ const content = await fsPromises.readFile(sessionFile, 'utf-8');
404
422
  this.lastFileSize = content.length;
405
423
  this.allEntries = parseAgentJsonl(content);
406
424
  this._applyFilter();
407
425
  if (this.autoFollow) {
408
426
  this.selectedIndex = Math.max(0, this.filteredEntries.length - 1);
409
427
  }
428
+ this.markDirty();
410
429
  } catch {
411
430
  this.allEntries = [];
412
431
  this.filteredEntries = [];
432
+ this.markDirty();
413
433
  }
414
434
  }
415
435
 
@@ -33,6 +33,7 @@ export class ApprovalPanel extends ScrollableListPanel<ApprovalRow> {
33
33
 
34
34
  public constructor(policyRuntimeState: Pick<PolicyRuntimeState, 'getSnapshot'>) {
35
35
  super('approval', 'Approval', 'A', 'monitoring');
36
+ this.showSelectionGutter = true; // I5: non-color selection affordance
36
37
  this.policyRuntimeState = policyRuntimeState;
37
38
  }
38
39
 
@@ -45,6 +45,7 @@ export class AutomationControlPanel extends ScrollableListPanel<AutomationRun> {
45
45
 
46
46
  public constructor(readModel?: UiReadModel<UiAutomationSnapshot>) {
47
47
  super('automation', 'Automation', 'M', 'monitoring');
48
+ this.showSelectionGutter = true; // I5: non-color selection affordance
48
49
  this.readModel = readModel;
49
50
  this.unsub = readModel ? readModel.subscribe(() => this.markDirty()) : null;
50
51
  }
@@ -11,14 +11,54 @@ export abstract class BasePanel implements Panel {
11
11
  public isPinned = false;
12
12
  protected readonly componentHealthMonitor?: ComponentHealthMonitor;
13
13
 
14
+ // -------------------------------------------------------------------------
15
+ // Timer registry
16
+ // -------------------------------------------------------------------------
17
+
18
+ /** All timers registered via registerTimer(). Cleared automatically on onDestroy(). */
19
+ private readonly _timers: Set<ReturnType<typeof setTimeout> | ReturnType<typeof setInterval>> = new Set();
20
+
21
+ /**
22
+ * Register a timer id (from setInterval or setTimeout) so it is
23
+ * automatically cleared when the panel is destroyed. Returns the id
24
+ * unchanged so the call can be chained inline:
25
+ *
26
+ * ```ts
27
+ * this.registerTimer(setInterval(() => this.refresh(), 5_000));
28
+ * ```
29
+ */
30
+ protected registerTimer<T extends ReturnType<typeof setTimeout> | ReturnType<typeof setInterval>>(id: T): T {
31
+ this._timers.add(id);
32
+ return id;
33
+ }
34
+
35
+ /**
36
+ * Clear a specific timer and remove it from the registry.
37
+ * Safe to call with an id that was never registered or already cleared.
38
+ */
39
+ protected clearTimer(id: ReturnType<typeof setTimeout> | ReturnType<typeof setInterval>): void {
40
+ clearInterval(id as ReturnType<typeof setInterval>);
41
+ this._timers.delete(id);
42
+ }
43
+
14
44
  // -------------------------------------------------------------------------
15
45
  // I2: Error surface slot
16
46
  // -------------------------------------------------------------------------
17
47
 
18
- /** Last error message to surface in the panel footer. Auto-cleared on next input. */
48
+ /**
49
+ * Last error message to surface in the panel footer.
50
+ * Auto-cleared on the next keystroke by `ScrollableListPanel.handleInput()` (and any
51
+ * subclass that calls `super.handleInput()` or manually calls `this.clearError()` at
52
+ * the start of its handler). BasePanel itself does NOT auto-clear — only subclasses
53
+ * that opt into the contract do.
54
+ */
19
55
  protected lastError: string | null = null;
20
56
 
21
- /** Set a transient error message. Triggers a re-render. */
57
+ /**
58
+ * Set a transient error message. Triggers a re-render.
59
+ * The error will be auto-cleared on the next keystroke if the panel extends
60
+ * `ScrollableListPanel` (which calls `clearError()` at the top of `handleInput()`).
61
+ */
22
62
  protected setError(msg: string): void {
23
63
  this.lastError = msg;
24
64
  this.needsRender = true;
@@ -66,6 +106,32 @@ export abstract class BasePanel implements Panel {
66
106
  this.needsRender = true;
67
107
  }
68
108
 
109
+ /**
110
+ * Run an async operation with the panel's loading spinner visible.
111
+ * The spinner is always cleared on completion, whether the operation succeeds or throws
112
+ * (uses try/finally). Rethrows any error so callers can handle it or forward to setError.
113
+ *
114
+ * @param label Optional label shown next to the spinner.
115
+ * @param fn The async work to run.
116
+ *
117
+ * @example
118
+ * ```ts
119
+ * try {
120
+ * await this.withLoading('Loading diff…', () => this.fetchDiff());
121
+ * } catch (err) {
122
+ * this.setError(summarizeError(err));
123
+ * }
124
+ * ```
125
+ */
126
+ protected async withLoading<T>(label: string | undefined, fn: () => Promise<T>): Promise<T> {
127
+ this.startLoading(label);
128
+ try {
129
+ return await fn();
130
+ } finally {
131
+ this.stopLoading();
132
+ }
133
+ }
134
+
69
135
  /**
70
136
  * Build a spinner Line for the loading state.
71
137
  * Returns null when loadingState is not 'loading'.
@@ -107,7 +173,17 @@ export abstract class BasePanel implements Panel {
107
173
 
108
174
  onActivate(): void { this.needsRender = true; }
109
175
  onDeactivate(): void {}
110
- onDestroy(): void {}
176
+
177
+ /**
178
+ * Called when the panel is permanently removed. Subclasses should call
179
+ * `super.onDestroy()` to ensure all registered timers are cleared.
180
+ */
181
+ onDestroy(): void {
182
+ for (const id of this._timers) {
183
+ clearInterval(id as ReturnType<typeof setInterval>);
184
+ }
185
+ this._timers.clear();
186
+ }
111
187
 
112
188
  abstract render(width: number, height: number): Line[];
113
189
 
@@ -146,4 +222,33 @@ export abstract class BasePanel implements Panel {
146
222
  protected reportRenderDuration(durationMs: number, now: number = Date.now()): void {
147
223
  this.componentHealthMonitor?.recordRender(this.id, durationMs, now);
148
224
  }
225
+
226
+ /** Cache of the most recent lines produced by trackedRender. */
227
+ private _lastTrackedLines: Line[] = [];
228
+
229
+ /**
230
+ * Wrap a render body with canRenderNow throttle check, wall-clock timing,
231
+ * and automatic reportRenderDuration.
232
+ *
233
+ * When throttled, returns the previously cached lines (stale but correctly
234
+ * sized) rather than empty lines, avoiding a flicker on every skipped frame.
235
+ *
236
+ * Usage:
237
+ * ```ts
238
+ * render(width: number, height: number): Line[] {
239
+ * return this.trackedRender(() => {
240
+ * // expensive render logic
241
+ * return lines;
242
+ * });
243
+ * }
244
+ * ```
245
+ */
246
+ protected trackedRender(fn: () => Line[]): Line[] {
247
+ if (!this.canRenderNow()) return this._lastTrackedLines;
248
+ const start = Date.now();
249
+ const lines = fn();
250
+ this.reportRenderDuration(Date.now() - start);
251
+ this._lastTrackedLines = lines;
252
+ return lines;
253
+ }
149
254
  }
@@ -32,6 +32,7 @@ export class CommunicationPanel extends ScrollableListPanel<CommunicationRecord>
32
32
 
33
33
  public constructor(readModel?: UiReadModel<UiCommunicationSnapshot>) {
34
34
  super('communication', 'Communication', 'Y', 'monitoring');
35
+ this.showSelectionGutter = true; // I5: non-color selection affordance
35
36
  this.readModel = readModel;
36
37
  this.unsub = readModel ? readModel.subscribe(() => this.markDirty()) : null;
37
38
  }
@@ -72,6 +72,7 @@ export class ContextVisualizerPanel extends BasePanel {
72
72
  }
73
73
 
74
74
  render(width: number, height: number): Line[] {
75
+ return this.trackedRender(() => {
75
76
  if (height <= 0 || width <= 0) return [];
76
77
 
77
78
  const input = this.snapshot.input;
@@ -131,6 +132,7 @@ export class ContextVisualizerPanel extends BasePanel {
131
132
  ],
132
133
  palette: DEFAULT_PANEL_PALETTE,
133
134
  });
135
+ });
134
136
  }
135
137
 
136
138
  private _renderBar(width: number, barWidth: number, input: number, limit: number): Line {
@@ -43,6 +43,7 @@ export class ControlPlanePanel extends ScrollableListPanel<ControlPlaneClient> {
43
43
 
44
44
  public constructor(private readonly readModel?: UiReadModel<UiControlPlaneSnapshot>) {
45
45
  super('control-plane', 'Control Plane', 'C', 'monitoring');
46
+ this.showSelectionGutter = true; // I5: non-color selection affordance
46
47
  this.unsub = readModel ? readModel.subscribe(() => this.markDirty()) : null;
47
48
  }
48
49
 
@@ -345,6 +345,7 @@ export class DiffPanel extends BasePanel {
345
345
  // -------------------------------------------------------------------------
346
346
 
347
347
  render(width: number, height: number): Line[] {
348
+ return this.trackedRender(() => {
348
349
  if (height <= 0 || width <= 0) return [];
349
350
 
350
351
  if (this.entries.length === 0) {
@@ -440,6 +441,7 @@ export class DiffPanel extends BasePanel {
440
441
  sections,
441
442
  footerLines: [this.renderStatusBar(width, entry)],
442
443
  });
444
+ });
443
445
  }
444
446
 
445
447
  // ── Tab bar ──────────────────────────────────────────────────────────────