@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
@@ -4,9 +4,14 @@ import type { SessionSnapshot } from '@/runtime/index.ts';
4
4
  import type { SystemMessageRouter } from '../core/system-message-router.ts';
5
5
  import type { ConversationMessageSnapshot } from '@pellux/goodvibes-sdk/platform/core';
6
6
  import { replayJournalForSession } from '../core/session-recovery.ts';
7
+ import { applyHunkKey, buildModifiedEditArgs, type HunkSelectionState } from '../permissions/hunk-selection.ts';
7
8
 
8
9
  export type PendingPermissionState = PermissionRequest & {
9
- resolve: (approved: boolean, remember?: boolean) => void;
10
+ resolve: (approved: boolean, remember?: boolean, modifiedArgs?: Record<string, unknown>) => void;
11
+ /** Present only when isHunkSelectable(request) was true when the prompt was opened. */
12
+ hunkState?: HunkSelectionState;
13
+ /** True once the user pressed `d` to expand a condensed low-risk card. (UX-B 2b.) */
14
+ detailsExpanded?: boolean;
10
15
  };
11
16
 
12
17
  export type BlockingInputHandlerOptions = {
@@ -19,6 +24,15 @@ export type BlockingInputHandlerOptions = {
19
24
  render: () => void;
20
25
  loadRecoveryConversation: () => SessionSnapshot | null;
21
26
  deleteRecoveryFile: () => void;
27
+ /**
28
+ * Copy the live recovery.jsonl aside to a preserved sibling so the dismiss
29
+ * message's promise ("still on disk") survives this session's later 60s
30
+ * autosaves, which would otherwise overwrite the shared recovery file with
31
+ * the CURRENT (post-dismiss) session's state (W3 Finding 3). Optional so
32
+ * every pre-existing test/caller that doesn't care about preservation is
33
+ * unaffected; when omitted, dismiss behaves exactly as before.
34
+ */
35
+ preserveRecoveryFile?: () => { readonly preserved: boolean; readonly replacedPrevious: boolean };
22
36
  /**
23
37
  * Absolute home directory used to locate the transcript journal for this
24
38
  * recovery session. Required for journal replay on Ctrl+R restore.
@@ -65,6 +79,7 @@ export function handleBlockingShellInput(
65
79
  render,
66
80
  loadRecoveryConversation,
67
81
  deleteRecoveryFile,
82
+ preserveRecoveryFile,
68
83
  homeDirectory,
69
84
  sessionId,
70
85
  persistSnapshot,
@@ -73,6 +88,24 @@ export function handleBlockingShellInput(
73
88
 
74
89
  if (pendingPermission) {
75
90
  const req = pendingPermission;
91
+
92
+ if (req.hunkState) {
93
+ const { state, commit } = applyHunkKey(req.hunkState, data);
94
+ if (commit === 'apply') {
95
+ req.resolve(true, false, buildModifiedEditArgs(req, state));
96
+ render();
97
+ return { handled: true, pendingPermission: null, recoveryPending };
98
+ }
99
+ if (commit === 'cancel') {
100
+ req.resolve(false, false);
101
+ abortTurn();
102
+ render();
103
+ return { handled: true, pendingPermission: null, recoveryPending };
104
+ }
105
+ render();
106
+ return { handled: true, pendingPermission: { ...req, hunkState: state }, recoveryPending };
107
+ }
108
+
76
109
  const key = data.toLowerCase().trim();
77
110
 
78
111
  if (key === 'y') {
@@ -94,6 +127,16 @@ export function handleBlockingShellInput(
94
127
  return { handled: true, pendingPermission: null, recoveryPending };
95
128
  }
96
129
 
130
+ if (key === 'd') {
131
+ // Toggle the condensed↔full detail view without resolving the request.
132
+ render();
133
+ return {
134
+ handled: true,
135
+ pendingPermission: { ...req, detailsExpanded: !req.detailsExpanded },
136
+ recoveryPending,
137
+ };
138
+ }
139
+
97
140
  render();
98
141
  return { handled: true, pendingPermission, recoveryPending };
99
142
  }
@@ -135,10 +178,31 @@ export function handleBlockingShellInput(
135
178
  return { handled: true, pendingPermission: null, recoveryPending: false };
136
179
  }
137
180
 
138
- // Stray key: leave the recovery prompt active so the user can still Ctrl+R or Esc.
139
- systemMessageRouter.high('[Recovery] Ctrl+R to restore · Esc to discard');
181
+ // Any other key demonstrates the user's intent to ignore the banner and
182
+ // keep working — the prompt's own text invites exactly this ("start
183
+ // typing to ignore it"). Previously this branch re-posted the same
184
+ // '[Recovery] Ctrl+R to restore...' line on EVERY such key and never
185
+ // cleared recoveryPending, so a user who took that invitation got a
186
+ // fresh [Recovery] line injected into the transcript around every
187
+ // character they typed, forever. Dismiss ONCE instead: clear
188
+ // recoveryPending so it stops re-asserting, but do NOT delete the
189
+ // recovery file — dismiss is not discard, and the file remains
190
+ // restorable by the automatic recovery check on the next launch.
191
+ //
192
+ // W3 Finding 3: the promise below ("still on disk; you will be asked
193
+ // again") used to go false silently — main.ts's 60s autosave overwrites
194
+ // the single shared recovery.jsonl with the CURRENT session's state
195
+ // within a minute. preserveRecoveryFile (when wired) copies it aside
196
+ // NOW, while it still holds the dismissed session's data, so the
197
+ // promise stays true. If an earlier dismiss's preserved snapshot gets
198
+ // replaced by this one, say so honestly instead of silently discarding it.
199
+ const preserveResult = preserveRecoveryFile?.();
200
+ if (preserveResult?.replacedPrevious) {
201
+ systemMessageRouter.low('[Recovery] Replacing the previously preserved (unrestored) snapshot with this one.');
202
+ }
203
+ systemMessageRouter.high('[Recovery] Dismissed — the unsaved session is still on disk; you will be asked again next time GoodVibes starts here.');
140
204
  render();
141
- return { handled: false, pendingPermission, recoveryPending: true };
205
+ return { handled: false, pendingPermission, recoveryPending: false };
142
206
  }
143
207
 
144
208
  return { handled: false, pendingPermission, recoveryPending };
@@ -5,8 +5,15 @@
5
5
  * architecture line ceiling; main() wires these with its live services.
6
6
  */
7
7
 
8
+ import { copyFileSync, existsSync, readFileSync, unlinkSync } from 'node:fs';
8
9
  import type { ConversationMessageSnapshot } from '../core/conversation.ts';
9
- import type { SessionSnapshot } from '@/runtime/index.ts';
10
+ import type { SessionReturnContextSummary, SessionSnapshot } from '@/runtime/index.ts';
11
+ import {
12
+ checkRecoveryFile,
13
+ deleteRecoveryFile as deleteLiveRecoveryFile,
14
+ getRecoveryFilePath,
15
+ loadRecoveryConversation as loadLiveRecoveryConversation,
16
+ } from '@/runtime/index.ts';
10
17
 
11
18
  export interface PersistRecoveryDeps {
12
19
  readonly sessionManager: {
@@ -41,6 +48,168 @@ export function createReopenRecoveryPanels(deps: ReopenPanelsDeps): (snapshot: S
41
48
  };
42
49
  }
43
50
 
51
+ // ---------------------------------------------------------------------------
52
+ // W3 Finding 3: preserve the dismissed recovery snapshot.
53
+ //
54
+ // main.ts's 60s autosave (writeRecoveryFile) overwrites the single shared
55
+ // recovery.jsonl with the CURRENT session's live state within a minute — so
56
+ // the dismiss banner's promise ("still on disk; you will be asked again next
57
+ // time") went false silently well before the next launch. Fix: the moment
58
+ // the user dismisses (blocking-input.ts), copy recovery.jsonl aside to a
59
+ // preserved sibling file (recovery.jsonl.preserved) so it survives this
60
+ // session's later autosaves. Startup then checks BOTH the live file and the
61
+ // preserved sibling and offers whichever is newer (pickNewestRecoveryInfo),
62
+ // bounded to exactly one preserved file — a later dismiss replaces it, and
63
+ // that replacement is reported honestly (PreserveRecoveryResult.replacedPrevious)
64
+ // rather than silently dropping the older snapshot.
65
+ // ---------------------------------------------------------------------------
66
+
67
+ export interface RecoveryFileDeps {
68
+ readonly homeDirectory: string;
69
+ readonly surfaceRoot?: string | undefined;
70
+ }
71
+
72
+ export interface PreservedRecoveryInfo {
73
+ readonly title: string;
74
+ readonly timestamp: number;
75
+ readonly sessionId: string;
76
+ readonly returnContext?: SessionReturnContextSummary | undefined;
77
+ }
78
+
79
+ export interface PreserveRecoveryResult {
80
+ /** True if a live recovery.jsonl existed and was copied to the preserved sibling. */
81
+ readonly preserved: boolean;
82
+ /** True if an existing preserved sibling (from an earlier dismiss) was overwritten. */
83
+ readonly replacedPrevious: boolean;
84
+ }
85
+
86
+ function preservedRecoveryPath(deps: RecoveryFileDeps): string {
87
+ return `${getRecoveryFilePath(deps.homeDirectory, deps.surfaceRoot)}.preserved`;
88
+ }
89
+
90
+ function readRecoveryMetaLine(path: string): { title?: string; timestamp?: number; sessionId?: string; returnContext?: SessionReturnContextSummary } | null {
91
+ try {
92
+ if (!existsSync(path)) return null;
93
+ const firstLine = readFileSync(path, 'utf-8').split('\n')[0];
94
+ if (!firstLine) return null;
95
+ return JSON.parse(firstLine) as { title?: string; timestamp?: number; sessionId?: string; returnContext?: SessionReturnContextSummary };
96
+ } catch {
97
+ return null;
98
+ }
99
+ }
100
+
101
+ /** Copy the live recovery.jsonl aside to the preserved sibling. Called once, at dismiss. */
102
+ export function createPreserveRecoveryFile(deps: RecoveryFileDeps): () => PreserveRecoveryResult {
103
+ return () => {
104
+ const live = getRecoveryFilePath(deps.homeDirectory, deps.surfaceRoot);
105
+ const preserved = preservedRecoveryPath(deps);
106
+ if (!existsSync(live)) return { preserved: false, replacedPrevious: false };
107
+ const replacedPrevious = existsSync(preserved);
108
+ try {
109
+ copyFileSync(live, preserved);
110
+ return { preserved: true, replacedPrevious };
111
+ } catch {
112
+ return { preserved: false, replacedPrevious: false };
113
+ }
114
+ };
115
+ }
116
+
117
+ /**
118
+ * Startup check for the preserved sibling — mirrors the SDK's
119
+ * checkRecoveryFile meta read, but for the fixed preserved path. No
120
+ * mtime-vs-last-clean-save gate: the preserved file is inert (never
121
+ * autosaved-over) until explicitly replaced or cleared, so its mere
122
+ * existence is actionable.
123
+ */
124
+ export function checkPreservedRecoveryFile(deps: RecoveryFileDeps): PreservedRecoveryInfo | null {
125
+ const meta = readRecoveryMetaLine(preservedRecoveryPath(deps));
126
+ if (!meta) return null;
127
+ return { title: meta.title ?? '', timestamp: meta.timestamp ?? 0, sessionId: meta.sessionId ?? '', returnContext: meta.returnContext };
128
+ }
129
+
130
+ /** Load the preserved sibling's full snapshot — same JSONL shape as recovery.jsonl since it's a byte-identical copy. */
131
+ export function loadPreservedRecoveryConversation(deps: RecoveryFileDeps): SessionSnapshot | null {
132
+ const path = preservedRecoveryPath(deps);
133
+ try {
134
+ if (!existsSync(path)) return null;
135
+ const lines = readFileSync(path, 'utf-8').split('\n').filter(Boolean);
136
+ if (lines.length < 2) return { messages: [] };
137
+ const metaLine = JSON.parse(lines[0]!) as { title?: string; titleSource?: SessionSnapshot['titleSource']; returnContext?: SessionSnapshot['returnContext'] };
138
+ return {
139
+ title: metaLine.title,
140
+ titleSource: metaLine.titleSource,
141
+ returnContext: metaLine.returnContext,
142
+ messages: lines.slice(1).map((line) => {
143
+ const { type: _type, ...rest } = JSON.parse(line) as { type: string } & Record<string, unknown>;
144
+ return rest;
145
+ }),
146
+ };
147
+ } catch {
148
+ return null;
149
+ }
150
+ }
151
+
152
+ /** Remove the preserved sibling — called after it has been restored or discarded. */
153
+ export function deletePreservedRecoveryFile(deps: RecoveryFileDeps): void {
154
+ try {
155
+ unlinkSync(preservedRecoveryPath(deps));
156
+ } catch {
157
+ // missing file is fine
158
+ }
159
+ }
160
+
161
+ /**
162
+ * Pick the newer of the live vs. preserved recovery info for the startup
163
+ * prompt, tagging which source it came from so the caller knows which
164
+ * load/delete pair to use. Null when neither exists.
165
+ */
166
+ export function pickNewestRecoveryInfo<T extends { readonly timestamp: number }>(
167
+ live: T | null,
168
+ preserved: T | null,
169
+ ): (T & { readonly source: 'live' | 'preserved' }) | null {
170
+ if (!live && !preserved) return null;
171
+ if (!live) return { ...(preserved as T), source: 'preserved' };
172
+ if (!preserved) return { ...live, source: 'live' };
173
+ return preserved.timestamp > live.timestamp
174
+ ? { ...(preserved as T), source: 'preserved' }
175
+ : { ...live, source: 'live' };
176
+ }
177
+
178
+ /**
179
+ * Startup entry point: resolve which recovery prompt (if any) main.ts should
180
+ * show — the live recovery.jsonl, or an earlier dismiss's preserved sibling,
181
+ * whichever is newer. Bundles checkRecoveryFile + checkPreservedRecoveryFile
182
+ * + pickNewestRecoveryInfo so main.ts's entrypoint stays a one-liner.
183
+ */
184
+ export function resolveStartupRecoveryInfo(
185
+ deps: RecoveryFileDeps & { readonly workingDirectory: string },
186
+ ): (PreservedRecoveryInfo & { readonly source: 'live' | 'preserved' }) | null {
187
+ const live = checkRecoveryFile({ workingDirectory: deps.workingDirectory, homeDirectory: deps.homeDirectory });
188
+ const preserved = checkPreservedRecoveryFile(deps);
189
+ return pickNewestRecoveryInfo(live, preserved);
190
+ }
191
+
192
+ export interface RecoveryFileOps {
193
+ readonly loadRecoveryConversation: () => SessionSnapshot | null;
194
+ readonly deleteRecoveryFile: () => void;
195
+ readonly preserveRecoveryFile?: () => PreserveRecoveryResult;
196
+ }
197
+
198
+ /**
199
+ * Build the three source-aware recovery callbacks main.ts's stdin handler
200
+ * wires into handleBlockingShellInput. `getSource` is read fresh by
201
+ * load/delete on every call (main.ts's recoverySource can change between
202
+ * events); preserveRecoveryFile is only ever wired when the CURRENT prompt
203
+ * came from the live file (see createPreserveRecoveryFile's doc comment).
204
+ */
205
+ export function createRecoveryFileOps(getSource: () => 'live' | 'preserved', deps: RecoveryFileDeps): RecoveryFileOps {
206
+ return {
207
+ loadRecoveryConversation: () => (getSource() === 'preserved' ? loadPreservedRecoveryConversation(deps) : loadLiveRecoveryConversation(deps)),
208
+ deleteRecoveryFile: () => (getSource() === 'preserved' ? deletePreservedRecoveryFile(deps) : deleteLiveRecoveryFile(deps)),
209
+ preserveRecoveryFile: getSource() === 'live' ? createPreserveRecoveryFile(deps) : undefined,
210
+ };
211
+ }
212
+
44
213
  export interface ErrorAffordanceDeps {
45
214
  /** True when the failover retry context is armed (a retry is actually possible). */
46
215
  readonly retryArmed: boolean;
@@ -10,9 +10,13 @@ import type { FeatureFlagManager } from '@/runtime/index.ts';
10
10
  import type { McpRegistry } from '@pellux/goodvibes-sdk/platform/mcp';
11
11
  import type { SubscriptionManager } from '@pellux/goodvibes-sdk/platform/config';
12
12
  import type { SecretsManager } from '@pellux/goodvibes-sdk/platform/config';
13
+ import type { MemoryEmbeddingProviderRegistry } from '@pellux/goodvibes-sdk/platform/state';
13
14
  import type { ServiceInspectionQuery } from '../runtime/ui-service-queries.ts';
14
- import type { ModelPickerTargetInfo } from '../input/model-picker.ts';
15
+ import type { EmbeddingProviderPickerEntry, ModelPickerTargetInfo } from '../input/model-picker.ts';
16
+ import type { SelectionItem } from '../input/selection-modal.ts';
15
17
  import { syncServiceSettingToPlatform } from './service-settings-sync.ts';
18
+ import { setActiveThemeMode } from '../renderer/theme.ts';
19
+ import { THEME_MODE_CONFIG_KEY, coerceThemeModeSetting } from '../renderer/theme-mode-config.ts';
16
20
  import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
17
21
 
18
22
  type WireShellUiOpenersOptions = {
@@ -28,6 +32,8 @@ type WireShellUiOpenersOptions = {
28
32
  subscriptionManager: SubscriptionManager;
29
33
  secretsManager?: Pick<SecretsManager, 'delete' | 'get' | 'set'>;
30
34
  serviceRegistry: Pick<ServiceInspectionQuery, 'getAll'>;
35
+ /** B29: backs the model picker's 'embeddings' target and its own item-list mode. */
36
+ memoryEmbeddingRegistry: Pick<MemoryEmbeddingProviderRegistry, 'getDefaultProviderId' | 'setDefaultProvider' | 'status'>;
31
37
  workingDirectory: string;
32
38
  homeDirectory: string;
33
39
  getConfiguredProviderIds: () => string[];
@@ -93,6 +99,7 @@ export function wireShellUiOpeners(options: WireShellUiOpenersOptions): void {
93
99
  subscriptionManager,
94
100
  secretsManager,
95
101
  serviceRegistry,
102
+ memoryEmbeddingRegistry,
96
103
  workingDirectory,
97
104
  homeDirectory,
98
105
  getConfiguredProviderIds,
@@ -125,6 +132,7 @@ export function wireShellUiOpeners(options: WireShellUiOpenersOptions): void {
125
132
  if (target === 'helper') return String(configManager.get('helper.globalModel') || runtime.model);
126
133
  if (target === 'tool') return String(configManager.get('tools.llmModel') || runtime.model);
127
134
  if (target === 'tts') return String(configManager.get('tts.llmModel') || runtime.model);
135
+ if (target === 'embeddings') return memoryEmbeddingRegistry.getDefaultProviderId();
128
136
  return runtime.model;
129
137
  };
130
138
 
@@ -134,10 +142,29 @@ export function wireShellUiOpeners(options: WireShellUiOpenersOptions): void {
134
142
  if (target === 'helper') return String(configManager.get('helper.globalProvider') || runtime.provider);
135
143
  if (target === 'tool') return String(configManager.get('tools.llmProvider') || runtime.provider);
136
144
  if (target === 'tts') return String(configManager.get('tts.llmProvider') || runtime.provider);
145
+ if (target === 'embeddings') return memoryEmbeddingRegistry.getDefaultProviderId();
137
146
  return runtime.provider;
138
147
  };
139
148
 
140
- const buildModelPickerTargets = (): ModelPickerTargetInfo[] => {
149
+ /**
150
+ * Fetch the embedding-provider status list once per picker-open and shape
151
+ * it into the picker's own tiny item type. Uses the registry's async
152
+ * `.status()` (not the sync `.list()`) specifically because `.list()` does
153
+ * not carry `configured` — and unconfigured providers must be shown
154
+ * honestly, not hidden.
155
+ */
156
+ async function resolveEmbeddingProviderEntries(): Promise<EmbeddingProviderPickerEntry[]> {
157
+ const statuses = await memoryEmbeddingRegistry.status();
158
+ return statuses.map((status) => ({
159
+ id: status.id,
160
+ label: status.label,
161
+ dimensions: status.dimensions,
162
+ configured: status.configured,
163
+ ...(status.detail ? { detail: status.detail } : {}),
164
+ }));
165
+ }
166
+
167
+ const buildModelPickerTargets = (embeddingEntries: EmbeddingProviderPickerEntry[]): ModelPickerTargetInfo[] => {
141
168
  const mainProvider = getProviderIdFromModel(configManager.get('provider.model') || runtime.provider).trim();
142
169
  const mainModel = String(configManager.get('provider.model') || runtime.model || '').trim();
143
170
  const helperProvider = String(configManager.get('helper.globalProvider') ?? '').trim();
@@ -146,6 +173,11 @@ export function wireShellUiOpeners(options: WireShellUiOpenersOptions): void {
146
173
  const toolModel = String(configManager.get('tools.llmModel') ?? '').trim();
147
174
  const ttsProvider = String(configManager.get('tts.llmProvider') ?? '').trim();
148
175
  const ttsModel = String(configManager.get('tts.llmModel') ?? '').trim();
176
+ const embeddingProviderId = memoryEmbeddingRegistry.getDefaultProviderId();
177
+ const embeddingEntry = embeddingEntries.find((entry) => entry.id === embeddingProviderId);
178
+ const embeddingNote = embeddingEntry
179
+ ? `${embeddingEntry.id} · ${embeddingEntry.dimensions}d${embeddingEntry.configured ? '' : ' · unconfigured'}`
180
+ : `${embeddingProviderId} · unregistered`;
149
181
 
150
182
  return [
151
183
  {
@@ -184,12 +216,28 @@ export function wireShellUiOpeners(options: WireShellUiOpenersOptions): void {
184
216
  enabled: true,
185
217
  inherited: ttsProvider.length === 0 && ttsModel.length === 0,
186
218
  },
219
+ {
220
+ target: 'embeddings',
221
+ label: 'Embeddings',
222
+ description: 'Embedding provider used for memory search and the code index. Not an LLM route — no model concept.',
223
+ provider: embeddingProviderId,
224
+ model: '',
225
+ enabled: true,
226
+ inherited: false,
227
+ configuredNote: embeddingNote,
228
+ },
187
229
  ];
188
230
  };
189
231
 
190
232
  commandContext.openModelPicker = () => {
191
233
  void (async () => {
192
- const models = providerRegistry.getSelectableModels();
234
+ // D5: getSelectableModels() is catalog-driven and can list models whose
235
+ // `provider` id (e.g. 'google', sourced from the pricing catalog) was never
236
+ // handed to providerRegistry.register()/registerRuntimeProvider(). Selecting
237
+ // such a model fails hard at turn time with ProviderNotFoundError
238
+ // ("Provider 'X' is not registered."). Filter to runtime-registered
239
+ // providers only, so the picker never offers a model that cannot work.
240
+ const models = providerRegistry.getSelectableModels().filter((m) => providerRegistry.has(m.provider));
193
241
  const configuredIds = new Set(getConfiguredProviderIds());
194
242
  input.modelPicker.configuredProviders = configuredIds;
195
243
  const providerIds = [...new Set(models.map((m) => m.provider))];
@@ -199,8 +247,10 @@ export function wireShellUiOpeners(options: WireShellUiOpenersOptions): void {
199
247
  input.modelPicker.pinnedIds = new Set(pinned);
200
248
  });
201
249
  void input.modelPicker.loadRecentModels().catch(() => {}); // best-effort: prefetch for UI, failure is non-visible
250
+ const embeddingEntries = await resolveEmbeddingProviderEntries();
251
+ input.modelPicker.embeddingProviders = embeddingEntries;
202
252
  input.modalOpened('modelPicker');
203
- input.modelPicker.setTargetInfos(buildModelPickerTargets());
253
+ input.modelPicker.setTargetInfos(buildModelPickerTargets(embeddingEntries));
204
254
  input.modelPicker.openAllModels(models, getCurrentModelForPickerTarget());
205
255
  render();
206
256
  })().catch((error: unknown) => {
@@ -214,13 +264,21 @@ export function wireShellUiOpeners(options: WireShellUiOpenersOptions): void {
214
264
 
215
265
  commandContext.openProviderPicker = () => {
216
266
  void (async () => {
217
- const providers = [...new Set(providerRegistry.listModels().map((model) => model.provider))];
267
+ // D5: listModels() surfaces every catalog provider id, not just the ones
268
+ // actually registered on this runtime (see openModelPicker above for the
269
+ // same class of bug). Intersect against providerRegistry.has() so the
270
+ // provider picker never offers a provider that will fail with
271
+ // "Provider not registered" once a model under it is selected.
272
+ const providers = [...new Set(providerRegistry.listModels().map((model) => model.provider))]
273
+ .filter((providerId) => providerRegistry.has(providerId));
218
274
  const configuredIds = new Set(getConfiguredProviderIds());
219
275
  input.modelPicker.configuredProviders = configuredIds;
220
276
  const secretProviderIds = await resolveSecretProviderIds();
221
277
  input.modelPicker.configuredViaMap = buildConfiguredViaMap(providers, configuredIds, subscriptionManager, secretProviderIds);
278
+ const embeddingEntries = await resolveEmbeddingProviderEntries();
279
+ input.modelPicker.embeddingProviders = embeddingEntries;
222
280
  input.modalOpened('modelPicker');
223
- input.modelPicker.setTargetInfos(buildModelPickerTargets());
281
+ input.modelPicker.setTargetInfos(buildModelPickerTargets(embeddingEntries));
224
282
  input.modelPicker.openProviders(providers, getCurrentProviderForPickerTarget());
225
283
  render();
226
284
  })().catch((error: unknown) => {
@@ -229,6 +287,15 @@ export function wireShellUiOpeners(options: WireShellUiOpenersOptions): void {
229
287
  });
230
288
  };
231
289
 
290
+ commandContext.completeEmbeddingProviderSelection = (providerId: string) => {
291
+ try {
292
+ memoryEmbeddingRegistry.setDefaultProvider(providerId);
293
+ } catch (error: unknown) {
294
+ commandContext.print?.(`Failed to set embedding provider: ${summarizeError(error)}`);
295
+ }
296
+ render();
297
+ };
298
+
232
299
  commandContext.openSelection = (title, items, opts, callback) => {
233
300
  input.openSelection(title, items, opts, callback);
234
301
  };
@@ -272,15 +339,69 @@ export function wireShellUiOpeners(options: WireShellUiOpenersOptions): void {
272
339
  commandContext.openSettingsModal = (target?: string) => {
273
340
  input.modalOpened('settings');
274
341
  input.settingsModal.open(configManager, featureFlags, subscriptionManager, serviceRegistry, mcpRegistry, secretsManager, {
275
- onSettingApplied: (change) => syncServiceSettingToPlatform(
276
- { configManager, workingDirectory, homeDirectory },
277
- change,
278
- ),
342
+ onSettingApplied: (change) => {
343
+ // DEBT-2: forced dark/light applies immediately (rebuild palettes + full
344
+ // repaint); auto only re-probes at startup, so it takes effect next launch.
345
+ if (String(change.key) === THEME_MODE_CONFIG_KEY) {
346
+ const next = coerceThemeModeSetting(change.value);
347
+ if (next === 'dark' || next === 'light') {
348
+ setActiveThemeMode(next);
349
+ commandContext.requestFullRepaint?.();
350
+ return { message: `Theme mode: ${next} (applied now)` };
351
+ }
352
+ return { message: 'Theme mode: auto (probes terminal on next startup)' };
353
+ }
354
+ return syncServiceSettingToPlatform(
355
+ { configManager, workingDirectory, homeDirectory },
356
+ change,
357
+ );
358
+ },
279
359
  });
280
360
  input.settingsModal.selectTarget(target);
281
361
  render();
282
362
  };
283
363
 
364
+ // W6.1 (the purge): open a MIGRATE-TO-MODAL surface by name. Fed from both
365
+ // ctx.openModal(name) (migrated command front-doors) and PanelManager's
366
+ // open()-time modal-redirect callback (a legacy panel id resolving to a modal
367
+ // name). Surfaces are registered in builtin-modals.ts; a name with no
368
+ // registered surface degrades honestly to a print rather than a blank modal.
369
+ // The stack name is the stable 'config' slot (one config modal at a time —
370
+ // opening another swaps the surface), so Esc close/return and the modal-stack
371
+ // machinery need only the single 'config' case (handler-ui-state.ts).
372
+ // Some panel-id redirects (and migrated front-doors) resolve to a NATIVE
373
+ // modal that is not a ConfigModalSurface — e.g. `sessions` -> `sessionPicker`,
374
+ // where 'sessionPicker' is the real session-picker modal opened by
375
+ // commandContext.openSessionPicker below, NOT a registered config surface.
376
+ // getModalSurface can never find these, so consult this small name->opener
377
+ // dispatch FIRST. Each entry reuses the same opener the front-door command
378
+ // uses (no duplicated open logic); resolved lazily so wiring order does not
379
+ // matter. Any name that is neither a native modal nor a registered surface
380
+ // still falls through to the honest "not available" print below.
381
+ const nativeModalOpeners: Record<string, () => void> = {
382
+ // openSessionPicker is wired below in this same function; call it lazily
383
+ // (optional-chained for the type, always present at invoke time).
384
+ sessionPicker: () => commandContext.openSessionPicker?.(),
385
+ };
386
+
387
+ commandContext.openModal = (name: string) => {
388
+ const openNative = nativeModalOpeners[name];
389
+ if (openNative) {
390
+ openNative();
391
+ return;
392
+ }
393
+ const surface = panelManager.getModalSurface(name);
394
+ if (!surface) {
395
+ commandContext.print(`'${name}' is not available yet in this build.`);
396
+ render();
397
+ return;
398
+ }
399
+ input.modalOpened('config');
400
+ input.configModal.open(surface, render);
401
+ render();
402
+ };
403
+ panelManager.setOpenModalCallback(commandContext.openModal);
404
+
284
405
  commandContext.openMcpWorkspace = () => {
285
406
  input.openMcpWorkspace(commandContext);
286
407
  render();
@@ -296,25 +417,43 @@ export function wireShellUiOpeners(options: WireShellUiOpenersOptions): void {
296
417
  // Focus ownership lives in PanelManager (focusTarget); read it there rather
297
418
  // than tracking a parallel input.panelFocused flag. Toggle semantics: if the
298
419
  // workspace is visible AND already focused, hide it; otherwise reveal and
299
- // focus it (opening the panel list when nothing is open yet).
420
+ // focus it (opening a picker when nothing is open yet).
300
421
  if (panelManager.isVisible() && panelManager.getFocusTarget() === 'panel') {
301
422
  panelManager.hide();
302
423
  panelManager.focusPrompt();
303
424
  conversation.setSplashSuppressed(false);
304
425
  conversation.rebuildHistory();
305
- } else {
306
- if (panelManager.getAllOpen().length === 0) {
307
- try {
308
- panelManager.open('panel-list');
309
- } catch {
310
- // non-fatal
311
- }
312
- }
313
- panelManager.show();
314
- panelManager.focusPanels();
315
- conversation.setSplashSuppressed(true);
316
- conversation.rebuildHistory();
426
+ render();
427
+ return;
428
+ }
429
+ if (panelManager.getAllOpen().length === 0) {
430
+ // W6.1 (the purge): 'panel-list' (a browse-all-panels picker PANEL)
431
+ // was DELETE-disposition — a picker over a handful of panels is dead
432
+ // weight now. Its replacement is this selection MODAL, built from the
433
+ // live registry (PanelManager.getRegisteredTypes()) rather than a
434
+ // hardcoded list, so it can never list a retired/deleted id.
435
+ const items: SelectionItem[] = panelManager.getRegisteredTypes().map((entry) => ({
436
+ id: entry.id,
437
+ label: `${entry.icon} ${entry.name}`,
438
+ detail: entry.description,
439
+ category: entry.category,
440
+ primaryAction: 'select',
441
+ }));
442
+ commandContext.openSelection?.('Open Panel', items, { allowSearch: true }, (result) => {
443
+ if (!result) return;
444
+ panelManager.open(result.item.id);
445
+ panelManager.show();
446
+ panelManager.focusPanels();
447
+ conversation.setSplashSuppressed(true);
448
+ conversation.rebuildHistory();
449
+ render();
450
+ });
451
+ return;
317
452
  }
453
+ panelManager.show();
454
+ panelManager.focusPanels();
455
+ conversation.setSplashSuppressed(true);
456
+ conversation.rebuildHistory();
318
457
  render();
319
458
  };
320
459
 
@@ -329,10 +468,16 @@ export function wireShellUiOpeners(options: WireShellUiOpenersOptions): void {
329
468
  render();
330
469
  };
331
470
 
332
- commandContext.showPanel = (panelId, pane) => {
333
- panelManager.open(panelId, pane);
471
+ commandContext.showPanel = (panelId, pane, target, opts) => {
472
+ // DEBT-5: forward the deep-link target so a jumped-to panel lands on the
473
+ // right row (fleet --target); undefined for ordinary opens.
474
+ panelManager.open(panelId, pane, target);
334
475
  panelManager.show();
335
- panelManager.focusPanels();
476
+ // UX-C focus rule 1a: every registered caller of showPanel is a slash
477
+ // command (/panel open, /routes, /approval, /tasks, /ops-control, ...) —
478
+ // the command path leaves focus in the composer ("the user is
479
+ // mid-command-flow") unless the caller explicitly asks to grab it.
480
+ if (opts?.focus) panelManager.focusPanels();
336
481
  conversation.setSplashSuppressed(true);
337
482
  conversation.rebuildHistory();
338
483
  render();
@@ -1,16 +1,21 @@
1
1
  /**
2
2
  * Shared ms-duration formatters for the panel layer.
3
3
  *
4
- * Three families are exported, each matching a distinct behavior cluster.
4
+ * Two families are exported, each matching a distinct behavior cluster.
5
5
  * DO NOT replace these with formatElapsed from utils/format-elapsed.ts —
6
6
  * formatElapsed floors to integer seconds and lacks the null/'?ms' guards
7
7
  * that the latency panels require.
8
+ *
9
+ * W6.1 (the purge): formatShortDuration (used only by incident-review-panel
10
+ * and eval-panel, both removed — RETIRE-INTO-FLEET and DELETE respectively)
11
+ * was removed as a genuinely orphaned export — no remaining caller anywhere
12
+ * in src/.
8
13
  */
9
14
 
10
15
  /**
11
16
  * Format a latency value in milliseconds with sub-second precision.
12
17
  *
13
- * Used by: debug-panel, provider-health-panel
18
+ * Used by: provider-health-panel
14
19
  *
15
20
  * ms <= 0 → 'n/a'
16
21
  * ms >= 10000 → '12.3s' (one decimal)
@@ -27,7 +32,7 @@ export function formatLatencyMs(ms: number): string {
27
32
  /**
28
33
  * Format a task/agent duration with minute-rolling notation.
29
34
  *
30
- * Used by: tool-inspector-panel, agent-inspector-shared
35
+ * Used by: agent-inspector-shared
31
36
  *
32
37
  * ms < 1000 → '500ms'
33
38
  * ms < 60000 → '3.5s'
@@ -38,18 +43,3 @@ export function formatDuration(ms: number): string {
38
43
  if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
39
44
  return `${Math.floor(ms / 60000)}m${Math.floor((ms % 60000) / 1000)}s`;
40
45
  }
41
-
42
- /**
43
- * Format a short eval/forensics duration; treats undefined as '?ms'.
44
- *
45
- * Used by: incident-review-panel, eval-panel
46
- *
47
- * undefined → '?ms'
48
- * ms < 1000 → '500ms'
49
- * else → '1.5s'
50
- */
51
- export function formatShortDuration(ms: number | undefined): string {
52
- if (ms === undefined) return '?ms';
53
- if (ms < 1000) return `${ms}ms`;
54
- return `${(ms / 1000).toFixed(1)}s`;
55
- }