@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.
- package/CHANGELOG.md +154 -0
- package/README.md +1 -1
- package/docs/foundation-artifacts/operator-contract.json +1 -1
- package/package.json +7 -3
- package/src/core/conversation-rendering.ts +22 -6
- package/src/core/orchestrator.ts +1 -1
- package/src/input/commands/diff-runtime.ts +6 -5
- package/src/input/commands/guidance-runtime.ts +1 -1
- package/src/input/commands/health-runtime.ts +2 -2
- package/src/input/commands/local-setup-review.ts +1 -1
- package/src/input/commands/session-content.ts +1 -1
- package/src/input/commands/session.ts +0 -1
- package/src/input/commands/shell-core.ts +3 -2
- package/src/input/commands/skills-runtime.ts +2 -2
- package/src/input/commands/subscription-runtime.ts +4 -4
- package/src/input/feed-context-factory.ts +236 -0
- package/src/input/handler-feed.ts +44 -6
- package/src/input/handler-shortcuts.ts +138 -125
- package/src/input/handler.ts +119 -119
- package/src/input/keybindings.ts +30 -0
- package/src/input/panel-integration-actions.ts +2 -1
- package/src/input/settings-modal-types.ts +60 -0
- package/src/input/settings-modal.ts +83 -65
- package/src/panels/agent-inspector-panel.ts +10 -9
- package/src/panels/agent-logs-panel.ts +26 -6
- package/src/panels/approval-panel.ts +55 -82
- package/src/panels/automation-control-panel.ts +120 -161
- package/src/panels/base-panel.ts +108 -3
- package/src/panels/communication-panel.ts +69 -107
- package/src/panels/context-visualizer-panel.ts +2 -0
- package/src/panels/control-plane-panel.ts +117 -172
- package/src/panels/diff-panel.ts +2 -0
- package/src/panels/file-explorer-panel.ts +51 -31
- package/src/panels/file-preview-panel.ts +57 -35
- package/src/panels/git-panel.ts +12 -13
- package/src/panels/hooks-panel.ts +103 -138
- package/src/panels/incident-review-panel.ts +59 -109
- package/src/panels/knowledge-panel.ts +75 -107
- package/src/panels/local-auth-panel.ts +77 -93
- package/src/panels/marketplace-panel.ts +51 -69
- package/src/panels/mcp-panel.ts +110 -155
- package/src/panels/memory-panel.ts +90 -158
- package/src/panels/ops-control-panel.ts +51 -85
- package/src/panels/orchestration-panel.ts +70 -51
- package/src/panels/panel-list-panel.ts +5 -4
- package/src/panels/panel-manager.ts +25 -2
- package/src/panels/plan-dashboard-panel.ts +2 -0
- package/src/panels/plugins-panel.ts +37 -60
- package/src/panels/polish.ts +51 -2
- package/src/panels/provider-accounts-panel.ts +1 -0
- package/src/panels/provider-health-panel.ts +6 -8
- package/src/panels/routes-panel.ts +91 -141
- package/src/panels/schedule-panel.ts +7 -6
- package/src/panels/scrollable-list-panel.ts +64 -16
- package/src/panels/security-panel.ts +118 -152
- package/src/panels/services-panel.ts +63 -105
- package/src/panels/session-browser-panel.ts +19 -18
- package/src/panels/settings-sync-panel.ts +79 -123
- package/src/panels/skills-panel.ts +114 -230
- package/src/panels/subscription-panel.ts +64 -86
- package/src/panels/system-messages-panel.ts +147 -141
- package/src/panels/tasks-panel.ts +130 -179
- package/src/panels/token-budget-panel.ts +2 -0
- package/src/panels/watchers-panel.ts +89 -137
- package/src/panels/worktree-panel.ts +1 -0
- package/src/panels/wrfc-panel.ts +2 -0
- package/src/renderer/agent-detail-modal.ts +2 -2
- package/src/renderer/ansi-sanitize.ts +76 -0
- package/src/renderer/buffer.ts +23 -1
- package/src/renderer/diff.ts +8 -0
- package/src/renderer/help-overlay.ts +48 -28
- package/src/renderer/markdown.ts +3 -145
- package/src/renderer/settings-modal-helpers.ts +27 -0
- package/src/renderer/settings-modal.ts +18 -1
- package/src/renderer/status-glyphs.ts +21 -0
- package/src/renderer/status-token.ts +4 -8
- package/src/renderer/tool-call.ts +4 -3
- package/src/runtime/bootstrap-core.ts +1 -1
- package/src/runtime/bootstrap-hook-bridge.ts +1 -1
- package/src/runtime/bootstrap.ts +7 -8
- package/src/runtime/diagnostics/panels/policy.ts +2 -1
- package/src/shell/ui-openers.ts +1 -1
- 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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
71
|
-
|
|
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
|
-
|
|
74
|
+
protected getItems(): readonly WatcherEntry[] {
|
|
87
75
|
if (!this.readModel) return [];
|
|
88
|
-
return
|
|
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.
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
-
|
|
137
|
+
return this.renderList(width, height, {
|
|
131
138
|
title: 'Watchers',
|
|
132
|
-
|
|
133
|
-
|
|
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.
|
|
144
|
+
this.clampSelection();
|
|
155
145
|
const selected = watchers[this.selectedIndex]!;
|
|
156
146
|
|
|
157
|
-
const
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
]
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
]
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
]
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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
|
-
|
|
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
|
-
|
|
234
|
-
|
|
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
|
}
|
package/src/panels/wrfc-panel.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/renderer/buffer.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/src/renderer/diff.ts
CHANGED
|
@@ -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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
'
|
|
72
|
-
'
|
|
73
|
-
'',
|
|
74
|
-
'
|
|
75
|
-
'
|
|
76
|
-
'
|
|
77
|
-
'
|
|
78
|
-
'
|
|
79
|
-
'
|
|
80
|
-
'
|
|
81
|
-
'',
|
|
82
|
-
'
|
|
83
|
-
'
|
|
84
|
-
'
|
|
85
|
-
'
|
|
86
|
-
'
|
|
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'];
|