@pellux/goodvibes-tui 1.1.0 → 1.7.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 (194) hide show
  1. package/CHANGELOG.md +85 -21
  2. package/README.md +20 -11
  3. package/docs/foundation-artifacts/operator-contract.json +1 -1
  4. package/package.json +2 -2
  5. package/src/core/alert-gating.ts +67 -0
  6. package/src/core/approval-alert.ts +72 -0
  7. package/src/core/budget-breach-notifier.ts +106 -0
  8. package/src/core/conversation-rendering.ts +19 -0
  9. package/src/core/conversation.ts +18 -0
  10. package/src/core/focus-tracker.ts +41 -0
  11. package/src/core/long-task-notifier.ts +33 -6
  12. package/src/core/system-message-router.ts +37 -51
  13. package/src/core/turn-cancellation.ts +20 -0
  14. package/src/core/turn-event-wiring.ts +64 -3
  15. package/src/export/cost-utils.ts +36 -0
  16. package/src/input/command-registry.ts +38 -1
  17. package/src/input/commands/checkpoint-runtime.ts +280 -0
  18. package/src/input/commands/codebase-runtime.ts +192 -0
  19. package/src/input/commands/eval.ts +1 -1
  20. package/src/input/commands/health-runtime.ts +1 -1
  21. package/src/input/commands/image-runtime.ts +112 -0
  22. package/src/input/commands/intelligence-runtime.ts +11 -1
  23. package/src/input/commands/local-auth-runtime.ts +4 -1
  24. package/src/input/commands/local-runtime.ts +9 -3
  25. package/src/input/commands/marketplace-runtime.ts +2 -2
  26. package/src/input/commands/memory.ts +6 -1
  27. package/src/input/commands/operator-panel-runtime.ts +40 -24
  28. package/src/input/commands/planning-runtime.ts +26 -3
  29. package/src/input/commands/platform-sandbox-runtime.ts +1 -6
  30. package/src/input/commands/plugin-runtime.ts +2 -2
  31. package/src/input/commands/policy-dispatch.ts +21 -0
  32. package/src/input/commands/provider-accounts-runtime.ts +1 -1
  33. package/src/input/commands/qrcode-runtime.ts +3 -3
  34. package/src/input/commands/recall-review.ts +35 -0
  35. package/src/input/commands/remote-runtime.ts +3 -3
  36. package/src/input/commands/runtime-services.ts +54 -13
  37. package/src/input/commands/services-runtime.ts +1 -1
  38. package/src/input/commands/session-workflow.ts +16 -1
  39. package/src/input/commands/settings-sync-runtime.ts +1 -1
  40. package/src/input/commands/shell-core.ts +15 -7
  41. package/src/input/commands/skills-runtime.ts +2 -8
  42. package/src/input/commands/subscription-runtime.ts +2 -2
  43. package/src/input/commands/test-runtime.ts +277 -0
  44. package/src/input/commands/websearch-runtime.ts +101 -0
  45. package/src/input/commands/work-plan-runtime.ts +36 -10
  46. package/src/input/commands/workstream-runtime.ts +338 -0
  47. package/src/input/commands.ts +12 -0
  48. package/src/input/config-modal-types.ts +161 -0
  49. package/src/input/config-modal.ts +377 -0
  50. package/src/input/feed-context-factory.ts +7 -8
  51. package/src/input/handler-command-route.ts +27 -17
  52. package/src/input/handler-feed-routes.ts +61 -23
  53. package/src/input/handler-feed.ts +47 -23
  54. package/src/input/handler-interactions.ts +1 -3
  55. package/src/input/handler-modal-routes.ts +65 -0
  56. package/src/input/handler-modal-stack.ts +3 -5
  57. package/src/input/handler-modal-token-routes.ts +16 -49
  58. package/src/input/handler-picker-routes.ts +11 -94
  59. package/src/input/handler-shortcuts.ts +24 -14
  60. package/src/input/handler-types.ts +2 -6
  61. package/src/input/handler-ui-state.ts +10 -22
  62. package/src/input/handler.ts +6 -28
  63. package/src/input/keybindings.ts +22 -8
  64. package/src/input/model-picker-types.ts +24 -6
  65. package/src/input/model-picker.ts +58 -0
  66. package/src/input/panel-integration-actions.ts +9 -170
  67. package/src/input/settings-modal-data.ts +95 -0
  68. package/src/input/settings-modal-mutations.ts +6 -4
  69. package/src/main.ts +24 -27
  70. package/src/panels/agent-inspector-shared.ts +2 -1
  71. package/src/panels/base-panel.ts +22 -1
  72. package/src/panels/builtin/agent.ts +21 -107
  73. package/src/panels/builtin/development.ts +15 -61
  74. package/src/panels/builtin/knowledge.ts +8 -32
  75. package/src/panels/builtin/operations.ts +84 -428
  76. package/src/panels/builtin/session.ts +21 -112
  77. package/src/panels/builtin/shared.ts +9 -43
  78. package/src/panels/builtin-modals.ts +218 -0
  79. package/src/panels/builtin-panels.ts +5 -0
  80. package/src/panels/cost-tracker-panel.ts +36 -10
  81. package/src/panels/diff-panel.ts +12 -43
  82. package/src/panels/eval-registry.ts +60 -0
  83. package/src/panels/fleet-panel.ts +800 -0
  84. package/src/panels/fleet-read-model.ts +477 -0
  85. package/src/panels/fleet-steer.ts +92 -0
  86. package/src/panels/fleet-stop.ts +125 -0
  87. package/src/panels/fleet-tabs.ts +230 -0
  88. package/src/panels/fleet-transcript.ts +356 -0
  89. package/src/panels/index.ts +7 -31
  90. package/src/panels/modals/hooks-modal.ts +187 -0
  91. package/src/panels/modals/keybindings-modal.ts +151 -0
  92. package/src/panels/modals/knowledge-modal.ts +158 -0
  93. package/src/panels/modals/local-auth-modal.ts +132 -0
  94. package/src/panels/modals/marketplace-modal.ts +218 -0
  95. package/src/panels/modals/memory-modal.ts +180 -0
  96. package/src/panels/modals/modal-surface-helpers.ts +54 -0
  97. package/src/panels/modals/modal-theme.ts +36 -0
  98. package/src/panels/modals/pairing-modal.ts +144 -0
  99. package/src/panels/modals/planning-modal.ts +282 -0
  100. package/src/panels/modals/plugins-modal.ts +174 -0
  101. package/src/panels/modals/policy-modal.ts +256 -0
  102. package/src/panels/modals/provider-health-modal.ts +136 -0
  103. package/src/panels/modals/remote-modal.ts +115 -0
  104. package/src/panels/modals/sandbox-modal.ts +150 -0
  105. package/src/panels/modals/security-modal.ts +217 -0
  106. package/src/panels/modals/services-modal.ts +179 -0
  107. package/src/panels/modals/settings-sync-modal.ts +142 -0
  108. package/src/panels/modals/skills-modal.ts +189 -0
  109. package/src/panels/modals/subscription-modal.ts +172 -0
  110. package/src/panels/modals/work-plan-modal.ts +149 -0
  111. package/src/panels/panel-confirm-overlay.ts +72 -0
  112. package/src/panels/panel-manager.ts +108 -5
  113. package/src/panels/project-planning-answer-actions.ts +4 -2
  114. package/src/panels/skills-panel.ts +7 -51
  115. package/src/panels/types.ts +13 -0
  116. package/src/permissions/hunk-selection.ts +179 -0
  117. package/src/permissions/prompt.ts +60 -4
  118. package/src/renderer/compaction-history-modal.ts +19 -3
  119. package/src/renderer/compaction-preview.ts +13 -2
  120. package/src/renderer/compaction-quality.ts +100 -0
  121. package/src/renderer/config-modal.ts +81 -0
  122. package/src/renderer/conversation-overlays.ts +10 -17
  123. package/src/renderer/fleet-tab-strip.ts +56 -0
  124. package/src/renderer/footer-tips.ts +10 -2
  125. package/src/renderer/git-status.ts +40 -0
  126. package/src/renderer/help-overlay.ts +2 -2
  127. package/src/renderer/model-workspace.ts +44 -1
  128. package/src/renderer/panel-workspace-bar.ts +8 -2
  129. package/src/renderer/turn-injection.ts +114 -0
  130. package/src/runtime/bootstrap-command-context.ts +21 -1
  131. package/src/runtime/bootstrap-command-parts.ts +34 -7
  132. package/src/runtime/bootstrap-core.ts +14 -4
  133. package/src/runtime/bootstrap-shell.ts +29 -16
  134. package/src/runtime/bootstrap.ts +11 -17
  135. package/src/runtime/code-index-services.ts +112 -0
  136. package/src/runtime/orchestrator-core-services.ts +49 -0
  137. package/src/runtime/process-lifecycle.ts +3 -1
  138. package/src/runtime/services.ts +91 -43
  139. package/src/runtime/ui-services.ts +8 -0
  140. package/src/runtime/workstream-services.ts +195 -0
  141. package/src/shell/blocking-input.ts +22 -1
  142. package/src/shell/ui-openers.ts +129 -17
  143. package/src/utils/format-duration.ts +8 -18
  144. package/src/utils/splash-lines.ts +1 -1
  145. package/src/version.ts +1 -1
  146. package/src/panels/agent-inspector-panel.ts +0 -786
  147. package/src/panels/approval-panel.ts +0 -252
  148. package/src/panels/automation-control-panel.ts +0 -479
  149. package/src/panels/cockpit-panel.ts +0 -481
  150. package/src/panels/cockpit-read-model.ts +0 -238
  151. package/src/panels/communication-panel.ts +0 -256
  152. package/src/panels/control-plane-panel.ts +0 -470
  153. package/src/panels/debug-panel.ts +0 -615
  154. package/src/panels/docs-panel.ts +0 -384
  155. package/src/panels/eval-panel.ts +0 -627
  156. package/src/panels/file-explorer-panel.ts +0 -673
  157. package/src/panels/file-preview-panel.ts +0 -517
  158. package/src/panels/hooks-panel.ts +0 -401
  159. package/src/panels/incident-review-panel.ts +0 -406
  160. package/src/panels/intelligence-panel.ts +0 -383
  161. package/src/panels/knowledge-graph-panel.ts +0 -515
  162. package/src/panels/marketplace-panel.ts +0 -399
  163. package/src/panels/memory-panel.ts +0 -558
  164. package/src/panels/ops-control-panel.ts +0 -329
  165. package/src/panels/ops-strategy-panel.ts +0 -321
  166. package/src/panels/orchestration-panel.ts +0 -465
  167. package/src/panels/panel-list-panel.ts +0 -566
  168. package/src/panels/plan-dashboard-panel.ts +0 -707
  169. package/src/panels/policy-panel.ts +0 -517
  170. package/src/panels/project-planning-panel.ts +0 -731
  171. package/src/panels/provider-health-panel.ts +0 -678
  172. package/src/panels/provider-health-tracker.ts +0 -310
  173. package/src/panels/provider-health-views.ts +0 -567
  174. package/src/panels/qr-panel.ts +0 -280
  175. package/src/panels/remote-panel.ts +0 -534
  176. package/src/panels/routes-panel.ts +0 -241
  177. package/src/panels/sandbox-panel.ts +0 -456
  178. package/src/panels/security-panel.ts +0 -447
  179. package/src/panels/services-panel.ts +0 -329
  180. package/src/panels/session-browser-panel.ts +0 -496
  181. package/src/panels/settings-sync-panel.ts +0 -398
  182. package/src/panels/subscription-panel.ts +0 -342
  183. package/src/panels/symbol-outline-panel.ts +0 -619
  184. package/src/panels/system-messages-panel.ts +0 -364
  185. package/src/panels/tasks-panel.ts +0 -608
  186. package/src/panels/thinking-panel.ts +0 -333
  187. package/src/panels/tool-inspector-panel.ts +0 -537
  188. package/src/panels/work-plan-panel.ts +0 -540
  189. package/src/panels/worktree-panel.ts +0 -360
  190. package/src/panels/wrfc-panel.ts +0 -790
  191. package/src/renderer/agent-detail-modal.ts +0 -466
  192. package/src/renderer/live-tail-modal.ts +0 -156
  193. package/src/renderer/process-modal.ts +0 -671
  194. package/src/renderer/qr-renderer.ts +0 -120
@@ -0,0 +1,377 @@
1
+ import type {
2
+ ConfigModalAction,
3
+ ConfigModalActionContext,
4
+ ConfigModalRow,
5
+ ConfigModalSurface,
6
+ ConfigModalTab,
7
+ ConfigModalView,
8
+ } from './config-modal-types.ts';
9
+ import type { ModalSectionStyle } from '../renderer/modal-factory.ts';
10
+
11
+ export type {
12
+ ConfigModalAction,
13
+ ConfigModalActionContext,
14
+ ConfigModalRow,
15
+ ConfigModalSurface,
16
+ ConfigModalTab,
17
+ ConfigModalView,
18
+ } from './config-modal-types.ts';
19
+
20
+ /** A tab as the renderer sees it (structure frozen, label live). */
21
+ export interface ConfigModalRenderTab {
22
+ readonly id: string;
23
+ readonly label: string;
24
+ readonly active: boolean;
25
+ }
26
+
27
+ /** A row as the renderer sees it: frozen identity, live label/value overlay. */
28
+ export interface ConfigModalRenderRow {
29
+ readonly id: string;
30
+ readonly label: string;
31
+ readonly style?: ModalSectionStyle;
32
+ readonly selected: boolean;
33
+ readonly selectable: boolean;
34
+ /** True when this frozen row has no live counterpart this tick (value went
35
+ * stale — kept in place, dimmed, until the next interaction boundary). */
36
+ readonly stale: boolean;
37
+ }
38
+
39
+ /** Everything renderConfigModal needs — computed by overlaying live values onto
40
+ * the frozen structure so layout stays stable between key presses. */
41
+ export interface ConfigModalRenderModel {
42
+ readonly title: string;
43
+ readonly tabs: readonly ConfigModalRenderTab[];
44
+ readonly header: readonly string[];
45
+ readonly rows: readonly ConfigModalRenderRow[];
46
+ readonly emptyText?: string;
47
+ readonly degraded?: string;
48
+ readonly status?: string;
49
+ readonly hints: readonly string[];
50
+ readonly scroll: { readonly offset: number; readonly total: number; readonly visible: number };
51
+ }
52
+
53
+ const DEFAULT_VISIBLE_ROWS = 10;
54
+
55
+ /**
56
+ * ConfigModal — the single, generic, named config-modal host. One instance
57
+ * lives on the InputHandler (like `settingsModal`); it renders whatever
58
+ * `ConfigModalSurface` is currently open. Key routing is a single
59
+ * `handleConfigModalToken` path (handler-modal-routes.ts) — no parallel input
60
+ * system. See config-modal-types.ts for the surface contract and the liveness
61
+ * doctrine this class enforces.
62
+ */
63
+ export class ConfigModal {
64
+ public active = false;
65
+
66
+ private surface: ConfigModalSurface | null = null;
67
+ private requestRender: () => void = () => {};
68
+
69
+ /** Active tab / selected row tracked by STABLE id (survives value refresh). */
70
+ private activeTabId = '';
71
+ private selectedRowId = '';
72
+
73
+ /** Transient status line (action result, error, or confirm prompt). */
74
+ private statusMessage = '';
75
+ /** Pending destructive confirm: the action key awaiting a second press. */
76
+ private pendingConfirmKey: string | null = null;
77
+
78
+ /**
79
+ * Structure captured at the last interaction boundary (open / key press).
80
+ * Renders overlay live values onto THIS so rows never reflow mid-interaction.
81
+ */
82
+ private frozenView: ConfigModalView | null = null;
83
+
84
+ private scrollOffset = 0;
85
+ private visibleRows = DEFAULT_VISIBLE_ROWS;
86
+
87
+ // ── Lifecycle ──────────────────────────────────────────────────────────────
88
+
89
+ open(surface: ConfigModalSurface, requestRender: () => void = () => {}): void {
90
+ // Re-opening a different surface closes the previous one cleanly.
91
+ if (this.surface && this.surface !== surface) this.surface.onClose?.();
92
+ this.surface = surface;
93
+ this.requestRender = requestRender;
94
+ this.active = true;
95
+ this.statusMessage = '';
96
+ this.pendingConfirmKey = null;
97
+ this.scrollOffset = 0;
98
+ const view = surface.buildView();
99
+ this.frozenView = view;
100
+ this.activeTabId = view.tabs[0]?.id ?? '';
101
+ this.selectedRowId = this._firstSelectableId(this._frozenTab());
102
+ surface.onOpen?.(requestRender);
103
+ }
104
+
105
+ close(): void {
106
+ if (!this.active) return;
107
+ this.surface?.onClose?.();
108
+ this.active = false;
109
+ this.surface = null;
110
+ this.frozenView = null;
111
+ this.statusMessage = '';
112
+ this.pendingConfirmKey = null;
113
+ }
114
+
115
+ /** Re-activate the current surface after a nested modal closes (Esc stack). */
116
+ reopen(): void {
117
+ if (!this.surface) return;
118
+ this.active = true;
119
+ this.syncStructure();
120
+ this.surface.onOpen?.(this.requestRender);
121
+ }
122
+
123
+ getSurfaceName(): string | null {
124
+ return this.surface?.name ?? null;
125
+ }
126
+
127
+ setViewportRows(rows: number): void {
128
+ this.visibleRows = Math.max(3, rows);
129
+ this._clampScroll();
130
+ }
131
+
132
+ // ── Interaction boundary ────────────────────────────────────────────────────
133
+
134
+ /**
135
+ * Re-capture the structure from the current live view. Called on every key
136
+ * press (never during a pure render tick) — this is what makes structural
137
+ * changes appear only at an interaction boundary. Selection + active tab are
138
+ * preserved by id and clamped if their target vanished.
139
+ */
140
+ syncStructure(): void {
141
+ if (!this.surface) return;
142
+ const view = this.surface.buildView();
143
+ this.frozenView = view;
144
+ if (!view.tabs.some((t) => t.id === this.activeTabId)) {
145
+ this.activeTabId = view.tabs[0]?.id ?? '';
146
+ }
147
+ const tab = this._frozenTab();
148
+ if (!this._selectableIds(tab).includes(this.selectedRowId)) {
149
+ this.selectedRowId = this._firstSelectableId(tab);
150
+ }
151
+ this._clampScroll();
152
+ }
153
+
154
+ // ── Navigation (each is an interaction boundary) ────────────────────────────
155
+
156
+ moveDown(): void {
157
+ this._clearConfirm();
158
+ this.syncStructure();
159
+ const ids = this._selectableIds(this._frozenTab());
160
+ if (ids.length === 0) return;
161
+ const i = ids.indexOf(this.selectedRowId);
162
+ this.selectedRowId = ids[(i + 1) % ids.length]!;
163
+ this._clampScroll();
164
+ }
165
+
166
+ moveUp(): void {
167
+ this._clearConfirm();
168
+ this.syncStructure();
169
+ const ids = this._selectableIds(this._frozenTab());
170
+ if (ids.length === 0) return;
171
+ const i = ids.indexOf(this.selectedRowId);
172
+ this.selectedRowId = ids[(i - 1 + ids.length) % ids.length]!;
173
+ this._clampScroll();
174
+ }
175
+
176
+ nextTab(): void {
177
+ this._switchTab(1);
178
+ }
179
+
180
+ prevTab(): void {
181
+ this._switchTab(-1);
182
+ }
183
+
184
+ private _switchTab(dir: 1 | -1): void {
185
+ this._clearConfirm();
186
+ this.syncStructure();
187
+ const tabs = this.frozenView?.tabs ?? [];
188
+ if (tabs.length <= 1) return;
189
+ const i = tabs.findIndex((t) => t.id === this.activeTabId);
190
+ const next = tabs[(i + dir + tabs.length) % tabs.length]!;
191
+ this.activeTabId = next.id;
192
+ this.selectedRowId = this._firstSelectableId(next);
193
+ this.scrollOffset = 0;
194
+ this.statusMessage = '';
195
+ }
196
+
197
+ // ── Actions ─────────────────────────────────────────────────────────────────
198
+
199
+ /** The action bound to `key` on the active tab/row, if enabled. */
200
+ resolveAction(key: string): ConfigModalAction | null {
201
+ const actions = this.surface?.actions ?? [];
202
+ const row = this.getSelectedRow();
203
+ for (const action of actions) {
204
+ if (action.key !== key) continue;
205
+ if (action.enabledFor && !action.enabledFor(row, this.activeTabId)) return null;
206
+ return action;
207
+ }
208
+ return null;
209
+ }
210
+
211
+ /**
212
+ * Attempt to fire the action bound to `key`. Returns true if a key was an
213
+ * action (consumed). Handles the two-press confirm for destructive actions.
214
+ * This is an interaction boundary (syncs structure first).
215
+ */
216
+ fireAction(key: string, ctx: Omit<ConfigModalActionContext, 'row' | 'tabId' | 'setStatus' | 'close' | 'requestRender'>): boolean {
217
+ this.syncStructure();
218
+ const action = this.resolveAction(key);
219
+ if (!action) return false;
220
+ if (action.confirm && this.pendingConfirmKey !== key) {
221
+ this.pendingConfirmKey = key;
222
+ this.statusMessage = `Press ${key} again to ${action.label}.`;
223
+ return true;
224
+ }
225
+ this._clearConfirm();
226
+ const fullCtx: ConfigModalActionContext = {
227
+ row: this.getSelectedRow(),
228
+ tabId: this.activeTabId,
229
+ print: ctx.print,
230
+ executeCommand: ctx.executeCommand,
231
+ openModal: ctx.openModal,
232
+ requestRender: this.requestRender,
233
+ setStatus: (m: string) => { this.statusMessage = m; },
234
+ close: () => this.close(),
235
+ };
236
+ this.surface?.onAction?.(action.id, fullCtx);
237
+ return true;
238
+ }
239
+
240
+ /** Clear a pending confirm when the user navigates or presses an unrelated key. */
241
+ clearConfirmOnMiss(): void {
242
+ this._clearConfirm();
243
+ }
244
+
245
+ // ── Read accessors (for the renderer + tests) ───────────────────────────────
246
+
247
+ getSelectedRow(): ConfigModalRow | null {
248
+ const tab = this._liveTab(this.activeTabId) ?? this._frozenTab();
249
+ return tab?.rows.find((r) => r.id === this.selectedRowId) ?? null;
250
+ }
251
+
252
+ getActiveTabId(): string {
253
+ return this.activeTabId;
254
+ }
255
+
256
+ getSelectedRowId(): string {
257
+ return this.selectedRowId;
258
+ }
259
+
260
+ getStatusMessage(): string {
261
+ return this.statusMessage;
262
+ }
263
+
264
+ /**
265
+ * Compute the render model: frozen structure + live value overlay. Pure —
266
+ * does NOT sync structure, so calling it repeatedly with only value mutations
267
+ * yields byte-identical layout (the liveness contract).
268
+ */
269
+ getRenderModel(): ConfigModalRenderModel {
270
+ const live = this.surface?.buildView() ?? null;
271
+ const frozen = this.frozenView;
272
+ if (!frozen) {
273
+ return { title: '', tabs: [], header: [], rows: [], hints: [], scroll: { offset: 0, total: 0, visible: this.visibleRows } };
274
+ }
275
+
276
+ const tabs: ConfigModalRenderTab[] = frozen.tabs.map((ft) => {
277
+ const lt = live?.tabs.find((t) => t.id === ft.id);
278
+ return { id: ft.id, label: lt?.label ?? ft.label, active: ft.id === this.activeTabId };
279
+ });
280
+
281
+ const frozenTab = frozen.tabs.find((t) => t.id === this.activeTabId) ?? frozen.tabs[0];
282
+ const liveTab = live?.tabs.find((t) => t.id === this.activeTabId);
283
+
284
+ // Header: all-or-nothing live overlay. Same line count → use live values;
285
+ // a count change is a structural change, deferred (keep frozen header).
286
+ const frozenHeader = frozenTab?.header ?? [];
287
+ const liveHeader = liveTab?.header ?? [];
288
+ const header = liveHeader.length === frozenHeader.length ? liveHeader : frozenHeader;
289
+
290
+ // Rows: iterate the frozen id order; overlay the live row (value refresh) by
291
+ // id, or keep the frozen row marked stale when its live counterpart is gone.
292
+ const frozenRows = frozenTab?.rows ?? [];
293
+ const allRows: ConfigModalRenderRow[] = frozenRows.map((fr) => {
294
+ const lr = liveTab?.rows.find((r) => r.id === fr.id);
295
+ const src = lr ?? fr;
296
+ return {
297
+ id: fr.id,
298
+ label: src.label,
299
+ style: src.style,
300
+ selected: fr.id === this.selectedRowId,
301
+ selectable: fr.selectable !== false,
302
+ stale: lr === undefined,
303
+ };
304
+ });
305
+
306
+ const visible = this.visibleRows;
307
+ const windowed = allRows.slice(this.scrollOffset, this.scrollOffset + visible);
308
+
309
+ const hints = [
310
+ ...(frozen.hints ?? []),
311
+ ...(frozenTab?.hints ?? []),
312
+ ...this._actionHints(),
313
+ 'Esc close',
314
+ ];
315
+
316
+ return {
317
+ title: live?.title ?? frozen.title,
318
+ tabs,
319
+ header,
320
+ rows: windowed,
321
+ emptyText: frozenRows.length === 0 ? (frozenTab?.emptyText ?? 'Nothing to show.') : undefined,
322
+ degraded: live?.degraded ?? frozen.degraded,
323
+ status: this.statusMessage || undefined,
324
+ hints,
325
+ scroll: { offset: this.scrollOffset, total: allRows.length, visible },
326
+ };
327
+ }
328
+
329
+ // ── internals ────────────────────────────────────────────────────────────────
330
+
331
+ private _actionHints(): string[] {
332
+ const actions = this.surface?.actions ?? [];
333
+ const row = this.getSelectedRow();
334
+ const out: string[] = [];
335
+ for (const a of actions) {
336
+ if (a.enabledFor && !a.enabledFor(row, this.activeTabId)) continue;
337
+ out.push(`${a.key} ${a.label}`);
338
+ }
339
+ return out;
340
+ }
341
+
342
+ private _clearConfirm(): void {
343
+ if (this.pendingConfirmKey !== null) {
344
+ this.pendingConfirmKey = null;
345
+ this.statusMessage = '';
346
+ }
347
+ }
348
+
349
+ private _frozenTab(): ConfigModalTab | undefined {
350
+ return this.frozenView?.tabs.find((t) => t.id === this.activeTabId) ?? this.frozenView?.tabs[0];
351
+ }
352
+
353
+ private _liveTab(id: string): ConfigModalTab | undefined {
354
+ return this.surface?.buildView().tabs.find((t) => t.id === id);
355
+ }
356
+
357
+ private _selectableIds(tab: ConfigModalTab | undefined): string[] {
358
+ return (tab?.rows ?? []).filter((r) => r.selectable !== false).map((r) => r.id);
359
+ }
360
+
361
+ private _firstSelectableId(tab: ConfigModalTab | undefined): string {
362
+ return this._selectableIds(tab)[0] ?? '';
363
+ }
364
+
365
+ private _clampScroll(): void {
366
+ const tab = this._frozenTab();
367
+ const rows = tab?.rows ?? [];
368
+ const idx = rows.findIndex((r) => r.id === this.selectedRowId);
369
+ const visible = Math.max(3, this.visibleRows);
370
+ if (idx >= 0) {
371
+ if (idx < this.scrollOffset) this.scrollOffset = idx;
372
+ else if (idx >= this.scrollOffset + visible) this.scrollOffset = idx - visible + 1;
373
+ }
374
+ const maxOffset = Math.max(0, rows.length - visible);
375
+ this.scrollOffset = Math.max(0, Math.min(this.scrollOffset, maxOffset));
376
+ }
377
+ }
@@ -22,15 +22,13 @@ import type { SelectionResult } from './selection-modal.ts';
22
22
  import type { SearchManager } from './search.ts';
23
23
  import type { InputHistory, HistorySearch } from './input-history.ts';
24
24
  import type { ConversationManager } from '../core/conversation';
25
- import type { ProcessModal } from '../renderer/process-modal.ts';
26
- import type { LiveTailModal } from '../renderer/live-tail-modal.ts';
27
25
  import type { BlockActionsMenu } from '../renderer/block-actions.ts';
28
- import type { AgentDetailModal } from '../renderer/agent-detail-modal.ts';
29
26
  import type { ContextInspectorModal } from '../renderer/context-inspector.ts';
30
27
  import type { BookmarkModal } from './bookmark-modal.ts';
31
28
  import type { SettingsModal } from './settings-modal.ts';
32
29
  import type { McpWorkspace } from './mcp-workspace.ts';
33
30
  import type { SessionPickerModal } from './session-picker-modal.ts';
31
+ import type { ConfigModal } from './config-modal.ts';
34
32
  import type { ProfilePickerModal } from './profile-picker-modal.ts';
35
33
  import type { OnboardingWizardController } from './onboarding/onboarding-wizard.ts';
36
34
  import type { WrappedPromptInfo } from './handler-prompt-buffer.ts';
@@ -40,6 +38,7 @@ import type { KeybindingsManager } from './keybindings.ts';
40
38
  import type { ModelPickerTarget } from './model-picker.ts';
41
39
  import type { KillRing } from './kill-ring.ts';
42
40
  import type { PanelMouseLayout } from './handler-feed-routes.ts';
41
+ import type { FocusTracker } from '../core/focus-tracker.ts';
43
42
 
44
43
  /**
45
44
  * Initial mutable scalar values for InputFeedContext.
@@ -82,8 +81,7 @@ export interface FeedContextMutableInit {
82
81
  * - `pasteRegistry`, `imageRegistry` — owned Maps, never replaced
83
82
  * - `selectionModal`, `bookmarkModal`, `settingsModal`, `sessionPickerModal`,
84
83
  * `profilePickerModal` — modal objects constructed once
85
- * - `filePicker`, `modelPicker`, `processModal`, `liveTailModal`,
86
- * `agentDetailModal`, `contextInspectorModal`, `blockActionsMenu`,
84
+ * - `filePicker`, `modelPicker`, `contextInspectorModal`, `blockActionsMenu`,
87
85
  * `searchManager`, `historySearch`, `onboardingWizard` — service objects constructed once
88
86
  * - `panelManager`, `keybindingsManager` — from uiServices, stable
89
87
  * - `modalStack` — reference to the handler's shared array
@@ -91,6 +89,8 @@ export interface FeedContextMutableInit {
91
89
  * - `commandRegistry`, `commandContext`, `autocomplete`, `inputHistory`,
92
90
  * `conversationManager` — wired after construction; synced at feed() entry only
93
91
  * (not per-action) since no in-feed action changes them
92
+ * - `focusTracker` (W2.3) — shared instance from uiServices.platform, mutated
93
+ * directly by feedInputTokens() on 'focus' tokens; never reallocated
94
94
  *
95
95
  * **Rationale:** per-feed mutation on a single object avoids per-keystroke GC pressure
96
96
  * from ~80-field object allocation. Stable references are service handles that never
@@ -107,6 +107,7 @@ export interface FeedContextStableRefs {
107
107
  mcpWorkspace: McpWorkspace;
108
108
  sessionPickerModal: SessionPickerModal;
109
109
  profilePickerModal: ProfilePickerModal;
110
+ configModal: ConfigModal;
110
111
  historySearch: HistorySearch;
111
112
  commandRegistry: CommandRegistry | null;
112
113
  commandContext: CommandContext | undefined;
@@ -114,9 +115,6 @@ export interface FeedContextStableRefs {
114
115
  filePicker: FilePickerModal;
115
116
  modelPicker: ModelPickerModal;
116
117
  onboardingWizard: OnboardingWizardController;
117
- processModal: ProcessModal;
118
- liveTailModal: LiveTailModal;
119
- agentDetailModal: AgentDetailModal;
120
118
  contextInspectorModal: ContextInspectorModal;
121
119
  blockActionsMenu: BlockActionsMenu;
122
120
  searchManager: SearchManager;
@@ -126,6 +124,7 @@ export interface FeedContextStableRefs {
126
124
  panelManager: PanelManager;
127
125
  keybindingsManager: KeybindingsManager;
128
126
  killRing: KillRing;
127
+ focusTracker: FocusTracker;
129
128
  getHistory: () => InfiniteBuffer;
130
129
  getViewportHeight: () => number;
131
130
  getScrollTop: () => number;
@@ -1,4 +1,5 @@
1
1
  import { loadSkillByTrigger } from '@pellux/goodvibes-sdk/platform/tools';
2
+ import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
2
3
  import type { CommandContext, CommandRegistry } from './command-registry.ts';
3
4
  import type { AutocompleteEngine } from './autocomplete.ts';
4
5
  import type { InputToken } from '@pellux/goodvibes-sdk/platform/core';
@@ -92,25 +93,34 @@ export function handleCommandModeToken(state: CommandModeRouteState, token: Inpu
92
93
  const commandPromise = state.commandRegistry.get(name)
93
94
  ? state.commandRegistry.execute(name, args, ctx)
94
95
  : (ctx.executeCommand?.(name, args) ?? Promise.resolve(false));
95
- commandPromise.then((handled) => {
96
- if (handled) {
97
- state.requestRender();
98
- } else {
99
- const shellPaths = state.commandContext?.workspace.shellPaths;
100
- const skillContent = shellPaths
101
- ? loadSkillByTrigger('/' + name, {
102
- workingDirectory: shellPaths.workingDirectory,
103
- homeDirectory: shellPaths.homeDirectory,
104
- })
105
- : null;
106
- if (skillContent) {
107
- state.commandContext?.submitInput?.(skillContent);
108
- } else {
109
- state.conversationManager?.log(`Unknown command: /${name}. Type /help for available commands.`, { fg: '#ef4444' });
96
+ commandPromise
97
+ .then((handled) => {
98
+ if (handled) {
110
99
  state.requestRender();
100
+ } else {
101
+ const shellPaths = state.commandContext?.workspace.shellPaths;
102
+ const skillContent = shellPaths
103
+ ? loadSkillByTrigger('/' + name, {
104
+ workingDirectory: shellPaths.workingDirectory,
105
+ homeDirectory: shellPaths.homeDirectory,
106
+ })
107
+ : null;
108
+ if (skillContent) {
109
+ state.commandContext?.submitInput?.(skillContent);
110
+ } else {
111
+ state.conversationManager?.log(`Unknown command: /${name}. Type /help for available commands.`, { fg: '#ef4444' });
112
+ state.requestRender();
113
+ }
111
114
  }
112
- }
113
- });
115
+ })
116
+ .catch((err: unknown) => {
117
+ // Defense in depth for EVERY command, not just the ones that
118
+ // already guard their own internals: a handler that throws or
119
+ // awaits a rejected promise must never become a silent unhandled
120
+ // rejection — it renders the same way an unknown command does.
121
+ state.conversationManager?.log(`Command /${name} failed: ${summarizeError(err)}`, { fg: '#ef4444' });
122
+ state.requestRender();
123
+ });
114
124
  } else {
115
125
  closeCommandMode();
116
126
  }
@@ -31,14 +31,26 @@ export type PanelFocusRouteState = {
31
31
  cyclePanelTab: (direction: 'next' | 'prev') => void;
32
32
  onPanelInputConsumed?: (activePanel: import('../panels/types.ts').Panel | null, key: string) => void;
33
33
  /**
34
- * True when the tokens produced by the current feed() call carry more than
35
- * one printable character total (a bracketed paste, or several 1-char text
36
- * tokens from a fast-typed burst in one stdin chunk). A burst can never be
37
- * a deliberate single-key panel hotkey, so it must not be exploded into
38
- * per-char activePanel.handleInput(ch) calls — see the text-token branch
34
+ * True when THIS token is a paste: a single 'text' token whose value holds
35
+ * more than one character. The SDK tokenizer emits a bracketed paste
36
+ * (\x1b[?2004h, enabled in main.ts init) as exactly one such token; discrete
37
+ * keystrokes even several batched into one feed() by render-tick latency
38
+ * arrive as separate 1-char tokens. See the text-token branch below.
39
+ */
40
+ isPasteToken: boolean;
41
+ /**
42
+ * One-shot hint emitted when a paste is dropped because the focused panel has
43
+ * no text capture (Invariant A: focus must not silently flip to the composer).
44
+ */
45
+ onPasteDropped?: (panelName: string) => void;
46
+ /**
47
+ * True while a turn is actively streaming; gates Escape's cancel-first
48
+ * precedence (I6.1) ahead of the panel's own two-stage escape contract
39
49
  * below.
40
50
  */
41
- isPrintableBurst: boolean;
51
+ isTurnActive: () => boolean;
52
+ /** Cancel the active turn. Wired to commandContext.cancelGeneration. */
53
+ cancelGeneration: () => void;
42
54
  };
43
55
 
44
56
  export function handlePanelFocusToken(state: PanelFocusRouteState, token: InputToken): {
@@ -75,6 +87,25 @@ export function handlePanelFocusToken(state: PanelFocusRouteState, token: InputT
75
87
  // (e.g. dismiss a confirm dialog or clear search). Only unfocus if the
76
88
  // panel returns false (unconsumed) or there is no active panel.
77
89
  if (token.logicalName === 'escape') {
90
+ // I6.1: while a turn is streaming, a focused panel must not be able to
91
+ // swallow the only way to cancel it (replay R5) — Escape's first job is
92
+ // always cancel-turn. The panel's own two-stage consume-or-unfocus
93
+ // contract below only runs once no turn is active, so a *second*
94
+ // Escape (now with the turn already cancelled) falls through to it
95
+ // normally. panelFocused is left unchanged here (panel stays open and
96
+ // focused) — the panel close moves to that second Escape, or to
97
+ // Ctrl+X (panel-close, keybindings.ts), which is handled earlier in
98
+ // handleGlobalShortcutToken and is unaffected by this branch. This
99
+ // deliberately calls cancelGeneration() directly instead of routing
100
+ // through the shared handleEscape() (handler-modal-stack.ts) — that
101
+ // function's fallthrough order (modal-pop -> active-modal-close ->
102
+ // clear-nonempty-prompt -> cancelGeneration) is not guaranteed to reach
103
+ // cancelGeneration first in every state combination.
104
+ if (state.isTurnActive()) {
105
+ state.cancelGeneration();
106
+ state.requestRender();
107
+ return { handled: true, panelFocused };
108
+ }
78
109
  const activePanel = state.panelManager.getActive();
79
110
  const panelConsumedEscape = activePanel?.handleInput?.('escape') ?? false;
80
111
  if (panelConsumedEscape) {
@@ -122,18 +153,19 @@ export function handlePanelFocusToken(state: PanelFocusRouteState, token: InputT
122
153
 
123
154
  if (token.type === 'text' && token.value) {
124
155
  const activePanel = state.panelManager.getActive();
125
- // A burst (paste or several fast-typed chars in one stdin chunk) is never
126
- // a deliberate single-key panel hotkey. Eating it silently as per-char
127
- // hotkeys leaves the user with no echo and no error, looking exactly
128
- // like dead keystrokes. Unless the active panel has its own text-capture
129
- // buffer open (a `/`-search or draft-answer field that deliberately
130
- // wants every character, burst or not see isCapturingTextBurst), unfocus
131
- // the panel and let the token fall through to the prompt route instead,
132
- // same as it would from an unfocused panel.
133
- if (state.isPrintableBurst && !activePanel?.isCapturingTextBurst?.()) {
134
- panelFocused = false;
156
+ // Invariant A/B (W6.2). A paste (isPasteToken: one multi-char text token)
157
+ // into a focused text-capturing panel (a `/`-search or steer-draft field
158
+ // isCapturingTextBurst) is forwarded verbatim below; into any other focused
159
+ // panel it is DROPPED with a one-shot hint never exploded into per-char
160
+ // hotkeys, and never a silent focus flip to the composer (what the old
161
+ // per-feed char-sum burst guard did, which also misfired on two quick nav
162
+ // keys in one feed). Focus moves only on an explicit transfer verb, so
163
+ // panelFocused is left unchanged. Discrete 1-char keystrokes are not pastes
164
+ // and fall through to the per-char dispatch below, one at a time.
165
+ if (state.isPasteToken && !activePanel?.isCapturingTextBurst?.()) {
166
+ state.onPasteDropped?.(activePanel?.name ?? 'the panel');
135
167
  state.requestRender();
136
- return { handled: false, panelFocused };
168
+ return { handled: true, panelFocused };
137
169
  }
138
170
  if (activePanel?.handleInput) {
139
171
  for (const ch of token.value) {
@@ -150,7 +182,8 @@ export function handlePanelFocusToken(state: PanelFocusRouteState, token: InputT
150
182
  export type IndicatorFocusRouteState = {
151
183
  indicatorFocused: boolean;
152
184
  modalOpened: (name: string) => void;
153
- processModal: { open: () => void };
185
+ /** W2.2: the footer process indicator's [Enter] opens the Fleet panel (F2 also opens the Fleet panel; the retired process modal was removed in W6.2). */
186
+ openFleetPanel: () => void;
154
187
  requestRender: () => void;
155
188
  };
156
189
 
@@ -171,8 +204,9 @@ export function handleIndicatorFocusToken(state: IndicatorFocusRouteState, token
171
204
  }
172
205
  if (token.logicalName === 'enter') {
173
206
  indicatorFocused = false;
174
- state.modalOpened('process');
175
- state.processModal.open();
207
+ // W2.2: repointed from the retired process modal (removed in W6.2) to the
208
+ // Fleet panel — F2 also opens the Fleet panel (handlePromptKeyToken f2).
209
+ state.openFleetPanel();
176
210
  state.requestRender();
177
211
  return { handled: true, indicatorFocused };
178
212
  }
@@ -297,7 +331,8 @@ export type KeyRouteState = {
297
331
  commandRegistry?: CommandRegistry | null;
298
332
  autocomplete: AutocompleteEngine | null;
299
333
  blockActionsMenu: { open: (block: BlockMeta) => void };
300
- processModal: { open: () => void };
334
+ /** W6.2 e: F2 opens+focuses the Fleet panel (which subsumes the retired process modal). */
335
+ openFleetPanel: () => void;
301
336
  modalOpened: (name: string) => void;
302
337
  saveUndoState: () => void;
303
338
  /** Break the undo coalescing group (call on cursor moves). */
@@ -570,8 +605,11 @@ export function handlePromptKeyToken(state: KeyRouteState, token: InputToken): {
570
605
 
571
606
  if (token.logicalName === 'f2') {
572
607
  indicatorFocused = false;
573
- state.modalOpened('process');
574
- state.processModal.open();
608
+ // W6.2 e: F2 opens AND focuses the Fleet panel, which subsumes the deleted
609
+ // process modal (ProcessModal/AgentDetailModal/LiveTailModal — removed in
610
+ // W6.1 once this repoint made them unreachable). openFleetPanel sets
611
+ // panelFocused on the shared context directly, so nothing more is returned.
612
+ state.openFleetPanel();
575
613
  return { handled: true, prompt, cursorPos, inputScrollTop, commandMode, indicatorFocused };
576
614
  }
577
615