@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
@@ -81,6 +81,7 @@ import {
81
81
  } from './handler-picker-routes.ts';
82
82
  import { handleGlobalShortcutToken } from './handler-shortcuts.ts';
83
83
  import { feedInputTokens } from './handler-feed.ts';
84
+ import { buildInitialFeedContext, syncFeedContextMutableFields } from './feed-context-factory.ts';
84
85
  import { handlePanelIntegrationAction as runPanelIntegrationAction } from './panel-integration-actions.ts';
85
86
  import type { Panel } from '../panels/types.ts';
86
87
  import type { UiRuntimeServices } from '../runtime/ui-services.ts';
@@ -112,6 +113,8 @@ export class InputHandler {
112
113
  private pasteRegistry = new Map<string, string>();
113
114
  private nextPasteId = 1;
114
115
  private lastCtrlCTime = 0;
116
+ /** Long-lived feed context — reused across every feed() call to avoid per-keystroke allocation. */
117
+ private feedContext!: import('./handler-feed.ts').InputFeedContext;
115
118
  private commandRegistry: CommandRegistry | null = null;
116
119
  private commandContext: CommandContext | undefined = undefined;
117
120
  public autocomplete: AutocompleteEngine | null = null;
@@ -208,32 +211,108 @@ export class InputHandler {
208
211
  this.bookmarkModal = new BookmarkModal(uiServices.shell.bookmarkManager);
209
212
  this.sessionPickerModal = new SessionPickerModal(uiServices.sessions.sessionManager);
210
213
  this.profilePickerModal = new ProfilePickerModal(uiServices.shell.profileManager);
214
+ this.initFeedContext();
211
215
  }
212
216
 
213
217
  /**
214
- * setHistory - Wire in the InputHistory instance.
215
- * Optional; if not set, history navigation is disabled.
218
+ * initFeedContext — Build the long-lived InputFeedContext once via factory.
219
+ * See feed-context-factory.ts for full field documentation.
216
220
  */
217
- public setHistory(history: InputHistory): void {
218
- this.inputHistory = history;
221
+ private initFeedContext(): void {
222
+ this.feedContext = buildInitialFeedContext(
223
+ {
224
+ prompt: this.prompt, cursorPos: this.cursorPos, commandMode: this.commandMode,
225
+ panelFocused: this.panelFocused, indicatorFocused: this.indicatorFocused,
226
+ helpOverlayActive: this.helpOverlayActive, helpScrollOffset: this.helpScrollOffset,
227
+ shortcutsOverlayActive: this.shortcutsOverlayActive, shortcutsScrollOffset: this.shortcutsScrollOffset,
228
+ nextPasteId: this.nextPasteId, nextImageId: this.nextImageId,
229
+ mouseDownRow: this.mouseDownRow, mouseDownCol: this.mouseDownCol,
230
+ contentWidth: this.contentWidth, selectionCallback: this.selectionCallback,
231
+ },
232
+ {
233
+ selection: this.selection,
234
+ pasteRegistry: this.pasteRegistry,
235
+ imageRegistry: this.imageRegistry,
236
+ selectionModal: this.selectionModal,
237
+ bookmarkModal: this.bookmarkModal,
238
+ settingsModal: this.settingsModal,
239
+ sessionPickerModal: this.sessionPickerModal,
240
+ profilePickerModal: this.profilePickerModal,
241
+ historySearch: this.historySearch,
242
+ commandRegistry: this.commandRegistry,
243
+ commandContext: this.commandContext,
244
+ autocomplete: this.autocomplete,
245
+ filePicker: this.filePicker,
246
+ modelPicker: this.modelPicker,
247
+ processModal: this.processModal,
248
+ liveTailModal: this.liveTailModal,
249
+ agentDetailModal: this.agentDetailModal,
250
+ contextInspectorModal: this.contextInspectorModal,
251
+ blockActionsMenu: this.blockActionsMenu,
252
+ searchManager: this.searchManager,
253
+ modalStack: this.modalStack,
254
+ inputHistory: this.inputHistory,
255
+ conversationManager: this.conversationManager,
256
+ panelManager: this.uiServices.shell.panelManager,
257
+ keybindingsManager: this.uiServices.shell.keybindingsManager,
258
+ getHistory: this.getHistory,
259
+ getViewportHeight: this.getViewportHeight,
260
+ getScrollTop: this.getScrollTop,
261
+ scroll: this.scroll,
262
+ exitApp: this.exitApp,
263
+ },
264
+ {
265
+ modalOpened: (name: string) => this.modalOpened(name),
266
+ handleEscape: () => { this.handleEscape(); this.syncFeedContextMutableFields(); },
267
+ handleCopy: () => this.handleCopy(),
268
+ handleCtrlC: () => { this.handleCtrlC(); this.syncFeedContextMutableFields(); },
269
+ handleBlockCopy: () => this.handleBlockCopy(),
270
+ handleBookmark: () => this.handleBookmark(),
271
+ handleBlockSave: () => this.handleBlockSave(),
272
+ handleDiffApply: () => this.handleDiffApply(),
273
+ handleUndo: () => { this.handleUndo(); this.syncFeedContextMutableFields(); },
274
+ handleRedo: () => { this.handleRedo(); this.syncFeedContextMutableFields(); },
275
+ handlePaste: () => { this.handlePaste(); this.syncFeedContextMutableFields(); },
276
+ saveUndoState: () => this.saveUndoState(),
277
+ ensureInputCursorVisible: (contentWidth?: number) => this.ensureInputCursorVisible(contentWidth),
278
+ registerPaste: (content: string) => this.registerPaste(content),
279
+ executeBlockAction: (id: string) => this.executeBlockAction(id),
280
+ cyclePanelTab: (direction: 'next' | 'prev') => this.cyclePanelTab(direction),
281
+ onPanelInputConsumed: (activePanel: Panel | null, key: string) => this.handlePanelIntegrationAction(activePanel, key),
282
+ getWrappedPromptInfo: (contentWidth: number) => this.getWrappedPromptInfo(contentWidth),
283
+ moveCursorVertical: (direction: -1 | 1) => this.moveCursorVertical(direction),
284
+ handlePathCompletion: () => this.handlePathCompletion(),
285
+ handleBlockToggle: () => this.handleBlockToggle(),
286
+ findMarkerAtPos: (pos: number) => this.findMarkerAtPos(pos),
287
+ cleanupMarkerRegistry: (text: string) => this.cleanupMarkerRegistry(text),
288
+ expandPrompt: (text: string) => this.expandPrompt(text),
289
+ },
290
+ );
219
291
  }
220
292
 
221
- /**
222
- * setCommandRegistry - Wire in the slash command registry and context.
223
- * Must be called before commands can be processed.
224
- */
293
+ /** Sync mutable handler fields back into feedContext after in-feed mutations. */
294
+ private syncFeedContextMutableFields(): void {
295
+ const h = this;
296
+ syncFeedContextMutableFields({ prompt: h.prompt, cursorPos: h.cursorPos, commandMode: h.commandMode,
297
+ panelFocused: h.panelFocused, indicatorFocused: h.indicatorFocused, helpOverlayActive: h.helpOverlayActive,
298
+ helpScrollOffset: h.helpScrollOffset, shortcutsOverlayActive: h.shortcutsOverlayActive,
299
+ shortcutsScrollOffset: h.shortcutsScrollOffset, selectionCallback: h.selectionCallback,
300
+ nextPasteId: h.nextPasteId, nextImageId: h.nextImageId, mouseDownRow: h.mouseDownRow,
301
+ mouseDownCol: h.mouseDownCol, contentWidth: h.contentWidth }, this.feedContext);
302
+ }
303
+
304
+ /** Wire in the InputHistory instance. Optional; disables history navigation if unset. */
305
+ public setHistory(history: InputHistory): void { this.inputHistory = history; }
306
+
307
+ /** Wire in the slash command registry and context. Must be called before commands work. */
225
308
  public setCommandRegistry(registry: CommandRegistry, context: CommandContext): void {
226
309
  this.commandRegistry = registry;
227
310
  this.commandContext = context;
228
311
  this.autocomplete = new AutocompleteEngine(registry);
229
312
  }
230
313
 
231
- /**
232
- * setConversationManager - Wire in the conversation manager for block copy/apply/collapse.
233
- */
234
- public setConversationManager(cm: ConversationManager): void {
235
- this.conversationManager = cm;
236
- }
314
+ /** Wire in the conversation manager for block copy/apply/collapse. */
315
+ public setConversationManager(cm: ConversationManager): void { this.conversationManager = cm; }
237
316
 
238
317
  /**
239
318
  * openSelection - Open the generic selection modal with a callback.
@@ -464,6 +543,7 @@ export class InputHandler {
464
543
 
465
544
  /**
466
545
  * feed - Process raw stdin data through the tokenizer.
546
+ * Reuses the long-lived this.feedContext to avoid per-keystroke object allocation.
467
547
  */
468
548
  public feed(data: string): void {
469
549
  const immediateRequestRender = this.requestRender;
@@ -479,111 +559,31 @@ export class InputHandler {
479
559
 
480
560
  this.requestRender = bufferedRequestRender;
481
561
  try {
482
- let context!: import('./handler-feed.ts').InputFeedContext;
483
- const syncFeedContextFromHandler = (): void => {
484
- context.prompt = this.prompt;
485
- context.cursorPos = this.cursorPos;
486
- context.commandMode = this.commandMode;
487
- context.panelFocused = this.panelFocused;
488
- context.indicatorFocused = this.indicatorFocused;
489
- context.helpOverlayActive = this.helpOverlayActive;
490
- context.helpScrollOffset = this.helpScrollOffset;
491
- context.shortcutsOverlayActive = this.shortcutsOverlayActive;
492
- context.shortcutsScrollOffset = this.shortcutsScrollOffset;
493
- context.selectionCallback = this.selectionCallback;
494
- context.nextPasteId = this.nextPasteId;
495
- context.nextImageId = this.nextImageId;
496
- context.mouseDownRow = this.mouseDownRow;
497
- context.mouseDownCol = this.mouseDownCol;
498
- };
499
-
500
- context = {
501
- prompt: this.prompt,
502
- cursorPos: this.cursorPos,
503
- commandMode: this.commandMode,
504
- panelFocused: this.panelFocused,
505
- indicatorFocused: this.indicatorFocused,
506
- helpOverlayActive: this.helpOverlayActive,
507
- helpScrollOffset: this.helpScrollOffset,
508
- shortcutsOverlayActive: this.shortcutsOverlayActive,
509
- shortcutsScrollOffset: this.shortcutsScrollOffset,
510
- nextPasteId: this.nextPasteId,
511
- nextImageId: this.nextImageId,
512
- mouseDownRow: this.mouseDownRow,
513
- mouseDownCol: this.mouseDownCol,
514
- contentWidth: this.contentWidth,
515
- pasteRegistry: this.pasteRegistry,
516
- imageRegistry: this.imageRegistry,
517
- selection: this.selection,
518
- selectionModal: this.selectionModal,
519
- selectionCallback: this.selectionCallback,
520
- bookmarkModal: this.bookmarkModal,
521
- settingsModal: this.settingsModal,
522
- sessionPickerModal: this.sessionPickerModal,
523
- profilePickerModal: this.profilePickerModal,
524
- historySearch: this.historySearch,
525
- commandRegistry: this.commandRegistry,
526
- commandContext: this.commandContext,
527
- autocomplete: this.autocomplete,
528
- filePicker: this.filePicker,
529
- modelPicker: this.modelPicker,
530
- processModal: this.processModal,
531
- liveTailModal: this.liveTailModal,
532
- agentDetailModal: this.agentDetailModal,
533
- contextInspectorModal: this.contextInspectorModal,
534
- blockActionsMenu: this.blockActionsMenu,
535
- searchManager: this.searchManager,
536
- modalStack: this.modalStack,
537
- inputHistory: this.inputHistory,
538
- conversationManager: this.conversationManager,
539
- getHistory: this.getHistory,
540
- getViewportHeight: this.getViewportHeight,
541
- getScrollTop: this.getScrollTop,
542
- scroll: this.scroll,
543
- requestRender: bufferedRequestRender,
544
- modalOpened: (name: string) => this.modalOpened(name),
545
- handleEscape: () => {
546
- this.handleEscape();
547
- syncFeedContextFromHandler();
548
- },
549
- handleCopy: () => this.handleCopy(),
550
- handleCtrlC: () => {
551
- this.handleCtrlC();
552
- syncFeedContextFromHandler();
553
- },
554
- handleBlockCopy: () => this.handleBlockCopy(),
555
- handleBookmark: () => this.handleBookmark(),
556
- handleBlockSave: () => this.handleBlockSave(),
557
- handleDiffApply: () => this.handleDiffApply(),
558
- handleUndo: () => {
559
- this.handleUndo();
560
- syncFeedContextFromHandler();
561
- },
562
- handleRedo: () => {
563
- this.handleRedo();
564
- syncFeedContextFromHandler();
565
- },
566
- handlePaste: () => {
567
- this.handlePaste();
568
- syncFeedContextFromHandler();
569
- },
570
- saveUndoState: () => this.saveUndoState(),
571
- ensureInputCursorVisible: (contentWidth?: number) => this.ensureInputCursorVisible(contentWidth),
572
- registerPaste: (content: string) => this.registerPaste(content),
573
- executeBlockAction: (id: string) => this.executeBlockAction(id),
574
- cyclePanelTab: (direction: 'next' | 'prev') => this.cyclePanelTab(direction),
575
- onPanelInputConsumed: (activePanel: Panel | null, key: string) => this.handlePanelIntegrationAction(activePanel, key),
576
- panelManager: this.uiServices.shell.panelManager,
577
- keybindingsManager: this.uiServices.shell.keybindingsManager,
578
- getWrappedPromptInfo: (contentWidth: number) => this.getWrappedPromptInfo(contentWidth),
579
- moveCursorVertical: (direction: -1 | 1) => this.moveCursorVertical(direction),
580
- handlePathCompletion: () => this.handlePathCompletion(),
581
- handleBlockToggle: () => this.handleBlockToggle(),
582
- findMarkerAtPos: (pos: number) => this.findMarkerAtPos(pos),
583
- cleanupMarkerRegistry: (text: string) => this.cleanupMarkerRegistry(text),
584
- expandPrompt: (text: string) => this.expandPrompt(text),
585
- exitApp: this.exitApp,
586
- };
562
+ const context = this.feedContext;
563
+ // Sync mutable scalars from handler into the reused context.
564
+ context.prompt = this.prompt;
565
+ context.cursorPos = this.cursorPos;
566
+ context.commandMode = this.commandMode;
567
+ context.panelFocused = this.panelFocused;
568
+ context.indicatorFocused = this.indicatorFocused;
569
+ context.helpOverlayActive = this.helpOverlayActive;
570
+ context.helpScrollOffset = this.helpScrollOffset;
571
+ context.shortcutsOverlayActive = this.shortcutsOverlayActive;
572
+ context.shortcutsScrollOffset = this.shortcutsScrollOffset;
573
+ context.selectionCallback = this.selectionCallback;
574
+ context.nextPasteId = this.nextPasteId;
575
+ context.nextImageId = this.nextImageId;
576
+ context.mouseDownRow = this.mouseDownRow;
577
+ context.mouseDownCol = this.mouseDownCol;
578
+ context.contentWidth = this.contentWidth;
579
+ // Sync semi-stable refs that may be wired after construction.
580
+ context.commandRegistry = this.commandRegistry;
581
+ context.commandContext = this.commandContext;
582
+ context.autocomplete = this.autocomplete;
583
+ context.inputHistory = this.inputHistory;
584
+ context.conversationManager = this.conversationManager;
585
+ // Swap requestRender to buffered version for this feed.
586
+ context.requestRender = bufferedRequestRender;
587
587
  this.syncFeedSelectionCallback = (callback) => {
588
588
  context.selectionCallback = callback;
589
589
  };
@@ -138,11 +138,14 @@ function resolveKeybindingsPath(options?: KeybindingsManagerOptions): string {
138
138
  export class KeybindingsManager {
139
139
  private bindings: Record<KeyAction, KeyCombo[]>;
140
140
  private configPath: string;
141
+ /** Inverted lookup map: composite key → KeyAction. Built in buildLookupMap(). */
142
+ private lookupMap = new Map<string, KeyAction>();
141
143
 
142
144
  constructor(options: KeybindingsManagerOptions) {
143
145
  this.configPath = resolveKeybindingsPath(options);
144
146
  // Start with deep copy of defaults
145
147
  this.bindings = this.cloneDefaults();
148
+ this.buildLookupMap();
146
149
  }
147
150
 
148
151
  private cloneDefaults(): Record<KeyAction, KeyCombo[]> {
@@ -184,6 +187,33 @@ export class KeybindingsManager {
184
187
  } catch (err) {
185
188
  logger.debug('keybindings: failed to load config file', { path: this.configPath, err: summarizeError(err) });
186
189
  }
190
+ this.buildLookupMap();
191
+ }
192
+
193
+ /**
194
+ * buildLookupMap — Rebuild the inverted lookup map from the current bindings table.
195
+ * Called after constructor init and after loadFromDisk().
196
+ * Map key format: "logicalName:ctrl:shift:alt" (booleans as 0/1).
197
+ * Last writer wins for duplicate combos (deterministic: iterate actions in order).
198
+ */
199
+ private buildLookupMap(): void {
200
+ this.lookupMap.clear();
201
+ for (const [action, combos] of Object.entries(this.bindings) as [KeyAction, KeyCombo[]][]) {
202
+ for (const combo of combos) {
203
+ const key = `${combo.key}:${combo.ctrl ? 1 : 0}:${combo.shift ? 1 : 0}:${combo.alt ? 1 : 0}`;
204
+ this.lookupMap.set(key, action);
205
+ }
206
+ }
207
+ }
208
+
209
+ /**
210
+ * lookup — O(1) keybinding lookup by token.
211
+ * Returns the matching KeyAction, or null if no binding matches.
212
+ */
213
+ lookup(token: { logicalName?: string; ctrl?: boolean; shift?: boolean; alt?: boolean }): KeyAction | null {
214
+ if (!token.logicalName) return null;
215
+ const key = `${token.logicalName}:${token.ctrl ? 1 : 0}:${token.shift ? 1 : 0}:${token.alt ? 1 : 0}`;
216
+ return this.lookupMap.get(key) ?? null;
187
217
  }
188
218
 
189
219
  private validateCombos(combos: unknown[]): combos is KeyCombo[] {
@@ -1,4 +1,5 @@
1
1
  import type { CommandContext } from './command-registry.ts';
2
+ import { logger } from '@pellux/goodvibes-sdk/platform/utils/logger';
2
3
  import type { Panel } from '../panels/types.ts';
3
4
  import type { PanelManager } from '../panels/panel-manager.ts';
4
5
  import { FileExplorerPanel } from '../panels/file-explorer-panel.ts';
@@ -69,7 +70,7 @@ export function handlePanelIntegrationAction(
69
70
  const parts = command.replace(/^\//, '').split(/\s+/).filter(Boolean);
70
71
  const [name, ...args] = parts;
71
72
  if (!name) return false;
72
- void commandContext.executeCommand(name, args).catch(() => {});
73
+ void commandContext.executeCommand(name, args).catch((err) => { logger.debug('approval panel command dispatch failed', { err }); });
73
74
  return true;
74
75
  }
75
76
 
@@ -0,0 +1,60 @@
1
+ import type { ConfigSetting } from '@pellux/goodvibes-sdk/platform/config/schema';
2
+ import type { ProviderAuthFreshness, ProviderAuthRoute } from '@pellux/goodvibes-sdk/platform/runtime/provider-accounts/registry';
3
+ import type { FeatureFlag, FlagState } from '@pellux/goodvibes-sdk/platform/runtime/feature-flags/types';
4
+
5
+ export type SettingsCategory = 'display' | 'ui' | 'provider' | 'subscriptions' | 'behavior' | 'storage' | 'permissions' | 'mcp' | 'sandbox' | 'danger' | 'tools' | 'flags' | 'network';
6
+
7
+ export const SETTINGS_CATEGORIES: SettingsCategory[] = [
8
+ 'display',
9
+ 'ui',
10
+ 'provider',
11
+ 'subscriptions',
12
+ 'behavior',
13
+ 'storage',
14
+ 'permissions',
15
+ 'mcp',
16
+ 'sandbox',
17
+ 'danger',
18
+ 'tools',
19
+ 'flags',
20
+ 'network',
21
+ ];
22
+
23
+ export interface SettingEntry {
24
+ setting: ConfigSetting;
25
+ currentValue: unknown;
26
+ isDefault: boolean;
27
+ effectiveSource?: 'default' | 'local' | 'synced' | 'managed';
28
+ locked?: boolean;
29
+ conflict?: boolean;
30
+ sourceLabel?: string;
31
+ lockReason?: string;
32
+ }
33
+
34
+ export interface FlagEntry {
35
+ flag: FeatureFlag;
36
+ state: FlagState;
37
+ }
38
+
39
+ export interface McpEntry {
40
+ name: string;
41
+ connected: boolean;
42
+ role: 'general' | 'docs' | 'filesystem' | 'git' | 'database' | 'browser' | 'automation' | 'ops' | 'remote';
43
+ trustMode: 'constrained' | 'ask-on-risk' | 'allow-all' | 'blocked';
44
+ allowedPaths: string[];
45
+ allowedHosts: string[];
46
+ }
47
+
48
+ export interface SubscriptionEntry {
49
+ provider: string;
50
+ state: 'active' | 'pending' | 'available';
51
+ tokenType?: string;
52
+ expiresAt?: number;
53
+ oauthConfigured: boolean;
54
+ activeRoute?: ProviderAuthRoute;
55
+ preferredRoute?: ProviderAuthRoute;
56
+ authFreshness?: ProviderAuthFreshness;
57
+ routeReason?: string;
58
+ issues?: string[];
59
+ nextActions?: string[];
60
+ }
@@ -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);
@@ -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