@pellux/goodvibes-tui 0.27.0 → 0.29.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 +124 -0
- package/README.md +1 -1
- package/package.json +1 -1
- package/src/core/context-usage.ts +53 -0
- package/src/core/conversation.ts +4 -6
- package/src/core/session-recovery.ts +2 -0
- package/src/core/turn-event-wiring.ts +1 -0
- package/src/input/commands/cost-runtime.ts +49 -0
- package/src/input/commands/eval.ts +4 -3
- package/src/input/commands/operator-runtime.ts +5 -1
- package/src/input/commands.ts +2 -0
- package/src/input/handler-feed-routes.ts +60 -26
- package/src/input/handler-feed.ts +17 -8
- package/src/input/handler-picker-routes.ts +18 -13
- package/src/input/handler-shortcuts.ts +51 -0
- package/src/input/handler-ui-state.ts +4 -0
- package/src/input/handler.ts +43 -6
- package/src/input/keybindings.ts +53 -8
- package/src/input/model-picker.ts +5 -0
- package/src/input/panel-integration-actions.ts +117 -1
- package/src/main.ts +5 -2
- package/src/panels/agent-inspector-panel.ts +139 -45
- package/src/panels/agent-inspector-shared.ts +147 -3
- package/src/panels/approval-panel.ts +204 -102
- package/src/panels/automation-control-panel.ts +370 -103
- package/src/panels/base-panel.ts +26 -2
- package/src/panels/builtin/agent.ts +28 -37
- package/src/panels/builtin/development.ts +40 -32
- package/src/panels/builtin/knowledge.ts +15 -6
- package/src/panels/builtin/operations.ts +178 -117
- package/src/panels/builtin/session.ts +35 -9
- package/src/panels/builtin/shared.ts +98 -6
- package/src/panels/cockpit-panel.ts +253 -75
- package/src/panels/cockpit-read-model.ts +2 -1
- package/src/panels/communication-panel.ts +159 -56
- package/src/panels/confirm-state.ts +8 -1
- package/src/panels/control-plane-panel.ts +354 -95
- package/src/panels/cost-tracker-panel.ts +243 -57
- package/src/panels/debug-panel.ts +347 -159
- package/src/panels/diff-panel.ts +257 -82
- package/src/panels/docs-panel.ts +166 -64
- package/src/panels/eval-panel.ts +336 -109
- package/src/panels/expandable-list-panel.ts +189 -0
- package/src/panels/file-explorer-panel.ts +238 -159
- package/src/panels/file-preview-panel.ts +141 -59
- package/src/panels/git-panel.ts +360 -191
- package/src/panels/hooks-panel.ts +181 -19
- package/src/panels/incident-review-panel.ts +252 -43
- package/src/panels/index.ts +0 -4
- package/src/panels/intelligence-panel.ts +310 -103
- package/src/panels/knowledge-graph-panel.ts +480 -58
- package/src/panels/local-auth-panel.ts +251 -33
- package/src/panels/marketplace-panel.ts +219 -33
- package/src/panels/memory-panel.ts +83 -81
- package/src/panels/ops-control-panel.ts +211 -32
- package/src/panels/ops-strategy-panel.ts +131 -45
- package/src/panels/orchestration-panel.ts +259 -67
- package/src/panels/panel-list-panel.ts +184 -136
- package/src/panels/panel-manager.ts +120 -13
- package/src/panels/plan-dashboard-panel.ts +507 -74
- package/src/panels/plugins-panel.ts +227 -34
- package/src/panels/policy-panel.ts +264 -55
- package/src/panels/polish-core.ts +162 -0
- package/src/panels/polish-tables.ts +258 -0
- package/src/panels/polish.ts +67 -149
- package/src/panels/project-planning-answer-actions.ts +134 -0
- package/src/panels/project-planning-panel.ts +84 -113
- package/src/panels/provider-health-panel.ts +438 -480
- package/src/panels/provider-health-routes.ts +203 -0
- package/src/panels/provider-health-tracker.ts +194 -6
- package/src/panels/provider-health-views.ts +560 -0
- package/src/panels/qr-panel.ts +136 -38
- package/src/panels/remote-panel.ts +123 -38
- package/src/panels/routes-panel.ts +87 -24
- package/src/panels/sandbox-panel.ts +232 -65
- package/src/panels/scrollable-list-panel.ts +143 -145
- package/src/panels/security-panel.ts +204 -52
- package/src/panels/services-panel.ts +174 -76
- package/src/panels/session-browser-panel.ts +101 -6
- package/src/panels/session-maintenance.ts +4 -122
- package/src/panels/settings-sync-panel.ts +346 -68
- package/src/panels/skills-panel.ts +157 -89
- package/src/panels/subscription-panel.ts +105 -34
- package/src/panels/symbol-outline-panel.ts +287 -154
- package/src/panels/system-messages-panel.ts +172 -38
- package/src/panels/tasks-panel.ts +348 -134
- package/src/panels/thinking-panel.ts +66 -32
- package/src/panels/token-budget-panel.ts +233 -46
- package/src/panels/tool-inspector-panel.ts +170 -54
- package/src/panels/types.ts +65 -4
- package/src/panels/work-plan-panel.ts +393 -39
- package/src/panels/worktree-panel.ts +239 -61
- package/src/panels/wrfc-panel-format.ts +133 -0
- package/src/panels/wrfc-panel.ts +146 -150
- package/src/renderer/compaction-preview.ts +2 -1
- package/src/renderer/compositor.ts +46 -43
- package/src/renderer/conversation-overlays.ts +25 -11
- package/src/renderer/footer-tips.ts +41 -0
- package/src/renderer/fullscreen-primitives.ts +22 -16
- package/src/renderer/help-overlay.ts +91 -14
- package/src/renderer/hint-grammar.ts +52 -0
- package/src/renderer/layout.ts +7 -7
- package/src/renderer/modal-factory.ts +23 -15
- package/src/renderer/model-picker-overlay.ts +30 -11
- package/src/renderer/model-workspace.ts +2 -3
- package/src/renderer/overlay-box.ts +16 -10
- package/src/renderer/panel-composite.ts +7 -20
- package/src/renderer/panel-workspace-bar.ts +14 -8
- package/src/renderer/process-indicator.ts +3 -1
- package/src/renderer/progress.ts +8 -6
- package/src/renderer/search-overlay.ts +27 -6
- package/src/renderer/session-picker-modal.ts +6 -4
- package/src/renderer/settings-modal.ts +70 -10
- package/src/renderer/shell-surface.ts +41 -15
- package/src/renderer/status-glyphs.ts +11 -9
- package/src/renderer/tab-strip.ts +148 -34
- package/src/renderer/theme.ts +60 -3
- package/src/renderer/ui-factory.ts +45 -36
- package/src/renderer/ui-primitives.ts +23 -2
- package/src/runtime/bootstrap-shell.ts +35 -18
- package/src/runtime/diagnostics/panels/index.ts +0 -3
- package/src/runtime/ui/model-picker/health-enrichment.ts +3 -0
- package/src/runtime/ui/model-picker/types.ts +4 -0
- package/src/shell/ui-openers.ts +14 -22
- package/src/utils/format-duration.ts +55 -0
- package/src/utils/format-number.ts +71 -0
- package/src/utils/splash-lines.ts +44 -3
- package/src/version.ts +1 -1
- package/src/work-plans/work-plan-store.ts +13 -0
- package/src/panels/agent-logs-panel.ts +0 -666
- package/src/panels/agent-logs-shared.ts +0 -129
- package/src/panels/context-visualizer-panel.ts +0 -214
- package/src/panels/forensics-panel.ts +0 -364
- package/src/panels/panel-picker.ts +0 -106
- package/src/panels/provider-account-snapshot.ts +0 -259
- package/src/panels/provider-accounts-panel.ts +0 -218
- package/src/panels/provider-stats-panel.ts +0 -366
- package/src/panels/schedule-panel.ts +0 -342
- package/src/panels/watchers-panel.ts +0 -193
- package/src/renderer/panel-picker-overlay.ts +0 -202
- package/src/renderer/panel-tab-bar.ts +0 -69
- package/src/runtime/diagnostics/panels/panel-resources.ts +0 -118
- package/src/runtime/diagnostics/panels/policy.ts +0 -177
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
import type { Line } from '../types/grid.ts';
|
|
2
2
|
import { createEmptyLine } from '../types/grid.ts';
|
|
3
3
|
import { ScrollableListPanel } from './scrollable-list-panel.ts';
|
|
4
|
+
import type { PanelIntegrationContext } from './types.ts';
|
|
5
|
+
import { SessionBrowserPanel } from './session-browser-panel.ts';
|
|
4
6
|
import type { UiReadModel, UiRoutesSnapshot } from '../runtime/ui-read-models.ts';
|
|
5
7
|
import { truncateDisplay } from '../utils/terminal-width.ts';
|
|
6
8
|
import {
|
|
9
|
+
buildDetailBlock,
|
|
7
10
|
buildEmptyState,
|
|
8
|
-
buildGuidanceLine,
|
|
9
11
|
buildKeyValueLine,
|
|
12
|
+
buildKeyboardHints,
|
|
10
13
|
buildPanelLine,
|
|
11
14
|
buildPanelWorkspace,
|
|
12
15
|
buildStatusPill,
|
|
@@ -14,16 +17,9 @@ import {
|
|
|
14
17
|
type PanelPalette,
|
|
15
18
|
} from './polish.ts';
|
|
16
19
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
headerBg: '#1e293b',
|
|
21
|
-
ok: '#22c55e',
|
|
22
|
-
warn: '#eab308',
|
|
23
|
-
error: '#ef4444',
|
|
24
|
-
info: '#38bdf8',
|
|
25
|
-
selectBg: '#0f172a',
|
|
26
|
-
} as const;
|
|
20
|
+
// Base chrome only — title band, state colors, and text tokens all come
|
|
21
|
+
// straight from DEFAULT_PANEL_PALETTE (WO-002).
|
|
22
|
+
const C = DEFAULT_PANEL_PALETTE;
|
|
27
23
|
|
|
28
24
|
function formatTime(value?: number): string {
|
|
29
25
|
if (!value) return 'n/a';
|
|
@@ -32,17 +28,71 @@ function formatTime(value?: number): string {
|
|
|
32
28
|
|
|
33
29
|
type RouteBinding = UiRoutesSnapshot['bindings'][number];
|
|
34
30
|
|
|
31
|
+
// Set by handleInput (enter/c) and consumed on the very next
|
|
32
|
+
// handlePanelIntegrationAction dispatch of that same key — handleInput has
|
|
33
|
+
// no access to the panelManager.
|
|
34
|
+
type PendingRouteAction =
|
|
35
|
+
| { readonly kind: 'open-session'; readonly sessionId: string }
|
|
36
|
+
| { readonly kind: 'open-communication' };
|
|
37
|
+
|
|
35
38
|
export class RoutesPanel extends ScrollableListPanel<RouteBinding> {
|
|
36
39
|
private readonly readModel?: UiReadModel<UiRoutesSnapshot>;
|
|
37
40
|
private readonly unsub: (() => void) | null;
|
|
41
|
+
private pendingAction: PendingRouteAction | null = null;
|
|
38
42
|
|
|
39
43
|
public constructor(readModel?: UiReadModel<UiRoutesSnapshot>) {
|
|
40
|
-
super('routes', 'Routes', 'R', '
|
|
44
|
+
super('routes', 'Routes', 'R', 'runtime-ops');
|
|
41
45
|
this.showSelectionGutter = true; // I5: non-color selection affordance
|
|
46
|
+
this.filterEnabled = true;
|
|
47
|
+
this.filterLabel = 'Filter routes';
|
|
42
48
|
this.readModel = readModel;
|
|
43
49
|
this.unsub = readModel ? readModel.subscribe(() => this.markDirty()) : null;
|
|
44
50
|
}
|
|
45
51
|
|
|
52
|
+
public override handleInput(key: string): boolean {
|
|
53
|
+
if (!this.filterActive) {
|
|
54
|
+
// Enter jumps to the session browser focused on this binding's session
|
|
55
|
+
// — a direct panel jump instead of a printed slash-command signpost.
|
|
56
|
+
if (key === 'enter' || key === 'return') {
|
|
57
|
+
const selected = this.getSelectedItem();
|
|
58
|
+
if (selected?.sessionId) {
|
|
59
|
+
this.pendingAction = { kind: 'open-session', sessionId: selected.sessionId };
|
|
60
|
+
return true;
|
|
61
|
+
}
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
// c opens the communication panel to inspect routed message flow.
|
|
65
|
+
if (key === 'c') {
|
|
66
|
+
this.pendingAction = { kind: 'open-communication' };
|
|
67
|
+
return true;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return super.handleInput(key);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
public handlePanelIntegrationAction(_key: string, ctx: PanelIntegrationContext): boolean {
|
|
74
|
+
if (!this.pendingAction) return false;
|
|
75
|
+
const action = this.pendingAction;
|
|
76
|
+
this.pendingAction = null;
|
|
77
|
+
if (action.kind === 'open-communication') {
|
|
78
|
+
ctx.panelManager.open('communication');
|
|
79
|
+
return true;
|
|
80
|
+
}
|
|
81
|
+
const panel = ctx.panelManager.open('sessions');
|
|
82
|
+
if (panel instanceof SessionBrowserPanel) {
|
|
83
|
+
panel.focusSession(action.sessionId);
|
|
84
|
+
}
|
|
85
|
+
return true;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
protected override filterMatches(binding: RouteBinding, q: string): boolean {
|
|
89
|
+
return binding.surfaceKind.toLowerCase().includes(q)
|
|
90
|
+
|| (binding.title ?? '').toLowerCase().includes(q)
|
|
91
|
+
|| binding.externalId.toLowerCase().includes(q)
|
|
92
|
+
|| (binding.sessionId ?? '').toLowerCase().includes(q)
|
|
93
|
+
|| (binding.runId ?? '').toLowerCase().includes(q);
|
|
94
|
+
}
|
|
95
|
+
|
|
46
96
|
public override onDestroy(): void {
|
|
47
97
|
this.unsub?.();
|
|
48
98
|
}
|
|
@@ -109,11 +159,10 @@ export class RoutesPanel extends ScrollableListPanel<RouteBinding> {
|
|
|
109
159
|
const headerLines: Line[] = [
|
|
110
160
|
buildKeyValueLine(width, [
|
|
111
161
|
{ label: 'bindings', value: String(snapshot.totalBindings), valueColor: snapshot.totalBindings > 0 ? C.info : C.dim },
|
|
112
|
-
{ label: 'active', value: String(snapshot.activeBindingIds.length), valueColor: snapshot.activeBindingIds.length > 0 ? C.
|
|
113
|
-
{ label: 'resolved', value: String(snapshot.totalResolved), valueColor: snapshot.totalResolved > 0 ? C.
|
|
114
|
-
{ label: 'failures', value: String(snapshot.totalFailures), valueColor: snapshot.totalFailures > 0 ? C.
|
|
162
|
+
{ label: 'active', value: String(snapshot.activeBindingIds.length), valueColor: snapshot.activeBindingIds.length > 0 ? C.good : C.dim },
|
|
163
|
+
{ label: 'resolved', value: String(snapshot.totalResolved), valueColor: snapshot.totalResolved > 0 ? C.good : C.dim },
|
|
164
|
+
{ label: 'failures', value: String(snapshot.totalFailures), valueColor: snapshot.totalFailures > 0 ? C.bad : C.dim },
|
|
115
165
|
], C),
|
|
116
|
-
buildGuidanceLine(width, '/communication', 'inspect routed message flow and delivery behavior across bound surfaces', C),
|
|
117
166
|
];
|
|
118
167
|
|
|
119
168
|
if (bindings.length === 0) {
|
|
@@ -125,9 +174,12 @@ export class RoutesPanel extends ScrollableListPanel<RouteBinding> {
|
|
|
125
174
|
}
|
|
126
175
|
|
|
127
176
|
this.clampSelection();
|
|
128
|
-
|
|
177
|
+
// Detail must describe the row the (possibly filtered) list highlights —
|
|
178
|
+
// getItems() would desync under an applied filter, and a filter that
|
|
179
|
+
// matches nothing leaves no selection at all.
|
|
180
|
+
const selected = this.getSelectedItem();
|
|
129
181
|
|
|
130
|
-
const
|
|
182
|
+
const detailRows: Line[] = selected ? [
|
|
131
183
|
buildPanelLine(width, [
|
|
132
184
|
[' Binding: ', C.label],
|
|
133
185
|
[selected.id, C.value],
|
|
@@ -156,23 +208,34 @@ export class RoutesPanel extends ScrollableListPanel<RouteBinding> {
|
|
|
156
208
|
[' Last seen: ', C.label],
|
|
157
209
|
[formatTime(selected.lastSeenAt), C.dim],
|
|
158
210
|
]),
|
|
159
|
-
];
|
|
211
|
+
] : [];
|
|
160
212
|
|
|
161
|
-
if (surfaceEntries.length > 0) {
|
|
162
|
-
|
|
163
|
-
...surfaceEntries.slice(0,
|
|
213
|
+
if (selected && surfaceEntries.length > 0) {
|
|
214
|
+
detailRows.push(
|
|
215
|
+
...surfaceEntries.slice(0, 4).map(([surface, ids]) => buildPanelLine(width, [
|
|
164
216
|
[' ', C.label],
|
|
165
217
|
[surface.padEnd(10), C.info],
|
|
166
218
|
[` ${String(ids.length)} binding(s)`, C.value],
|
|
167
219
|
])),
|
|
168
220
|
);
|
|
169
221
|
}
|
|
170
|
-
|
|
222
|
+
|
|
223
|
+
const hints = this.filterActive
|
|
224
|
+
? [{ keys: 'type', label: 'filter' }, { keys: 'Enter', label: 'apply' }, { keys: 'Esc', label: 'clear' }]
|
|
225
|
+
: [
|
|
226
|
+
{ keys: 'Up/Down', label: 'move' },
|
|
227
|
+
...(selected?.sessionId ? [{ keys: 'Enter', label: 'open session' }] : []),
|
|
228
|
+
{ keys: 'c', label: 'communication' },
|
|
229
|
+
{ keys: '/', label: 'filter' },
|
|
230
|
+
];
|
|
171
231
|
|
|
172
232
|
return this.renderList(width, height, {
|
|
173
233
|
title: 'Route Bindings',
|
|
174
234
|
header: headerLines,
|
|
175
|
-
footer:
|
|
235
|
+
footer: [
|
|
236
|
+
...(selected ? buildDetailBlock(width, `Binding · ${selected.surfaceKind}`, detailRows, C) : []),
|
|
237
|
+
buildKeyboardHints(width, hints, C),
|
|
238
|
+
],
|
|
176
239
|
});
|
|
177
240
|
}
|
|
178
241
|
}
|
|
@@ -3,44 +3,151 @@ import { createEmptyLine } from '../types/grid.ts';
|
|
|
3
3
|
import { BasePanel } from './base-panel.ts';
|
|
4
4
|
import { ConfigManager } from '@pellux/goodvibes-sdk/platform/config';
|
|
5
5
|
import { buildSandboxReview, listSandboxPresets, listSandboxProfiles } from '@/runtime/index.ts';
|
|
6
|
-
import type { SandboxSessionRegistry } from '@/runtime/index.ts';
|
|
6
|
+
import type { SandboxProfile, SandboxReview, SandboxSession, SandboxSessionRegistry } from '@/runtime/index.ts';
|
|
7
|
+
import { type ConfirmState, handleConfirmInput, renderConfirmLines } from './confirm-state.ts';
|
|
8
|
+
import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
|
|
7
9
|
import {
|
|
10
|
+
buildAlignedRow,
|
|
8
11
|
buildBodyText,
|
|
9
12
|
buildEmptyState,
|
|
10
13
|
buildGuidanceLine,
|
|
14
|
+
buildKeyboardHints,
|
|
11
15
|
buildKeyValueLine,
|
|
12
16
|
buildPanelLine,
|
|
13
17
|
buildPanelWorkspace,
|
|
14
18
|
resolveStackedScrollableSections,
|
|
15
19
|
DEFAULT_PANEL_PALETTE,
|
|
20
|
+
type PanelPalette,
|
|
16
21
|
type PanelWorkspaceSection,
|
|
17
22
|
} from './polish.ts';
|
|
18
23
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
+
// Base chrome only — title band comes straight from DEFAULT_PANEL_PALETTE
|
|
25
|
+
// (WO-002).
|
|
26
|
+
const C = DEFAULT_PANEL_PALETTE;
|
|
27
|
+
|
|
28
|
+
/** Poll cadence for live session-state refresh; SandboxSessionRegistry has no event subscription. */
|
|
29
|
+
const POLL_INTERVAL_MS = 3_000;
|
|
30
|
+
|
|
31
|
+
type Selectable =
|
|
32
|
+
| { readonly kind: 'profile'; readonly id: SandboxProfile['id'] }
|
|
33
|
+
| { readonly kind: 'session'; readonly id: string };
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* One contextual next-step line, derived from the current review/session
|
|
37
|
+
* state instead of the previous nine-line guidance wall. Picks the single
|
|
38
|
+
* most relevant action given host warnings, QEMU config gaps, and whether
|
|
39
|
+
* any session is running yet.
|
|
40
|
+
*/
|
|
41
|
+
function buildContextualGuidance(width: number, review: SandboxReview, sessionCount: number, palette: PanelPalette): Line {
|
|
42
|
+
if (review.host.warnings.length > 0) {
|
|
43
|
+
return buildGuidanceLine(width, '/sandbox review', `resolve ${review.host.warnings.length} host warning(s) before enabling QEMU-backed execution`, palette);
|
|
44
|
+
}
|
|
45
|
+
if (review.config.vmBackend === 'qemu' && !review.config.qemuImagePath) {
|
|
46
|
+
return buildGuidanceLine(width, '/sandbox set-qemu-image <path>', 'set the guest image path before enabling QEMU-backed session execution', palette);
|
|
47
|
+
}
|
|
48
|
+
if (review.config.vmBackend === 'qemu' && !review.config.qemuExecWrapper) {
|
|
49
|
+
return buildGuidanceLine(width, '/sandbox set-qemu-wrapper <path>', 'configure the host bridge that actually executes commands inside the QEMU guest', palette);
|
|
50
|
+
}
|
|
51
|
+
if (sessionCount === 0) {
|
|
52
|
+
return buildGuidanceLine(width, 's', 'select a profile below and press s to start a sandbox session', palette);
|
|
53
|
+
}
|
|
54
|
+
return buildGuidanceLine(width, '/sandbox session run <id> <command> [args...]', 'run a custom command against a running session from the command line', palette);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const SANDBOX_HINTS = [
|
|
58
|
+
{ keys: '↑/↓', label: 'select' },
|
|
59
|
+
{ keys: 'Home/End', label: 'first profile/session' },
|
|
60
|
+
{ keys: 's', label: 'start' },
|
|
61
|
+
{ keys: 'x', label: 'stop' },
|
|
62
|
+
{ keys: 'e', label: 'execute probe' },
|
|
63
|
+
] as const;
|
|
24
64
|
|
|
25
65
|
export class SandboxPanel extends BasePanel {
|
|
26
66
|
private selectedIndex = 0;
|
|
27
|
-
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The profile/session row under the cursor. This panel owns its own
|
|
70
|
+
* selection state (`selectedIndex` navigates the combined `_selectable()`
|
|
71
|
+
* list directly), so every selected-row read routes through this one
|
|
72
|
+
* accessor — indexing the `_selectable()` list by the cursor directly is
|
|
73
|
+
* banned by the no-raw-selectedindex-read architecture rule.
|
|
74
|
+
*/
|
|
75
|
+
private selectedSelectable(): Selectable | undefined {
|
|
76
|
+
return this._selectable().at(this.selectedIndex);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
private sessionsScrollOffset = 0;
|
|
80
|
+
private profilesScrollOffset = 0;
|
|
81
|
+
private confirm: ConfirmState<string> | null = null;
|
|
28
82
|
private readonly config: ConfigManager;
|
|
29
83
|
private readonly sessions: SandboxSessionRegistry;
|
|
84
|
+
private readonly requestRender: () => void;
|
|
30
85
|
|
|
31
86
|
public constructor(
|
|
32
87
|
config: ConfigManager,
|
|
33
88
|
sessions: SandboxSessionRegistry,
|
|
89
|
+
requestRender: () => void = () => {},
|
|
34
90
|
) {
|
|
35
|
-
super('sandbox', 'Sandbox', '
|
|
91
|
+
super('sandbox', 'Sandbox', '▪', 'security-policy');
|
|
36
92
|
this.config = config;
|
|
37
93
|
this.sessions = sessions;
|
|
94
|
+
this.requestRender = requestRender;
|
|
95
|
+
// Live state: SandboxSessionRegistry has no event subscription, so poll
|
|
96
|
+
// for session state changes (start/stop/execute results) while the
|
|
97
|
+
// panel is registered.
|
|
98
|
+
this.registerTimer(setInterval(() => {
|
|
99
|
+
this.markDirty();
|
|
100
|
+
this.requestRender();
|
|
101
|
+
}, POLL_INTERVAL_MS));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
private _selectable(): Selectable[] {
|
|
105
|
+
const profiles = listSandboxProfiles(this.config);
|
|
106
|
+
const sessions = this.sessions.list();
|
|
107
|
+
return [
|
|
108
|
+
...profiles.map((profile): Selectable => ({ kind: 'profile', id: profile.id })),
|
|
109
|
+
...sessions.map((session): Selectable => ({ kind: 'session', id: session.id })),
|
|
110
|
+
];
|
|
38
111
|
}
|
|
39
112
|
|
|
40
113
|
public handleInput(key: string): boolean {
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
const
|
|
114
|
+
if (this.lastError !== null) this.clearError();
|
|
115
|
+
|
|
116
|
+
const confirmResult = handleConfirmInput(this.confirm, key);
|
|
117
|
+
if (confirmResult === 'confirmed') {
|
|
118
|
+
const sessionId = this.confirm!.subject;
|
|
119
|
+
this.confirm = null;
|
|
120
|
+
this.sessions.stop(sessionId);
|
|
121
|
+
this.markDirty();
|
|
122
|
+
return true;
|
|
123
|
+
}
|
|
124
|
+
if (confirmResult === 'cancelled') {
|
|
125
|
+
this.confirm = null;
|
|
126
|
+
this.markDirty();
|
|
127
|
+
return true;
|
|
128
|
+
}
|
|
129
|
+
if (confirmResult === 'absorbed') return true;
|
|
130
|
+
|
|
131
|
+
const selectable = this._selectable();
|
|
132
|
+
const profileCount = selectable.filter((entry) => entry.kind === 'profile').length;
|
|
133
|
+
const sessionCount = selectable.length - profileCount;
|
|
134
|
+
const selected = this.selectedSelectable();
|
|
135
|
+
|
|
136
|
+
if (key === 's' && selected?.kind === 'profile') {
|
|
137
|
+
void this._startSession(selected.id);
|
|
138
|
+
return true;
|
|
139
|
+
}
|
|
140
|
+
if (key === 'x' && selected?.kind === 'session') {
|
|
141
|
+
this.confirm = { subject: selected.id, label: `sandbox session ${selected.id}`, verb: 'Stop' };
|
|
142
|
+
this.markDirty();
|
|
143
|
+
return true;
|
|
144
|
+
}
|
|
145
|
+
if (key === 'e' && selected?.kind === 'session') {
|
|
146
|
+
void this._executeProbe(selected.id);
|
|
147
|
+
return true;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const itemCount = selectable.length;
|
|
44
151
|
if (itemCount === 0) return false;
|
|
45
152
|
if (key === 'up' || key === 'k') {
|
|
46
153
|
this.selectedIndex = Math.max(0, this.selectedIndex - 1);
|
|
@@ -65,18 +172,57 @@ export class SandboxPanel extends BasePanel {
|
|
|
65
172
|
return false;
|
|
66
173
|
}
|
|
67
174
|
|
|
175
|
+
/** Start a sandbox session for `profileId` via the shared registry, then repaint. */
|
|
176
|
+
private async _startSession(profileId: SandboxProfile['id']): Promise<void> {
|
|
177
|
+
try {
|
|
178
|
+
await this.sessions.start(profileId, undefined, this.config);
|
|
179
|
+
} catch (err) {
|
|
180
|
+
this.setError(summarizeError(err));
|
|
181
|
+
} finally {
|
|
182
|
+
this.markDirty();
|
|
183
|
+
this.requestRender();
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Execute a lightweight liveness probe against the selected session so its
|
|
189
|
+
* live state (last run, exit status, stdout/stderr preview) is genuinely
|
|
190
|
+
* populated from `SandboxSessionRegistry.execute()`. Panels have no
|
|
191
|
+
* free-text input widget today, so this proves the execute verb is wired
|
|
192
|
+
* without inventing one; `/sandbox session run <id> <command> [args...]`
|
|
193
|
+
* remains available for arbitrary commands.
|
|
194
|
+
*/
|
|
195
|
+
private async _executeProbe(sessionId: string): Promise<void> {
|
|
196
|
+
try {
|
|
197
|
+
this.sessions.execute(sessionId, process.execPath, ['-e', "console.log('sandbox panel probe ok')"], this.config, { timeoutMs: 5_000 });
|
|
198
|
+
} catch (err) {
|
|
199
|
+
this.setError(summarizeError(err));
|
|
200
|
+
} finally {
|
|
201
|
+
this.markDirty();
|
|
202
|
+
this.requestRender();
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
68
206
|
public render(width: number, height: number): Line[] {
|
|
69
207
|
this.needsRender = false;
|
|
208
|
+
|
|
209
|
+
if (this.confirm) {
|
|
210
|
+
const lines = buildPanelWorkspace(width, height, {
|
|
211
|
+
title: 'Sandbox Control Room',
|
|
212
|
+
sections: [{ title: 'Confirmation', lines: renderConfirmLines(width, this.confirm) }],
|
|
213
|
+
palette: C,
|
|
214
|
+
});
|
|
215
|
+
while (lines.length < height) lines.push(createEmptyLine(width));
|
|
216
|
+
return lines.slice(0, height);
|
|
217
|
+
}
|
|
218
|
+
|
|
70
219
|
const review = buildSandboxReview(this.config);
|
|
71
220
|
const profiles = listSandboxProfiles(this.config);
|
|
72
221
|
const presets = listSandboxPresets();
|
|
73
222
|
const sessions = this.sessions.list();
|
|
74
|
-
const selectable =
|
|
75
|
-
...profiles.map((profile) => ({ kind: 'profile' as const, id: profile.id })),
|
|
76
|
-
...sessions.map((session) => ({ kind: 'session' as const, id: session.id })),
|
|
77
|
-
];
|
|
223
|
+
const selectable = this._selectable();
|
|
78
224
|
this.selectedIndex = Math.min(this.selectedIndex, Math.max(0, selectable.length - 1));
|
|
79
|
-
const selected =
|
|
225
|
+
const selected = this.selectedSelectable() ?? null;
|
|
80
226
|
const selectedProfile = selected?.kind === 'profile'
|
|
81
227
|
? profiles.find((profile) => profile.id === selected.id) ?? null
|
|
82
228
|
: null;
|
|
@@ -116,16 +262,9 @@ export class SandboxPanel extends BasePanel {
|
|
|
116
262
|
buildKeyValueLine(width, [
|
|
117
263
|
{ label: 'guest workspace', value: review.config.qemuWorkspacePath || '(not configured)', valueColor: review.config.qemuWorkspacePath ? C.value : C.warn },
|
|
118
264
|
], C),
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
buildGuidanceLine(width, '/sandbox set-qemu-wrapper <path>', 'configure the host bridge that actually executes commands inside the QEMU guest', C),
|
|
123
|
-
buildGuidanceLine(width, '/sandbox set-qemu-guest-host <host>', 'switch wrapper-backed execution from host bridge mode to real guest SSH transport', C),
|
|
124
|
-
buildGuidanceLine(width, '/sandbox guest-test <profile>', 'verify SSH guest transport plus workspace projection against the configured QEMU guest host', C),
|
|
125
|
-
buildGuidanceLine(width, '/sandbox wrapper-test <profile>', 'validate the wrapper bridge contract before wiring a real guest transport', C),
|
|
126
|
-
buildGuidanceLine(width, '/sandbox session run <id> <command> [args...]', 'execute through a tracked sandbox session and capture runtime metadata on the session record', C),
|
|
127
|
-
buildGuidanceLine(width, 'GV_SANDBOX_WRAPPER_MODE=host-exec', 'validate the wrapper contract on the host before wiring a real guest transport', C),
|
|
128
|
-
buildPanelLine(width, [[` Up/Down move Home/End jump focus=profiles+sessions`, C.dim]]),
|
|
265
|
+
// Single contextual next-step line (replaces the former nine-line
|
|
266
|
+
// guidance wall) — the only buildGuidanceLine call in this panel.
|
|
267
|
+
buildContextualGuidance(width, review, sessions.length, C),
|
|
129
268
|
];
|
|
130
269
|
|
|
131
270
|
const selectionLines: Line[] = [];
|
|
@@ -179,57 +318,84 @@ export class SandboxPanel extends BasePanel {
|
|
|
179
318
|
sessionLines.push(...buildEmptyState(
|
|
180
319
|
width,
|
|
181
320
|
' No active sandbox sessions.',
|
|
182
|
-
'
|
|
183
|
-
[
|
|
321
|
+
'Select a profile above and press s to start a sandbox session.',
|
|
322
|
+
[],
|
|
184
323
|
C,
|
|
185
324
|
));
|
|
186
325
|
} else {
|
|
187
326
|
for (const session of sessions) {
|
|
188
|
-
const
|
|
189
|
-
sessionLines.push(
|
|
190
|
-
|
|
191
|
-
[
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
327
|
+
const selectedRow = selectedSession?.id === session.id;
|
|
328
|
+
sessionLines.push(buildAlignedRow(
|
|
329
|
+
width,
|
|
330
|
+
[
|
|
331
|
+
{ text: session.profileId, fg: C.info },
|
|
332
|
+
{ text: session.state, fg: session.state === 'running' ? C.good : session.state === 'failed' ? C.bad : C.warn },
|
|
333
|
+
{ text: session.shared ? 'shared' : 'dedicated', fg: C.value },
|
|
334
|
+
{ text: String(session.resolvedBackend ?? session.backend), fg: C.dim },
|
|
335
|
+
{ text: session.startupStatus ?? 'n/a', fg: session.startupStatus === 'verified' ? C.good : session.startupStatus === 'failed' ? C.bad : C.warn },
|
|
336
|
+
{ text: `${session.executionCount ?? 0}x`, fg: C.info },
|
|
337
|
+
{ text: session.id, fg: C.dim },
|
|
338
|
+
],
|
|
339
|
+
[
|
|
340
|
+
{ width: 15 },
|
|
341
|
+
{ width: 10 },
|
|
342
|
+
{ width: 12 },
|
|
343
|
+
{ width: 8 },
|
|
344
|
+
{ width: 9 },
|
|
345
|
+
{ width: 5, align: 'right' },
|
|
346
|
+
{ width: Math.max(8, width - 72) },
|
|
347
|
+
],
|
|
348
|
+
{ selected: selectedRow, selectedBg: C.headerBg },
|
|
349
|
+
));
|
|
199
350
|
}
|
|
200
351
|
}
|
|
201
352
|
|
|
202
353
|
const presetLines: Line[] = [];
|
|
203
354
|
for (const preset of presets.slice(0, 2)) {
|
|
204
|
-
presetLines.push(
|
|
205
|
-
|
|
206
|
-
[
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
355
|
+
presetLines.push(buildAlignedRow(
|
|
356
|
+
width,
|
|
357
|
+
[
|
|
358
|
+
{ text: preset.id, fg: C.info },
|
|
359
|
+
{ text: preset.config.replIsolation, fg: C.value },
|
|
360
|
+
{ text: preset.config.mcpIsolation, fg: C.dim },
|
|
361
|
+
{ text: preset.config.windowsMode, fg: C.warn },
|
|
362
|
+
],
|
|
363
|
+
[
|
|
364
|
+
{ width: 18 },
|
|
365
|
+
{ width: 16 },
|
|
366
|
+
{ width: 16 },
|
|
367
|
+
{ width: Math.max(8, width - 62) },
|
|
368
|
+
],
|
|
369
|
+
{},
|
|
370
|
+
));
|
|
211
371
|
}
|
|
212
372
|
|
|
213
373
|
const profileLines: Line[] = [];
|
|
214
374
|
for (const profile of profiles) {
|
|
215
|
-
const
|
|
216
|
-
profileLines.push(
|
|
217
|
-
|
|
218
|
-
[
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
375
|
+
const selectedRow = selectedProfile?.id === profile.id;
|
|
376
|
+
profileLines.push(buildAlignedRow(
|
|
377
|
+
width,
|
|
378
|
+
[
|
|
379
|
+
{ text: profile.id, fg: C.info },
|
|
380
|
+
{ text: profile.isolation, fg: C.value },
|
|
381
|
+
{ text: profile.kind, fg: C.dim },
|
|
382
|
+
{ text: `vm=${profile.requiresVm ? 'yes' : 'no'}`, fg: profile.requiresVm ? C.good : C.warn },
|
|
383
|
+
],
|
|
384
|
+
[
|
|
385
|
+
{ width: 15 },
|
|
386
|
+
{ width: 14 },
|
|
387
|
+
{ width: 12 },
|
|
388
|
+
{ width: Math.max(6, width - 53) },
|
|
389
|
+
],
|
|
390
|
+
{ selected: selectedRow, selectedBg: C.headerBg },
|
|
391
|
+
));
|
|
223
392
|
}
|
|
224
393
|
const postureSection: PanelWorkspaceSection = { title: 'Sandbox posture', lines: overviewLines };
|
|
225
394
|
const selectedSection: PanelWorkspaceSection = { title: selectedProfile ? 'Selected Profile' : 'Selected Session', lines: selectionLines };
|
|
226
395
|
const presetsSection: PanelWorkspaceSection = { title: 'Presets', lines: presetLines };
|
|
227
396
|
const [sessionsSection, profilesSection] = resolveStackedScrollableSections(width, height, {
|
|
228
397
|
intro,
|
|
229
|
-
footerLines: [
|
|
230
|
-
buildGuidanceLine(width, '/sandbox presets', 'compare secure, balanced, and shared sandbox operating modes', C),
|
|
231
|
-
buildGuidanceLine(width, '/sandbox apply-preset <id>', 'change local vs QEMU isolation policy without editing config by hand', C),
|
|
232
|
-
],
|
|
398
|
+
footerLines: [buildKeyboardHints(width, SANDBOX_HINTS, C)],
|
|
233
399
|
palette: C,
|
|
234
400
|
beforeSections: [
|
|
235
401
|
postureSection,
|
|
@@ -240,7 +406,7 @@ export class SandboxPanel extends BasePanel {
|
|
|
240
406
|
title: 'Sessions',
|
|
241
407
|
scrollableLines: sessionLines,
|
|
242
408
|
selectedIndex: selectedSession ? Math.max(0, sessions.findIndex((session) => session.id === selectedSession.id)) : undefined,
|
|
243
|
-
scrollOffset: this.
|
|
409
|
+
scrollOffset: this.sessionsScrollOffset,
|
|
244
410
|
minRows: 2,
|
|
245
411
|
weight: 1,
|
|
246
412
|
appendWindowSummary: sessions.length > 0 ? {
|
|
@@ -252,7 +418,7 @@ export class SandboxPanel extends BasePanel {
|
|
|
252
418
|
title: 'Profiles',
|
|
253
419
|
scrollableLines: profileLines,
|
|
254
420
|
selectedIndex: selectedProfile ? Math.max(0, profiles.findIndex((profile) => profile.id === selectedProfile.id)) : undefined,
|
|
255
|
-
scrollOffset: this.
|
|
421
|
+
scrollOffset: this.profilesScrollOffset,
|
|
256
422
|
minRows: 2,
|
|
257
423
|
weight: 1,
|
|
258
424
|
appendWindowSummary: profiles.length > 0 ? {
|
|
@@ -263,7 +429,8 @@ export class SandboxPanel extends BasePanel {
|
|
|
263
429
|
],
|
|
264
430
|
afterSections: [presetsSection],
|
|
265
431
|
});
|
|
266
|
-
this.
|
|
432
|
+
this.sessionsScrollOffset = sessionsSection?.scrollOffset ?? this.sessionsScrollOffset;
|
|
433
|
+
this.profilesScrollOffset = profilesSection?.scrollOffset ?? this.profilesScrollOffset;
|
|
267
434
|
|
|
268
435
|
const sections: PanelWorkspaceSection[] = [
|
|
269
436
|
postureSection,
|
|
@@ -273,14 +440,14 @@ export class SandboxPanel extends BasePanel {
|
|
|
273
440
|
profilesSection?.section ?? { title: 'Profiles', lines: profileLines },
|
|
274
441
|
];
|
|
275
442
|
|
|
443
|
+
const errorLine = this.renderErrorLine(width);
|
|
444
|
+
if (errorLine) sections.push({ title: 'Error', lines: [errorLine] });
|
|
445
|
+
|
|
276
446
|
const lines = buildPanelWorkspace(width, height, {
|
|
277
447
|
title: 'Sandbox Control Room',
|
|
278
448
|
intro,
|
|
279
449
|
sections,
|
|
280
|
-
footerLines: [
|
|
281
|
-
buildGuidanceLine(width, '/sandbox presets', 'compare secure, balanced, and shared sandbox operating modes', C),
|
|
282
|
-
buildGuidanceLine(width, '/sandbox apply-preset <id>', 'change local vs QEMU isolation policy without editing config by hand', C),
|
|
283
|
-
],
|
|
450
|
+
footerLines: [buildKeyboardHints(width, SANDBOX_HINTS, C)],
|
|
284
451
|
palette: C,
|
|
285
452
|
});
|
|
286
453
|
while (lines.length < height) lines.push(createEmptyLine(width));
|