@pellux/goodvibes-tui 0.28.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 +101 -0
- package/README.md +1 -1
- package/package.json +1 -1
- package/src/input/commands/cost-runtime.ts +49 -0
- package/src/input/commands/operator-runtime.ts +5 -1
- package/src/input/commands.ts +2 -0
- package/src/input/handler-feed-routes.ts +12 -35
- package/src/input/handler-feed.ts +4 -8
- package/src/input/handler-shortcuts.ts +51 -0
- package/src/input/handler.ts +43 -6
- package/src/input/keybindings.ts +48 -8
- package/src/input/panel-integration-actions.ts +107 -1
- package/src/main.ts +5 -1
- package/src/panels/agent-inspector-panel.ts +125 -36
- package/src/panels/agent-inspector-shared.ts +144 -0
- package/src/panels/approval-panel.ts +67 -16
- package/src/panels/automation-control-panel.ts +368 -124
- 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 +232 -73
- package/src/panels/communication-panel.ts +58 -20
- package/src/panels/confirm-state.ts +8 -1
- package/src/panels/control-plane-panel.ts +351 -118
- package/src/panels/cost-tracker-panel.ts +165 -7
- package/src/panels/debug-panel.ts +312 -159
- package/src/panels/diff-panel.ts +203 -57
- package/src/panels/docs-panel.ts +152 -66
- package/src/panels/eval-panel.ts +220 -42
- package/src/panels/file-explorer-panel.ts +202 -122
- package/src/panels/file-preview-panel.ts +132 -57
- package/src/panels/git-panel.ts +309 -128
- package/src/panels/hooks-panel.ts +150 -22
- package/src/panels/incident-review-panel.ts +223 -35
- package/src/panels/index.ts +0 -4
- package/src/panels/intelligence-panel.ts +212 -86
- package/src/panels/knowledge-graph-panel.ts +461 -101
- package/src/panels/local-auth-panel.ts +240 -28
- package/src/panels/marketplace-panel.ts +193 -26
- package/src/panels/memory-panel.ts +78 -77
- package/src/panels/ops-control-panel.ts +146 -29
- package/src/panels/ops-strategy-panel.ts +72 -4
- package/src/panels/orchestration-panel.ts +231 -69
- package/src/panels/panel-list-panel.ts +154 -131
- package/src/panels/panel-manager.ts +97 -9
- package/src/panels/plan-dashboard-panel.ts +333 -17
- package/src/panels/plugins-panel.ts +184 -29
- package/src/panels/policy-panel.ts +210 -38
- package/src/panels/polish-core.ts +7 -2
- package/src/panels/polish.ts +23 -4
- package/src/panels/project-planning-answer-actions.ts +134 -0
- package/src/panels/project-planning-panel.ts +62 -113
- package/src/panels/provider-health-panel.ts +434 -518
- 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 +116 -30
- package/src/panels/remote-panel.ts +114 -36
- package/src/panels/routes-panel.ts +63 -22
- package/src/panels/sandbox-panel.ts +174 -42
- package/src/panels/scrollable-list-panel.ts +19 -135
- package/src/panels/security-panel.ts +133 -33
- package/src/panels/services-panel.ts +116 -64
- package/src/panels/session-browser-panel.ts +73 -2
- package/src/panels/session-maintenance.ts +4 -122
- package/src/panels/settings-sync-panel.ts +335 -72
- package/src/panels/skills-panel.ts +157 -89
- package/src/panels/subscription-panel.ts +86 -33
- package/src/panels/symbol-outline-panel.ts +248 -108
- package/src/panels/system-messages-panel.ts +114 -13
- package/src/panels/tasks-panel.ts +326 -139
- package/src/panels/thinking-panel.ts +43 -10
- package/src/panels/token-budget-panel.ts +194 -18
- package/src/panels/tool-inspector-panel.ts +144 -34
- package/src/panels/types.ts +40 -4
- package/src/panels/work-plan-panel.ts +280 -17
- package/src/panels/worktree-panel.ts +175 -42
- package/src/panels/wrfc-panel.ts +116 -24
- 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 +21 -7
- package/src/renderer/overlay-box.ts +16 -10
- package/src/renderer/process-indicator.ts +3 -1
- package/src/renderer/progress.ts +5 -4
- 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/theme.ts +60 -3
- package/src/renderer/ui-factory.ts +41 -30
- 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/shell/ui-openers.ts +14 -22
- package/src/utils/format-duration.ts +2 -2
- 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 -635
- package/src/panels/agent-logs-shared.ts +0 -129
- package/src/panels/context-visualizer-panel.ts +0 -238
- package/src/panels/forensics-panel.ts +0 -378
- package/src/panels/provider-account-snapshot.ts +0 -259
- package/src/panels/provider-accounts-panel.ts +0 -255
- package/src/panels/provider-stats-panel.ts +0 -391
- package/src/panels/schedule-panel.ts +0 -365
- package/src/panels/watchers-panel.ts +0 -213
- package/src/runtime/diagnostics/panels/panel-resources.ts +0 -118
- package/src/runtime/diagnostics/panels/policy.ts +0 -177
|
@@ -1,12 +1,13 @@
|
|
|
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 {
|
|
7
9
|
buildDetailBlock,
|
|
8
10
|
buildEmptyState,
|
|
9
|
-
buildGuidanceLine,
|
|
10
11
|
buildKeyValueLine,
|
|
11
12
|
buildKeyboardHints,
|
|
12
13
|
buildPanelLine,
|
|
@@ -16,16 +17,9 @@ import {
|
|
|
16
17
|
type PanelPalette,
|
|
17
18
|
} from './polish.ts';
|
|
18
19
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
headerBg: '#1e293b',
|
|
23
|
-
ok: '#22c55e',
|
|
24
|
-
warn: '#eab308',
|
|
25
|
-
error: '#ef4444',
|
|
26
|
-
info: '#38bdf8',
|
|
27
|
-
selectBg: '#0f172a',
|
|
28
|
-
} 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;
|
|
29
23
|
|
|
30
24
|
function formatTime(value?: number): string {
|
|
31
25
|
if (!value) return 'n/a';
|
|
@@ -34,12 +28,20 @@ function formatTime(value?: number): string {
|
|
|
34
28
|
|
|
35
29
|
type RouteBinding = UiRoutesSnapshot['bindings'][number];
|
|
36
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
|
+
|
|
37
38
|
export class RoutesPanel extends ScrollableListPanel<RouteBinding> {
|
|
38
39
|
private readonly readModel?: UiReadModel<UiRoutesSnapshot>;
|
|
39
40
|
private readonly unsub: (() => void) | null;
|
|
41
|
+
private pendingAction: PendingRouteAction | null = null;
|
|
40
42
|
|
|
41
43
|
public constructor(readModel?: UiReadModel<UiRoutesSnapshot>) {
|
|
42
|
-
super('routes', 'Routes', 'R', '
|
|
44
|
+
super('routes', 'Routes', 'R', 'runtime-ops');
|
|
43
45
|
this.showSelectionGutter = true; // I5: non-color selection affordance
|
|
44
46
|
this.filterEnabled = true;
|
|
45
47
|
this.filterLabel = 'Filter routes';
|
|
@@ -47,6 +49,42 @@ export class RoutesPanel extends ScrollableListPanel<RouteBinding> {
|
|
|
47
49
|
this.unsub = readModel ? readModel.subscribe(() => this.markDirty()) : null;
|
|
48
50
|
}
|
|
49
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
|
+
|
|
50
88
|
protected override filterMatches(binding: RouteBinding, q: string): boolean {
|
|
51
89
|
return binding.surfaceKind.toLowerCase().includes(q)
|
|
52
90
|
|| (binding.title ?? '').toLowerCase().includes(q)
|
|
@@ -121,11 +159,10 @@ export class RoutesPanel extends ScrollableListPanel<RouteBinding> {
|
|
|
121
159
|
const headerLines: Line[] = [
|
|
122
160
|
buildKeyValueLine(width, [
|
|
123
161
|
{ label: 'bindings', value: String(snapshot.totalBindings), valueColor: snapshot.totalBindings > 0 ? C.info : C.dim },
|
|
124
|
-
{ label: 'active', value: String(snapshot.activeBindingIds.length), valueColor: snapshot.activeBindingIds.length > 0 ? C.
|
|
125
|
-
{ label: 'resolved', value: String(snapshot.totalResolved), valueColor: snapshot.totalResolved > 0 ? C.
|
|
126
|
-
{ 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 },
|
|
127
165
|
], C),
|
|
128
|
-
buildGuidanceLine(width, '/communication', 'inspect routed message flow and delivery behavior across bound surfaces', C),
|
|
129
166
|
];
|
|
130
167
|
|
|
131
168
|
if (bindings.length === 0) {
|
|
@@ -137,9 +174,12 @@ export class RoutesPanel extends ScrollableListPanel<RouteBinding> {
|
|
|
137
174
|
}
|
|
138
175
|
|
|
139
176
|
this.clampSelection();
|
|
140
|
-
|
|
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();
|
|
141
181
|
|
|
142
|
-
const detailRows: Line[] = [
|
|
182
|
+
const detailRows: Line[] = selected ? [
|
|
143
183
|
buildPanelLine(width, [
|
|
144
184
|
[' Binding: ', C.label],
|
|
145
185
|
[selected.id, C.value],
|
|
@@ -168,9 +208,9 @@ export class RoutesPanel extends ScrollableListPanel<RouteBinding> {
|
|
|
168
208
|
[' Last seen: ', C.label],
|
|
169
209
|
[formatTime(selected.lastSeenAt), C.dim],
|
|
170
210
|
]),
|
|
171
|
-
];
|
|
211
|
+
] : [];
|
|
172
212
|
|
|
173
|
-
if (surfaceEntries.length > 0) {
|
|
213
|
+
if (selected && surfaceEntries.length > 0) {
|
|
174
214
|
detailRows.push(
|
|
175
215
|
...surfaceEntries.slice(0, 4).map(([surface, ids]) => buildPanelLine(width, [
|
|
176
216
|
[' ', C.label],
|
|
@@ -184,7 +224,8 @@ export class RoutesPanel extends ScrollableListPanel<RouteBinding> {
|
|
|
184
224
|
? [{ keys: 'type', label: 'filter' }, { keys: 'Enter', label: 'apply' }, { keys: 'Esc', label: 'clear' }]
|
|
185
225
|
: [
|
|
186
226
|
{ keys: 'Up/Down', label: 'move' },
|
|
187
|
-
{ keys: '
|
|
227
|
+
...(selected?.sessionId ? [{ keys: 'Enter', label: 'open session' }] : []),
|
|
228
|
+
{ keys: 'c', label: 'communication' },
|
|
188
229
|
{ keys: '/', label: 'filter' },
|
|
189
230
|
];
|
|
190
231
|
|
|
@@ -192,7 +233,7 @@ export class RoutesPanel extends ScrollableListPanel<RouteBinding> {
|
|
|
192
233
|
title: 'Route Bindings',
|
|
193
234
|
header: headerLines,
|
|
194
235
|
footer: [
|
|
195
|
-
...buildDetailBlock(width, `Binding · ${selected.surfaceKind}`, detailRows, C),
|
|
236
|
+
...(selected ? buildDetailBlock(width, `Binding · ${selected.surfaceKind}`, detailRows, C) : []),
|
|
196
237
|
buildKeyboardHints(width, hints, C),
|
|
197
238
|
],
|
|
198
239
|
});
|
|
@@ -3,7 +3,9 @@ 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 {
|
|
8
10
|
buildAlignedRow,
|
|
9
11
|
buildBodyText,
|
|
@@ -15,34 +17,137 @@ import {
|
|
|
15
17
|
buildPanelWorkspace,
|
|
16
18
|
resolveStackedScrollableSections,
|
|
17
19
|
DEFAULT_PANEL_PALETTE,
|
|
20
|
+
type PanelPalette,
|
|
18
21
|
type PanelWorkspaceSection,
|
|
19
22
|
} from './polish.ts';
|
|
20
23
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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;
|
|
26
64
|
|
|
27
65
|
export class SandboxPanel extends BasePanel {
|
|
28
66
|
private selectedIndex = 0;
|
|
29
|
-
|
|
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;
|
|
30
82
|
private readonly config: ConfigManager;
|
|
31
83
|
private readonly sessions: SandboxSessionRegistry;
|
|
84
|
+
private readonly requestRender: () => void;
|
|
32
85
|
|
|
33
86
|
public constructor(
|
|
34
87
|
config: ConfigManager,
|
|
35
88
|
sessions: SandboxSessionRegistry,
|
|
89
|
+
requestRender: () => void = () => {},
|
|
36
90
|
) {
|
|
37
|
-
super('sandbox', 'Sandbox', '
|
|
91
|
+
super('sandbox', 'Sandbox', '▪', 'security-policy');
|
|
38
92
|
this.config = config;
|
|
39
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
|
+
];
|
|
40
111
|
}
|
|
41
112
|
|
|
42
113
|
public handleInput(key: string): boolean {
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
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;
|
|
46
151
|
if (itemCount === 0) return false;
|
|
47
152
|
if (key === 'up' || key === 'k') {
|
|
48
153
|
this.selectedIndex = Math.max(0, this.selectedIndex - 1);
|
|
@@ -67,18 +172,57 @@ export class SandboxPanel extends BasePanel {
|
|
|
67
172
|
return false;
|
|
68
173
|
}
|
|
69
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
|
+
|
|
70
206
|
public render(width: number, height: number): Line[] {
|
|
71
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
|
+
|
|
72
219
|
const review = buildSandboxReview(this.config);
|
|
73
220
|
const profiles = listSandboxProfiles(this.config);
|
|
74
221
|
const presets = listSandboxPresets();
|
|
75
222
|
const sessions = this.sessions.list();
|
|
76
|
-
const selectable =
|
|
77
|
-
...profiles.map((profile) => ({ kind: 'profile' as const, id: profile.id })),
|
|
78
|
-
...sessions.map((session) => ({ kind: 'session' as const, id: session.id })),
|
|
79
|
-
];
|
|
223
|
+
const selectable = this._selectable();
|
|
80
224
|
this.selectedIndex = Math.min(this.selectedIndex, Math.max(0, selectable.length - 1));
|
|
81
|
-
const selected =
|
|
225
|
+
const selected = this.selectedSelectable() ?? null;
|
|
82
226
|
const selectedProfile = selected?.kind === 'profile'
|
|
83
227
|
? profiles.find((profile) => profile.id === selected.id) ?? null
|
|
84
228
|
: null;
|
|
@@ -118,19 +262,9 @@ export class SandboxPanel extends BasePanel {
|
|
|
118
262
|
buildKeyValueLine(width, [
|
|
119
263
|
{ label: 'guest workspace', value: review.config.qemuWorkspacePath || '(not configured)', valueColor: review.config.qemuWorkspacePath ? C.value : C.warn },
|
|
120
264
|
], C),
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
buildGuidanceLine(width, '/sandbox set-qemu-wrapper <path>', 'configure the host bridge that actually executes commands inside the QEMU guest', C),
|
|
125
|
-
buildGuidanceLine(width, '/sandbox set-qemu-guest-host <host>', 'switch wrapper-backed execution from host bridge mode to real guest SSH transport', C),
|
|
126
|
-
buildGuidanceLine(width, '/sandbox guest-test <profile>', 'verify SSH guest transport plus workspace projection against the configured QEMU guest host', C),
|
|
127
|
-
buildGuidanceLine(width, '/sandbox wrapper-test <profile>', 'validate the wrapper bridge contract before wiring a real guest transport', C),
|
|
128
|
-
buildGuidanceLine(width, '/sandbox session run <id> <command> [args...]', 'execute through a tracked sandbox session and capture runtime metadata on the session record', C),
|
|
129
|
-
buildGuidanceLine(width, 'GV_SANDBOX_WRAPPER_MODE=host-exec', 'validate the wrapper contract on the host before wiring a real guest transport', C),
|
|
130
|
-
buildKeyboardHints(width, [
|
|
131
|
-
{ keys: '↑/↓', label: 'select profile/session' },
|
|
132
|
-
{ keys: 'Home/End', label: 'jump to first profile / first session' },
|
|
133
|
-
], C),
|
|
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),
|
|
134
268
|
];
|
|
135
269
|
|
|
136
270
|
const selectionLines: Line[] = [];
|
|
@@ -184,8 +318,8 @@ export class SandboxPanel extends BasePanel {
|
|
|
184
318
|
sessionLines.push(...buildEmptyState(
|
|
185
319
|
width,
|
|
186
320
|
' No active sandbox sessions.',
|
|
187
|
-
'
|
|
188
|
-
[
|
|
321
|
+
'Select a profile above and press s to start a sandbox session.',
|
|
322
|
+
[],
|
|
189
323
|
C,
|
|
190
324
|
));
|
|
191
325
|
} else {
|
|
@@ -261,10 +395,7 @@ export class SandboxPanel extends BasePanel {
|
|
|
261
395
|
const presetsSection: PanelWorkspaceSection = { title: 'Presets', lines: presetLines };
|
|
262
396
|
const [sessionsSection, profilesSection] = resolveStackedScrollableSections(width, height, {
|
|
263
397
|
intro,
|
|
264
|
-
footerLines: [
|
|
265
|
-
buildGuidanceLine(width, '/sandbox presets', 'compare secure, balanced, and shared sandbox operating modes', C),
|
|
266
|
-
buildGuidanceLine(width, '/sandbox apply-preset <id>', 'change local vs QEMU isolation policy without editing config by hand', C),
|
|
267
|
-
],
|
|
398
|
+
footerLines: [buildKeyboardHints(width, SANDBOX_HINTS, C)],
|
|
268
399
|
palette: C,
|
|
269
400
|
beforeSections: [
|
|
270
401
|
postureSection,
|
|
@@ -275,7 +406,7 @@ export class SandboxPanel extends BasePanel {
|
|
|
275
406
|
title: 'Sessions',
|
|
276
407
|
scrollableLines: sessionLines,
|
|
277
408
|
selectedIndex: selectedSession ? Math.max(0, sessions.findIndex((session) => session.id === selectedSession.id)) : undefined,
|
|
278
|
-
scrollOffset: this.
|
|
409
|
+
scrollOffset: this.sessionsScrollOffset,
|
|
279
410
|
minRows: 2,
|
|
280
411
|
weight: 1,
|
|
281
412
|
appendWindowSummary: sessions.length > 0 ? {
|
|
@@ -287,7 +418,7 @@ export class SandboxPanel extends BasePanel {
|
|
|
287
418
|
title: 'Profiles',
|
|
288
419
|
scrollableLines: profileLines,
|
|
289
420
|
selectedIndex: selectedProfile ? Math.max(0, profiles.findIndex((profile) => profile.id === selectedProfile.id)) : undefined,
|
|
290
|
-
scrollOffset: this.
|
|
421
|
+
scrollOffset: this.profilesScrollOffset,
|
|
291
422
|
minRows: 2,
|
|
292
423
|
weight: 1,
|
|
293
424
|
appendWindowSummary: profiles.length > 0 ? {
|
|
@@ -298,7 +429,8 @@ export class SandboxPanel extends BasePanel {
|
|
|
298
429
|
],
|
|
299
430
|
afterSections: [presetsSection],
|
|
300
431
|
});
|
|
301
|
-
this.
|
|
432
|
+
this.sessionsScrollOffset = sessionsSection?.scrollOffset ?? this.sessionsScrollOffset;
|
|
433
|
+
this.profilesScrollOffset = profilesSection?.scrollOffset ?? this.profilesScrollOffset;
|
|
302
434
|
|
|
303
435
|
const sections: PanelWorkspaceSection[] = [
|
|
304
436
|
postureSection,
|
|
@@ -308,14 +440,14 @@ export class SandboxPanel extends BasePanel {
|
|
|
308
440
|
profilesSection?.section ?? { title: 'Profiles', lines: profileLines },
|
|
309
441
|
];
|
|
310
442
|
|
|
443
|
+
const errorLine = this.renderErrorLine(width);
|
|
444
|
+
if (errorLine) sections.push({ title: 'Error', lines: [errorLine] });
|
|
445
|
+
|
|
311
446
|
const lines = buildPanelWorkspace(width, height, {
|
|
312
447
|
title: 'Sandbox Control Room',
|
|
313
448
|
intro,
|
|
314
449
|
sections,
|
|
315
|
-
footerLines: [
|
|
316
|
-
buildGuidanceLine(width, '/sandbox presets', 'compare secure, balanced, and shared sandbox operating modes', C),
|
|
317
|
-
buildGuidanceLine(width, '/sandbox apply-preset <id>', 'change local vs QEMU isolation policy without editing config by hand', C),
|
|
318
|
-
],
|
|
450
|
+
footerLines: [buildKeyboardHints(width, SANDBOX_HINTS, C)],
|
|
319
451
|
palette: C,
|
|
320
452
|
});
|
|
321
453
|
while (lines.length < height) lines.push(createEmptyLine(width));
|
|
@@ -13,11 +13,6 @@ import {
|
|
|
13
13
|
type PanelPalette,
|
|
14
14
|
} from './polish.ts';
|
|
15
15
|
import { GLYPHS } from '../renderer/ui-primitives.ts';
|
|
16
|
-
import {
|
|
17
|
-
isPanelSearchBackspace,
|
|
18
|
-
isPanelSearchCancel,
|
|
19
|
-
isPanelSearchPrintable,
|
|
20
|
-
} from './search-focus.ts';
|
|
21
16
|
|
|
22
17
|
// ---------------------------------------------------------------------------
|
|
23
18
|
// ScrollableListPanel<T>
|
|
@@ -87,6 +82,25 @@ export abstract class ScrollableListPanel<T> extends BasePanel {
|
|
|
87
82
|
return all.filter((item) => this.filterMatches(item, q));
|
|
88
83
|
}
|
|
89
84
|
|
|
85
|
+
/**
|
|
86
|
+
* The item currently under the cursor, resolved against the FILTERED list
|
|
87
|
+
* that navigation actually moves over (`getVisibleItems()`) — never the raw
|
|
88
|
+
* `getItems()` source.
|
|
89
|
+
*
|
|
90
|
+
* Subclasses MUST read the selected row through this method (or, when a
|
|
91
|
+
* function also needs the list for counts/windows, through a function-scope
|
|
92
|
+
* `const visible = this.getVisibleItems()` local — indexing that local, never
|
|
93
|
+
* the raw source). Indexing a raw item array (`this.rows`, `this.entries`,
|
|
94
|
+
* `getItems()`, …) with `this.selectedIndex` silently returns the wrong row
|
|
95
|
+
* whenever a filter is active, because `selectedIndex` is an offset into the
|
|
96
|
+
* visible list, not the raw one. The `no-raw-selectedindex-read` architecture
|
|
97
|
+
* rule enforces this by banning the `[this.selectedIndex]` token outside the
|
|
98
|
+
* base classes.
|
|
99
|
+
*/
|
|
100
|
+
protected getSelectedItem(): T | undefined {
|
|
101
|
+
return this.getVisibleItems()[this.selectedIndex];
|
|
102
|
+
}
|
|
103
|
+
|
|
90
104
|
/**
|
|
91
105
|
* Filter-mode key handling. Returns `true`/`false` when consumed/ignored in
|
|
92
106
|
* filter context, or `null` to fall through to normal navigation.
|
|
@@ -473,133 +487,3 @@ export abstract class ScrollableListPanel<T> extends BasePanel {
|
|
|
473
487
|
return lines.slice(0, height);
|
|
474
488
|
}
|
|
475
489
|
}
|
|
476
|
-
|
|
477
|
-
// ---------------------------------------------------------------------------
|
|
478
|
-
// SearchableListPanel<T>
|
|
479
|
-
// ---------------------------------------------------------------------------
|
|
480
|
-
|
|
481
|
-
/**
|
|
482
|
-
* Extends `ScrollableListPanel<T>` with inline search/filter support.
|
|
483
|
-
*
|
|
484
|
-
* Subclasses implement:
|
|
485
|
-
* - `getAllItems()` — the full (unfiltered) item list
|
|
486
|
-
* - `matchesSearch(item, query)` — case-insensitive filter predicate
|
|
487
|
-
*
|
|
488
|
-
* `getItems()` is implemented here and returns filtered results. Do NOT
|
|
489
|
-
* override `getItems()` in subclasses — override `getAllItems()` instead.
|
|
490
|
-
*
|
|
491
|
-
* Search state:
|
|
492
|
-
* - Printable characters append to `searchQuery`.
|
|
493
|
-
* - Backspace/Delete removes the last character.
|
|
494
|
-
* - Escape clears the query.
|
|
495
|
-
* - Navigation keys (up/down/etc.) are forwarded to the parent.
|
|
496
|
-
*
|
|
497
|
-
* Render the search input line by calling `buildSearchInput(width)` from
|
|
498
|
-
* your panel's header builder.
|
|
499
|
-
*/
|
|
500
|
-
export abstract class SearchableListPanel<T> extends ScrollableListPanel<T> {
|
|
501
|
-
protected searchQuery = '';
|
|
502
|
-
|
|
503
|
-
private _filteredItems: readonly T[] = [];
|
|
504
|
-
private _filterDirty = true;
|
|
505
|
-
|
|
506
|
-
// -------------------------------------------------------------------------
|
|
507
|
-
// Abstract — subclasses must implement
|
|
508
|
-
// -------------------------------------------------------------------------
|
|
509
|
-
|
|
510
|
-
/** Return the full unfiltered item list. */
|
|
511
|
-
protected abstract getAllItems(): readonly T[];
|
|
512
|
-
|
|
513
|
-
/** Return true if `item` matches the search `query`. */
|
|
514
|
-
protected abstract matchesSearch(item: T, query: string): boolean;
|
|
515
|
-
|
|
516
|
-
// -------------------------------------------------------------------------
|
|
517
|
-
// getItems — returns filtered list (do NOT override in subclasses)
|
|
518
|
-
// -------------------------------------------------------------------------
|
|
519
|
-
|
|
520
|
-
protected getItems(): readonly T[] {
|
|
521
|
-
if (this._filterDirty) {
|
|
522
|
-
const all = this.getAllItems();
|
|
523
|
-
this._filteredItems = this.searchQuery
|
|
524
|
-
? all.filter((item) => this.matchesSearch(item, this.searchQuery))
|
|
525
|
-
: all;
|
|
526
|
-
this._filterDirty = false;
|
|
527
|
-
// Clamp after filter to keep selection in bounds
|
|
528
|
-
this.clampSelection();
|
|
529
|
-
}
|
|
530
|
-
return this._filteredItems;
|
|
531
|
-
}
|
|
532
|
-
|
|
533
|
-
/**
|
|
534
|
-
* Mark the filter cache as stale.
|
|
535
|
-
* Call this whenever `getAllItems()` returns new data.
|
|
536
|
-
*/
|
|
537
|
-
protected invalidateFilter(): void {
|
|
538
|
-
this._filterDirty = true;
|
|
539
|
-
this.needsRender = true;
|
|
540
|
-
}
|
|
541
|
-
|
|
542
|
-
// -------------------------------------------------------------------------
|
|
543
|
-
// Input — search first, navigation second
|
|
544
|
-
// -------------------------------------------------------------------------
|
|
545
|
-
|
|
546
|
-
handleInput(key: string): boolean {
|
|
547
|
-
// Backspace: trim query
|
|
548
|
-
if (isPanelSearchBackspace(key)) {
|
|
549
|
-
if (this.searchQuery.length > 0) {
|
|
550
|
-
this.searchQuery = this.searchQuery.slice(0, -1);
|
|
551
|
-
this._filterDirty = true;
|
|
552
|
-
this.needsRender = true;
|
|
553
|
-
return true;
|
|
554
|
-
}
|
|
555
|
-
return false;
|
|
556
|
-
}
|
|
557
|
-
|
|
558
|
-
// Escape: clear query
|
|
559
|
-
if (isPanelSearchCancel(key)) {
|
|
560
|
-
if (this.searchQuery.length > 0) {
|
|
561
|
-
this.searchQuery = '';
|
|
562
|
-
this._filterDirty = true;
|
|
563
|
-
this.needsRender = true;
|
|
564
|
-
return true;
|
|
565
|
-
}
|
|
566
|
-
return false;
|
|
567
|
-
}
|
|
568
|
-
|
|
569
|
-
// Printable characters: append to query
|
|
570
|
-
if (isPanelSearchPrintable(key)) {
|
|
571
|
-
this.searchQuery += key;
|
|
572
|
-
this._filterDirty = true;
|
|
573
|
-
this.needsRender = true;
|
|
574
|
-
return true;
|
|
575
|
-
}
|
|
576
|
-
|
|
577
|
-
// Navigation and Enter: delegate to parent
|
|
578
|
-
return super.handleInput(key);
|
|
579
|
-
}
|
|
580
|
-
|
|
581
|
-
/**
|
|
582
|
-
* Build the filter input `Line` for use in a panel header section.
|
|
583
|
-
*
|
|
584
|
-
* Renders the filter label and current query with context-sensitive formatting:
|
|
585
|
-
*
|
|
586
|
-
* - **Focused** (`focused = true`): `[Filter] query_` — active, bold, cursor visible
|
|
587
|
-
* - **Unfocused** (`focused = false`): `Filter: query` — dim, no cursor
|
|
588
|
-
*
|
|
589
|
-
* @param width Panel width in columns.
|
|
590
|
-
* @param label Label text (default: `'Filter'`).
|
|
591
|
-
* @param focused Whether the filter input is currently active.
|
|
592
|
-
*/
|
|
593
|
-
protected buildFilterInputLine(width: number, label = 'Filter', focused: boolean): Line {
|
|
594
|
-
const palette = this.getPalette();
|
|
595
|
-
const formattedLabel = focused ? `[${label}] ` : `${label}: `;
|
|
596
|
-
const value = focused ? `${this.searchQuery}_` : this.searchQuery;
|
|
597
|
-
// Pass active:false when focused to prevent buildSearchInputLine from converting the
|
|
598
|
-
// trailing '_' cursor to the block-glyph (GLYPHS.surface.cursor). The focused visual
|
|
599
|
-
// affordance is provided by the '[Label] ' bracket format and explicit inputBg/info colors.
|
|
600
|
-
const opts = focused
|
|
601
|
-
? { active: false, bg: palette.inputBg, valueColor: palette.info }
|
|
602
|
-
: { active: false };
|
|
603
|
-
return buildSearchInputLine(width, formattedLabel, value, palette, opts);
|
|
604
|
-
}
|
|
605
|
-
}
|