@pellux/goodvibes-tui 1.0.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 (209) hide show
  1. package/CHANGELOG.md +130 -127
  2. package/README.md +32 -14
  3. package/docs/foundation-artifacts/operator-contract.json +1 -1
  4. package/package.json +2 -2
  5. package/src/cli/ensure-goodvibes-gitignore.ts +32 -0
  6. package/src/cli/entrypoint.ts +2 -0
  7. package/src/core/alert-gating.ts +67 -0
  8. package/src/core/approval-alert.ts +72 -0
  9. package/src/core/budget-breach-notifier.ts +106 -0
  10. package/src/core/conversation-rendering.ts +19 -0
  11. package/src/core/conversation.ts +58 -0
  12. package/src/core/focus-tracker.ts +41 -0
  13. package/src/core/long-task-notifier.ts +33 -6
  14. package/src/core/stream-event-wiring.ts +104 -2
  15. package/src/core/stream-stall-watchdog.ts +41 -17
  16. package/src/core/system-message-router.ts +37 -51
  17. package/src/core/turn-cancellation.ts +20 -0
  18. package/src/core/turn-event-wiring.ts +64 -3
  19. package/src/export/cost-utils.ts +126 -8
  20. package/src/input/command-registry.ts +54 -1
  21. package/src/input/commands/checkpoint-runtime.ts +280 -0
  22. package/src/input/commands/codebase-runtime.ts +192 -0
  23. package/src/input/commands/diff-runtime.ts +61 -30
  24. package/src/input/commands/eval.ts +1 -1
  25. package/src/input/commands/health-runtime.ts +1 -1
  26. package/src/input/commands/image-runtime.ts +112 -0
  27. package/src/input/commands/intelligence-runtime.ts +11 -1
  28. package/src/input/commands/local-auth-runtime.ts +4 -1
  29. package/src/input/commands/local-runtime.ts +9 -3
  30. package/src/input/commands/marketplace-runtime.ts +2 -2
  31. package/src/input/commands/memory.ts +6 -1
  32. package/src/input/commands/operator-panel-runtime.ts +40 -24
  33. package/src/input/commands/planning-runtime.ts +26 -3
  34. package/src/input/commands/platform-sandbox-runtime.ts +1 -6
  35. package/src/input/commands/plugin-runtime.ts +2 -2
  36. package/src/input/commands/policy-dispatch.ts +21 -0
  37. package/src/input/commands/provider-accounts-runtime.ts +1 -1
  38. package/src/input/commands/qrcode-runtime.ts +3 -3
  39. package/src/input/commands/recall-review.ts +35 -0
  40. package/src/input/commands/remote-runtime.ts +3 -3
  41. package/src/input/commands/runtime-services.ts +54 -13
  42. package/src/input/commands/services-runtime.ts +1 -1
  43. package/src/input/commands/session-content.ts +12 -0
  44. package/src/input/commands/session-workflow.ts +19 -1
  45. package/src/input/commands/settings-sync-runtime.ts +1 -1
  46. package/src/input/commands/share-runtime.ts +9 -2
  47. package/src/input/commands/shell-core.ts +15 -7
  48. package/src/input/commands/skills-runtime.ts +2 -8
  49. package/src/input/commands/subscription-runtime.ts +2 -2
  50. package/src/input/commands/test-runtime.ts +277 -0
  51. package/src/input/commands/websearch-runtime.ts +101 -0
  52. package/src/input/commands/work-plan-runtime.ts +36 -10
  53. package/src/input/commands/workstream-runtime.ts +338 -0
  54. package/src/input/commands.ts +12 -0
  55. package/src/input/config-modal-types.ts +161 -0
  56. package/src/input/config-modal.ts +377 -0
  57. package/src/input/feed-context-factory.ts +7 -8
  58. package/src/input/handler-command-route.ts +27 -17
  59. package/src/input/handler-content-actions.ts +22 -8
  60. package/src/input/handler-feed-routes.ts +107 -7
  61. package/src/input/handler-feed.ts +49 -13
  62. package/src/input/handler-interactions.ts +3 -3
  63. package/src/input/handler-modal-routes.ts +65 -0
  64. package/src/input/handler-modal-stack.ts +3 -5
  65. package/src/input/handler-modal-token-routes.ts +16 -49
  66. package/src/input/handler-picker-routes.ts +11 -94
  67. package/src/input/handler-shortcuts.ts +24 -14
  68. package/src/input/handler-types.ts +3 -6
  69. package/src/input/handler-ui-state.ts +10 -22
  70. package/src/input/handler.ts +10 -28
  71. package/src/input/keybindings.ts +22 -8
  72. package/src/input/model-picker-types.ts +24 -6
  73. package/src/input/model-picker.ts +58 -0
  74. package/src/input/panel-integration-actions.ts +9 -170
  75. package/src/input/settings-modal-data.ts +95 -0
  76. package/src/input/settings-modal-mutations.ts +6 -4
  77. package/src/main.ts +41 -44
  78. package/src/panels/agent-inspector-shared.ts +35 -11
  79. package/src/panels/base-panel.ts +22 -1
  80. package/src/panels/builtin/agent.ts +21 -107
  81. package/src/panels/builtin/development.ts +16 -62
  82. package/src/panels/builtin/knowledge.ts +8 -32
  83. package/src/panels/builtin/operations.ts +84 -428
  84. package/src/panels/builtin/session.ts +21 -112
  85. package/src/panels/builtin/shared.ts +9 -43
  86. package/src/panels/builtin-modals.ts +218 -0
  87. package/src/panels/builtin-panels.ts +5 -0
  88. package/src/panels/cost-tracker-panel.ts +63 -14
  89. package/src/panels/diff-panel.ts +123 -60
  90. package/src/panels/eval-registry.ts +60 -0
  91. package/src/panels/fleet-panel.ts +800 -0
  92. package/src/panels/fleet-read-model.ts +477 -0
  93. package/src/panels/fleet-steer.ts +92 -0
  94. package/src/panels/fleet-stop.ts +125 -0
  95. package/src/panels/fleet-tabs.ts +230 -0
  96. package/src/panels/fleet-transcript.ts +356 -0
  97. package/src/panels/git-panel.ts +9 -9
  98. package/src/panels/index.ts +7 -31
  99. package/src/panels/local-auth-panel.ts +10 -0
  100. package/src/panels/modals/hooks-modal.ts +187 -0
  101. package/src/panels/modals/keybindings-modal.ts +151 -0
  102. package/src/panels/modals/knowledge-modal.ts +158 -0
  103. package/src/panels/modals/local-auth-modal.ts +132 -0
  104. package/src/panels/modals/marketplace-modal.ts +218 -0
  105. package/src/panels/modals/memory-modal.ts +180 -0
  106. package/src/panels/modals/modal-surface-helpers.ts +54 -0
  107. package/src/panels/modals/modal-theme.ts +36 -0
  108. package/src/panels/modals/pairing-modal.ts +144 -0
  109. package/src/panels/modals/planning-modal.ts +282 -0
  110. package/src/panels/modals/plugins-modal.ts +174 -0
  111. package/src/panels/modals/policy-modal.ts +256 -0
  112. package/src/panels/modals/provider-health-modal.ts +136 -0
  113. package/src/panels/modals/remote-modal.ts +115 -0
  114. package/src/panels/modals/sandbox-modal.ts +150 -0
  115. package/src/panels/modals/security-modal.ts +217 -0
  116. package/src/panels/modals/services-modal.ts +179 -0
  117. package/src/panels/modals/settings-sync-modal.ts +142 -0
  118. package/src/panels/modals/skills-modal.ts +189 -0
  119. package/src/panels/modals/subscription-modal.ts +172 -0
  120. package/src/panels/modals/work-plan-modal.ts +149 -0
  121. package/src/panels/panel-confirm-overlay.ts +72 -0
  122. package/src/panels/panel-manager.ts +108 -5
  123. package/src/panels/project-planning-answer-actions.ts +4 -2
  124. package/src/panels/scrollable-list-panel.ts +9 -0
  125. package/src/panels/skills-panel.ts +7 -51
  126. package/src/panels/token-budget-panel.ts +5 -3
  127. package/src/panels/types.ts +26 -0
  128. package/src/permissions/hunk-selection.ts +179 -0
  129. package/src/permissions/prompt.ts +60 -4
  130. package/src/renderer/compaction-history-modal.ts +19 -3
  131. package/src/renderer/compaction-preview.ts +13 -2
  132. package/src/renderer/compaction-quality.ts +100 -0
  133. package/src/renderer/config-modal.ts +81 -0
  134. package/src/renderer/conversation-overlays.ts +10 -17
  135. package/src/renderer/fleet-tab-strip.ts +56 -0
  136. package/src/renderer/footer-tips.ts +10 -2
  137. package/src/renderer/git-status.ts +40 -0
  138. package/src/renderer/help-overlay.ts +2 -2
  139. package/src/renderer/model-workspace.ts +44 -1
  140. package/src/renderer/panel-workspace-bar.ts +8 -2
  141. package/src/renderer/shell-surface.ts +8 -1
  142. package/src/renderer/turn-injection.ts +114 -0
  143. package/src/renderer/ui-factory.ts +91 -7
  144. package/src/runtime/bootstrap-command-context.ts +24 -1
  145. package/src/runtime/bootstrap-command-parts.ts +36 -7
  146. package/src/runtime/bootstrap-core.ts +19 -4
  147. package/src/runtime/bootstrap-hook-bridge.ts +5 -0
  148. package/src/runtime/bootstrap-shell.ts +41 -17
  149. package/src/runtime/bootstrap.ts +11 -17
  150. package/src/runtime/code-index-services.ts +112 -0
  151. package/src/runtime/orchestrator-core-services.ts +49 -0
  152. package/src/runtime/process-lifecycle.ts +3 -1
  153. package/src/runtime/services.ts +91 -43
  154. package/src/runtime/ui-services.ts +8 -0
  155. package/src/runtime/workstream-services.ts +195 -0
  156. package/src/shell/blocking-input.ts +22 -1
  157. package/src/shell/ui-openers.ts +129 -17
  158. package/src/utils/format-duration.ts +8 -18
  159. package/src/utils/splash-lines.ts +1 -1
  160. package/src/version.ts +1 -1
  161. package/src/panels/agent-inspector-panel.ts +0 -786
  162. package/src/panels/approval-panel.ts +0 -252
  163. package/src/panels/automation-control-panel.ts +0 -479
  164. package/src/panels/cockpit-panel.ts +0 -481
  165. package/src/panels/cockpit-read-model.ts +0 -233
  166. package/src/panels/communication-panel.ts +0 -256
  167. package/src/panels/control-plane-panel.ts +0 -470
  168. package/src/panels/debug-panel.ts +0 -609
  169. package/src/panels/docs-panel.ts +0 -375
  170. package/src/panels/eval-panel.ts +0 -627
  171. package/src/panels/file-explorer-panel.ts +0 -664
  172. package/src/panels/file-preview-panel.ts +0 -517
  173. package/src/panels/hooks-panel.ts +0 -401
  174. package/src/panels/incident-review-panel.ts +0 -406
  175. package/src/panels/intelligence-panel.ts +0 -383
  176. package/src/panels/knowledge-graph-panel.ts +0 -506
  177. package/src/panels/marketplace-panel.ts +0 -399
  178. package/src/panels/memory-panel.ts +0 -558
  179. package/src/panels/ops-control-panel.ts +0 -329
  180. package/src/panels/ops-strategy-panel.ts +0 -321
  181. package/src/panels/orchestration-panel.ts +0 -465
  182. package/src/panels/panel-list-panel.ts +0 -557
  183. package/src/panels/plan-dashboard-panel.ts +0 -707
  184. package/src/panels/policy-panel.ts +0 -517
  185. package/src/panels/project-planning-panel.ts +0 -721
  186. package/src/panels/provider-health-panel.ts +0 -678
  187. package/src/panels/provider-health-tracker.ts +0 -306
  188. package/src/panels/provider-health-views.ts +0 -560
  189. package/src/panels/qr-panel.ts +0 -280
  190. package/src/panels/remote-panel.ts +0 -534
  191. package/src/panels/routes-panel.ts +0 -241
  192. package/src/panels/sandbox-panel.ts +0 -456
  193. package/src/panels/security-panel.ts +0 -447
  194. package/src/panels/services-panel.ts +0 -329
  195. package/src/panels/session-browser-panel.ts +0 -487
  196. package/src/panels/settings-sync-panel.ts +0 -398
  197. package/src/panels/subscription-panel.ts +0 -342
  198. package/src/panels/symbol-outline-panel.ts +0 -619
  199. package/src/panels/system-messages-panel.ts +0 -364
  200. package/src/panels/tasks-panel.ts +0 -608
  201. package/src/panels/thinking-panel.ts +0 -333
  202. package/src/panels/tool-inspector-panel.ts +0 -537
  203. package/src/panels/work-plan-panel.ts +0 -530
  204. package/src/panels/worktree-panel.ts +0 -360
  205. package/src/panels/wrfc-panel.ts +0 -790
  206. package/src/renderer/agent-detail-modal.ts +0 -465
  207. package/src/renderer/live-tail-modal.ts +0 -156
  208. package/src/renderer/process-modal.ts +0 -656
  209. package/src/renderer/qr-renderer.ts +0 -120
@@ -0,0 +1,161 @@
1
+ import type { ModalSectionStyle } from '../renderer/modal-factory.ts';
2
+
3
+ /**
4
+ * Config-modal host — the shared seam every W6.1 MIGRATE-TO-MODAL surface
5
+ * (provider-health, services, subscription, remote, local-auth, settings-sync,
6
+ * sandbox, and the WO-B ecosystem/governance set) is expressed through.
7
+ *
8
+ * A surface is PURE DATA + ACTIONS: it hands the host a `buildView()` that maps
9
+ * live read-models to a tabbed, list-structured view, plus a declarative action
10
+ * table. The host owns all key routing (tab switch, list nav, Esc, confirm) and
11
+ * the stable-layout liveness contract — the surface never touches the input
12
+ * system directly (the charter's "do not invent a parallel input system").
13
+ *
14
+ * Liveness doctrine (charter: "live data, stable layout"): `buildView()` is
15
+ * called every render tick so values (latency, status, counts) update in place,
16
+ * but the host renders against the row/tab STRUCTURE captured at the last
17
+ * interaction boundary (open, or a key press). Rows never reflow under the
18
+ * cursor mid-interaction; a structural change (a row appears/disappears) is
19
+ * deferred until the next key press. See ConfigModal for the mechanism.
20
+ */
21
+
22
+ /** A single row within a tab. Identity is `id` (structural); `label` is live. */
23
+ export interface ConfigModalRow {
24
+ /**
25
+ * Stable identity across live-refresh ticks. The liveness contract keys
26
+ * structural stability off this: two builds with the same ordered id set are
27
+ * "structurally identical" and repaint values in place; a differing id set is
28
+ * a structural change, deferred to the next interaction boundary.
29
+ */
30
+ readonly id: string;
31
+ /** Display label. MAY embed live value fields (latency, status dot, counts). */
32
+ readonly label: string;
33
+ /** Optional per-row style (e.g. bad/warn tone for an errored row). */
34
+ readonly style?: ModalSectionStyle;
35
+ /**
36
+ * Non-selectable informational row (sub-header, spacer text). Skipped by
37
+ * up/down navigation and never receives an action. Defaults to selectable.
38
+ */
39
+ readonly selectable?: boolean;
40
+ }
41
+
42
+ /** One tab (section) of a surface. Tabs are switched with left/right or Tab. */
43
+ export interface ConfigModalTab {
44
+ /** Stable id — preserves the active tab across refresh + re-open. */
45
+ readonly id: string;
46
+ /** Tab-strip label. */
47
+ readonly label: string;
48
+ /** Live status/posture lines rendered above the list (non-selectable). */
49
+ readonly header?: readonly string[];
50
+ /** Selectable/informational rows. */
51
+ readonly rows: readonly ConfigModalRow[];
52
+ /** Honest empty-state text shown when `rows` is empty. */
53
+ readonly emptyText?: string;
54
+ /** Footer hints specific to this tab, appended after the surface hints. */
55
+ readonly hints?: readonly string[];
56
+ }
57
+
58
+ /** The whole surface view for one render tick. */
59
+ export interface ConfigModalView {
60
+ /** Live title (may change tick-to-tick; not structural). */
61
+ readonly title: string;
62
+ readonly tabs: readonly ConfigModalTab[];
63
+ /**
64
+ * Non-null when the read-model is unavailable/degraded — the host renders a
65
+ * banner and still shows whatever tabs/rows are present (honest degraded
66
+ * state rather than a blank or a throw).
67
+ */
68
+ readonly degraded?: string;
69
+ /** Base footer hints (Esc close is always appended by the host). */
70
+ readonly hints?: readonly string[];
71
+ }
72
+
73
+ /** Context handed to a surface's action handler when an action key fires. */
74
+ export interface ConfigModalActionContext {
75
+ /** The selected row the action targets (null when the active tab has none). */
76
+ readonly row: ConfigModalRow | null;
77
+ /** The active tab's id. */
78
+ readonly tabId: string;
79
+ /** Print a line to the conversation transcript. */
80
+ readonly print: (message: string) => void;
81
+ /**
82
+ * Execute a slash command — the same seam panels used via
83
+ * `PanelIntegrationContext.executeCommand`. This is how a migrated action
84
+ * preserves parity: the mutation still runs through its existing, tested
85
+ * command (e.g. `/settings-sync resolve`, `/local-auth delete-user`).
86
+ */
87
+ readonly executeCommand?: (name: string, args: string[]) => Promise<unknown>;
88
+ /** Open another named modal surface (cross-surface navigation, e.g. the
89
+ * services surface jumping to the subscription surface). Swaps the active
90
+ * surface in place — the same seam ctx.openModal uses. */
91
+ readonly openModal?: (name: string) => void;
92
+ /** Request a re-render. */
93
+ readonly requestRender: () => void;
94
+ /** Set the modal's transient status line (e.g. a result or error message). */
95
+ readonly setStatus: (message: string) => void;
96
+ /** Close the modal (focus returns to whatever opened it). */
97
+ readonly close: () => void;
98
+ }
99
+
100
+ /** A declarative action bound to a key. */
101
+ export interface ConfigModalAction {
102
+ /**
103
+ * Trigger key: a single printable char ('r', 'd') or a named key ('enter').
104
+ * Must not collide with the host-reserved nav keys (up/down/left/right/tab/
105
+ * j/k/escape) — those are consumed by the host before actions are consulted.
106
+ */
107
+ readonly key: string;
108
+ /** Action id passed to `onAction`. */
109
+ readonly id: string;
110
+ /** Short verb for the footer hint (e.g. "refresh", "delete user"). */
111
+ readonly label: string;
112
+ /**
113
+ * Destructive: require an explicit second press of the same key to fire
114
+ * (the host shows "press <key> again to <label>" between presses). Matches
115
+ * the session-picker delete-confirm precedent.
116
+ */
117
+ readonly confirm?: boolean;
118
+ /**
119
+ * When set, the action is only offered while the selected row's id satisfies
120
+ * this predicate (e.g. sandbox "stop" only on a session row). Rows failing
121
+ * the predicate neither show the hint nor fire the action.
122
+ */
123
+ readonly enabledFor?: (row: ConfigModalRow | null, tabId: string) => boolean;
124
+ }
125
+
126
+ /**
127
+ * A named config-modal surface. Pure seam — the host knows nothing about the
128
+ * surface's data. Built once (closing over its read-models) and registered on
129
+ * PanelManager via `registerModalSurface`; opened by name from a panel-id
130
+ * redirect hit or `ctx.openModal(name)`.
131
+ */
132
+ export interface ConfigModalSurface {
133
+ /** Modal name — the key `registerModalRedirect` / `ctx.openModal` use. */
134
+ readonly name: string;
135
+ /** Fallback title (buildView().title wins per tick). */
136
+ readonly title: string;
137
+ /**
138
+ * Build the current view from live read-models. Called on open, on every
139
+ * interaction boundary, AND on every render tick. MUST be pure and cheap —
140
+ * snapshot reads only, no async, no mutation.
141
+ */
142
+ buildView(): ConfigModalView;
143
+ /** Declarative action table (optional — a read-only surface has none). */
144
+ readonly actions?: readonly ConfigModalAction[];
145
+ /** Handle an action fired by its key. */
146
+ onAction?(actionId: string, ctx: ConfigModalActionContext): void;
147
+ /**
148
+ * Optional lifecycle: called when the modal opens this surface and when it
149
+ * closes. A live surface uses `onOpen` to subscribe to its read-model/events
150
+ * (calling the provided `requestRender` on change so live values refresh
151
+ * between key presses) and `onClose` to unsubscribe. The host guarantees
152
+ * `onClose` runs exactly once per `onOpen`.
153
+ */
154
+ onOpen?(requestRender: () => void): void;
155
+ onClose?(): void;
156
+ }
157
+
158
+ /** Read-only registry the host consults to resolve a modal name to a surface. */
159
+ export interface ConfigModalSurfaceRegistry {
160
+ getModalSurface(name: string): ConfigModalSurface | undefined;
161
+ }
@@ -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
  }