@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.
Files changed (143) hide show
  1. package/CHANGELOG.md +124 -0
  2. package/README.md +1 -1
  3. package/package.json +1 -1
  4. package/src/core/context-usage.ts +53 -0
  5. package/src/core/conversation.ts +4 -6
  6. package/src/core/session-recovery.ts +2 -0
  7. package/src/core/turn-event-wiring.ts +1 -0
  8. package/src/input/commands/cost-runtime.ts +49 -0
  9. package/src/input/commands/eval.ts +4 -3
  10. package/src/input/commands/operator-runtime.ts +5 -1
  11. package/src/input/commands.ts +2 -0
  12. package/src/input/handler-feed-routes.ts +60 -26
  13. package/src/input/handler-feed.ts +17 -8
  14. package/src/input/handler-picker-routes.ts +18 -13
  15. package/src/input/handler-shortcuts.ts +51 -0
  16. package/src/input/handler-ui-state.ts +4 -0
  17. package/src/input/handler.ts +43 -6
  18. package/src/input/keybindings.ts +53 -8
  19. package/src/input/model-picker.ts +5 -0
  20. package/src/input/panel-integration-actions.ts +117 -1
  21. package/src/main.ts +5 -2
  22. package/src/panels/agent-inspector-panel.ts +139 -45
  23. package/src/panels/agent-inspector-shared.ts +147 -3
  24. package/src/panels/approval-panel.ts +204 -102
  25. package/src/panels/automation-control-panel.ts +370 -103
  26. package/src/panels/base-panel.ts +26 -2
  27. package/src/panels/builtin/agent.ts +28 -37
  28. package/src/panels/builtin/development.ts +40 -32
  29. package/src/panels/builtin/knowledge.ts +15 -6
  30. package/src/panels/builtin/operations.ts +178 -117
  31. package/src/panels/builtin/session.ts +35 -9
  32. package/src/panels/builtin/shared.ts +98 -6
  33. package/src/panels/cockpit-panel.ts +253 -75
  34. package/src/panels/cockpit-read-model.ts +2 -1
  35. package/src/panels/communication-panel.ts +159 -56
  36. package/src/panels/confirm-state.ts +8 -1
  37. package/src/panels/control-plane-panel.ts +354 -95
  38. package/src/panels/cost-tracker-panel.ts +243 -57
  39. package/src/panels/debug-panel.ts +347 -159
  40. package/src/panels/diff-panel.ts +257 -82
  41. package/src/panels/docs-panel.ts +166 -64
  42. package/src/panels/eval-panel.ts +336 -109
  43. package/src/panels/expandable-list-panel.ts +189 -0
  44. package/src/panels/file-explorer-panel.ts +238 -159
  45. package/src/panels/file-preview-panel.ts +141 -59
  46. package/src/panels/git-panel.ts +360 -191
  47. package/src/panels/hooks-panel.ts +181 -19
  48. package/src/panels/incident-review-panel.ts +252 -43
  49. package/src/panels/index.ts +0 -4
  50. package/src/panels/intelligence-panel.ts +310 -103
  51. package/src/panels/knowledge-graph-panel.ts +480 -58
  52. package/src/panels/local-auth-panel.ts +251 -33
  53. package/src/panels/marketplace-panel.ts +219 -33
  54. package/src/panels/memory-panel.ts +83 -81
  55. package/src/panels/ops-control-panel.ts +211 -32
  56. package/src/panels/ops-strategy-panel.ts +131 -45
  57. package/src/panels/orchestration-panel.ts +259 -67
  58. package/src/panels/panel-list-panel.ts +184 -136
  59. package/src/panels/panel-manager.ts +120 -13
  60. package/src/panels/plan-dashboard-panel.ts +507 -74
  61. package/src/panels/plugins-panel.ts +227 -34
  62. package/src/panels/policy-panel.ts +264 -55
  63. package/src/panels/polish-core.ts +162 -0
  64. package/src/panels/polish-tables.ts +258 -0
  65. package/src/panels/polish.ts +67 -149
  66. package/src/panels/project-planning-answer-actions.ts +134 -0
  67. package/src/panels/project-planning-panel.ts +84 -113
  68. package/src/panels/provider-health-panel.ts +438 -480
  69. package/src/panels/provider-health-routes.ts +203 -0
  70. package/src/panels/provider-health-tracker.ts +194 -6
  71. package/src/panels/provider-health-views.ts +560 -0
  72. package/src/panels/qr-panel.ts +136 -38
  73. package/src/panels/remote-panel.ts +123 -38
  74. package/src/panels/routes-panel.ts +87 -24
  75. package/src/panels/sandbox-panel.ts +232 -65
  76. package/src/panels/scrollable-list-panel.ts +143 -145
  77. package/src/panels/security-panel.ts +204 -52
  78. package/src/panels/services-panel.ts +174 -76
  79. package/src/panels/session-browser-panel.ts +101 -6
  80. package/src/panels/session-maintenance.ts +4 -122
  81. package/src/panels/settings-sync-panel.ts +346 -68
  82. package/src/panels/skills-panel.ts +157 -89
  83. package/src/panels/subscription-panel.ts +105 -34
  84. package/src/panels/symbol-outline-panel.ts +287 -154
  85. package/src/panels/system-messages-panel.ts +172 -38
  86. package/src/panels/tasks-panel.ts +348 -134
  87. package/src/panels/thinking-panel.ts +66 -32
  88. package/src/panels/token-budget-panel.ts +233 -46
  89. package/src/panels/tool-inspector-panel.ts +170 -54
  90. package/src/panels/types.ts +65 -4
  91. package/src/panels/work-plan-panel.ts +393 -39
  92. package/src/panels/worktree-panel.ts +239 -61
  93. package/src/panels/wrfc-panel-format.ts +133 -0
  94. package/src/panels/wrfc-panel.ts +146 -150
  95. package/src/renderer/compaction-preview.ts +2 -1
  96. package/src/renderer/compositor.ts +46 -43
  97. package/src/renderer/conversation-overlays.ts +25 -11
  98. package/src/renderer/footer-tips.ts +41 -0
  99. package/src/renderer/fullscreen-primitives.ts +22 -16
  100. package/src/renderer/help-overlay.ts +91 -14
  101. package/src/renderer/hint-grammar.ts +52 -0
  102. package/src/renderer/layout.ts +7 -7
  103. package/src/renderer/modal-factory.ts +23 -15
  104. package/src/renderer/model-picker-overlay.ts +30 -11
  105. package/src/renderer/model-workspace.ts +2 -3
  106. package/src/renderer/overlay-box.ts +16 -10
  107. package/src/renderer/panel-composite.ts +7 -20
  108. package/src/renderer/panel-workspace-bar.ts +14 -8
  109. package/src/renderer/process-indicator.ts +3 -1
  110. package/src/renderer/progress.ts +8 -6
  111. package/src/renderer/search-overlay.ts +27 -6
  112. package/src/renderer/session-picker-modal.ts +6 -4
  113. package/src/renderer/settings-modal.ts +70 -10
  114. package/src/renderer/shell-surface.ts +41 -15
  115. package/src/renderer/status-glyphs.ts +11 -9
  116. package/src/renderer/tab-strip.ts +148 -34
  117. package/src/renderer/theme.ts +60 -3
  118. package/src/renderer/ui-factory.ts +45 -36
  119. package/src/renderer/ui-primitives.ts +23 -2
  120. package/src/runtime/bootstrap-shell.ts +35 -18
  121. package/src/runtime/diagnostics/panels/index.ts +0 -3
  122. package/src/runtime/ui/model-picker/health-enrichment.ts +3 -0
  123. package/src/runtime/ui/model-picker/types.ts +4 -0
  124. package/src/shell/ui-openers.ts +14 -22
  125. package/src/utils/format-duration.ts +55 -0
  126. package/src/utils/format-number.ts +71 -0
  127. package/src/utils/splash-lines.ts +44 -3
  128. package/src/version.ts +1 -1
  129. package/src/work-plans/work-plan-store.ts +13 -0
  130. package/src/panels/agent-logs-panel.ts +0 -666
  131. package/src/panels/agent-logs-shared.ts +0 -129
  132. package/src/panels/context-visualizer-panel.ts +0 -214
  133. package/src/panels/forensics-panel.ts +0 -364
  134. package/src/panels/panel-picker.ts +0 -106
  135. package/src/panels/provider-account-snapshot.ts +0 -259
  136. package/src/panels/provider-accounts-panel.ts +0 -218
  137. package/src/panels/provider-stats-panel.ts +0 -366
  138. package/src/panels/schedule-panel.ts +0 -342
  139. package/src/panels/watchers-panel.ts +0 -193
  140. package/src/renderer/panel-picker-overlay.ts +0 -202
  141. package/src/renderer/panel-tab-bar.ts +0 -69
  142. package/src/runtime/diagnostics/panels/panel-resources.ts +0 -118
  143. 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
- const C = {
18
- ...DEFAULT_PANEL_PALETTE,
19
- header: '#94a3b8',
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', 'monitoring');
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.ok : C.dim },
113
- { label: 'resolved', value: String(snapshot.totalResolved), valueColor: snapshot.totalResolved > 0 ? C.ok : C.dim },
114
- { label: 'failures', value: String(snapshot.totalFailures), valueColor: snapshot.totalFailures > 0 ? C.error : C.dim },
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
- const selected = bindings[this.selectedIndex]!;
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 footerLines: Line[] = [
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
- footerLines.push(
163
- ...surfaceEntries.slice(0, 6).map(([surface, ids]) => buildPanelLine(width, [
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
- footerLines.push(buildPanelLine(width, [[' Up/Down move through route bindings', C.dim]]));
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: footerLines,
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
- const C = {
20
- ...DEFAULT_PANEL_PALETTE,
21
- header: '#e2e8f0',
22
- headerBg: '#0f172a',
23
- } as const;
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
- private scrollOffset = 0;
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', 'X', 'monitoring');
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
- const profileCount = listSandboxProfiles(this.config).length;
42
- const sessionCount = this.sessions.list().length;
43
- const itemCount = profileCount + sessionCount;
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 = selectable[this.selectedIndex] ?? null;
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
- buildGuidanceLine(width, '/sandbox review', 'inspect local vs QEMU posture, host readiness, and isolation defaults', C),
120
- buildGuidanceLine(width, '/sandbox set-qemu-image <path>', 'set the guest image path before enabling QEMU-backed session execution', C),
121
- buildGuidanceLine(width, '/sandbox scaffold-qemu-wrapper <path>', 'generate a host-side wrapper scaffold with a bring-up bridge mode and a real guest handoff contract', C),
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
- 'Start a sandbox session from a profile to make the running VM/session posture visible here.',
183
- [{ command: '/sandbox session start <profile>', summary: 'start a sandbox session and capture its VM/session record' }],
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 bg = selectedSession?.id === session.id ? C.headerBg : undefined;
189
- sessionLines.push(buildPanelLine(width, [
190
- [' ', C.label],
191
- [session.profileId.padEnd(15), C.info, bg],
192
- [session.state.padEnd(10), session.state === 'running' ? C.good : session.state === 'failed' ? C.bad : C.warn, bg],
193
- [(session.shared ? 'shared' : 'dedicated').padEnd(12), C.value, bg],
194
- [String(session.resolvedBackend ?? session.backend).padEnd(8), C.dim, bg],
195
- [` ${(session.startupStatus ?? 'n/a').slice(0, 8).padEnd(8)}`, session.startupStatus === 'verified' ? C.good : session.startupStatus === 'failed' ? C.bad : C.warn, bg],
196
- [` ${String(session.executionCount ?? 0).padStart(3)}x`, C.info, bg],
197
- [` ${session.id.slice(0, Math.max(8, Math.min(14, width - 64)))}`, C.dim, bg],
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(buildPanelLine(width, [
205
- [' ', C.label],
206
- [preset.id.padEnd(18), C.info],
207
- [preset.config.replIsolation.padEnd(16), C.value],
208
- [preset.config.mcpIsolation.padEnd(16), C.dim],
209
- [preset.config.windowsMode, C.warn],
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 bg = selectedProfile?.id === profile.id ? C.headerBg : undefined;
216
- profileLines.push(buildPanelLine(width, [
217
- [' ', C.label],
218
- [profile.id.padEnd(15), C.info, bg],
219
- [profile.isolation.padEnd(14), C.value, bg],
220
- [profile.kind.padEnd(12), C.dim, bg],
221
- [` vm=${profile.requiresVm ? 'yes' : 'no'}`, profile.requiresVm ? C.good : C.warn, bg],
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.scrollOffset,
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.scrollOffset,
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.scrollOffset = sessionsSection?.scrollOffset ?? this.scrollOffset;
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));