@pellux/goodvibes-tui 0.18.20 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/CHANGELOG.md +154 -0
  2. package/README.md +1 -1
  3. package/docs/foundation-artifacts/operator-contract.json +1 -1
  4. package/package.json +7 -3
  5. package/src/core/conversation-rendering.ts +22 -6
  6. package/src/core/orchestrator.ts +1 -1
  7. package/src/input/commands/diff-runtime.ts +6 -5
  8. package/src/input/commands/guidance-runtime.ts +1 -1
  9. package/src/input/commands/health-runtime.ts +2 -2
  10. package/src/input/commands/local-setup-review.ts +1 -1
  11. package/src/input/commands/session-content.ts +1 -1
  12. package/src/input/commands/session.ts +0 -1
  13. package/src/input/commands/shell-core.ts +3 -2
  14. package/src/input/commands/skills-runtime.ts +2 -2
  15. package/src/input/commands/subscription-runtime.ts +4 -4
  16. package/src/input/feed-context-factory.ts +236 -0
  17. package/src/input/handler-feed.ts +44 -6
  18. package/src/input/handler-shortcuts.ts +138 -125
  19. package/src/input/handler.ts +119 -119
  20. package/src/input/keybindings.ts +30 -0
  21. package/src/input/panel-integration-actions.ts +2 -1
  22. package/src/input/settings-modal-types.ts +60 -0
  23. package/src/input/settings-modal.ts +83 -65
  24. package/src/panels/agent-inspector-panel.ts +10 -9
  25. package/src/panels/agent-logs-panel.ts +26 -6
  26. package/src/panels/approval-panel.ts +55 -82
  27. package/src/panels/automation-control-panel.ts +120 -161
  28. package/src/panels/base-panel.ts +108 -3
  29. package/src/panels/communication-panel.ts +69 -107
  30. package/src/panels/context-visualizer-panel.ts +2 -0
  31. package/src/panels/control-plane-panel.ts +117 -172
  32. package/src/panels/diff-panel.ts +2 -0
  33. package/src/panels/file-explorer-panel.ts +51 -31
  34. package/src/panels/file-preview-panel.ts +57 -35
  35. package/src/panels/git-panel.ts +12 -13
  36. package/src/panels/hooks-panel.ts +103 -138
  37. package/src/panels/incident-review-panel.ts +59 -109
  38. package/src/panels/knowledge-panel.ts +75 -107
  39. package/src/panels/local-auth-panel.ts +77 -93
  40. package/src/panels/marketplace-panel.ts +51 -69
  41. package/src/panels/mcp-panel.ts +110 -155
  42. package/src/panels/memory-panel.ts +90 -158
  43. package/src/panels/ops-control-panel.ts +51 -85
  44. package/src/panels/orchestration-panel.ts +70 -51
  45. package/src/panels/panel-list-panel.ts +5 -4
  46. package/src/panels/panel-manager.ts +25 -2
  47. package/src/panels/plan-dashboard-panel.ts +2 -0
  48. package/src/panels/plugins-panel.ts +37 -60
  49. package/src/panels/polish.ts +51 -2
  50. package/src/panels/provider-accounts-panel.ts +1 -0
  51. package/src/panels/provider-health-panel.ts +6 -8
  52. package/src/panels/routes-panel.ts +91 -141
  53. package/src/panels/schedule-panel.ts +7 -6
  54. package/src/panels/scrollable-list-panel.ts +64 -16
  55. package/src/panels/security-panel.ts +118 -152
  56. package/src/panels/services-panel.ts +63 -105
  57. package/src/panels/session-browser-panel.ts +19 -18
  58. package/src/panels/settings-sync-panel.ts +79 -123
  59. package/src/panels/skills-panel.ts +114 -230
  60. package/src/panels/subscription-panel.ts +64 -86
  61. package/src/panels/system-messages-panel.ts +147 -141
  62. package/src/panels/tasks-panel.ts +130 -179
  63. package/src/panels/token-budget-panel.ts +2 -0
  64. package/src/panels/watchers-panel.ts +89 -137
  65. package/src/panels/worktree-panel.ts +1 -0
  66. package/src/panels/wrfc-panel.ts +2 -0
  67. package/src/renderer/agent-detail-modal.ts +2 -2
  68. package/src/renderer/ansi-sanitize.ts +76 -0
  69. package/src/renderer/buffer.ts +23 -1
  70. package/src/renderer/diff.ts +8 -0
  71. package/src/renderer/help-overlay.ts +48 -28
  72. package/src/renderer/markdown.ts +3 -145
  73. package/src/renderer/settings-modal-helpers.ts +27 -0
  74. package/src/renderer/settings-modal.ts +18 -1
  75. package/src/renderer/status-glyphs.ts +21 -0
  76. package/src/renderer/status-token.ts +4 -8
  77. package/src/renderer/tool-call.ts +4 -3
  78. package/src/runtime/bootstrap-core.ts +1 -1
  79. package/src/runtime/bootstrap-hook-bridge.ts +1 -1
  80. package/src/runtime/bootstrap.ts +7 -8
  81. package/src/runtime/diagnostics/panels/policy.ts +2 -1
  82. package/src/shell/ui-openers.ts +1 -1
  83. package/src/version.ts +1 -1
@@ -1,6 +1,6 @@
1
1
  import type { Line } from '../types/grid.ts';
2
2
  import { createEmptyLine } from '../types/grid.ts';
3
- import { BasePanel } from './base-panel.ts';
3
+ import { ScrollableListPanel } from './scrollable-list-panel.ts';
4
4
  import type { UiReadModel, UiWatchersSnapshot } from '../runtime/ui-read-models.ts';
5
5
  import { truncateDisplay } from '../utils/terminal-width.ts';
6
6
  import {
@@ -10,8 +10,7 @@ import {
10
10
  buildPanelLine,
11
11
  buildPanelWorkspace,
12
12
  DEFAULT_PANEL_PALETTE,
13
- resolvePrimaryScrollableSection,
14
- type PanelWorkspaceSection,
13
+ type PanelPalette,
15
14
  } from './polish.ts';
16
15
 
17
16
  const C = {
@@ -51,14 +50,15 @@ function formatTime(value?: number): string {
51
50
  return new Date(value).toLocaleString();
52
51
  }
53
52
 
54
- export class WatchersPanel extends BasePanel {
53
+ type WatcherEntry = UiWatchersSnapshot['watchers'][number];
54
+
55
+ export class WatchersPanel extends ScrollableListPanel<WatcherEntry> {
55
56
  private readonly readModel?: UiReadModel<UiWatchersSnapshot>;
56
57
  private readonly unsub: (() => void) | null;
57
- private selectedIndex = 0;
58
- private scrollOffset = 0;
59
58
 
60
59
  public constructor(readModel?: UiReadModel<UiWatchersSnapshot>) {
61
60
  super('watchers', 'Watchers', 'W', 'monitoring');
61
+ this.showSelectionGutter = true; // I5: non-color selection affordance
62
62
  this.readModel = readModel;
63
63
  this.unsub = readModel ? readModel.subscribe(() => this.markDirty()) : null;
64
64
  }
@@ -67,29 +67,38 @@ export class WatchersPanel extends BasePanel {
67
67
  this.unsub?.();
68
68
  }
69
69
 
70
- public handleInput(key: string): boolean {
71
- const watchers = this.watchers();
72
- if (watchers.length === 0) return false;
73
- if (key === 'up' || key === 'k') {
74
- this.selectedIndex = Math.max(0, this.selectedIndex - 1);
75
- this.markDirty();
76
- return true;
77
- }
78
- if (key === 'down' || key === 'j') {
79
- this.selectedIndex = Math.min(watchers.length - 1, this.selectedIndex + 1);
80
- this.markDirty();
81
- return true;
82
- }
83
- return false;
70
+ protected override getPalette(): PanelPalette {
71
+ return C;
84
72
  }
85
73
 
86
- private watchers() {
74
+ protected getItems(): readonly WatcherEntry[] {
87
75
  if (!this.readModel) return [];
88
- return [...this.readModel.getSnapshot().watchers];
76
+ return this.readModel.getSnapshot().watchers;
77
+ }
78
+
79
+ protected renderItem(watcher: WatcherEntry, _index: number, selected: boolean, width: number): Line {
80
+ const bg = selected ? C.selectBg : undefined;
81
+ return buildPanelLine(width, [
82
+ [' ', C.label, bg],
83
+ [watcher.state.padEnd(10), stateColor(watcher.state), bg],
84
+ [` ${truncateDisplay(watcher.label, 18).padEnd(18)}`, C.value, bg],
85
+ [` ${String(watcher.sourceStatus ?? 'unknown').padEnd(10)}`, sourceStatusColor(watcher.sourceStatus), bg],
86
+ [` ${truncateDisplay(formatLag(watcher.sourceLagMs), Math.max(0, width - 43))}`, C.dim, bg],
87
+ ]);
88
+ }
89
+
90
+ protected override getEmptyStateMessage(): string {
91
+ return ' No watchers registered.';
92
+ }
93
+
94
+ protected override getEmptyStateActions(): Array<{ command: string; summary: string }> {
95
+ return [
96
+ { command: '/schedule list', summary: 'review automation that will consume watcher events' },
97
+ { command: '/services auth-review', summary: 'validate integration credentials before enabling remote watchers' },
98
+ ];
89
99
  }
90
100
 
91
101
  public render(width: number, height: number): Line[] {
92
- this.needsRender = false;
93
102
  const intro = 'Managed watchers and source health used to trigger automation, refresh routes, and surface degraded upstream conditions.';
94
103
 
95
104
  if (!this.readModel) {
@@ -112,130 +121,73 @@ export class WatchersPanel extends BasePanel {
112
121
  }
113
122
 
114
123
  const snapshot = this.readModel.getSnapshot();
115
- const watchers = this.watchers();
116
- const summarySection: PanelWorkspaceSection = {
117
- title: 'Posture',
118
- lines: [
119
- buildKeyValueLine(width, [
120
- { label: 'watchers', value: String(snapshot.totalWatchers), valueColor: snapshot.totalWatchers > 0 ? C.info : C.dim },
121
- { label: 'active', value: String(snapshot.activeWatcherIds.length), valueColor: snapshot.activeWatcherIds.length > 0 ? C.ok : C.dim },
122
- { label: 'degraded', value: String(snapshot.totalDegraded), valueColor: snapshot.totalDegraded > 0 ? C.warn : C.dim },
123
- { label: 'lagged', value: String(snapshot.totalLagged), valueColor: snapshot.totalLagged > 0 ? C.warn : C.dim },
124
- ], C),
125
- buildGuidanceLine(width, '/schedule list', 'verify jobs consuming these sources and use daemon APIs for watcher lifecycle control', C),
126
- ],
127
- };
124
+ const watchers = this.getItems();
125
+
126
+ const headerLines: Line[] = [
127
+ buildKeyValueLine(width, [
128
+ { label: 'watchers', value: String(snapshot.totalWatchers), valueColor: snapshot.totalWatchers > 0 ? C.info : C.dim },
129
+ { label: 'active', value: String(snapshot.activeWatcherIds.length), valueColor: snapshot.activeWatcherIds.length > 0 ? C.ok : C.dim },
130
+ { label: 'degraded', value: String(snapshot.totalDegraded), valueColor: snapshot.totalDegraded > 0 ? C.warn : C.dim },
131
+ { label: 'lagged', value: String(snapshot.totalLagged), valueColor: snapshot.totalLagged > 0 ? C.warn : C.dim },
132
+ ], C),
133
+ buildGuidanceLine(width, '/schedule list', 'verify jobs consuming these sources and use daemon APIs for watcher lifecycle control', C),
134
+ ];
128
135
 
129
136
  if (watchers.length === 0) {
130
- const workspace = buildPanelWorkspace(width, height, {
137
+ return this.renderList(width, height, {
131
138
  title: 'Watchers',
132
- intro,
133
- sections: [
134
- summarySection,
135
- {
136
- lines: buildEmptyState(
137
- width,
138
- ' No watchers registered.',
139
- 'Register daemon watchers or enable polling/integration sources to populate this control room.',
140
- [
141
- { command: '/schedule list', summary: 'review automation that will consume watcher events' },
142
- { command: '/services auth-review', summary: 'validate integration credentials before enabling remote watchers' },
143
- ],
144
- C,
145
- ),
146
- },
147
- ],
148
- palette: C,
139
+ header: headerLines,
140
+ emptyMessage: ' No watchers registered.',
149
141
  });
150
- while (workspace.length < height) workspace.push(createEmptyLine(width));
151
- return workspace;
152
142
  }
153
143
 
154
- this.selectedIndex = Math.min(this.selectedIndex, Math.max(0, watchers.length - 1));
144
+ this.clampSelection();
155
145
  const selected = watchers[this.selectedIndex]!;
156
146
 
157
- const detailSection: PanelWorkspaceSection = {
158
- title: 'Selected Watcher',
159
- lines: [
160
- buildPanelLine(width, [
161
- [' Watcher: ', C.label],
162
- [selected.label, C.value],
163
- [' Kind: ', C.label],
164
- [selected.kind, C.info],
165
- ]),
166
- buildPanelLine(width, [
167
- [' State: ', C.label],
168
- [selected.state, stateColor(selected.state)],
169
- [' Source: ', C.label],
170
- [selected.source.kind, C.value],
171
- ]),
172
- buildPanelLine(width, [
173
- [' Source status: ', C.label],
174
- [selected.sourceStatus ?? 'unknown', sourceStatusColor(selected.sourceStatus)],
175
- [' Lag: ', C.label],
176
- [formatLag(selected.sourceLagMs), selected.sourceLagMs ? C.warn : C.dim],
177
- ]),
178
- buildPanelLine(width, [
179
- [' Heartbeat: ', C.label],
180
- [formatTime(selected.lastHeartbeatAt), C.dim],
181
- [' Checkpoint: ', C.label],
182
- [truncateDisplay(selected.lastCheckpoint ?? 'n/a', Math.max(0, width - 38)), C.dim],
183
- ]),
184
- ...(selected.degradedReason ? [
185
- buildPanelLine(width, [
186
- [' Reason: ', C.label],
187
- [truncateDisplay(selected.degradedReason, Math.max(0, width - 11)), C.warn],
188
- ]),
189
- ] : []),
190
- ...(selected.lastError ? [
191
- buildPanelLine(width, [
192
- [' Error: ', C.label],
193
- [truncateDisplay(selected.lastError, Math.max(0, width - 10)), C.error],
194
- ]),
195
- ] : []),
196
- ],
197
- };
198
-
199
- const resolvedWatchers = resolvePrimaryScrollableSection(width, height, {
200
- intro,
201
- footerLines: [buildPanelLine(width, [[' Up/Down move through watchers', C.dim]])],
202
- palette: C,
203
- beforeSections: [summarySection],
204
- section: {
205
- title: 'Watchers',
206
- scrollableLines: watchers.map((watcher, absolute) => {
207
- const bg = absolute === this.selectedIndex ? C.selectBg : undefined;
208
- return buildPanelLine(width, [
209
- [' ', C.label, bg],
210
- [watcher.state.padEnd(10), stateColor(watcher.state), bg],
211
- [` ${truncateDisplay(watcher.label, 18).padEnd(18)}`, C.value, bg],
212
- [` ${String(watcher.sourceStatus ?? 'unknown').padEnd(10)}`, sourceStatusColor(watcher.sourceStatus), bg],
213
- [` ${truncateDisplay(formatLag(watcher.sourceLagMs), Math.max(0, width - 43))}`, C.dim, bg],
214
- ]);
215
- }),
216
- selectedIndex: this.selectedIndex,
217
- scrollOffset: this.scrollOffset,
218
- guardRows: 1,
219
- minRows: 5,
220
- appendWindowSummary: { dimColor: C.dim },
221
- },
222
- afterSections: [detailSection],
223
- });
224
- this.scrollOffset = resolvedWatchers.scrollOffset;
225
-
226
- const sections: PanelWorkspaceSection[] = [
227
- summarySection,
228
- resolvedWatchers.section,
229
- detailSection,
147
+ const footerLines: Line[] = [
148
+ buildPanelLine(width, [
149
+ [' Watcher: ', C.label],
150
+ [selected.label, C.value],
151
+ [' Kind: ', C.label],
152
+ [selected.kind, C.info],
153
+ ]),
154
+ buildPanelLine(width, [
155
+ [' State: ', C.label],
156
+ [selected.state, stateColor(selected.state)],
157
+ [' Source: ', C.label],
158
+ [selected.source.kind, C.value],
159
+ ]),
160
+ buildPanelLine(width, [
161
+ [' Source status: ', C.label],
162
+ [selected.sourceStatus ?? 'unknown', sourceStatusColor(selected.sourceStatus)],
163
+ [' Lag: ', C.label],
164
+ [formatLag(selected.sourceLagMs), selected.sourceLagMs ? C.warn : C.dim],
165
+ ]),
166
+ buildPanelLine(width, [
167
+ [' Heartbeat: ', C.label],
168
+ [formatTime(selected.lastHeartbeatAt), C.dim],
169
+ [' Checkpoint: ', C.label],
170
+ [truncateDisplay(selected.lastCheckpoint ?? 'n/a', Math.max(0, width - 38)), C.dim],
171
+ ]),
230
172
  ];
231
- const lines = buildPanelWorkspace(width, height, {
173
+ if (selected.degradedReason) {
174
+ footerLines.push(buildPanelLine(width, [
175
+ [' Reason: ', C.label],
176
+ [truncateDisplay(selected.degradedReason, Math.max(0, width - 11)), C.warn],
177
+ ]));
178
+ }
179
+ if (selected.lastError) {
180
+ footerLines.push(buildPanelLine(width, [
181
+ [' Error: ', C.label],
182
+ [truncateDisplay(selected.lastError, Math.max(0, width - 10)), C.error],
183
+ ]));
184
+ }
185
+ footerLines.push(buildPanelLine(width, [[' Up/Down move through watchers', C.dim]]));
186
+
187
+ return this.renderList(width, height, {
232
188
  title: 'Watchers',
233
- intro,
234
- sections,
235
- footerLines: [buildPanelLine(width, [[' Up/Down move through watchers', C.dim]])],
236
- palette: C,
189
+ header: headerLines,
190
+ footer: footerLines,
237
191
  });
238
- while (lines.length < height) lines.push(createEmptyLine(width));
239
- return lines.slice(0, height);
240
192
  }
241
193
  }
@@ -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
+ }
@@ -2,17 +2,34 @@ import { type Line, type Cell, createEmptyLine, createEmptyCell } from '../types
2
2
 
3
3
  /**
4
4
  * TerminalBuffer - Represents a 2D grid of styled cells.
5
+ * Tracks a per-row dirty bitmap so the diff engine can skip rows that were
6
+ * never written in the current frame.
5
7
  */
6
8
  export class TerminalBuffer {
7
9
  public cells: Line[];
10
+ /** dirtyRows[y] is true if row y was written since the last reset(). */
11
+ public dirtyRows: boolean[];
8
12
 
9
13
  constructor(public width: number, public height: number) {
10
14
  this.cells = Array.from({ length: height }, () => createEmptyLine(width));
15
+ this.dirtyRows = new Array(height).fill(false);
11
16
  }
12
17
 
13
18
  public setCell(x: number, y: number, cell: Partial<Cell>): void {
14
19
  if (y >= 0 && y < this.height && x >= 0 && x < this.width) {
15
- 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 };
32
+ this.dirtyRows[y] = true;
16
33
  }
17
34
  }
18
35
 
@@ -23,30 +40,35 @@ export class TerminalBuffer {
23
40
  public blitLine(row: number, line: Line): void {
24
41
  if (row >= 0 && row < this.height) {
25
42
  this.cells[row] = [...line];
43
+ this.dirtyRows[row] = true;
26
44
  }
27
45
  }
28
46
 
29
47
  public clone(): TerminalBuffer {
30
48
  const newBuf = new TerminalBuffer(this.width, this.height);
31
49
  newBuf.cells = this.cells.map(line => line.map(cell => ({ ...cell })));
50
+ newBuf.dirtyRows = [...this.dirtyRows];
32
51
  return newBuf;
33
52
  }
34
53
 
35
54
  /**
36
55
  * Reset all cells in-place to empty, reusing this buffer instance.
37
56
  * If dimensions changed, reallocates cells array.
57
+ * Always clears the dirty bitmap.
38
58
  */
39
59
  public reset(width: number, height: number): void {
40
60
  if (width !== this.width || height !== this.height) {
41
61
  this.width = width;
42
62
  this.height = height;
43
63
  this.cells = Array.from({ length: height }, () => createEmptyLine(width));
64
+ this.dirtyRows = new Array(height).fill(false);
44
65
  } else {
45
66
  for (let y = 0; y < this.height; y++) {
46
67
  const row = this.cells[y]!;
47
68
  for (let x = 0; x < this.width; x++) {
48
69
  row[x] = createEmptyCell();
49
70
  }
71
+ this.dirtyRows[y] = false;
50
72
  }
51
73
  }
52
74
  }
@@ -29,6 +29,14 @@ export class DiffEngine {
29
29
  let output = '';
30
30
 
31
31
  for (let y = 0; y < newBuffer.height; y++) {
32
+ // Skip rows that were not written in either the old or new buffer.
33
+ // If neither side touched the row, both must match the prior frame:
34
+ // old row was never written this frame (clean) and new row is also
35
+ // clean, so the on-screen content is still correct. No diff needed.
36
+ const newDirty = newBuffer.dirtyRows[y] ?? false;
37
+ const oldDirty = oldBuffer ? (oldBuffer.dirtyRows[y] ?? false) : true;
38
+ if (!newDirty && !oldDirty) continue;
39
+
32
40
  for (let x = 0; x < newBuffer.width; x++) {
33
41
  const oldCell = oldBuffer?.getCell(x, y);
34
42
  const newCell = newBuffer.cells[y]?.[x];
@@ -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) => {
@@ -64,36 +65,55 @@ export function renderHelpOverlay(
64
65
  '',
65
66
  ];
66
67
 
67
- const commandRows: string[] = [
68
- ' Quick Start',
69
- ' ' + '\u2500'.repeat(40),
70
- ' /setup onboarding Guided first-run review and environment posture',
71
- ' /cockpit Unified runtime control room',
72
- ' /settings Settings and config browser',
73
- '',
74
- ' Build And Operate',
75
- ' ' + '\u2500'.repeat(40),
76
- ' /provider Choose provider or model family',
77
- ' /subscription Review provider logins and subscriptions',
78
- ' /marketplace open Browse plugins, skills, and packs',
79
- ' /remote setup Review remote, bridge, and tunnel flows',
80
- ' /sandbox review Inspect secure execution posture',
81
- '',
82
- ' Review And Govern',
83
- ' ' + '\u2500'.repeat(40),
84
- ' /security Security review workspace',
85
- ' /policy Simulation, lint, and preflight review',
86
- ' /incident Incident workspace and export flows',
87
- ' /knowledge Durable knowledge and review queue',
88
- '',
89
- ' Power Surfaces',
90
- ' ' + '\u2500'.repeat(40),
91
- ' /hooks Hook workbench and runtime activity',
92
- ' /orchestration Graph and recursive-agent control room',
93
- ' /communication Structured agent communication workspace',
94
- ' /tasks Task surface for list/show/pause/resume/output',
68
+ // Featured commands shown in the Quick Start section.
69
+ // Each entry is [commandName, subcommandOrArgHint, description].
70
+ // Commands not registered in the live registry are omitted at render time.
71
+ const FEATURED_COMMANDS: Array<[name: string, argHint: string, desc: string]> = [
72
+ ['setup', 'onboarding', 'Guided first-run review and environment posture'],
73
+ ['cockpit', '', 'Unified runtime control room'],
74
+ ['settings', '', 'Settings and config browser'],
75
+ ['provider', '', 'Choose provider or model family'],
76
+ ['subscription', '', 'Review provider logins and subscriptions'],
77
+ ['marketplace', 'open', 'Browse plugins, skills, and packs'],
78
+ ['remote', 'setup', 'Review remote, bridge, and tunnel flows'],
79
+ ['sandbox', 'review', 'Inspect secure execution posture'],
80
+ ['security', '', 'Security review workspace'],
81
+ ['policy', '', 'Simulation, lint, and preflight review'],
82
+ ['incident', '', 'Incident workspace and export flows'],
83
+ ['knowledge', '', 'Durable knowledge and review queue'],
84
+ ['hooks', '', 'Hook workbench and runtime activity'],
85
+ ['orchestration','', 'Graph and recursive-agent control room'],
86
+ ['communication','', 'Structured agent communication workspace'],
87
+ ['tasks', '', 'Task surface for list/show/pause/resume/output'],
95
88
  ];
96
89
 
90
+ // Build command rows from featured list, filtering out unregistered commands.
91
+ function featuredRow(name: string, argHint: string, desc: string): string {
92
+ const invocation = argHint ? `/${name} ${argHint}` : `/${name}`;
93
+ return ` ${invocation.padEnd(23)} ${desc}`;
94
+ }
95
+
96
+ const quickStartRows: string[] = [];
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
+ }
110
+ }
111
+
112
+ const commandRows: string[] = [];
113
+ if (quickStartRows.length > 0) {
114
+ commandRows.push(' Quick Start', ' ' + '\u2500'.repeat(40), ...quickStartRows, '');
115
+ }
116
+
97
117
  if (commands && commands.length > 0) {
98
118
  commandRows.push('', ' Available Slash Commands', ' ' + '\u2500'.repeat(40));
99
119
  const preferred = ['setup', 'cockpit', 'settings', 'provider', 'subscription', 'marketplace', 'remote', 'sandbox', 'security', 'policy', 'incident', 'knowledge', 'hooks', 'orchestration', 'communication', 'tasks'];