@pellux/goodvibes-tui 1.1.0 → 1.9.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 (267) hide show
  1. package/CHANGELOG.md +85 -21
  2. package/README.md +28 -18
  3. package/docs/foundation-artifacts/operator-contract.json +4045 -1763
  4. package/package.json +2 -2
  5. package/src/audio/spoken-turn-controller.ts +12 -2
  6. package/src/audio/spoken-turn-wiring.ts +2 -1
  7. package/src/cli/help.ts +8 -1
  8. package/src/cli/service-posture.ts +2 -1
  9. package/src/cli/status.ts +2 -1
  10. package/src/cli/surface-command.ts +2 -2
  11. package/src/core/alert-gating.ts +67 -0
  12. package/src/core/approval-alert.ts +72 -0
  13. package/src/core/budget-breach-notifier.ts +106 -0
  14. package/src/core/composer-state.ts +11 -3
  15. package/src/core/conversation-line-cache.ts +27 -3
  16. package/src/core/conversation-rendering.ts +90 -14
  17. package/src/core/conversation.ts +18 -0
  18. package/src/core/focus-tracker.ts +41 -0
  19. package/src/core/long-task-notifier.ts +33 -6
  20. package/src/core/stream-event-wiring.ts +20 -1
  21. package/src/core/system-message-noise.ts +87 -0
  22. package/src/core/system-message-router.ts +104 -51
  23. package/src/core/turn-cancellation.ts +25 -0
  24. package/src/core/turn-event-wiring.ts +72 -3
  25. package/src/daemon/cli.ts +29 -2
  26. package/src/daemon/handlers/register.ts +8 -1
  27. package/src/daemon/service-commands.ts +329 -0
  28. package/src/export/cost-utils.ts +36 -0
  29. package/src/input/autocomplete.ts +27 -1
  30. package/src/input/command-registry.ts +84 -5
  31. package/src/input/commands/checkpoint-runtime.ts +280 -0
  32. package/src/input/commands/codebase-runtime.ts +232 -0
  33. package/src/input/commands/config.ts +43 -3
  34. package/src/input/commands/eval.ts +1 -1
  35. package/src/input/commands/health-runtime.ts +10 -2
  36. package/src/input/commands/image-runtime.ts +112 -0
  37. package/src/input/commands/intelligence-runtime.ts +11 -1
  38. package/src/input/commands/local-auth-runtime.ts +4 -1
  39. package/src/input/commands/local-runtime.ts +9 -3
  40. package/src/input/commands/marketplace-runtime.ts +2 -2
  41. package/src/input/commands/memory.ts +73 -35
  42. package/src/input/commands/operator-panel-runtime.ts +71 -31
  43. package/src/input/commands/planning-runtime.ts +116 -4
  44. package/src/input/commands/platform-sandbox-runtime.ts +1 -6
  45. package/src/input/commands/plugin-runtime.ts +2 -2
  46. package/src/input/commands/policy-dispatch.ts +21 -0
  47. package/src/input/commands/provider-accounts-runtime.ts +1 -1
  48. package/src/input/commands/qrcode-runtime.ts +26 -6
  49. package/src/input/commands/recall-review.ts +35 -0
  50. package/src/input/commands/remote-runtime-setup.ts +5 -3
  51. package/src/input/commands/remote-runtime.ts +3 -3
  52. package/src/input/commands/runtime-services.ts +54 -13
  53. package/src/input/commands/services-runtime.ts +1 -1
  54. package/src/input/commands/session-content.ts +20 -9
  55. package/src/input/commands/session-workflow.ts +16 -1
  56. package/src/input/commands/settings-sync-runtime.ts +16 -4
  57. package/src/input/commands/shell-core.ts +25 -8
  58. package/src/input/commands/skills-runtime.ts +2 -8
  59. package/src/input/commands/subscription-runtime.ts +2 -2
  60. package/src/input/commands/test-runtime.ts +277 -0
  61. package/src/input/commands/websearch-runtime.ts +101 -0
  62. package/src/input/commands/work-plan-runtime.ts +36 -10
  63. package/src/input/commands/workstream-runtime.ts +488 -0
  64. package/src/input/commands.ts +12 -0
  65. package/src/input/config-modal-types.ts +175 -0
  66. package/src/input/config-modal.ts +592 -0
  67. package/src/input/feed-context-factory.ts +10 -8
  68. package/src/input/handler-command-route.ts +37 -20
  69. package/src/input/handler-content-actions.ts +17 -2
  70. package/src/input/handler-feed-routes.ts +74 -114
  71. package/src/input/handler-feed.ts +79 -27
  72. package/src/input/handler-interactions.ts +1 -3
  73. package/src/input/handler-modal-routes.ts +136 -6
  74. package/src/input/handler-modal-stack.ts +14 -7
  75. package/src/input/handler-modal-token-routes.ts +16 -49
  76. package/src/input/handler-onboarding-daemon-adopt.ts +149 -0
  77. package/src/input/handler-onboarding.ts +71 -59
  78. package/src/input/handler-picker-routes.ts +26 -102
  79. package/src/input/handler-shortcuts.ts +74 -14
  80. package/src/input/handler-types.ts +2 -6
  81. package/src/input/handler-ui-state.ts +10 -22
  82. package/src/input/handler.ts +12 -29
  83. package/src/input/keybindings.ts +23 -8
  84. package/src/input/model-picker-types.ts +24 -6
  85. package/src/input/model-picker.ts +77 -2
  86. package/src/input/onboarding/onboarding-runtime-status.ts +10 -1
  87. package/src/input/onboarding/onboarding-wizard-apply.ts +8 -1
  88. package/src/input/onboarding/onboarding-wizard-constants.ts +4 -1
  89. package/src/input/onboarding/onboarding-wizard-network-adopt.ts +136 -0
  90. package/src/input/onboarding/onboarding-wizard-steps.ts +9 -6
  91. package/src/input/onboarding/onboarding-wizard-types.ts +3 -1
  92. package/src/input/panel-integration-actions.ts +9 -170
  93. package/src/input/panel-mouse-geometry.ts +97 -0
  94. package/src/input/panel-paste-flood-guard.ts +86 -0
  95. package/src/input/selection-modal.ts +11 -0
  96. package/src/input/session-picker-modal.ts +44 -4
  97. package/src/input/settings-modal-data.ts +146 -2
  98. package/src/input/settings-modal-mutations.ts +6 -4
  99. package/src/input/settings-modal-types.ts +4 -2
  100. package/src/main.ts +49 -51
  101. package/src/panels/agent-inspector-shared.ts +2 -1
  102. package/src/panels/base-panel.ts +22 -1
  103. package/src/panels/builtin/agent.ts +21 -107
  104. package/src/panels/builtin/development.ts +15 -61
  105. package/src/panels/builtin/knowledge.ts +8 -32
  106. package/src/panels/builtin/operations.ts +84 -428
  107. package/src/panels/builtin/session.ts +21 -112
  108. package/src/panels/builtin/shared.ts +18 -46
  109. package/src/panels/builtin-modals.ts +218 -0
  110. package/src/panels/builtin-panels.ts +5 -0
  111. package/src/panels/cost-tracker-panel.ts +36 -10
  112. package/src/panels/diff-panel.ts +12 -43
  113. package/src/panels/eval-registry.ts +60 -0
  114. package/src/panels/fleet-deep-link.ts +31 -0
  115. package/src/panels/fleet-panel-format.ts +62 -0
  116. package/src/panels/fleet-panel-worktree-detail.ts +48 -0
  117. package/src/panels/fleet-panel.ts +758 -0
  118. package/src/panels/fleet-read-model.ts +520 -0
  119. package/src/panels/fleet-steer.ts +125 -0
  120. package/src/panels/fleet-stop.ts +153 -0
  121. package/src/panels/fleet-tabs.ts +230 -0
  122. package/src/panels/fleet-transcript.ts +356 -0
  123. package/src/panels/index.ts +7 -31
  124. package/src/panels/modals/hooks-modal.ts +187 -0
  125. package/src/panels/modals/keybindings-modal.ts +166 -0
  126. package/src/panels/modals/knowledge-modal.ts +158 -0
  127. package/src/panels/modals/local-auth-modal.ts +132 -0
  128. package/src/panels/modals/marketplace-modal.ts +218 -0
  129. package/src/panels/modals/memory-modal.ts +180 -0
  130. package/src/panels/modals/modal-surface-helpers.ts +54 -0
  131. package/src/panels/modals/modal-theme.ts +42 -0
  132. package/src/panels/modals/pairing-modal.ts +163 -0
  133. package/src/panels/modals/planning-modal.ts +304 -0
  134. package/src/panels/modals/plugins-modal.ts +174 -0
  135. package/src/panels/modals/policy-modal.ts +256 -0
  136. package/src/panels/modals/provider-health-modal.ts +136 -0
  137. package/src/panels/modals/remote-modal.ts +115 -0
  138. package/src/panels/modals/sandbox-modal.ts +150 -0
  139. package/src/panels/modals/security-modal.ts +217 -0
  140. package/src/panels/modals/services-modal.ts +179 -0
  141. package/src/panels/modals/settings-sync-modal.ts +142 -0
  142. package/src/panels/modals/skills-modal.ts +189 -0
  143. package/src/panels/modals/subscription-modal.ts +172 -0
  144. package/src/panels/modals/work-plan-modal.ts +177 -0
  145. package/src/panels/panel-confirm-overlay.ts +72 -0
  146. package/src/panels/panel-manager.ts +123 -9
  147. package/src/panels/polish-core.ts +38 -25
  148. package/src/panels/project-planning-answer-actions.ts +8 -11
  149. package/src/panels/skills-panel.ts +7 -51
  150. package/src/panels/types.ts +37 -0
  151. package/src/permissions/hunk-selection.ts +179 -0
  152. package/src/permissions/prompt.ts +216 -13
  153. package/src/renderer/autocomplete-overlay.ts +28 -2
  154. package/src/renderer/compaction-history-modal.ts +19 -3
  155. package/src/renderer/compaction-preview.ts +13 -2
  156. package/src/renderer/compaction-quality.ts +100 -0
  157. package/src/renderer/compositor.ts +2 -3
  158. package/src/renderer/config-modal.ts +95 -0
  159. package/src/renderer/conversation-overlays.ts +10 -17
  160. package/src/renderer/fleet-tab-strip.ts +56 -0
  161. package/src/renderer/footer-tips.ts +15 -3
  162. package/src/renderer/fullscreen-primitives.ts +32 -22
  163. package/src/renderer/git-status.ts +43 -1
  164. package/src/renderer/help-overlay.ts +2 -2
  165. package/src/renderer/layout.ts +0 -4
  166. package/src/renderer/markdown.ts +7 -3
  167. package/src/renderer/modal-factory.ts +25 -20
  168. package/src/renderer/model-workspace.ts +62 -4
  169. package/src/renderer/overlay-box.ts +21 -17
  170. package/src/renderer/panel-workspace-bar.ts +8 -2
  171. package/src/renderer/process-indicator.ts +14 -3
  172. package/src/renderer/selection-modal-overlay.ts +6 -1
  173. package/src/renderer/session-picker-modal.ts +196 -3
  174. package/src/renderer/settings-modal-helpers.ts +2 -0
  175. package/src/renderer/settings-modal.ts +7 -0
  176. package/src/renderer/shell-surface.ts +8 -1
  177. package/src/renderer/status-glyphs.ts +14 -15
  178. package/src/renderer/system-message.ts +15 -3
  179. package/src/renderer/terminal-bg-probe.ts +339 -0
  180. package/src/renderer/terminal-escapes.ts +20 -0
  181. package/src/renderer/theme-mode-config.ts +67 -0
  182. package/src/renderer/theme.ts +91 -1
  183. package/src/renderer/thinking.ts +11 -3
  184. package/src/renderer/tool-call.ts +15 -9
  185. package/src/renderer/tool-result-summary.ts +148 -0
  186. package/src/renderer/turn-injection.ts +133 -0
  187. package/src/renderer/ui-factory.ts +154 -85
  188. package/src/renderer/ui-primitives.ts +30 -129
  189. package/src/runtime/bootstrap-command-context.ts +27 -1
  190. package/src/runtime/bootstrap-command-parts.ts +41 -10
  191. package/src/runtime/bootstrap-core.ts +47 -15
  192. package/src/runtime/bootstrap-hook-bridge.ts +7 -0
  193. package/src/runtime/bootstrap-shell.ts +48 -17
  194. package/src/runtime/bootstrap.ts +129 -22
  195. package/src/runtime/code-index-services.ts +135 -0
  196. package/src/runtime/legacy-daemon-migration.ts +516 -0
  197. package/src/runtime/memory-fold.ts +26 -0
  198. package/src/runtime/onboarding/derivation.ts +7 -2
  199. package/src/runtime/onboarding/snapshot.ts +27 -1
  200. package/src/runtime/onboarding/types.ts +31 -1
  201. package/src/runtime/operator-token-cleanup.ts +82 -1
  202. package/src/runtime/orchestrator-core-services.ts +59 -0
  203. package/src/runtime/process-lifecycle.ts +3 -1
  204. package/src/runtime/resume-notice.ts +209 -0
  205. package/src/runtime/services.ts +98 -46
  206. package/src/runtime/session-inbound-inputs.ts +252 -0
  207. package/src/runtime/session-spine-transport.ts +64 -0
  208. package/src/runtime/terminal-output-guard.ts +15 -8
  209. package/src/runtime/ui-services.ts +27 -3
  210. package/src/runtime/workstream-services.ts +327 -0
  211. package/src/runtime/wrfc-persistence.ts +124 -17
  212. package/src/shell/blocking-input.ts +68 -4
  213. package/src/shell/recovery-input-helpers.ts +170 -1
  214. package/src/shell/ui-openers.ts +171 -26
  215. package/src/utils/format-duration.ts +8 -18
  216. package/src/utils/splash-lines.ts +1 -1
  217. package/src/utils/terminal-width.ts +52 -0
  218. package/src/version.ts +1 -1
  219. package/src/panels/agent-inspector-panel.ts +0 -786
  220. package/src/panels/approval-panel.ts +0 -252
  221. package/src/panels/automation-control-panel.ts +0 -479
  222. package/src/panels/cockpit-panel.ts +0 -481
  223. package/src/panels/cockpit-read-model.ts +0 -238
  224. package/src/panels/communication-panel.ts +0 -256
  225. package/src/panels/control-plane-panel.ts +0 -470
  226. package/src/panels/debug-panel.ts +0 -615
  227. package/src/panels/docs-panel.ts +0 -384
  228. package/src/panels/eval-panel.ts +0 -627
  229. package/src/panels/file-explorer-panel.ts +0 -673
  230. package/src/panels/file-preview-panel.ts +0 -517
  231. package/src/panels/hooks-panel.ts +0 -401
  232. package/src/panels/incident-review-panel.ts +0 -406
  233. package/src/panels/intelligence-panel.ts +0 -383
  234. package/src/panels/knowledge-graph-panel.ts +0 -515
  235. package/src/panels/marketplace-panel.ts +0 -399
  236. package/src/panels/memory-panel.ts +0 -558
  237. package/src/panels/ops-control-panel.ts +0 -329
  238. package/src/panels/ops-strategy-panel.ts +0 -321
  239. package/src/panels/orchestration-panel.ts +0 -465
  240. package/src/panels/panel-list-panel.ts +0 -566
  241. package/src/panels/plan-dashboard-panel.ts +0 -707
  242. package/src/panels/policy-panel.ts +0 -517
  243. package/src/panels/project-planning-panel.ts +0 -731
  244. package/src/panels/provider-health-panel.ts +0 -678
  245. package/src/panels/provider-health-tracker.ts +0 -310
  246. package/src/panels/provider-health-views.ts +0 -567
  247. package/src/panels/qr-panel.ts +0 -280
  248. package/src/panels/remote-panel.ts +0 -534
  249. package/src/panels/routes-panel.ts +0 -241
  250. package/src/panels/sandbox-panel.ts +0 -456
  251. package/src/panels/security-panel.ts +0 -447
  252. package/src/panels/services-panel.ts +0 -329
  253. package/src/panels/session-browser-panel.ts +0 -496
  254. package/src/panels/settings-sync-panel.ts +0 -398
  255. package/src/panels/subscription-panel.ts +0 -342
  256. package/src/panels/symbol-outline-panel.ts +0 -619
  257. package/src/panels/system-messages-panel.ts +0 -364
  258. package/src/panels/tasks-panel.ts +0 -608
  259. package/src/panels/thinking-panel.ts +0 -333
  260. package/src/panels/tool-inspector-panel.ts +0 -537
  261. package/src/panels/work-plan-panel.ts +0 -540
  262. package/src/panels/worktree-panel.ts +0 -360
  263. package/src/panels/wrfc-panel.ts +0 -790
  264. package/src/renderer/agent-detail-modal.ts +0 -466
  265. package/src/renderer/live-tail-modal.ts +0 -156
  266. package/src/renderer/process-modal.ts +0 -671
  267. package/src/renderer/qr-renderer.ts +0 -120
@@ -0,0 +1,592 @@
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
+ import { truncateDisplay, wrapText } from '../utils/terminal-width.ts';
11
+
12
+ export type {
13
+ ConfigModalAction,
14
+ ConfigModalActionContext,
15
+ ConfigModalRow,
16
+ ConfigModalSurface,
17
+ ConfigModalTab,
18
+ ConfigModalView,
19
+ } from './config-modal-types.ts';
20
+
21
+ /** A tab as the renderer sees it (structure frozen, label live). */
22
+ export interface ConfigModalRenderTab {
23
+ readonly id: string;
24
+ readonly label: string;
25
+ readonly active: boolean;
26
+ }
27
+
28
+ /** A row as the renderer sees it: frozen identity, live label/value overlay. */
29
+ export interface ConfigModalRenderRow {
30
+ readonly id: string;
31
+ readonly label: string;
32
+ readonly style?: ModalSectionStyle;
33
+ readonly selected: boolean;
34
+ readonly selectable: boolean;
35
+ /** True when this frozen row has no live counterpart this tick (value went
36
+ * stale — kept in place, dimmed, until the next interaction boundary). */
37
+ readonly stale: boolean;
38
+ }
39
+
40
+ /** Everything renderConfigModal needs — computed by overlaying live values onto
41
+ * the frozen structure so layout stays stable between key presses. */
42
+ export interface ConfigModalRenderModel {
43
+ readonly title: string;
44
+ readonly tabs: readonly ConfigModalRenderTab[];
45
+ readonly header: readonly string[];
46
+ readonly rows: readonly ConfigModalRenderRow[];
47
+ readonly emptyText?: string;
48
+ readonly degraded?: string;
49
+ readonly status?: string;
50
+ readonly hints: readonly string[];
51
+ readonly scroll: { readonly offset: number; readonly total: number; readonly visible: number };
52
+ }
53
+
54
+ const DEFAULT_VISIBLE_ROWS = 10;
55
+
56
+ /**
57
+ * Default label wrap width for getRenderModel() callers that don't pass one
58
+ * (tests, mainly). Matches the renderer's real list-item wrap width at the
59
+ * common terminal sizes this codebase tests against: default box width 76,
60
+ * minus the 4-column text margin and 2-column selection indicator (see
61
+ * ModalFactory._renderListSection's `contentW - 2`). renderConfigModal()
62
+ * itself always computes and passes the ACTUAL width for the current
63
+ * terminal size — this constant only matters when getRenderModel() is
64
+ * called directly without going through the renderer.
65
+ */
66
+ const DEFAULT_LABEL_WRAP_WIDTH = 70;
67
+
68
+ /**
69
+ * Stable id for the synthesized "no matches" row DEBT-5 item 1 injects when a
70
+ * filter query excludes every selectable row in a tab. Non-selectable (info
71
+ * rows are never filtered — see ConfigModal's filter doc) so this id can
72
+ * never collide with a real surface row id.
73
+ */
74
+ const FILTER_NO_MATCH_ROW_ID = '__config_modal_filter_no_match__';
75
+
76
+ /**
77
+ * ConfigModal — the single, generic, named config-modal host. One instance
78
+ * lives on the InputHandler (like `settingsModal`); it renders whatever
79
+ * `ConfigModalSurface` is currently open. Key routing is a single
80
+ * `handleConfigModalToken` path (handler-modal-routes.ts) — no parallel input
81
+ * system. See config-modal-types.ts for the surface contract and the liveness
82
+ * doctrine this class enforces.
83
+ */
84
+ export class ConfigModal {
85
+ public active = false;
86
+
87
+ private surface: ConfigModalSurface | null = null;
88
+ private requestRender: () => void = () => {};
89
+
90
+ /** Active tab / selected row tracked by STABLE id (survives value refresh). */
91
+ private activeTabId = '';
92
+ private selectedRowId = '';
93
+
94
+ /** Transient status line (action result, error, or confirm prompt). */
95
+ private statusMessage = '';
96
+ /** Pending destructive confirm: the action key awaiting a second press. */
97
+ private pendingConfirmKey: string | null = null;
98
+
99
+ /**
100
+ * DEBT-5 item 1 — the host's own type-to-filter, armed with '/' (matching
101
+ * the pre-existing "'/' to filter" convention: scrollable-list-panel.ts's
102
+ * opt-in filter, and SettingsModal's own '/'-armed search). `filterActive`
103
+ * gates whether printable keys go to the query instead of nav/actions;
104
+ * `filterQuery` is the TEXT-CAPTURE buffer itself (a multi-char paste token
105
+ * appends in one shot — see handleConfigModalToken). Filtering only ever
106
+ * narrows the ACTIVE tab's rows and is reset on tab switch (a query typed
107
+ * against one tab's rows has no defined meaning against another's).
108
+ *
109
+ * FILTER-CONVENTION RULING (batch integration — where '/'-armed vs instant
110
+ * filtering is decided across the TUI's list UIs):
111
+ * - '/'-ARMED here (config-modal host surfaces): these surfaces bind PLAIN
112
+ * single keys to ACTION HOTKEYS (e.g. 'r' refresh, 'd' delete). A key
113
+ * can't be both an action and a filter character, so filtering must be
114
+ * explicitly armed with '/' first. This is DEBT-5's design.
115
+ * - INSTANT filtering (pure pickers — help overlay, command palette,
116
+ * selection-modal): these have no single-key actions, so every printable
117
+ * key is unambiguously a filter character and narrows the list on the
118
+ * first keystroke. This is UX-C's design (selection-modal-overlay.ts).
119
+ * - Both keep single-Esc-close semantics. The only extra step is here: a
120
+ * two-stage Esc (first Esc clears a NON-EMPTY query, second Esc closes)
121
+ * — see the Esc branch in handleConfigModalToken. With an empty query,
122
+ * one Esc closes, exactly like the instant pickers.
123
+ */
124
+ private filterActive = false;
125
+ private filterQuery = '';
126
+
127
+ /**
128
+ * Structure captured at the last interaction boundary (open / key press).
129
+ * Renders overlay live values onto THIS so rows never reflow mid-interaction.
130
+ */
131
+ private frozenView: ConfigModalView | null = null;
132
+
133
+ private scrollOffset = 0;
134
+ private visibleRows = DEFAULT_VISIBLE_ROWS;
135
+
136
+ /**
137
+ * False until the first token reaches the open modal. While false, renders
138
+ * may re-sync structure so an async onOpen load replaces the "Loading…"
139
+ * placeholder WITHOUT requiring a keypress; the freeze-to-interaction-
140
+ * boundary rule protects a cursor the user has engaged, and before the
141
+ * first interaction there is nothing to protect (refutation finding 3).
142
+ */
143
+ private interactedSinceOpen = false;
144
+
145
+ // ── Lifecycle ──────────────────────────────────────────────────────────────
146
+
147
+ open(surface: ConfigModalSurface, requestRender: () => void = () => {}): void {
148
+ // Re-opening a different surface closes the previous one cleanly.
149
+ if (this.surface && this.surface !== surface) this.surface.onClose?.();
150
+ this.surface = surface;
151
+ this.requestRender = requestRender;
152
+ this.active = true;
153
+ this.statusMessage = '';
154
+ this.pendingConfirmKey = null;
155
+ this.scrollOffset = 0;
156
+ this.filterActive = false;
157
+ this.filterQuery = '';
158
+ this.interactedSinceOpen = false;
159
+ const view = surface.buildView();
160
+ this.frozenView = view;
161
+ this.activeTabId = view.tabs[0]?.id ?? '';
162
+ this.selectedRowId = this._firstSelectableId(this._frozenTab());
163
+ surface.onOpen?.(requestRender);
164
+ }
165
+
166
+ close(): void {
167
+ if (!this.active) return;
168
+ this.surface?.onClose?.();
169
+ this.active = false;
170
+ this.surface = null;
171
+ this.frozenView = null;
172
+ this.statusMessage = '';
173
+ this.pendingConfirmKey = null;
174
+ this.filterActive = false;
175
+ this.filterQuery = '';
176
+ }
177
+
178
+ /** Re-activate the current surface after a nested modal closes (Esc stack). */
179
+ reopen(): void {
180
+ if (!this.surface) return;
181
+ this.active = true;
182
+ this.syncStructure();
183
+ this.surface.onOpen?.(this.requestRender);
184
+ }
185
+
186
+ getSurfaceName(): string | null {
187
+ return this.surface?.name ?? null;
188
+ }
189
+
190
+ setViewportRows(rows: number): void {
191
+ this.visibleRows = Math.max(3, rows);
192
+ this._clampScroll();
193
+ }
194
+
195
+ // ── Interaction boundary ────────────────────────────────────────────────────
196
+
197
+ /** Token router calls this for every token the open modal receives. */
198
+ noteInteraction(): void {
199
+ this.interactedSinceOpen = true;
200
+ }
201
+
202
+ /**
203
+ * Re-capture the structure from the current live view. Called on every key
204
+ * press (never during a pure render tick — with one exception: before the
205
+ * FIRST interaction, getRenderModel syncs so async loads paint) — this is
206
+ * what makes structural changes appear only at an interaction boundary.
207
+ * Selection + active tab are preserved by id and clamped if their target
208
+ * vanished.
209
+ */
210
+ syncStructure(): void {
211
+ if (!this.surface) return;
212
+ const view = this._applyFilter(this.surface.buildView());
213
+ this.frozenView = view;
214
+ if (!view.tabs.some((t) => t.id === this.activeTabId)) {
215
+ this.activeTabId = view.tabs[0]?.id ?? '';
216
+ }
217
+ const tab = this._frozenTab();
218
+ if (!this._selectableIds(tab).includes(this.selectedRowId)) {
219
+ this.selectedRowId = this._firstSelectableId(tab);
220
+ }
221
+ this._clampScroll();
222
+ }
223
+
224
+ // ── Filter (DEBT-5 item 1 — each mutation is an interaction boundary) ──────
225
+
226
+ isFilterActive(): boolean {
227
+ return this.filterActive;
228
+ }
229
+
230
+ getFilterQuery(): string {
231
+ return this.filterQuery;
232
+ }
233
+
234
+ /** Arm the filter (handleConfigModalToken calls this on '/'). Idempotent. */
235
+ activateFilter(): void {
236
+ if (this.filterActive) return;
237
+ this._clearConfirm();
238
+ this.filterActive = true;
239
+ this.syncStructure();
240
+ }
241
+
242
+ /**
243
+ * Append text to the query — the WHOLE token value in one call, so a
244
+ * multi-char paste token lands in the filter atomically rather than being
245
+ * split into per-char nav/action dispatch (the text-capture invariant this
246
+ * item's brief calls out). A no-op when the filter isn't armed.
247
+ */
248
+ appendFilterText(text: string): void {
249
+ if (!this.filterActive || text.length === 0) return;
250
+ this.filterQuery += text;
251
+ this.syncStructure();
252
+ }
253
+
254
+ backspaceFilter(): void {
255
+ if (!this.filterActive || this.filterQuery.length === 0) return;
256
+ this.filterQuery = this.filterQuery.slice(0, -1);
257
+ this.syncStructure();
258
+ }
259
+
260
+ /**
261
+ * Esc while filtering: a non-empty query is CLEARED (stays armed, ready to
262
+ * retype without pressing '/' again) and this returns true so the caller
263
+ * (handleConfigModalToken) stops here instead of closing the modal. An
264
+ * empty query returns false — the caller falls through to the ordinary
265
+ * close path, preserving single-Esc-close for the no-filter case (the one
266
+ * documented exception: two-stage Esc only when there is something to
267
+ * clear).
268
+ */
269
+ clearFilterOrFallthrough(): boolean {
270
+ if (!this.filterActive || this.filterQuery.length === 0) return false;
271
+ this.filterQuery = '';
272
+ this.syncStructure();
273
+ return true;
274
+ }
275
+
276
+ // ── Navigation (each is an interaction boundary) ────────────────────────────
277
+
278
+ moveDown(): void {
279
+ this._clearConfirm();
280
+ this.syncStructure();
281
+ const ids = this._selectableIds(this._frozenTab());
282
+ if (ids.length === 0) return;
283
+ const i = ids.indexOf(this.selectedRowId);
284
+ this.selectedRowId = ids[(i + 1) % ids.length]!;
285
+ this._clampScroll();
286
+ }
287
+
288
+ moveUp(): void {
289
+ this._clearConfirm();
290
+ this.syncStructure();
291
+ const ids = this._selectableIds(this._frozenTab());
292
+ if (ids.length === 0) return;
293
+ const i = ids.indexOf(this.selectedRowId);
294
+ this.selectedRowId = ids[(i - 1 + ids.length) % ids.length]!;
295
+ this._clampScroll();
296
+ }
297
+
298
+ nextTab(): void {
299
+ this._switchTab(1);
300
+ }
301
+
302
+ prevTab(): void {
303
+ this._switchTab(-1);
304
+ }
305
+
306
+ private _switchTab(dir: 1 | -1): void {
307
+ this._clearConfirm();
308
+ // A filter query is scoped to the tab it was typed against — switching
309
+ // tabs resets it (DEBT-5 item 1), same as statusMessage below.
310
+ this.filterActive = false;
311
+ this.filterQuery = '';
312
+ this.syncStructure();
313
+ const tabs = this.frozenView?.tabs ?? [];
314
+ if (tabs.length <= 1) return;
315
+ const i = tabs.findIndex((t) => t.id === this.activeTabId);
316
+ const next = tabs[(i + dir + tabs.length) % tabs.length]!;
317
+ this.activeTabId = next.id;
318
+ this.selectedRowId = this._firstSelectableId(next);
319
+ this.scrollOffset = 0;
320
+ this.statusMessage = '';
321
+ }
322
+
323
+ // ── Actions ─────────────────────────────────────────────────────────────────
324
+
325
+ /** The action bound to `key` on the active tab/row, if enabled. */
326
+ resolveAction(key: string): ConfigModalAction | null {
327
+ const actions = this.surface?.actions ?? [];
328
+ const row = this.getSelectedRow();
329
+ for (const action of actions) {
330
+ if (action.key !== key) continue;
331
+ if (action.enabledFor && !action.enabledFor(row, this.activeTabId)) return null;
332
+ return action;
333
+ }
334
+ return null;
335
+ }
336
+
337
+ /**
338
+ * Attempt to fire the action bound to `key`. Returns true if a key was an
339
+ * action (consumed). Handles the two-press confirm for destructive actions.
340
+ * This is an interaction boundary (syncs structure first).
341
+ */
342
+ fireAction(key: string, ctx: Omit<ConfigModalActionContext, 'row' | 'tabId' | 'setStatus' | 'close' | 'requestRender'>): boolean {
343
+ this.syncStructure();
344
+ const action = this.resolveAction(key);
345
+ if (!action) return false;
346
+ if (action.confirm && this.pendingConfirmKey !== key) {
347
+ this.pendingConfirmKey = key;
348
+ this.statusMessage = `Press ${key} again to ${action.label}.`;
349
+ return true;
350
+ }
351
+ this._clearConfirm();
352
+ const fullCtx: ConfigModalActionContext = {
353
+ row: this.getSelectedRow(),
354
+ tabId: this.activeTabId,
355
+ print: ctx.print,
356
+ executeCommand: ctx.executeCommand,
357
+ openModal: ctx.openModal,
358
+ submitInput: ctx.submitInput,
359
+ requestRender: this.requestRender,
360
+ setStatus: (m: string) => { this.statusMessage = m; },
361
+ close: () => this.close(),
362
+ };
363
+ this.surface?.onAction?.(action.id, fullCtx);
364
+ return true;
365
+ }
366
+
367
+ /** Clear a pending confirm when the user navigates or presses an unrelated key. */
368
+ clearConfirmOnMiss(): void {
369
+ this._clearConfirm();
370
+ }
371
+
372
+ // ── Read accessors (for the renderer + tests) ───────────────────────────────
373
+
374
+ getSelectedRow(): ConfigModalRow | null {
375
+ const tab = this._liveTab(this.activeTabId) ?? this._frozenTab();
376
+ return tab?.rows.find((r) => r.id === this.selectedRowId) ?? null;
377
+ }
378
+
379
+ getActiveTabId(): string {
380
+ return this.activeTabId;
381
+ }
382
+
383
+ getSelectedRowId(): string {
384
+ return this.selectedRowId;
385
+ }
386
+
387
+ getStatusMessage(): string {
388
+ return this.statusMessage;
389
+ }
390
+
391
+ /**
392
+ * Compute the render model: frozen structure + live value overlay. Pure —
393
+ * does NOT sync structure, so calling it repeatedly with only value mutations
394
+ * yields byte-identical layout (the liveness contract).
395
+ *
396
+ * `labelWrapWidth` is the wrap column ModalFactory's list section will use
397
+ * for row labels (renderConfigModal computes and passes the real one for
398
+ * the current terminal size). It exists so the wrap-clamp below (DEBT-5
399
+ * item 2) can pre-empt a live label growing past ModalFactory's wrap width
400
+ * mid-tick — a structural change (an extra visible line) without an
401
+ * interaction — by measuring wrapped line counts here, before the label
402
+ * ever reaches the renderer.
403
+ */
404
+ getRenderModel(labelWrapWidth: number = DEFAULT_LABEL_WRAP_WIDTH): ConfigModalRenderModel {
405
+ // Pre-first-interaction: async onOpen loads may restructure freely (the
406
+ // user hasn't engaged a cursor yet) — sync so "Loading…" is replaced by
407
+ // real content on the load's own requestRender, not the next keypress.
408
+ if (this.active && !this.interactedSinceOpen) this.syncStructure();
409
+ const live = this.surface?.buildView() ?? null;
410
+ const frozen = this.frozenView;
411
+ if (!frozen) {
412
+ return { title: '', tabs: [], header: [], rows: [], hints: [], scroll: { offset: 0, total: 0, visible: this.visibleRows } };
413
+ }
414
+
415
+ const tabs: ConfigModalRenderTab[] = frozen.tabs.map((ft) => {
416
+ const lt = live?.tabs.find((t) => t.id === ft.id);
417
+ return { id: ft.id, label: lt?.label ?? ft.label, active: ft.id === this.activeTabId };
418
+ });
419
+
420
+ const frozenTab = frozen.tabs.find((t) => t.id === this.activeTabId) ?? frozen.tabs[0];
421
+ const liveTab = live?.tabs.find((t) => t.id === this.activeTabId);
422
+
423
+ // Header: all-or-nothing live overlay. Same line count → use live values;
424
+ // a count change is a structural change, deferred (keep frozen header).
425
+ const frozenHeader = frozenTab?.header ?? [];
426
+ const liveHeader = liveTab?.header ?? [];
427
+ const header = liveHeader.length === frozenHeader.length ? liveHeader : frozenHeader;
428
+
429
+ // Rows: iterate the frozen id order; overlay the live row (value refresh) by
430
+ // id, or keep the frozen row marked stale when its live counterpart is gone.
431
+ const frozenRows = frozenTab?.rows ?? [];
432
+ const allRows: ConfigModalRenderRow[] = frozenRows.map((fr) => {
433
+ const lr = liveTab?.rows.find((r) => r.id === fr.id);
434
+ const src = lr ?? fr;
435
+ return {
436
+ id: fr.id,
437
+ label: this._clampRowLabel(fr.label, src.label, labelWrapWidth),
438
+ style: src.style,
439
+ selected: fr.id === this.selectedRowId,
440
+ selectable: fr.selectable !== false,
441
+ stale: lr === undefined,
442
+ };
443
+ });
444
+
445
+ const visible = this.visibleRows;
446
+ const windowed = allRows.slice(this.scrollOffset, this.scrollOffset + visible);
447
+
448
+ // DEBT-5 item 1: while filtering, the surface's own action/tab hints are
449
+ // unreliable (their printable-letter keys are captured by the filter
450
+ // instead of firing — see handleConfigModalToken), so the footer swaps
451
+ // to just the filter status (query + a truthful match count) and the
452
+ // Esc contract for this mode. See getFilterQuery/isFilterActive callers.
453
+ const filtering = this.filterActive;
454
+ const hasQuery = filtering && this.filterQuery.length > 0;
455
+ const hints = filtering
456
+ ? [
457
+ this._filterStatusHint(
458
+ frozenRows.filter((r) => r.selectable !== false).length,
459
+ (liveTab?.rows ?? []).filter((r) => r.selectable !== false).length,
460
+ ),
461
+ hasQuery ? 'Esc clear · Esc close' : 'Esc close',
462
+ ]
463
+ : [
464
+ ...(frozen.hints ?? []),
465
+ ...(frozenTab?.hints ?? []),
466
+ ...this._actionHints(),
467
+ 'Esc close',
468
+ ];
469
+
470
+ return {
471
+ title: live?.title ?? frozen.title,
472
+ tabs,
473
+ header,
474
+ rows: windowed,
475
+ emptyText: frozenRows.length === 0 ? (frozenTab?.emptyText ?? 'Nothing to show.') : undefined,
476
+ degraded: live?.degraded ?? frozen.degraded,
477
+ status: this.statusMessage || undefined,
478
+ hints,
479
+ scroll: { offset: this.scrollOffset, total: allRows.length, visible },
480
+ };
481
+ }
482
+
483
+ // ── internals ────────────────────────────────────────────────────────────────
484
+
485
+ /**
486
+ * DEBT-5 item 2 (wrap-clamp overlay): keep a live label within the FROZEN
487
+ * row's line footprint until the next interaction boundary re-freezes it.
488
+ * `frozenLabel` is what the row wrapped to when the structure was captured
489
+ * (open/nav/filter keystroke); `liveLabel` is this tick's value. If the
490
+ * live label would wrap into MORE lines than the frozen one, that is a
491
+ * structural change (an extra visible row) happening without a keypress —
492
+ * exactly the hazard this closes. Clamp to the frozen line count, ellipsis
493
+ * on the last line to signal truncation; a live label that wraps to the
494
+ * SAME or FEWER lines passes through untouched (not the documented hazard).
495
+ * Identical strings short-circuit (the common per-tick case: unchanged or
496
+ * non-selected/stale rows) without doing any wrap work.
497
+ */
498
+ private _clampRowLabel(frozenLabel: string, liveLabel: string, width: number): string {
499
+ if (liveLabel === frozenLabel) return liveLabel;
500
+ const frozenLineCount = Math.max(1, wrapText(frozenLabel, width).length);
501
+ const liveLines = wrapText(liveLabel, width);
502
+ if (liveLines.length <= frozenLineCount) return liveLabel;
503
+ const clamped = liveLines.slice(0, frozenLineCount);
504
+ const lastIdx = clamped.length - 1;
505
+ clamped[lastIdx] = `${truncateDisplay(clamped[lastIdx]!, Math.max(1, width - 1))}…`;
506
+ return clamped.join('\n');
507
+ }
508
+
509
+ /** Footer text for the active filter: the query + a truthful match count. */
510
+ private _filterStatusHint(matched: number, total: number): string {
511
+ const q = this.filterQuery;
512
+ if (q.length === 0) return `/ type to filter (${total} row${total === 1 ? '' : 's'})`;
513
+ return `/${q} — ${matched} of ${total} match${matched === 1 ? '' : 'es'}`;
514
+ }
515
+
516
+ /** Apply the active filter query to every tab's rows. A no-op passthrough when not filtering. */
517
+ private _applyFilter(view: ConfigModalView): ConfigModalView {
518
+ if (!this.filterActive || this.filterQuery === '') return view;
519
+ const query = this.filterQuery.toLowerCase();
520
+ return { ...view, tabs: view.tabs.map((tab) => this._filterTab(tab, query)) };
521
+ }
522
+
523
+ /**
524
+ * Narrow one tab's rows to those matching `query` (case-insensitive
525
+ * substring on the label). Non-selectable rows (section titles, honest
526
+ * empty-state copy, warning banners) always pass through unfiltered —
527
+ * they're context, not data. When the query excludes every selectable row
528
+ * but the tab genuinely had some, append the honest "no rows match" line
529
+ * (DEBT-5 item 1's empty-result case) instead of silently showing nothing
530
+ * or misleadingly falling back to the surface's own emptyText (which
531
+ * describes "no data at all", not "no data matches your filter").
532
+ */
533
+ private _filterTab(tab: ConfigModalTab, query: string): ConfigModalTab {
534
+ const kept = tab.rows.filter((r) => r.selectable === false || r.label.toLowerCase().includes(query));
535
+ const hadSelectable = tab.rows.some((r) => r.selectable !== false);
536
+ const stillMatched = kept.some((r) => r.selectable !== false);
537
+ if (hadSelectable && !stillMatched) {
538
+ return {
539
+ ...tab,
540
+ rows: [...kept, { id: FILTER_NO_MATCH_ROW_ID, label: `No rows match "${this.filterQuery}".`, selectable: false }],
541
+ };
542
+ }
543
+ return { ...tab, rows: kept };
544
+ }
545
+
546
+ private _actionHints(): string[] {
547
+ const actions = this.surface?.actions ?? [];
548
+ const row = this.getSelectedRow();
549
+ const out: string[] = [];
550
+ for (const a of actions) {
551
+ if (a.enabledFor && !a.enabledFor(row, this.activeTabId)) continue;
552
+ out.push(`${a.key} ${a.label}`);
553
+ }
554
+ return out;
555
+ }
556
+
557
+ private _clearConfirm(): void {
558
+ if (this.pendingConfirmKey !== null) {
559
+ this.pendingConfirmKey = null;
560
+ this.statusMessage = '';
561
+ }
562
+ }
563
+
564
+ private _frozenTab(): ConfigModalTab | undefined {
565
+ return this.frozenView?.tabs.find((t) => t.id === this.activeTabId) ?? this.frozenView?.tabs[0];
566
+ }
567
+
568
+ private _liveTab(id: string): ConfigModalTab | undefined {
569
+ return this.surface?.buildView().tabs.find((t) => t.id === id);
570
+ }
571
+
572
+ private _selectableIds(tab: ConfigModalTab | undefined): string[] {
573
+ return (tab?.rows ?? []).filter((r) => r.selectable !== false).map((r) => r.id);
574
+ }
575
+
576
+ private _firstSelectableId(tab: ConfigModalTab | undefined): string {
577
+ return this._selectableIds(tab)[0] ?? '';
578
+ }
579
+
580
+ private _clampScroll(): void {
581
+ const tab = this._frozenTab();
582
+ const rows = tab?.rows ?? [];
583
+ const idx = rows.findIndex((r) => r.id === this.selectedRowId);
584
+ const visible = Math.max(3, this.visibleRows);
585
+ if (idx >= 0) {
586
+ if (idx < this.scrollOffset) this.scrollOffset = idx;
587
+ else if (idx >= this.scrollOffset + visible) this.scrollOffset = idx - visible + 1;
588
+ }
589
+ const maxOffset = Math.max(0, rows.length - visible);
590
+ this.scrollOffset = Math.max(0, Math.min(this.scrollOffset, maxOffset));
591
+ }
592
+ }
@@ -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,8 @@ 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';
42
+ import type { PanelBurstGuardState } from './panel-paste-flood-guard.ts';
43
43
 
44
44
  /**
45
45
  * Initial mutable scalar values for InputFeedContext.
@@ -82,8 +82,7 @@ export interface FeedContextMutableInit {
82
82
  * - `pasteRegistry`, `imageRegistry` — owned Maps, never replaced
83
83
  * - `selectionModal`, `bookmarkModal`, `settingsModal`, `sessionPickerModal`,
84
84
  * `profilePickerModal` — modal objects constructed once
85
- * - `filePicker`, `modelPicker`, `processModal`, `liveTailModal`,
86
- * `agentDetailModal`, `contextInspectorModal`, `blockActionsMenu`,
85
+ * - `filePicker`, `modelPicker`, `contextInspectorModal`, `blockActionsMenu`,
87
86
  * `searchManager`, `historySearch`, `onboardingWizard` — service objects constructed once
88
87
  * - `panelManager`, `keybindingsManager` — from uiServices, stable
89
88
  * - `modalStack` — reference to the handler's shared array
@@ -91,6 +90,8 @@ export interface FeedContextMutableInit {
91
90
  * - `commandRegistry`, `commandContext`, `autocomplete`, `inputHistory`,
92
91
  * `conversationManager` — wired after construction; synced at feed() entry only
93
92
  * (not per-action) since no in-feed action changes them
93
+ * - `focusTracker` (W2.3) — shared instance from uiServices.platform, mutated
94
+ * directly by feedInputTokens() on 'focus' tokens; never reallocated
94
95
  *
95
96
  * **Rationale:** per-feed mutation on a single object avoids per-keystroke GC pressure
96
97
  * from ~80-field object allocation. Stable references are service handles that never
@@ -107,6 +108,7 @@ export interface FeedContextStableRefs {
107
108
  mcpWorkspace: McpWorkspace;
108
109
  sessionPickerModal: SessionPickerModal;
109
110
  profilePickerModal: ProfilePickerModal;
111
+ configModal: ConfigModal;
110
112
  historySearch: HistorySearch;
111
113
  commandRegistry: CommandRegistry | null;
112
114
  commandContext: CommandContext | undefined;
@@ -114,9 +116,6 @@ export interface FeedContextStableRefs {
114
116
  filePicker: FilePickerModal;
115
117
  modelPicker: ModelPickerModal;
116
118
  onboardingWizard: OnboardingWizardController;
117
- processModal: ProcessModal;
118
- liveTailModal: LiveTailModal;
119
- agentDetailModal: AgentDetailModal;
120
119
  contextInspectorModal: ContextInspectorModal;
121
120
  blockActionsMenu: BlockActionsMenu;
122
121
  searchManager: SearchManager;
@@ -126,6 +125,9 @@ export interface FeedContextStableRefs {
126
125
  panelManager: PanelManager;
127
126
  keybindingsManager: KeybindingsManager;
128
127
  killRing: KillRing;
128
+ focusTracker: FocusTracker;
129
+ /** DEBT-5 item 5 — paste-flood guard state, mutated in place (never reallocated). */
130
+ panelBurstGuard: PanelBurstGuardState;
129
131
  getHistory: () => InfiniteBuffer;
130
132
  getViewportHeight: () => number;
131
133
  getScrollTop: () => number;