@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,9 +1,12 @@
1
1
  /**
2
2
  * SystemMessagesPanel — displays operational system messages routed away
3
3
  * from the main conversation.
4
+ *
5
+ * Migrated (Wave B2): extends ScrollableListPanel<SystemMessageEntry>.
6
+ * Navigation (up/down/j/k/pageup/pagedown/g/G) is handled by the base class.
4
7
  */
5
8
 
6
- import { BasePanel } from './base-panel.ts';
9
+ import { ScrollableListPanel } from './scrollable-list-panel.ts';
7
10
  import type { Line } from '../types/grid.ts';
8
11
  import type { ComponentHealthMonitor } from '../runtime/perf/panel-health-monitor.ts';
9
12
  import {
@@ -17,20 +20,21 @@ import {
17
20
  buildPanelWorkspace,
18
21
  resolvePrimaryScrollableSection,
19
22
  DEFAULT_PANEL_PALETTE,
23
+ extendPalette,
24
+ type PanelPalette,
20
25
  type PanelWorkspaceSection,
21
26
  } from './polish.ts';
22
27
  import { ConfigManager } from '@pellux/goodvibes-sdk/platform/config/manager';
23
28
 
24
29
  const MAX_MESSAGES = 500;
25
30
 
26
- const C = {
27
- ...DEFAULT_PANEL_PALETTE,
31
+ const C = extendPalette(DEFAULT_PANEL_PALETTE, {
28
32
  header: '#00ffff',
29
33
  headerBg: '#0f172a',
30
34
  high: '#fbbf24',
31
35
  low: '#9ca3af',
32
36
  ts: '#6b7280',
33
- } as const;
37
+ } as const);
34
38
 
35
39
  export type SystemMessagePriority = 'high' | 'low';
36
40
 
@@ -48,10 +52,8 @@ function fmtTime(ts: number): string {
48
52
  return `${hh}:${mm}:${ss}`;
49
53
  }
50
54
 
51
- export class SystemMessagesPanel extends BasePanel {
55
+ export class SystemMessagesPanel extends ScrollableListPanel<SystemMessageEntry> {
52
56
  private _messages: SystemMessageEntry[] = [];
53
- private _lastVisibleIdx = 0;
54
- private _scrollOffset = 0;
55
57
  private readonly configManager: ConfigManager;
56
58
 
57
59
  constructor(configManager: ConfigManager, componentHealthMonitor?: ComponentHealthMonitor) {
@@ -59,14 +61,55 @@ export class SystemMessagesPanel extends BasePanel {
59
61
  this.configManager = configManager;
60
62
  }
61
63
 
64
+ // ---------------------------------------------------------------------------
65
+ // ScrollableListPanel contract
66
+ // ---------------------------------------------------------------------------
67
+
68
+ protected getItems(): readonly SystemMessageEntry[] {
69
+ return this._messages;
70
+ }
71
+
72
+ protected renderItem(
73
+ entry: SystemMessageEntry,
74
+ index: number,
75
+ selected: boolean,
76
+ width: number,
77
+ ): Line {
78
+ const preview = entry.text.replace(/\s+/g, ' ').trim();
79
+ return buildPanelListRow(width, [
80
+ { text: `${fmtTime(entry.ts)} `, fg: C.ts },
81
+ {
82
+ text: `${entry.priority === 'high' ? 'HIGH' : 'LOW '.padEnd(4)} `,
83
+ fg: entry.priority === 'high' ? C.high : C.low,
84
+ bold: entry.priority === 'high',
85
+ },
86
+ { text: preview, fg: C.value },
87
+ ], C, {
88
+ selected,
89
+ marker: entry.priority === 'high' ? '!' : '\u00b7',
90
+ });
91
+ }
92
+
93
+ protected override getPalette(): PanelPalette {
94
+ return C;
95
+ }
96
+
97
+ protected override getEmptyStateMessage(): string {
98
+ return ' No system messages yet.';
99
+ }
100
+
101
+ // ---------------------------------------------------------------------------
102
+ // Public API
103
+ // ---------------------------------------------------------------------------
104
+
62
105
  push(text: string, priority: SystemMessagePriority): void {
63
106
  this._messages.push({ ts: Date.now(), text, priority });
64
107
  if (this._messages.length > MAX_MESSAGES) {
65
108
  this._messages.shift();
66
- if (this._lastVisibleIdx > 0) this._lastVisibleIdx--;
109
+ if (this.selectedIndex > 0) this.selectedIndex--;
67
110
  }
68
- this._lastVisibleIdx = Math.max(0, this._messages.length - 1);
69
- this._scrollOffset = Math.max(0, this._messages.length - 1);
111
+ // Auto-follow: jump to latest message
112
+ this.selectedIndex = Math.max(0, this._messages.length - 1);
70
113
  this.markDirty();
71
114
  }
72
115
 
@@ -78,147 +121,110 @@ export class SystemMessagesPanel extends BasePanel {
78
121
  return this._messages;
79
122
  }
80
123
 
81
- handleInput(key: string): boolean {
82
- const prev = this._lastVisibleIdx;
83
- switch (key) {
84
- case 'j':
85
- case '\x1b[B':
86
- this._lastVisibleIdx = Math.min(this._lastVisibleIdx + 1, Math.max(0, this._messages.length - 1));
87
- break;
88
- case 'k':
89
- case '\x1b[A':
90
- this._lastVisibleIdx = Math.max(this._lastVisibleIdx - 1, 0);
91
- break;
92
- case '\x1b[6~':
93
- this._lastVisibleIdx = Math.min(this._lastVisibleIdx + 20, Math.max(0, this._messages.length - 1));
94
- break;
95
- case '\x1b[5~':
96
- this._lastVisibleIdx = Math.max(this._lastVisibleIdx - 20, 0);
97
- break;
98
- case 'g':
99
- this._lastVisibleIdx = 0;
100
- break;
101
- case 'G':
102
- this._lastVisibleIdx = Math.max(0, this._messages.length - 1);
103
- break;
104
- default:
105
- return false;
106
- }
107
- if (this._lastVisibleIdx !== prev) this.markDirty();
108
- return true;
109
- }
110
-
111
- override render(width: number, height: number): Line[] {
112
- if (!this.canRenderNow()) {
113
- return Array.from({ length: height }, () => buildPanelLine(width, [['', C.dim]]));
114
- }
124
+ // ---------------------------------------------------------------------------
125
+ // Input base class handles all navigation; nothing custom here
126
+ // ---------------------------------------------------------------------------
115
127
 
116
- const start = Date.now();
117
- const intro = 'Operational system traffic routed out of the main conversation to reduce noise and keep runtime status reviewable.';
128
+ // ---------------------------------------------------------------------------
129
+ // Render multi-section layout (posture + list + detail)
130
+ // ---------------------------------------------------------------------------
118
131
 
119
- if (this._messages.length === 0) {
132
+ override render(width: number, height: number): Line[] {
133
+ return this.trackedRender(() => {
134
+ const intro = 'Operational system traffic routed out of the main conversation to reduce noise and keep runtime status reviewable.';
135
+
136
+ if (this._messages.length === 0) {
137
+ this.needsRender = false;
138
+ const lines = buildPanelWorkspace(width, height, {
139
+ title: 'System Messages',
140
+ intro,
141
+ sections: [{
142
+ lines: buildEmptyState(
143
+ width,
144
+ this.getEmptyStateMessage(),
145
+ 'Model switches, scan notices, provider/system state, and other operational updates will appear here once the runtime starts emitting them.',
146
+ [
147
+ { command: '/help', summary: 'review command and workflow surfaces' },
148
+ { command: '/cockpit', summary: 'open the unified runtime control room' },
149
+ ],
150
+ C,
151
+ ),
152
+ }],
153
+ footerLines: [
154
+ buildPanelLine(width, [[' j/k or Up/Down scroll g/G jump low-priority system traffic lands here by default', C.dim]]),
155
+ ],
156
+ palette: C,
157
+ });
158
+ return lines;
159
+ }
160
+
161
+ const highCount = this._messages.filter((entry) => entry.priority === 'high').length;
162
+ const lowCount = this._messages.length - highCount;
163
+ this.selectedIndex = Math.min(this.selectedIndex, this._messages.length - 1);
164
+ const ui = this.configManager.getRaw().ui;
165
+ const postureLines = [
166
+ buildKeyValueLine(width, [
167
+ { label: 'messages', value: String(this._messages.length), valueColor: C.value },
168
+ { label: 'high', value: String(highCount), valueColor: highCount > 0 ? C.high : C.dim },
169
+ { label: 'low', value: String(lowCount), valueColor: lowCount > 0 ? C.low : C.dim },
170
+ ], C),
171
+ buildKeyValueLine(width, [
172
+ { label: 'system route', value: ui.systemMessages, valueColor: C.info },
173
+ { label: 'ops route', value: ui.operationalMessages, valueColor: C.info },
174
+ { label: 'wrfc route', value: ui.wrfcMessages, valueColor: C.info },
175
+ ], C),
176
+ buildGuidanceLine(width, '/settings', 'adjust where operational and WRFC messages render across panels and conversation', C),
177
+ ];
178
+
179
+ const selected = this._messages[this.selectedIndex]!;
180
+ const messageRows: Line[] = this._messages.map((entry, index) =>
181
+ this.renderItem(entry, index, index === this.selectedIndex, width),
182
+ );
183
+
184
+ const postureSection: PanelWorkspaceSection = { lines: buildSummaryBlock(width, 'System posture', postureLines, C) };
185
+ const detailSection: PanelWorkspaceSection = {
186
+ title: 'Selected Message',
187
+ lines: [
188
+ buildPanelLine(width, [
189
+ [' Time ', C.label],
190
+ [fmtTime(selected.ts), C.value],
191
+ [' Priority ', C.label],
192
+ [selected.priority, selected.priority === 'high' ? C.high : C.low],
193
+ ]),
194
+ ...buildBodyText(width, selected.text, C, C.value),
195
+ ],
196
+ };
197
+ const messagesSection = resolvePrimaryScrollableSection(width, height, {
198
+ intro,
199
+ palette: C,
200
+ beforeSections: [postureSection],
201
+ section: {
202
+ title: 'Timeline',
203
+ scrollableLines: messageRows,
204
+ selectedIndex: this.selectedIndex,
205
+ scrollOffset: this.scrollStart,
206
+ minRows: 4,
207
+ appendWindowSummary: { dimColor: C.ts },
208
+ },
209
+ afterSections: [detailSection],
210
+ });
211
+ this.scrollStart = messagesSection.scrollOffset;
212
+ const sections: PanelWorkspaceSection[] = [
213
+ postureSection,
214
+ messagesSection.section,
215
+ detailSection,
216
+ ];
217
+ this.needsRender = false;
120
218
  const lines = buildPanelWorkspace(width, height, {
121
219
  title: 'System Messages',
122
220
  intro,
123
- sections: [{
124
- lines: buildEmptyState(
125
- width,
126
- ' No system messages yet.',
127
- 'Model switches, scan notices, provider/system state, and other operational updates will appear here once the runtime starts emitting them.',
128
- [
129
- { command: '/help', summary: 'review command and workflow surfaces' },
130
- { command: '/cockpit', summary: 'open the unified runtime control room' },
131
- ],
132
- C,
133
- ),
134
- }],
221
+ sections,
135
222
  footerLines: [
136
- buildPanelLine(width, [[' j/k or Up/Down scroll g/G jump low-priority system traffic lands here by default', C.dim]]),
223
+ buildPanelLine(width, [[' j/k or Up/Down scroll PgUp/PgDn page g/G jump', C.dim]]),
137
224
  ],
138
225
  palette: C,
139
226
  });
140
- this.reportRenderDuration(Date.now() - start);
141
227
  return lines;
142
- }
143
-
144
- const highCount = this._messages.filter((entry) => entry.priority === 'high').length;
145
- const lowCount = this._messages.length - highCount;
146
- this._lastVisibleIdx = Math.min(this._lastVisibleIdx, this._messages.length - 1);
147
- const ui = this.configManager.getRaw().ui;
148
- const postureLines = [
149
- buildKeyValueLine(width, [
150
- { label: 'messages', value: String(this._messages.length), valueColor: C.value },
151
- { label: 'high', value: String(highCount), valueColor: highCount > 0 ? C.high : C.dim },
152
- { label: 'low', value: String(lowCount), valueColor: lowCount > 0 ? C.low : C.dim },
153
- ], C),
154
- buildKeyValueLine(width, [
155
- { label: 'system route', value: ui.systemMessages, valueColor: C.info },
156
- { label: 'ops route', value: ui.operationalMessages, valueColor: C.info },
157
- { label: 'wrfc route', value: ui.wrfcMessages, valueColor: C.info },
158
- ], C),
159
- buildGuidanceLine(width, '/settings', 'adjust where operational and WRFC messages render across panels and conversation', C),
160
- ];
161
-
162
- const selected = this._messages[this._lastVisibleIdx]!;
163
- const messageRows: Line[] = this._messages.map((entry, index) => {
164
- const preview = entry.text.replace(/\s+/g, ' ').trim();
165
- return buildPanelListRow(width, [
166
- { text: `${fmtTime(entry.ts)} `, fg: C.ts },
167
- {
168
- text: `${entry.priority === 'high' ? 'HIGH' : 'LOW '.padEnd(4)} `,
169
- fg: entry.priority === 'high' ? C.high : C.low,
170
- bold: entry.priority === 'high',
171
- },
172
- { text: preview, fg: C.value },
173
- ], C, {
174
- selected: index === this._lastVisibleIdx,
175
- marker: entry.priority === 'high' ? '!' : '·',
176
- });
177
- });
178
-
179
- const postureSection: PanelWorkspaceSection = { lines: buildSummaryBlock(width, 'System posture', postureLines, C) };
180
- const detailSection: PanelWorkspaceSection = {
181
- title: 'Selected Message',
182
- lines: [
183
- buildPanelLine(width, [
184
- [' Time ', C.label],
185
- [fmtTime(selected.ts), C.value],
186
- [' Priority ', C.label],
187
- [selected.priority, selected.priority === 'high' ? C.high : C.low],
188
- ]),
189
- ...buildBodyText(width, selected.text, C, C.value),
190
- ],
191
- };
192
- const messagesSection = resolvePrimaryScrollableSection(width, height, {
193
- intro,
194
- palette: C,
195
- beforeSections: [postureSection],
196
- section: {
197
- title: 'Timeline',
198
- scrollableLines: messageRows,
199
- selectedIndex: this._lastVisibleIdx,
200
- scrollOffset: this._scrollOffset,
201
- minRows: 4,
202
- appendWindowSummary: { dimColor: C.ts },
203
- },
204
- afterSections: [detailSection],
205
- });
206
- this._scrollOffset = messagesSection.scrollOffset;
207
- const sections: PanelWorkspaceSection[] = [
208
- postureSection,
209
- messagesSection.section,
210
- detailSection,
211
- ];
212
- const lines = buildPanelWorkspace(width, height, {
213
- title: 'System Messages',
214
- intro,
215
- sections,
216
- footerLines: [
217
- buildPanelLine(width, [[' j/k or Up/Down scroll PgUp/PgDn page g/G jump', C.dim]]),
218
- ],
219
- palette: C,
220
228
  });
221
- this.reportRenderDuration(Date.now() - start);
222
- return lines;
223
229
  }
224
230
  }
@@ -156,6 +156,7 @@ export class TasksPanel extends ScrollableListPanel<RuntimeTask> {
156
156
  worktrees?: UiReadModel<UiWorktreeSnapshot>,
157
157
  ) {
158
158
  super('tasks', 'Tasks', 'J', 'monitoring');
159
+ this.showSelectionGutter = true; // I5: non-color selection affordance
159
160
  this.readModel = readModel;
160
161
  this.worktrees = worktrees;
161
162
  this.unsubscribers = [
@@ -199,6 +199,7 @@ export class TokenBudgetPanel extends BasePanel {
199
199
  // ---------------------------------------------------------------------------
200
200
 
201
201
  override render(width: number, height: number): Line[] {
202
+ return this.trackedRender(() => {
202
203
  const sections: PanelWorkspaceSection[] = [];
203
204
 
204
205
  if (this.contextWindow > 0) {
@@ -258,6 +259,7 @@ export class TokenBudgetPanel extends BasePanel {
258
259
  sections,
259
260
  palette: DEFAULT_PANEL_PALETTE,
260
261
  });
262
+ });
261
263
  }
262
264
 
263
265
  private renderMaintenance(width: number): Line[] {
@@ -58,6 +58,7 @@ export class WatchersPanel extends ScrollableListPanel<WatcherEntry> {
58
58
 
59
59
  public constructor(readModel?: UiReadModel<UiWatchersSnapshot>) {
60
60
  super('watchers', 'Watchers', 'W', 'monitoring');
61
+ this.showSelectionGutter = true; // I5: non-color selection affordance
61
62
  this.readModel = readModel;
62
63
  this.unsub = readModel ? readModel.subscribe(() => this.markDirty()) : null;
63
64
  }
@@ -29,6 +29,7 @@ export class WorktreePanel extends ScrollableListPanel<WorktreeStatusRecord> {
29
29
 
30
30
  public constructor(worktreeRegistry: WorktreeRegistry) {
31
31
  super('worktrees', 'Worktrees', 'W', 'monitoring');
32
+ this.showSelectionGutter = true; // I5: non-color selection affordance
32
33
  this.worktreeRegistry = worktreeRegistry;
33
34
  void this.refresh();
34
35
  }
@@ -170,6 +170,7 @@ export class WrfcPanel extends BasePanel {
170
170
  // Render
171
171
  // -------------------------------------------------------------------------
172
172
  render(width: number, height: number): Line[] {
173
+ return this.trackedRender(() => {
173
174
  const activeCount = this.chains.filter(c => !['passed', 'failed'].includes(c.state)).length;
174
175
  const passedCount = this.chains.filter(c => c.state === 'passed').length;
175
176
  const failedCount = this.chains.filter(c => c.state === 'failed').length;
@@ -275,6 +276,7 @@ export class WrfcPanel extends BasePanel {
275
276
  ],
276
277
  palette: DEFAULT_PANEL_PALETTE,
277
278
  });
279
+ });
278
280
  }
279
281
 
280
282
  // -------------------------------------------------------------------------
@@ -53,11 +53,11 @@ export class AgentDetailModal {
53
53
  this.active = true;
54
54
  this.logEntries = [];
55
55
  this.logTotal = 0;
56
- this.loadLog().catch(() => {});
56
+ this.loadLog().catch((err) => { logger.debug('agent detail log load failed', { err }); });
57
57
  // Auto-refresh log every 500ms while modal is open
58
58
  if (this.refreshTimer) clearInterval(this.refreshTimer);
59
59
  this.refreshTimer = setInterval(() => {
60
- this.loadLog().then(() => this.onRefresh?.()).catch(() => {});
60
+ this.loadLog().then(() => this.onRefresh?.()).catch((err) => { logger.debug('agent detail log refresh tick failed', { err }); });
61
61
  }, 500);
62
62
  }
63
63
 
@@ -0,0 +1,76 @@
1
+ /**
2
+ * ANSI sanitizer for untrusted content entering the renderer.
3
+ *
4
+ * The TUI grid renders content character-by-character via writeStyledText,
5
+ * which already drops zero-width characters (including ESC \x1b) by checking
6
+ * display width. However, that is incidental protection — not a contract.
7
+ * This module provides explicit, intentional sanitization.
8
+ *
9
+ * Strategy:
10
+ * - STRIP all non-SGR escape sequences (cursor moves, OSC, BEL, alt-screen,
11
+ * DECSET/private mode, and any other CSI/ESC sequences).
12
+ * - PRESERVE SGR color/style codes (\x1b[<params>m) — used legitimately by
13
+ * the TUI's own colorized output paths.
14
+ * - STRIP bare BEL (\x07) characters.
15
+ *
16
+ * Safe SGR pattern: \x1b[ followed by digits/semicolons, ending in 'm'.
17
+ * Everything else that starts with \x1b is dangerous and stripped.
18
+ */
19
+
20
+ // Matches safe SGR sequences: ESC [ <digits/semicolons> m
21
+ const SGR_PATTERN = /\x1b\[([0-9;]*)m/g;
22
+
23
+ // Matches ALL CSI sequences: ESC [ ... <final byte 0x40-0x7E>
24
+ const CSI_SEQUENCE = /\x1b\[[\x20-\x3f]*[\x40-\x7e]/g;
25
+
26
+ // Matches OSC sequences: ESC ] ... (ESC \ or BEL)
27
+ const OSC_SEQUENCE = /\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g;
28
+
29
+ // Matches other ESC sequences (ESC + single character that is not '[' or ']')
30
+ const ESC_OTHER = /\x1b[^\[\]]/g;
31
+
32
+ // Matches standalone BEL
33
+ const BEL = /\x07/g;
34
+
35
+ /**
36
+ * Strip dangerous ANSI escape sequences from untrusted content.
37
+ *
38
+ * Preserves SGR color codes (\x1b[<n>m). Removes:
39
+ * - Cursor movement CSI sequences (\x1b[<n>A/B/C/D, \x1b[H, etc.)
40
+ * - OSC sequences (\x1b]...\x07 or \x1b]...\x1b\\)
41
+ * - Alt-screen and DECSET private mode (\x1b[?...h/l)
42
+ * - Any other CSI or ESC sequences
43
+ * - Bare BEL (\x07)
44
+ *
45
+ * @param input - Raw string that may contain ANSI escape sequences
46
+ * @returns Sanitized string safe for grid rendering
47
+ */
48
+ export function stripDangerousAnsi(input: string): string {
49
+ // Step 1: Extract and preserve SGR sequences by replacing them with placeholders,
50
+ // then strip all other escape sequences, then restore SGR sequences.
51
+ // This approach avoids complex negative lookahead regexes.
52
+
53
+ // Collect SGR sequences and replace with unique markers
54
+ const sgrTokens: string[] = [];
55
+ const withPlaceholders = input.replace(SGR_PATTERN, (match) => {
56
+ const idx = sgrTokens.length;
57
+ sgrTokens.push(match);
58
+ return `\x00SGR${idx}\x00`;
59
+ });
60
+
61
+ // Strip all remaining dangerous sequences
62
+ let sanitized = withPlaceholders
63
+ .replace(CSI_SEQUENCE, '') // removes cursor moves, alt-screen, DECSET, etc.
64
+ .replace(OSC_SEQUENCE, '') // removes OSC
65
+ .replace(ESC_OTHER, '') // removes remaining ESC+char sequences
66
+ .replace(/\x1b/g, '') // removes any leftover bare ESC
67
+ .replace(BEL, ''); // removes BEL
68
+
69
+ // Restore SGR sequences from placeholders
70
+ sanitized = sanitized.replace(/\x00SGR(\d+)\x00/g, (_match, idxStr) => {
71
+ const idx = parseInt(idxStr, 10);
72
+ return sgrTokens[idx] ?? '';
73
+ });
74
+
75
+ return sanitized;
76
+ }
@@ -17,7 +17,18 @@ export class TerminalBuffer {
17
17
 
18
18
  public setCell(x: number, y: number, cell: Partial<Cell>): void {
19
19
  if (y >= 0 && y < this.height && x >= 0 && x < this.width) {
20
- this.cells[y][x] = { ...this.cells[y][x], ...cell };
20
+ // No-op guard: skip the dirty mark and allocation if every field in `cell`
21
+ // already matches the current cell value (idempotent write).
22
+ const current = this.cells[y][x]!;
23
+ let changed = false;
24
+ for (const k in cell) {
25
+ if ((cell as unknown as Record<string, unknown>)[k] !== (current as unknown as Record<string, unknown>)[k]) {
26
+ changed = true;
27
+ break;
28
+ }
29
+ }
30
+ if (!changed) return;
31
+ this.cells[y][x] = { ...current, ...cell };
21
32
  this.dirtyRows[y] = true;
22
33
  }
23
34
  }
@@ -10,6 +10,7 @@ import type { SlashCommand } from '../input/command-registry.ts';
10
10
  import type { KeybindingsManager } from '../input/keybindings.ts';
11
11
  import { getOverlaySurfaceMetrics } from './overlay-viewport.ts';
12
12
  import { getVisibleWindow } from './surface-layout.ts';
13
+ import { logger } from '@pellux/goodvibes-sdk/platform/utils/logger';
13
14
 
14
15
  function toModalSections(rows: readonly string[]): import('./modal-factory.ts').ModalSection[] {
15
16
  return rows.map((row) => {
@@ -93,9 +94,19 @@ export function renderHelpOverlay(
93
94
  }
94
95
 
95
96
  const quickStartRows: string[] = [];
96
- for (const [name, argHint, desc] of FEATURED_COMMANDS) {
97
- if (!hasCommand(name)) continue; // omit if not in live registry
98
- quickStartRows.push(featuredRow(name, argHint, desc));
97
+ try {
98
+ for (const [name, argHint, desc] of FEATURED_COMMANDS) {
99
+ if (!hasCommand(name)) continue; // omit if not in live registry
100
+ quickStartRows.push(featuredRow(name, argHint, desc));
101
+ }
102
+ } catch (err) {
103
+ // A plugin command getter threw during registry traversal. Fall back to an
104
+ // unfiltered quick-start list so /help remains reachable.
105
+ logger.warn(`[help-overlay] registry traversal error during command filter; using unfiltered list: ${err}`);
106
+ quickStartRows.length = 0;
107
+ for (const [name, argHint, desc] of FEATURED_COMMANDS) {
108
+ quickStartRows.push(featuredRow(name, argHint, desc));
109
+ }
99
110
  }
100
111
 
101
112
  const commandRows: string[] = [];
@@ -294,12 +294,19 @@ export function renderModelPickerOverlay(
294
294
  const isSelected = selectableIdx === picker.selectedIndex;
295
295
  const indicator = isSelected ? `${OVERLAY_GLYPHS.selected} ` : ' ';
296
296
  const checkmark = item.isConfigured ? '✓ ' : ' ';
297
- const labelW = contentW - 2 - 2; // indicator(2) + checkmark(2)
297
+ // configuredVia badge: right-aligned short label (env/sub/anon)
298
+ const viaBadge = item.configuredVia === 'env' ? ' [env]'
299
+ : item.configuredVia === 'secrets' ? ' [key]'
300
+ : item.configuredVia === 'subscription' ? ' [sub]'
301
+ : item.configuredVia === 'anonymous' ? ' [anon]'
302
+ : '';
303
+ const badgeW = viaBadge.length;
304
+ const labelW = contentW - 2 - 2 - badgeW; // indicator(2) + checkmark(2) + badge
298
305
  const labelStr = item.label.length > labelW
299
306
  ? item.label.slice(0, labelW - 3) + '...'
300
307
  : item.label.padEnd(labelW);
301
308
  const row = createOverlayContentLine(width, layout, borderFg, isSelected ? selectedBg : DEFAULT_OVERLAY_PALETTE.bodyBg);
302
- const rowText = indicator + checkmark + labelStr;
309
+ const rowText = indicator + checkmark + labelStr + viaBadge;
303
310
  putRowText(row, layout.margin + 2, contentW, fitDisplay(truncateDisplay(rowText, contentW), contentW), isSelected ? titleFg : bodyFg, isSelected ? selectedBg : DEFAULT_OVERLAY_PALETTE.bodyBg, isSelected);
304
311
  lines.push(row);
305
312
  }
@@ -78,6 +78,7 @@ export const CATEGORY_LABELS: Record<(typeof SETTINGS_CATEGORIES)[number], strin
78
78
  danger: 'Danger',
79
79
  tools: 'Tools',
80
80
  flags: 'Flags',
81
+ network: 'Network',
81
82
  };
82
83
 
83
84
  export const SETTING_LABELS: Partial<Record<string, string>> = {
@@ -102,6 +103,32 @@ export const SETTING_LABELS: Partial<Record<string, string>> = {
102
103
  'helper.enabled': 'Helper Enabled',
103
104
  'helper.globalProvider': 'Helper Provider',
104
105
  'helper.globalModel': 'Helper Model',
106
+ // Control Plane
107
+ 'controlPlane.enabled': 'CP Enabled',
108
+ 'controlPlane.hostMode': 'CP Host Mode',
109
+ 'controlPlane.host': 'CP Host',
110
+ 'controlPlane.port': 'CP Port',
111
+ 'controlPlane.baseUrl': 'CP Base URL',
112
+ 'controlPlane.streamMode': 'CP Stream Mode',
113
+ 'controlPlane.allowRemote': 'CP Allow Remote',
114
+ 'controlPlane.trustProxy': 'CP Trust Proxy',
115
+ 'controlPlane.tls.mode': 'CP TLS Mode',
116
+ 'controlPlane.tls.certFile': 'CP TLS Cert',
117
+ 'controlPlane.tls.keyFile': 'CP TLS Key',
118
+ // HTTP Listener
119
+ 'httpListener.hostMode': 'HTTP Host Mode',
120
+ 'httpListener.host': 'HTTP Host',
121
+ 'httpListener.port': 'HTTP Port',
122
+ 'httpListener.trustProxy': 'HTTP Trust Proxy',
123
+ 'httpListener.tls.mode': 'HTTP TLS Mode',
124
+ 'httpListener.tls.certFile': 'HTTP TLS Cert',
125
+ // Web Server
126
+ 'web.enabled': 'Web Enabled',
127
+ 'web.hostMode': 'Web Host Mode',
128
+ 'web.host': 'Web Host',
129
+ 'web.port': 'Web Port',
130
+ 'web.publicBaseUrl': 'Web Public Base URL',
131
+ 'web.staticAssetsDir': 'Web Static Assets Dir',
105
132
  };
106
133
 
107
134
  export function getSettingLabel(entry: SettingEntry): string {
@@ -63,6 +63,7 @@ export function renderSettingsModal(
63
63
  const isFlagsTab = SETTINGS_CATEGORIES[modal.categoryIndex] === 'flags';
64
64
  const isUiTab = SETTINGS_CATEGORIES[modal.categoryIndex] === 'ui';
65
65
  const isToolsTab = SETTINGS_CATEGORIES[modal.categoryIndex] === 'tools';
66
+ const isNetworkTab = SETTINGS_CATEGORIES[modal.categoryIndex] === 'network';
66
67
  let persistentHelpers: import('./modal-factory.ts').ModalHelperRow[] | undefined;
67
68
  sections.push({
68
69
  type: 'text',
@@ -78,12 +79,28 @@ export function renderSettingsModal(
78
79
  ? 'Feature flags control staged or experimental behavior. Some changes may require restart.'
79
80
  : isToolsTab
80
81
  ? 'Configure tool LLM routing and helper model. Provider and model fields are optional — empty means use the active provider.'
81
- : 'Browse and adjust operator-facing runtime settings by category.',
82
+ : isNetworkTab
83
+ ? 'Configure control-plane and HTTP-listener binding. hostMode local/network use preset hosts; custom enables the host field. Changes trigger auto-restart.'
84
+ : 'Browse and adjust operator-facing runtime settings by category.',
82
85
  style: { fg: '246', dim: true },
83
86
  });
84
87
 
85
88
  sections.push({ type: 'separator' });
86
89
 
90
+ // ── Network tab restart notice ─────────────────────────────────
91
+ if (isNetworkTab && modal.lastSaveTriggeredRestart !== null) {
92
+ const restartTarget = modal.lastSaveTriggeredRestart === 'control-plane'
93
+ ? 'control-plane server'
94
+ : modal.lastSaveTriggeredRestart === 'http-listener'
95
+ ? 'HTTP listener'
96
+ : 'web server';
97
+ sections.push({
98
+ type: 'text',
99
+ content: truncateDisplay(`Restarting ${restartTarget}… server will reconnect momentarily.`, contentW),
100
+ style: { fg: '#38bdf8' },
101
+ });
102
+ }
103
+
87
104
  // ── Flags tab ──────────────────────────────────────────────────
88
105
  if (isFlagsTab) {
89
106
  const flagEntries: FlagEntry[] = modal.flagEntries;