@pellux/goodvibes-tui 0.28.0 → 1.0.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 (144) hide show
  1. package/CHANGELOG.md +133 -0
  2. package/README.md +1 -1
  3. package/package.json +1 -1
  4. package/src/core/conversation-line-cache.ts +432 -0
  5. package/src/core/conversation-rendering.ts +11 -3
  6. package/src/core/conversation.ts +50 -4
  7. package/src/input/commands/cost-runtime.ts +49 -0
  8. package/src/input/commands/operator-runtime.ts +5 -1
  9. package/src/input/commands.ts +2 -0
  10. package/src/input/handler-content-actions.ts +8 -3
  11. package/src/input/handler-feed-routes.ts +12 -35
  12. package/src/input/handler-feed.ts +4 -8
  13. package/src/input/handler-shortcuts.ts +51 -0
  14. package/src/input/handler.ts +43 -6
  15. package/src/input/input-history.ts +17 -4
  16. package/src/input/keybindings.ts +48 -8
  17. package/src/input/panel-integration-actions.ts +107 -1
  18. package/src/main.ts +19 -11
  19. package/src/panels/agent-inspector-panel.ts +125 -36
  20. package/src/panels/agent-inspector-shared.ts +144 -0
  21. package/src/panels/approval-panel.ts +67 -16
  22. package/src/panels/automation-control-panel.ts +368 -124
  23. package/src/panels/base-panel.ts +1 -1
  24. package/src/panels/builtin/agent.ts +28 -37
  25. package/src/panels/builtin/development.ts +40 -32
  26. package/src/panels/builtin/knowledge.ts +15 -6
  27. package/src/panels/builtin/operations.ts +178 -117
  28. package/src/panels/builtin/session.ts +35 -9
  29. package/src/panels/builtin/shared.ts +98 -6
  30. package/src/panels/cockpit-panel.ts +232 -73
  31. package/src/panels/communication-panel.ts +58 -20
  32. package/src/panels/confirm-state.ts +8 -1
  33. package/src/panels/control-plane-panel.ts +351 -118
  34. package/src/panels/cost-tracker-panel.ts +165 -7
  35. package/src/panels/debug-panel.ts +312 -159
  36. package/src/panels/diff-panel.ts +209 -57
  37. package/src/panels/docs-panel.ts +152 -66
  38. package/src/panels/eval-panel.ts +220 -42
  39. package/src/panels/file-explorer-panel.ts +202 -122
  40. package/src/panels/file-preview-panel.ts +132 -57
  41. package/src/panels/git-panel.ts +310 -129
  42. package/src/panels/hooks-panel.ts +150 -22
  43. package/src/panels/incident-review-panel.ts +223 -35
  44. package/src/panels/index.ts +0 -4
  45. package/src/panels/intelligence-panel.ts +212 -86
  46. package/src/panels/knowledge-graph-panel.ts +461 -101
  47. package/src/panels/local-auth-panel.ts +240 -28
  48. package/src/panels/marketplace-panel.ts +193 -26
  49. package/src/panels/memory-panel.ts +78 -77
  50. package/src/panels/ops-control-panel.ts +146 -29
  51. package/src/panels/ops-strategy-panel.ts +72 -4
  52. package/src/panels/orchestration-panel.ts +231 -69
  53. package/src/panels/panel-list-panel.ts +154 -131
  54. package/src/panels/panel-manager.ts +97 -9
  55. package/src/panels/plan-dashboard-panel.ts +333 -17
  56. package/src/panels/plugins-panel.ts +184 -29
  57. package/src/panels/policy-panel.ts +210 -38
  58. package/src/panels/polish-core.ts +7 -2
  59. package/src/panels/polish.ts +23 -4
  60. package/src/panels/project-planning-answer-actions.ts +134 -0
  61. package/src/panels/project-planning-panel.ts +62 -113
  62. package/src/panels/provider-health-panel.ts +434 -518
  63. package/src/panels/provider-health-routes.ts +203 -0
  64. package/src/panels/provider-health-tracker.ts +194 -6
  65. package/src/panels/provider-health-views.ts +560 -0
  66. package/src/panels/qr-panel.ts +116 -30
  67. package/src/panels/remote-panel.ts +114 -36
  68. package/src/panels/routes-panel.ts +63 -22
  69. package/src/panels/sandbox-panel.ts +174 -42
  70. package/src/panels/scrollable-list-panel.ts +19 -135
  71. package/src/panels/security-panel.ts +133 -33
  72. package/src/panels/services-panel.ts +116 -64
  73. package/src/panels/session-browser-panel.ts +73 -2
  74. package/src/panels/session-maintenance.ts +4 -122
  75. package/src/panels/settings-sync-panel.ts +335 -72
  76. package/src/panels/skills-panel.ts +157 -89
  77. package/src/panels/subscription-panel.ts +86 -33
  78. package/src/panels/symbol-outline-panel.ts +248 -108
  79. package/src/panels/system-messages-panel.ts +114 -13
  80. package/src/panels/tasks-panel.ts +326 -139
  81. package/src/panels/thinking-panel.ts +43 -10
  82. package/src/panels/token-budget-panel.ts +194 -18
  83. package/src/panels/tool-inspector-panel.ts +144 -34
  84. package/src/panels/types.ts +40 -4
  85. package/src/panels/work-plan-panel.ts +280 -17
  86. package/src/panels/worktree-panel.ts +175 -42
  87. package/src/panels/wrfc-panel.ts +116 -24
  88. package/src/renderer/agent-detail-modal.ts +37 -8
  89. package/src/renderer/code-block.ts +58 -28
  90. package/src/renderer/context-inspector.ts +3 -6
  91. package/src/renderer/conversation-overlays.ts +25 -11
  92. package/src/renderer/conversation-surface.ts +1 -21
  93. package/src/renderer/diff-view.ts +6 -4
  94. package/src/renderer/footer-tips.ts +41 -0
  95. package/src/renderer/fullscreen-primitives.ts +23 -17
  96. package/src/renderer/fullscreen-workspace.ts +2 -7
  97. package/src/renderer/help-overlay.ts +91 -14
  98. package/src/renderer/hint-grammar.ts +52 -0
  99. package/src/renderer/history-search-overlay.ts +10 -16
  100. package/src/renderer/layout.ts +7 -7
  101. package/src/renderer/markdown.ts +9 -1
  102. package/src/renderer/modal-factory.ts +23 -15
  103. package/src/renderer/model-picker-overlay.ts +8 -485
  104. package/src/renderer/model-workspace.ts +9 -24
  105. package/src/renderer/overlay-box.ts +23 -19
  106. package/src/renderer/process-indicator.ts +15 -25
  107. package/src/renderer/profile-picker-modal.ts +13 -14
  108. package/src/renderer/prompt-content-width.ts +16 -0
  109. package/src/renderer/search-overlay.ts +27 -6
  110. package/src/renderer/selection-modal-overlay.ts +3 -1
  111. package/src/renderer/semantic-diff.ts +1 -1
  112. package/src/renderer/session-picker-modal.ts +6 -4
  113. package/src/renderer/settings-modal-helpers.ts +10 -34
  114. package/src/renderer/settings-modal.ts +70 -10
  115. package/src/renderer/shell-surface.ts +54 -15
  116. package/src/renderer/status-glyphs.ts +11 -9
  117. package/src/renderer/surface-layout.ts +0 -12
  118. package/src/renderer/term-caps.ts +1 -1
  119. package/src/renderer/theme.ts +60 -3
  120. package/src/renderer/tool-call.ts +6 -20
  121. package/src/renderer/ui-factory.ts +48 -37
  122. package/src/renderer/ui-primitives.ts +41 -3
  123. package/src/runtime/bootstrap-shell.ts +35 -18
  124. package/src/runtime/diagnostics/panels/index.ts +0 -3
  125. package/src/runtime/render-scheduler.ts +80 -0
  126. package/src/shell/ui-openers.ts +14 -22
  127. package/src/utils/format-duration.ts +2 -2
  128. package/src/utils/splash-lines.ts +54 -5
  129. package/src/version.ts +1 -1
  130. package/src/work-plans/work-plan-store.ts +13 -0
  131. package/src/panels/agent-logs-panel.ts +0 -635
  132. package/src/panels/agent-logs-shared.ts +0 -129
  133. package/src/panels/context-visualizer-panel.ts +0 -238
  134. package/src/panels/forensics-panel.ts +0 -378
  135. package/src/panels/provider-account-snapshot.ts +0 -259
  136. package/src/panels/provider-accounts-panel.ts +0 -255
  137. package/src/panels/provider-stats-panel.ts +0 -391
  138. package/src/panels/schedule-panel.ts +0 -365
  139. package/src/panels/watchers-panel.ts +0 -213
  140. package/src/renderer/file-tree.ts +0 -153
  141. package/src/renderer/progress.ts +0 -99
  142. package/src/renderer/status-token.ts +0 -67
  143. package/src/runtime/diagnostics/panels/panel-resources.ts +0 -118
  144. 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
- const C = {
20
- ...DEFAULT_PANEL_PALETTE,
21
- header: '#94a3b8',
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', 'monitoring');
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.ok : C.dim },
125
- { label: 'resolved', value: String(snapshot.totalResolved), valueColor: snapshot.totalResolved > 0 ? C.ok : C.dim },
126
- { 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 },
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
- 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();
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: '/communication', label: 'inspect flow' },
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
- const C = {
22
- ...DEFAULT_PANEL_PALETTE,
23
- header: '#e2e8f0',
24
- headerBg: '#0f172a',
25
- } 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;
26
64
 
27
65
  export class SandboxPanel extends BasePanel {
28
66
  private selectedIndex = 0;
29
- 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;
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', 'X', 'monitoring');
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
- const profileCount = listSandboxProfiles(this.config).length;
44
- const sessionCount = this.sessions.list().length;
45
- 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;
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 = selectable[this.selectedIndex] ?? null;
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
- buildGuidanceLine(width, '/sandbox review', 'inspect local vs QEMU posture, host readiness, and isolation defaults', C),
122
- buildGuidanceLine(width, '/sandbox set-qemu-image <path>', 'set the guest image path before enabling QEMU-backed session execution', C),
123
- 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),
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
- 'Start a sandbox session from a profile to make the running VM/session posture visible here.',
188
- [{ 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
+ [],
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.scrollOffset,
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.scrollOffset,
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.scrollOffset = sessionsSection?.scrollOffset ?? this.scrollOffset;
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
- }