@pi-unipi/unipi 3.0.0-alpha.2 → 3.0.0-alpha.21

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 (359) hide show
  1. package/CHANGELOG.md +292 -0
  2. package/README.md +8 -11
  3. package/docs/prefix-cache-architecture.md +14 -0
  4. package/package.json +22 -24
  5. package/packages/ask-user/README.md +55 -107
  6. package/packages/ask-user/ask-ui.ts +306 -732
  7. package/packages/ask-user/clipboard.ts +50 -0
  8. package/packages/ask-user/config.ts +74 -78
  9. package/packages/ask-user/package.json +2 -2
  10. package/packages/ask-user/questions.ts +151 -0
  11. package/packages/ask-user/skills/ask-user/SKILL.md +59 -133
  12. package/packages/ask-user/tools.ts +198 -432
  13. package/packages/autocomplete/package.json +1 -1
  14. package/packages/autocomplete/src/constants.ts +38 -43
  15. package/packages/autocomplete/src/sorting.ts +8 -0
  16. package/packages/background-tasks/package.json +2 -2
  17. package/packages/background-tasks/src/config.ts +0 -73
  18. package/packages/background-tasks/src/index.ts +20 -16
  19. package/packages/background-tasks/src/registry.ts +1 -171
  20. package/packages/background-tasks/src/types.ts +0 -32
  21. package/packages/btw/README.md +13 -56
  22. package/packages/btw/extensions/btw.ts +403 -1796
  23. package/packages/btw/package.json +5 -2
  24. package/packages/btw/skills/btw/SKILL.md +7 -143
  25. package/packages/compactor/README.md +56 -287
  26. package/packages/compactor/package.json +5 -9
  27. package/packages/compactor/skills/compactor/SKILL.md +13 -20
  28. package/packages/compactor/src/card.ts +90 -0
  29. package/packages/compactor/src/commands/index.ts +73 -130
  30. package/packages/compactor/src/compaction/compact-args.ts +5 -3
  31. package/packages/compactor/src/compaction/filter-noise.ts +1 -1
  32. package/packages/compactor/src/compaction/hooks.ts +487 -311
  33. package/packages/compactor/src/compaction/source.ts +178 -0
  34. package/packages/compactor/src/compaction/summarize.ts +586 -52
  35. package/packages/compactor/src/config/manager.ts +130 -127
  36. package/packages/compactor/src/config/schema.ts +32 -48
  37. package/packages/compactor/src/index.ts +40 -431
  38. package/packages/compactor/src/info-screen.ts +44 -190
  39. package/packages/compactor/src/session/recall-blocks.ts +1 -1
  40. package/packages/compactor/src/stats.ts +76 -0
  41. package/packages/compactor/src/tools/context-budget.ts +17 -34
  42. package/packages/compactor/src/tools/ctx-doctor.ts +45 -36
  43. package/packages/compactor/src/tools/register.ts +29 -269
  44. package/packages/compactor/src/types.ts +47 -186
  45. package/packages/core/README.md +25 -0
  46. package/packages/core/child-agent.ts +1 -0
  47. package/packages/core/command-echo.ts +63 -0
  48. package/packages/core/compaction-context.ts +41 -0
  49. package/packages/core/constants.ts +8 -38
  50. package/packages/core/events.ts +0 -53
  51. package/packages/core/fusion-status.ts +4 -0
  52. package/packages/core/global.d.ts +10 -0
  53. package/packages/core/harness-messages.ts +392 -0
  54. package/packages/core/index.ts +14 -0
  55. package/packages/core/kanboard-status.ts +49 -0
  56. package/packages/core/long-horizon-owner-status.ts +46 -0
  57. package/packages/core/model-cache.ts +81 -7
  58. package/packages/core/package.json +6 -3
  59. package/packages/core/src/attach/detect.ts +163 -0
  60. package/packages/core/src/child-agent/child-args.ts +31 -0
  61. package/packages/core/src/child-agent/delivery.ts +41 -0
  62. package/packages/core/src/child-agent/index.ts +5 -0
  63. package/packages/core/src/child-agent/readers.ts +203 -0
  64. package/packages/core/src/child-agent/runtime.ts +584 -0
  65. package/packages/core/src/evidence.ts +78 -0
  66. package/packages/core/src/hints/browser.ts +278 -0
  67. package/packages/core/src/hints/crab-data.ts +19 -0
  68. package/packages/core/src/hints/index.ts +865 -0
  69. package/packages/core/src/hints/lines.ts +607 -0
  70. package/packages/core/src/hints/store.ts +120 -0
  71. package/packages/core/src/jev/chatter.ts +24 -0
  72. package/packages/core/src/jev/settings.ts +177 -14
  73. package/packages/core/src/package-colors.ts +2 -0
  74. package/packages/core/src/settings/catalog.ts +39 -15
  75. package/packages/core/src/settings/engine.ts +48 -2
  76. package/packages/core/src/settings/hub.ts +83 -20
  77. package/packages/core/src/settings/open.ts +49 -0
  78. package/packages/core/src/settings/schema.ts +27 -7
  79. package/packages/core/src/tui/hub-kit.ts +18 -5
  80. package/packages/core/src/tui/kit.ts +198 -0
  81. package/packages/core/src/tui/progress.ts +73 -0
  82. package/packages/core/src/turn/arbiter.ts +290 -0
  83. package/packages/footer/README.md +4 -6
  84. package/packages/footer/package.json +3 -3
  85. package/packages/footer/src/glance-editor.ts +19 -11
  86. package/packages/footer/src/help.ts +1 -1
  87. package/packages/footer/src/index.ts +36 -3
  88. package/packages/footer/src/presets.ts +3 -4
  89. package/packages/footer/src/rendering/icons.ts +0 -20
  90. package/packages/footer/src/segments/compactor.ts +52 -142
  91. package/packages/footer/src/segments/status-ext.ts +0 -1
  92. package/packages/fusion/README.md +14 -4
  93. package/packages/fusion/package.json +4 -3
  94. package/packages/fusion/src/child-args.ts +1 -0
  95. package/packages/fusion/src/index.ts +161 -8
  96. package/packages/fusion/src/pi-settings.ts +57 -0
  97. package/packages/fusion/src/picker.ts +186 -51
  98. package/packages/fusion/src/preset.ts +56 -2
  99. package/packages/fusion/src/prompts.ts +1 -1
  100. package/packages/fusion/src/sidekick-runtime.ts +1 -420
  101. package/packages/fusion/src/tools.ts +107 -128
  102. package/packages/fusion/src/transcript.ts +27 -85
  103. package/packages/info-screen/package.json +2 -2
  104. package/packages/input-shortcuts/README.md +18 -30
  105. package/packages/input-shortcuts/package.json +2 -2
  106. package/packages/input-shortcuts/src/burst.ts +88 -0
  107. package/packages/input-shortcuts/src/chord-overlay.ts +20 -70
  108. package/packages/input-shortcuts/src/cleared-input.ts +97 -0
  109. package/packages/input-shortcuts/src/index.ts +99 -193
  110. package/packages/input-shortcuts/src/last-response.ts +40 -0
  111. package/packages/input-shortcuts/src/registers.ts +8 -23
  112. package/packages/input-shortcuts/src/types.ts +4 -16
  113. package/packages/input-shortcuts/src/undo-redo.ts +59 -47
  114. package/packages/kanboard/README.md +51 -47
  115. package/packages/kanboard/index.ts +293 -55
  116. package/packages/kanboard/package.json +8 -8
  117. package/packages/kanboard/skills/kanboard/SKILL.md +61 -27
  118. package/packages/kanboard/src/badges.ts +93 -0
  119. package/packages/kanboard/src/bin.ts +4 -2
  120. package/packages/kanboard/src/commands.ts +218 -84
  121. package/packages/kanboard/src/debug.ts +20 -0
  122. package/packages/kanboard/src/guard.ts +267 -109
  123. package/packages/kanboard/src/monitor.ts +330 -0
  124. package/packages/kanboard/src/notice-buffer.ts +74 -0
  125. package/packages/kanboard/src/progress.ts +27 -0
  126. package/packages/kanboard/src/reminders.ts +234 -0
  127. package/packages/kanboard/src/settings.ts +69 -15
  128. package/packages/kanboard/src/shapes.ts +5 -0
  129. package/packages/long-horizon/index.ts +190 -13
  130. package/packages/long-horizon/package.json +3 -3
  131. package/packages/long-horizon/src/commands.ts +103 -7
  132. package/packages/long-horizon/src/compaction-brief.ts +35 -0
  133. package/packages/long-horizon/src/engine/continuation.ts +84 -15
  134. package/packages/long-horizon/src/engine/goal-state.ts +64 -13
  135. package/packages/long-horizon/src/engine/nudge-stash.ts +71 -0
  136. package/packages/long-horizon/src/engine/ralph.ts +3 -2
  137. package/packages/long-horizon/src/engine/runaway.ts +68 -11
  138. package/packages/long-horizon/src/engine/verifier.ts +19 -0
  139. package/packages/long-horizon/src/gate.ts +37 -12
  140. package/packages/long-horizon/src/judge/resolve.ts +1 -0
  141. package/packages/long-horizon/src/owner.ts +35 -2
  142. package/packages/long-horizon/src/progress.ts +87 -0
  143. package/packages/long-horizon/src/prompts/goal.ts +1 -1
  144. package/packages/long-horizon/src/runtime.ts +172 -18
  145. package/packages/long-horizon/src/settings.ts +54 -39
  146. package/packages/long-horizon/src/tools/goal.ts +6 -0
  147. package/packages/long-horizon/src/tools/graph.ts +1 -1
  148. package/packages/long-horizon/src/tools/swarm.ts +3 -3
  149. package/packages/mcp/package.json +2 -2
  150. package/packages/memory/commands.ts +7 -2
  151. package/packages/memory/index.ts +210 -32
  152. package/packages/memory/package.json +3 -3
  153. package/packages/memory/save-session.ts +264 -0
  154. package/packages/memory/settings.ts +17 -0
  155. package/packages/memory/tools.ts +253 -203
  156. package/packages/notify/package.json +2 -2
  157. package/packages/skill-registry/README.md +53 -16
  158. package/packages/skill-registry/index.ts +230 -0
  159. package/packages/skill-registry/package.json +19 -8
  160. package/packages/skill-registry/src/editor.ts +206 -0
  161. package/packages/skill-registry/src/judge.ts +289 -0
  162. package/packages/skill-registry/src/registry.ts +87 -0
  163. package/packages/skill-registry/src/settings.ts +259 -0
  164. package/packages/skill-registry/src/vault.ts +80 -0
  165. package/packages/subagents/README.md +39 -119
  166. package/packages/subagents/package.json +6 -13
  167. package/packages/subagents/skills/subagents/SKILL.md +18 -42
  168. package/packages/subagents/src/agents.ts +213 -0
  169. package/packages/subagents/src/cards.ts +182 -0
  170. package/packages/subagents/src/index.ts +609 -1084
  171. package/packages/subagents/src/manager.ts +429 -0
  172. package/packages/subagents/src/profiles.ts +130 -0
  173. package/packages/subagents/src/transcript.ts +154 -0
  174. package/packages/subagents/src/ui.ts +389 -0
  175. package/packages/unipi/bundled.js +39756 -45569
  176. package/packages/unipi/index.ts +55 -10
  177. package/packages/updater/package.json +2 -2
  178. package/packages/utility/README.md +76 -69
  179. package/packages/utility/package.json +2 -2
  180. package/packages/utility/skills/image/SKILL.md +37 -0
  181. package/packages/utility/skills/utility/SKILL.md +17 -60
  182. package/packages/utility/src/answer/extract.ts +128 -0
  183. package/packages/utility/src/answer/index.ts +213 -0
  184. package/packages/utility/src/answer/reply.ts +133 -0
  185. package/packages/utility/src/answer/web.ts +167 -0
  186. package/packages/utility/src/attach/index.ts +205 -0
  187. package/packages/utility/src/commands.ts +38 -142
  188. package/packages/utility/src/diagnostics/engine.ts +50 -5
  189. package/packages/utility/src/image/index.ts +33 -0
  190. package/packages/utility/src/image/models.ts +221 -0
  191. package/packages/{image/src → utility/src/image}/recognize.ts +2 -2
  192. package/packages/utility/src/image/settings.ts +178 -0
  193. package/packages/{image/src/image-source.ts → utility/src/image/source.ts} +1 -1
  194. package/packages/utility/src/image/tools.ts +168 -0
  195. package/packages/utility/src/image/transports.ts +186 -0
  196. package/packages/utility/src/index.ts +68 -389
  197. package/packages/utility/src/lifecycle/cleanup.ts +116 -311
  198. package/packages/utility/src/rename/gate.ts +102 -0
  199. package/packages/utility/src/rename/index.ts +191 -0
  200. package/packages/utility/src/rename/session.ts +170 -0
  201. package/packages/utility/src/render/delegated.ts +285 -0
  202. package/packages/utility/src/render/harness.ts +683 -0
  203. package/packages/utility/src/render/parse.ts +156 -0
  204. package/packages/utility/src/render/pi-settings.ts +38 -0
  205. package/packages/utility/src/render/reply-bg.ts +307 -0
  206. package/packages/utility/src/render/simple.ts +724 -0
  207. package/packages/utility/src/render/spacing.ts +235 -0
  208. package/packages/utility/src/render/styled.ts +251 -0
  209. package/packages/utility/src/render/tools.ts +353 -0
  210. package/packages/utility/src/settings.ts +166 -233
  211. package/packages/utility/src/types.ts +1 -222
  212. package/packages/watchdog/index.ts +17 -6
  213. package/packages/watchdog/package.json +3 -3
  214. package/packages/watchdog/src/config.ts +3 -2
  215. package/packages/web-api/package.json +2 -2
  216. package/packages/workflow/README.md +3 -0
  217. package/packages/workflow/index.ts +0 -2
  218. package/packages/workflow/package.json +2 -2
  219. package/packages/workflow/src/permission/decide.ts +6 -4
  220. package/packages/workflow/src/permission/index.ts +17 -7
  221. package/packages/workflow/src/permission/settings.ts +39 -4
  222. package/packages/workflow/src/plan/index.ts +19 -4
  223. package/packages/background-tasks/extensions/delegate-child.ts +0 -1
  224. package/packages/background-tasks/src/context-parent-snapshot.ts +0 -142
  225. package/packages/background-tasks/src/context-token-budget.ts +0 -903
  226. package/packages/background-tasks/src/context-visible-conversation-v2.ts +0 -551
  227. package/packages/background-tasks/src/delegate/artifacts.ts +0 -487
  228. package/packages/background-tasks/src/delegate/budget.ts +0 -415
  229. package/packages/background-tasks/src/delegate/hook-contract-evidence.json +0 -18
  230. package/packages/background-tasks/src/delegate/hook-contract.ts +0 -154
  231. package/packages/background-tasks/src/delegate/launch.ts +0 -484
  232. package/packages/background-tasks/src/delegate/result-package.ts +0 -459
  233. package/packages/background-tasks/src/delegate/runner.ts +0 -430
  234. package/packages/background-tasks/src/delegate/seed.ts +0 -423
  235. package/packages/background-tasks/src/delegate/types.ts +0 -323
  236. package/packages/background-tasks/src/delegate-child-extension.ts +0 -978
  237. package/packages/background-tasks/src/delegate-extension.ts +0 -654
  238. package/packages/background-tasks/src/fixtures/delegate-context-incident.json +0 -17
  239. package/packages/compactor/skills/compactor-detail/SKILL.md +0 -116
  240. package/packages/compactor/skills/compactor-doctor/SKILL.md +0 -71
  241. package/packages/compactor/skills/compactor-stats/SKILL.md +0 -51
  242. package/packages/compactor/src/compaction/build-sections.ts +0 -76
  243. package/packages/compactor/src/compaction/extract/goals.ts +0 -36
  244. package/packages/compactor/src/compaction/extract/preferences.ts +0 -40
  245. package/packages/compactor/src/compaction/format.ts +0 -53
  246. package/packages/compactor/src/compaction/merge.ts +0 -143
  247. package/packages/compactor/src/config/presets.ts +0 -160
  248. package/packages/compactor/src/executor/executor.ts +0 -233
  249. package/packages/compactor/src/executor/runtime.ts +0 -115
  250. package/packages/compactor/src/security/evaluator.ts +0 -89
  251. package/packages/compactor/src/security/policy.ts +0 -96
  252. package/packages/compactor/src/security/scanner.ts +0 -65
  253. package/packages/compactor/src/session/auto-inject.ts +0 -60
  254. package/packages/compactor/src/session/db.ts +0 -325
  255. package/packages/compactor/src/session/extract.ts +0 -144
  256. package/packages/compactor/src/session/resume-inject.ts +0 -64
  257. package/packages/compactor/src/session/snapshot.ts +0 -327
  258. package/packages/compactor/src/tools/ctx-batch-execute.ts +0 -41
  259. package/packages/compactor/src/tools/ctx-execute-file.ts +0 -23
  260. package/packages/compactor/src/tools/ctx-execute.ts +0 -23
  261. package/packages/compactor/src/tools/ctx-stats.ts +0 -96
  262. package/packages/image/README.md +0 -99
  263. package/packages/image/package.json +0 -61
  264. package/packages/image/skills/image/SKILL.md +0 -76
  265. package/packages/image/src/generate.ts +0 -256
  266. package/packages/image/src/index.ts +0 -135
  267. package/packages/image/src/models.ts +0 -519
  268. package/packages/image/src/openai-images-api.ts +0 -282
  269. package/packages/image/src/register-providers.ts +0 -209
  270. package/packages/image/src/settings.ts +0 -173
  271. package/packages/image/src/tools.ts +0 -326
  272. package/packages/input-shortcuts/src/clipboard.ts +0 -119
  273. package/packages/kanboard/src/runner.ts +0 -817
  274. package/packages/skill-registry/skills/auto/SKILL.md +0 -465
  275. package/packages/skill-registry/skills/brainstorm/SKILL.md +0 -219
  276. package/packages/skill-registry/skills/chore-create/SKILL.md +0 -315
  277. package/packages/skill-registry/skills/chore-execute/SKILL.md +0 -314
  278. package/packages/skill-registry/skills/consolidate/SKILL.md +0 -142
  279. package/packages/skill-registry/skills/consultant/SKILL.md +0 -97
  280. package/packages/skill-registry/skills/debug/SKILL.md +0 -228
  281. package/packages/skill-registry/skills/document/SKILL.md +0 -120
  282. package/packages/skill-registry/skills/fix/SKILL.md +0 -212
  283. package/packages/skill-registry/skills/gather-context/SKILL.md +0 -145
  284. package/packages/skill-registry/skills/plan/SKILL.md +0 -231
  285. package/packages/skill-registry/skills/quick-fix/SKILL.md +0 -135
  286. package/packages/skill-registry/skills/quick-work/SKILL.md +0 -110
  287. package/packages/skill-registry/skills/research/SKILL.md +0 -251
  288. package/packages/skill-registry/skills/review-work/SKILL.md +0 -187
  289. package/packages/skill-registry/skills/scan-issues/SKILL.md +0 -195
  290. package/packages/skill-registry/skills/work/SKILL.md +0 -218
  291. package/packages/skill-registry/skills/worktree-create/SKILL.md +0 -69
  292. package/packages/skill-registry/skills/worktree-list/SKILL.md +0 -78
  293. package/packages/skill-registry/skills/worktree-merge/SKILL.md +0 -89
  294. package/packages/subagents/agents/delegate.md +0 -14
  295. package/packages/subagents/agents/oracle.md +0 -78
  296. package/packages/subagents/agents/researcher.md +0 -52
  297. package/packages/subagents/agents/reviewer.md +0 -80
  298. package/packages/subagents/agents/scout.md +0 -50
  299. package/packages/subagents/agents/worker.md +0 -59
  300. package/packages/subagents/prompts/council.md +0 -48
  301. package/packages/subagents/prompts/gather-context-and-clarify.md +0 -13
  302. package/packages/subagents/prompts/parallel-cleanup.md +0 -59
  303. package/packages/subagents/prompts/parallel-research.md +0 -50
  304. package/packages/subagents/prompts/parallel-review.md +0 -54
  305. package/packages/subagents/prompts/review-loop.md +0 -43
  306. package/packages/subagents/skills/council-mode/SKILL.md +0 -230
  307. package/packages/subagents/skills/subagents/references/constraints-and-recipes.md +0 -259
  308. package/packages/subagents/skills/subagents/references/execution-controls.md +0 -454
  309. package/packages/subagents/skills/subagents/references/management-authoring-rpc.md +0 -161
  310. package/packages/subagents/skills/subagents/references/multi-lane-orchestration.md +0 -39
  311. package/packages/subagents/skills/subagents/references/prompting-and-roles.md +0 -267
  312. package/packages/subagents/src/acceptance.ts +0 -331
  313. package/packages/subagents/src/agent-manager.ts +0 -405
  314. package/packages/subagents/src/agent-memory.ts +0 -300
  315. package/packages/subagents/src/agent-overrides.ts +0 -183
  316. package/packages/subagents/src/agent-runner.ts +0 -271
  317. package/packages/subagents/src/async-runner.ts +0 -422
  318. package/packages/subagents/src/authority-policy.ts +0 -47
  319. package/packages/subagents/src/budgets.ts +0 -285
  320. package/packages/subagents/src/child-safety.ts +0 -140
  321. package/packages/subagents/src/config.ts +0 -249
  322. package/packages/subagents/src/conversation-viewer.ts +0 -281
  323. package/packages/subagents/src/core-compat.ts +0 -94
  324. package/packages/subagents/src/custom-agents.ts +0 -343
  325. package/packages/subagents/src/file-system-retry.ts +0 -50
  326. package/packages/subagents/src/fleet-data.ts +0 -105
  327. package/packages/subagents/src/fleet-view.ts +0 -322
  328. package/packages/subagents/src/foreground-detach.ts +0 -59
  329. package/packages/subagents/src/fork-context.ts +0 -285
  330. package/packages/subagents/src/global.d.ts +0 -7
  331. package/packages/subagents/src/guide.ts +0 -129
  332. package/packages/subagents/src/mission-state.ts +0 -133
  333. package/packages/subagents/src/mission-store.ts +0 -436
  334. package/packages/subagents/src/output-limits.ts +0 -142
  335. package/packages/subagents/src/parity-types.ts +0 -315
  336. package/packages/subagents/src/pi-args.ts +0 -167
  337. package/packages/subagents/src/result-files.ts +0 -232
  338. package/packages/subagents/src/result-watcher.ts +0 -221
  339. package/packages/subagents/src/retained-children.ts +0 -176
  340. package/packages/subagents/src/run-fanout-budget.ts +0 -356
  341. package/packages/subagents/src/scheduled-runs.ts +0 -349
  342. package/packages/subagents/src/schemas.ts +0 -263
  343. package/packages/subagents/src/slash-commands.ts +0 -80
  344. package/packages/subagents/src/supervisor-channel.ts +0 -249
  345. package/packages/subagents/src/tool-handler.ts +0 -1144
  346. package/packages/subagents/src/types.ts +0 -238
  347. package/packages/subagents/src/widget.ts +0 -457
  348. package/packages/subagents/src/workflow-script.ts +0 -737
  349. package/packages/subagents/src/workflow-worker.ts +0 -384
  350. package/packages/subagents/src/worktree.ts +0 -614
  351. package/packages/utility/src/analytics/collector.ts +0 -293
  352. package/packages/utility/src/info-screen.ts +0 -80
  353. package/packages/utility/src/lifecycle/process.ts +0 -162
  354. package/packages/utility/src/prefix-cache.ts +0 -263
  355. package/packages/utility/src/skill-discovery.ts +0 -542
  356. package/packages/utility/src/tools/env.ts +0 -113
  357. package/packages/utility/src/tui/name-badge-state.ts +0 -315
  358. package/packages/utility/src/tui/name-badge.ts +0 -109
  359. /package/packages/{subagents/src → core/src/child-agent}/pi-spawn.ts +0 -0
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Persist pi's startup model into its global settings file
3
+ * ($PI_CODING_AGENT_DIR/settings.json, default ~/.pi/agent/settings.json) —
4
+ * the same keys pi's own `/model` ctrl+s writes
5
+ * (settings-manager.d.ts setDefaultModelAndProvider/setDefaultThinkingLevel).
6
+ * Atomic: write temp file + rename. Preserves every other key.
7
+ */
8
+
9
+ import { readFileSync, renameSync, writeFileSync } from "node:fs";
10
+ import { homedir } from "node:os";
11
+ import { join } from "node:path";
12
+
13
+ export function piSettingsPath(env: NodeJS.ProcessEnv = process.env, home = homedir()): string {
14
+ const envDir = env.PI_CODING_AGENT_DIR?.trim();
15
+ return join(envDir !== undefined && envDir.length > 0 ? envDir : join(home, ".pi", "agent"), "settings.json");
16
+ }
17
+
18
+ export function mergeDefaultModel(
19
+ raw: unknown,
20
+ patch: { provider: string; model: string; thinkingLevel?: string },
21
+ ): Record<string, unknown> {
22
+ const settings = typeof raw === "object" && raw !== null && !Array.isArray(raw) ? { ...(raw as Record<string, unknown>) } : {};
23
+ settings.defaultProvider = patch.provider;
24
+ settings.defaultModel = patch.model;
25
+ if (patch.thinkingLevel !== undefined) settings.defaultThinkingLevel = patch.thinkingLevel;
26
+ return settings;
27
+ }
28
+
29
+ /** pi's saved startup model (`provider/id`) and thinking level, if any. */
30
+ export function readDefaultModel(deps: { readFile?: (p: string) => string; env?: NodeJS.ProcessEnv } = {}): { key?: string; thinking?: string } {
31
+ try {
32
+ const path = piSettingsPath(deps.env);
33
+ const raw = JSON.parse(deps.readFile !== undefined ? deps.readFile(path) : readFileSync(path, "utf8")) as Record<string, unknown>;
34
+ const key = typeof raw.defaultProvider === "string" && typeof raw.defaultModel === "string" ? `${raw.defaultProvider}/${raw.defaultModel}` : undefined;
35
+ return { key, thinking: typeof raw.defaultThinkingLevel === "string" ? raw.defaultThinkingLevel : undefined };
36
+ } catch {
37
+ return {};
38
+ }
39
+ }
40
+
41
+ export function persistDefaultModel(
42
+ patch: { provider: string; model: string; thinkingLevel?: string },
43
+ deps: { readFile?: (p: string) => string; env?: NodeJS.ProcessEnv } = {},
44
+ ): string {
45
+ const path = piSettingsPath(deps.env);
46
+ let current: unknown = {};
47
+ try {
48
+ current = JSON.parse(deps.readFile !== undefined ? deps.readFile(path) : readFileSync(path, "utf8")) as unknown;
49
+ } catch {
50
+ current = {};
51
+ }
52
+ const next = mergeDefaultModel(current, patch);
53
+ const tmp = `${path}.tmp-${String(process.pid)}`;
54
+ writeFileSync(tmp, `${JSON.stringify(next, null, 2)}\n`, "utf8");
55
+ renameSync(tmp, path);
56
+ return path;
57
+ }
@@ -30,10 +30,11 @@
30
30
  *
31
31
  * On the Fusion row, Tab cycles effort → lead → sidekick (Shift+Tab
32
32
  * reverses); the lead/sidekick focus opens an inline dropdown fed by the
33
- * preset lists.
33
+ * preset lists. With the effort control focused, Space toggles whether ←/→
34
+ * adjusts the lead's or the sidekick's effort (default: lead).
34
35
  */
35
36
 
36
- import { Key, matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
37
+ import { Key, matchesKey, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
37
38
  import { frameOverlay } from "@pi-unipi/core";
38
39
  import {
39
40
  effortLabel,
@@ -67,6 +68,8 @@ export interface PickerState {
67
68
  active: ActiveSelection | undefined;
68
69
  /** The session's current model — its row lights up with ✓. */
69
70
  currentModelKey: ModelKey | undefined;
71
+ /** pi's persisted startup model — its row gets a dim `default` tag. */
72
+ defaultModelKey: ModelKey | undefined;
70
73
  effort: Readonly<Record<ModelKey, EffortLevel>>;
71
74
  /** Effort used for a model with no remembered level. */
72
75
  fallbackEffort: EffortLevel;
@@ -98,6 +101,8 @@ export interface PickerOptions {
98
101
  state: PickerState;
99
102
  theme: PickerTheme;
100
103
  onDone: (result: PickerResult) => void;
104
+ /** alt+enter: persist the highlighted row as the startup default (then apply). */
105
+ onSetDefault?: (result: PickerResult) => void;
101
106
  onRenderRequest?: (() => void) | undefined;
102
107
  /** Rows visible in the list window. */
103
108
  visibleRows?: number | undefined;
@@ -171,6 +176,7 @@ function pad(text: string, width: number): string {
171
176
  export class ModelPicker {
172
177
  private readonly theme: PickerTheme;
173
178
  private readonly onDone: (result: PickerResult) => void;
179
+ private readonly onSetDefault?: (result: PickerResult) => void;
174
180
  private readonly onRenderRequest: (() => void) | undefined;
175
181
  private readonly visibleRows: number;
176
182
  private readonly modelsByKey: Map<ModelKey, PickerModel>;
@@ -189,6 +195,8 @@ export class ModelPicker {
189
195
  private search = "";
190
196
  private selected = 0;
191
197
  private focus: FusionFocus = "effort";
198
+ /** Which side ←/→ adjusts on the Fusion row's effort control. */
199
+ private effortTarget: "lead" | "sidekick" = "lead";
192
200
  private dropdownIndex = 0;
193
201
  private done = false;
194
202
 
@@ -196,6 +204,7 @@ export class ModelPicker {
196
204
  this.state = options.state;
197
205
  this.theme = options.theme;
198
206
  this.onDone = options.onDone;
207
+ this.onSetDefault = options.onSetDefault;
199
208
  this.onRenderRequest = options.onRenderRequest;
200
209
  this.visibleRows = options.visibleRows ?? DEFAULT_VISIBLE_ROWS;
201
210
  this.modelsByKey = new Map(options.state.models.map((m) => [m.key, m]));
@@ -209,14 +218,21 @@ export class ModelPicker {
209
218
  this.priceRange = { min: prices.length > 0 ? Math.min(...prices) : 0, max: prices.length > 0 ? Math.max(...prices) : 0 };
210
219
  this.effort = { ...options.state.effort };
211
220
  const active = options.state.active;
212
- this.lead =
213
- (active?.kind === "fusion" ? active.lead : undefined) ??
214
- options.state.fusionDefault.lead ??
215
- options.state.fusionLeads[0];
216
- this.sidekick =
217
- (active?.kind === "fusion" ? active.sidekick : undefined) ??
218
- options.state.fusionDefault.sidekick ??
219
- options.state.fusionSidekicks[0];
221
+ // Saved keys can go stale (provider renamed a model, logged out); only
222
+ // accept ones still in the catalogue, else the Fusion row looks ready but
223
+ // cannot be applied.
224
+ const known = (...keys: (ModelKey | undefined)[]): ModelKey | undefined =>
225
+ keys.find((k) => k !== undefined && this.modelsByKey.has(k));
226
+ this.lead = known(
227
+ active?.kind === "fusion" ? active.lead : undefined,
228
+ options.state.fusionDefault.lead,
229
+ ...options.state.fusionLeads,
230
+ );
231
+ this.sidekick = known(
232
+ active?.kind === "fusion" ? active.sidekick : undefined,
233
+ options.state.fusionDefault.sidekick,
234
+ ...options.state.fusionSidekicks,
235
+ );
220
236
  this.fusionLeadEffort =
221
237
  (active?.kind === "fusion" ? active.leadEffort : undefined) ??
222
238
  (this.lead !== undefined ? this.effort[this.lead] : undefined) ??
@@ -266,8 +282,9 @@ export class ModelPicker {
266
282
  ...this.state.fusionLeads,
267
283
  ...this.state.fusionSidekicks,
268
284
  ];
269
- // The whole catalogue always follows; the preset only controls ordering.
270
- ordered.push(...this.state.models.map((m) => m.key));
285
+ // The preset IS the list; the rest of the catalogue appears only while
286
+ // searching (or when nothing is curated yet, so Fusion stays usable).
287
+ if (this.search.length > 0 || !this.hasPreset()) ordered.push(...this.state.models.map((m) => m.key));
271
288
  for (const key of ordered) {
272
289
  if (seen.has(key) || !this.modelsByKey.has(key)) continue;
273
290
  if (!this.matchesSearch(key)) continue;
@@ -286,9 +303,27 @@ export class ModelPicker {
286
303
  return this.rows()[this.selected];
287
304
  }
288
305
 
289
- private dropdownItems(): ModelKey[] {
306
+ /** Whether any lead/sidekick is curated (and still in the catalogue). */
307
+ private hasPreset(): boolean {
308
+ return [...this.state.fusionLeads, ...this.state.fusionSidekicks].some((k) => this.modelsByKey.has(k));
309
+ }
310
+
311
+ /** Dropdown entries: only the curated list for this side. Typing searches
312
+ * the whole catalogue (curated matches first, a dim separator, the rest);
313
+ * an empty list offers the whole catalogue (Fusion works uncurated). */
314
+ private dropdownEntries(): Array<ModelKey | "sep"> {
290
315
  const source = this.focus === "lead" ? this.state.fusionLeads : this.state.fusionSidekicks;
291
- return source.filter((k) => this.modelsByKey.has(k));
316
+ const listed = source.filter((k) => this.modelsByKey.has(k));
317
+ const curated = listed.filter((k) => this.matchesSearch(k));
318
+ if (listed.length > 0 && this.search.length === 0) return curated;
319
+ const rest = this.state.models.map((m) => m.key).filter((k) => !curated.includes(k) && this.matchesSearch(k));
320
+ if (curated.length === 0) return rest;
321
+ if (rest.length === 0) return curated;
322
+ return [...curated, "sep", ...rest];
323
+ }
324
+
325
+ private dropdownItems(): ModelKey[] {
326
+ return this.dropdownEntries().filter((e): e is ModelKey => e !== "sep");
292
327
  }
293
328
 
294
329
  // ── Input ────────────────────────────────────────────────────────────────
@@ -301,6 +336,7 @@ export class ModelPicker {
301
336
  if (matchesKey(data, Key.escape)) {
302
337
  if (inDropdown) {
303
338
  this.focus = "effort";
339
+ this.search = "";
304
340
  this.changed();
305
341
  return;
306
342
  }
@@ -309,15 +345,14 @@ export class ModelPicker {
309
345
  }
310
346
 
311
347
  const disabledFusion = row?.kind === "fusion" && !this.fusionAvailable();
312
- if (
313
- disabledFusion &&
314
- (matchesKey(data, Key.tab) ||
315
- matchesKey(data, Key.left) ||
316
- matchesKey(data, Key.right) ||
317
- matchesKey(data, Key.enter) ||
318
- data === "\r")
319
- ) {
320
- return;
348
+ if (disabledFusion && !inDropdown) {
349
+ // Incomplete pair: Enter/Tab open the dropdown for the missing half.
350
+ if (matchesKey(data, Key.tab) || matchesKey(data, Key.enter) || data === "\r") {
351
+ this.openMissing();
352
+ this.changed();
353
+ return;
354
+ }
355
+ if (matchesKey(data, Key.left) || matchesKey(data, Key.right) || matchesKey(data, "alt+enter")) return;
321
356
  }
322
357
 
323
358
  if (inDropdown) {
@@ -329,11 +364,29 @@ export class ModelPicker {
329
364
  } else if (matchesKey(data, Key.tab)) {
330
365
  this.applyDropdown(items);
331
366
  this.cycleFocus(matchesKey(data, "shift+tab"));
367
+ } else if (matchesKey(data, "alt+enter")) {
368
+ this.applyDropdown(items);
369
+ if (!this.fusionAvailable()) this.openMissing();
370
+ else {
371
+ this.focus = "effort";
372
+ this.setDefault(row);
373
+ this.confirm(row);
374
+ return;
375
+ }
332
376
  } else if (matchesKey(data, Key.enter) || data === "\r") {
333
377
  this.applyDropdown(items);
334
- this.focus = "effort";
378
+ this.search = "";
379
+ if (this.fusionAvailable()) this.focus = "effort";
380
+ else this.openMissing();
381
+ } else if (matchesKey(data, Key.backspace) || data === "\x7f") {
382
+ // Typing in a dropdown searches the whole catalogue.
383
+ this.search = this.search.slice(0, -1);
384
+ this.dropdownIndex = 0;
335
385
  } else {
336
- return;
386
+ const ch = printable(data);
387
+ if (ch === undefined || ch === " ") return;
388
+ this.search += ch;
389
+ this.dropdownIndex = 0;
337
390
  }
338
391
  this.changed();
339
392
  return;
@@ -351,7 +404,11 @@ export class ModelPicker {
351
404
  const delta: -1 | 1 = matchesKey(data, Key.left) ? -1 : 1;
352
405
  if (row?.kind === "fusion") {
353
406
  // Fusion-row effort is its own state: never touches per-model memory.
354
- if (this.focus === "effort") this.fusionLeadEffort = stepEffort(this.fusionLeadEffort, delta);
407
+ // Space picks which side ←/→ adjusts.
408
+ if (this.focus === "effort") {
409
+ if (this.effortTarget === "lead") this.fusionLeadEffort = stepEffort(this.fusionLeadEffort, delta);
410
+ else this.fusionSidekickEffort = stepEffort(this.fusionSidekickEffort, delta);
411
+ }
355
412
  } else if (row?.key !== undefined) {
356
413
  this.effort[row.key] = stepEffort(this.effortFor(row.key), delta);
357
414
  }
@@ -364,13 +421,27 @@ export class ModelPicker {
364
421
  return;
365
422
  }
366
423
  } else if (data === " " || matchesKey(data, Key.space)) {
367
- // Space = quick action: stage the highlighted model as the Fusion lead.
424
+ // Space = quick action. On a model row it stages the highlighted model
425
+ // as the Fusion lead; on the Fusion row it toggles which side the
426
+ // effort control adjusts (Tab is taken by the lead/sidekick cycle).
427
+ if (row?.kind === "fusion") {
428
+ if (this.focus === "effort" && this.fusionAvailable()) {
429
+ this.effortTarget = this.effortTarget === "lead" ? "sidekick" : "lead";
430
+ this.changed();
431
+ }
432
+ return;
433
+ }
368
434
  if (row?.kind === "model" && !disabledFusion) {
369
435
  this.lead = row.key;
370
436
  this.changed();
371
437
  return;
372
438
  }
373
439
  return;
440
+ } else if (matchesKey(data, "alt+enter")) {
441
+ // Alt+Enter = apply AND keep it as the startup default.
442
+ this.setDefault(row);
443
+ this.confirm(row);
444
+ return;
374
445
  } else if (matchesKey(data, Key.enter) || data === "\r") {
375
446
  this.confirm(row);
376
447
  return;
@@ -399,6 +470,12 @@ export class ModelPicker {
399
470
  }
400
471
  }
401
472
 
473
+ /** Open the lead (or, if the lead is set, the sidekick) dropdown. */
474
+ private openMissing(): void {
475
+ this.focus = this.lead === undefined ? "effort" : "lead"; // cycleFocus steps one past this
476
+ this.cycleFocus();
477
+ }
478
+
402
479
  private applyDropdown(items: ModelKey[]): void {
403
480
  const pick = items[this.dropdownIndex];
404
481
  if (pick === undefined) return;
@@ -406,6 +483,31 @@ export class ModelPicker {
406
483
  else this.sidekick = pick;
407
484
  }
408
485
 
486
+ private setDefault(row: Row | undefined): void {
487
+ if (row === undefined || this.onSetDefault === undefined) return;
488
+ const mark = row.kind === "fusion" ? this.lead : row.key;
489
+ if (mark !== undefined) this.state.defaultModelKey = mark;
490
+ this.changed();
491
+ if (row.kind === "fusion") {
492
+ if (this.lead === undefined || this.sidekick === undefined) return;
493
+ this.onSetDefault({
494
+ type: "fusion",
495
+ lead: this.lead,
496
+ sidekick: this.sidekick,
497
+ leadEffort: this.fusionLeadEffort,
498
+ sidekickEffort: this.fusionSidekickEffort,
499
+ effortMap: { ...this.effort },
500
+ });
501
+ } else if (row.key !== undefined) {
502
+ this.onSetDefault({
503
+ type: "single",
504
+ model: row.key,
505
+ effort: this.effortFor(row.key),
506
+ effortMap: { ...this.effort },
507
+ });
508
+ }
509
+ }
510
+
409
511
  private confirm(row: Row | undefined): void {
410
512
  if (row === undefined) return;
411
513
  if (row.kind === "fusion") {
@@ -507,11 +609,16 @@ export class ModelPicker {
507
609
  : highlighted
508
610
  ? t.fg("accent", nameRaw)
509
611
  : t.fg("text", nameRaw);
510
- const name = `${providerPrefix}${styled}`;
612
+ const name = `${providerPrefix}${styled}${row.kind === "model" && row.key === this.state.defaultModelKey ? ` ${t.fg("dim", "default")}` : ""}`;
511
613
  const badge = model?.badge;
512
614
  const badgeGlyph = badge === undefined ? "" : ` ${t.fg(badge === "new" ? "success" : badge === "promotion" ? "accent" : "warning", "✱")}`;
513
615
 
514
- const level = row.kind === "fusion" ? this.fusionLeadEffort : this.effortFor(row.key);
616
+ const level =
617
+ row.kind === "fusion"
618
+ ? this.effortTarget === "lead"
619
+ ? this.fusionLeadEffort
620
+ : this.fusionSidekickEffort
621
+ : this.effortFor(row.key);
515
622
  const arrowsOn = !disabledFusion && highlighted && this.focus === "effort";
516
623
  const left = arrowsOn ? t.fg("accent", "←") : " ";
517
624
  const right = arrowsOn ? t.fg("accent", "→") : " ";
@@ -524,19 +631,24 @@ export class ModelPicker {
524
631
  let line = `${pointer} ${marker} ${pad(`${name}${badgeGlyph}`, nameCol)} ${left} ${this.bar(level, !disabledFusion && highlighted)} ${right} ${pad(label, 8)}`;
525
632
 
526
633
  if (row.kind === "fusion") {
527
- if (disabledFusion) {
528
- line += ` ${t.fg("dim", "not configured — open /unipi:settings → Fusion → Edit fusion presets…")}`;
529
- } else {
634
+ if (!disabledFusion) {
530
635
  const leadName = this.nameOf(this.lead, 14);
531
636
  const sideName = this.nameOf(this.sidekick, 14);
532
637
  const leadFocused = highlighted && this.focus === "lead";
533
638
  const sideFocused = highlighted && this.focus === "sidekick";
639
+ // Each side shows its effort; a ▸ marks the side ←/→ adjusts.
640
+ const effortSuffix = (side: "lead" | "sidekick") => {
641
+ const lv = effortLabel(side === "lead" ? this.fusionLeadEffort : this.fusionSidekickEffort);
642
+ return highlighted && this.focus === "effort" && this.effortTarget === side
643
+ ? t.fg("accent", t.bold(` ▸${lv}`))
644
+ : t.fg("dim", ` ${lv}`);
645
+ };
534
646
  const leadText = leadFocused
535
- ? `${t.fg("accent", t.bold("Lead"))} ${t.fg("accent", leadName)} ${t.fg("accent", "▾")}`
536
- : `${t.fg("dim", "Lead")} ${t.fg("text", leadName)} ${t.fg("dim", "▾")}`;
647
+ ? `${t.fg("accent", t.bold("Lead"))} ${t.fg("accent", leadName)} ${t.fg("accent", "▾")}${effortSuffix("lead")}`
648
+ : `${t.fg("dim", "Lead")} ${t.fg("text", leadName)} ${t.fg("dim", "▾")}${effortSuffix("lead")}`;
537
649
  const sideText = sideFocused
538
- ? `${t.fg("accent", t.bold("Sidekick"))} ${t.fg("accent", sideName)} ${t.fg("accent", "▾")}`
539
- : `${t.fg("dim", "Sidekick")} ${t.fg("text", sideName)} ${t.fg("dim", "▾")}`;
650
+ ? `${t.fg("accent", t.bold("Sidekick"))} ${t.fg("accent", sideName)} ${t.fg("accent", "▾")}${effortSuffix("sidekick")}`
651
+ : `${t.fg("dim", "Sidekick")} ${t.fg("text", sideName)} ${t.fg("dim", "▾")}${effortSuffix("sidekick")}`;
540
652
  line += ` ${leadText} ${sideText}`;
541
653
  }
542
654
  }
@@ -545,23 +657,37 @@ export class ModelPicker {
545
657
 
546
658
  private renderDropdown(width: number, nameCol: number): string[] {
547
659
  const t = this.theme;
660
+ const entries = this.dropdownEntries();
548
661
  const items = this.dropdownItems();
549
662
  const indent = " ".repeat(MARKER_COL + nameCol + 5);
550
663
  if (items.length === 0) {
551
- return [`${indent}${t.fg("warning", `no ${this.focus} models in preset — open /unipi:settings → Fusion → "Edit fusion presets…"`)}`];
664
+ return [`${indent}${t.fg("warning", "no matching models")}`];
552
665
  }
553
- const win = 6;
554
- const start = Math.max(0, Math.min(this.dropdownIndex - Math.floor(win / 2), items.length - win));
555
- const slice = items.slice(start, start + win);
556
- return slice.map((key, i) => {
557
- const idx = start + i;
558
- const isCur = idx === this.dropdownIndex;
559
- const isSet = key === (this.focus === "lead" ? this.lead : this.sidekick);
666
+ // Selectable index ↔ entry index mapping (a "sep" row renders but can't be picked).
667
+ const selectableIdx = new Map<ModelKey, number>();
668
+ items.forEach((key, i) => selectableIdx.set(key, i));
669
+ const firstEntryOfSel = items.map((key) => entries.indexOf(key));
670
+ const win = 8;
671
+ const selStart = Math.max(0, Math.min(this.dropdownIndex - Math.floor(win / 2), items.length - win));
672
+ const sliceStart = firstEntryOfSel[selStart] ?? 0;
673
+ const slice = entries.slice(sliceStart, sliceStart + win);
674
+ const above = sliceStart;
675
+ const below = Math.max(0, entries.length - (sliceStart + slice.length));
676
+ const more = (n: number, dir: string) => truncateToWidth(`${indent}${t.fg("dim", `${dir} ${String(n)} more`)}`, Math.max(1, width - 1));
677
+ return [
678
+ ...(above > 0 ? [more(above, "↑")] : []),
679
+ ...slice.map((entry) => {
680
+ if (entry === "sep") return truncateToWidth(`${indent}${t.fg("dim", "── all models ──")}`, Math.max(1, width - 1));
681
+ const selIdx = selectableIdx.get(entry) ?? 0;
682
+ const isCur = selIdx === this.dropdownIndex;
683
+ const isSet = entry === (this.focus === "lead" ? this.lead : this.sidekick);
560
684
  const glyph = isCur ? t.fg("accent", "▸") : " ";
561
- const label = isCur ? t.fg("accent", t.bold(this.nameOf(key, 28))) : t.fg("text", this.nameOf(key, 28));
685
+ const label = isCur ? t.fg("accent", t.bold(this.nameOf(entry, 28))) : t.fg("text", this.nameOf(entry, 28));
562
686
  const star = isSet ? t.fg("dim", " *") : "";
563
687
  return truncateToWidth(`${indent}${glyph} ${label}${star}`, Math.max(1, width - 1));
564
- });
688
+ }),
689
+ ...(below > 0 ? [more(below, "↓")] : []),
690
+ ];
565
691
  }
566
692
 
567
693
  private renderPricePanel(row: Row | undefined, width: number): string[] {
@@ -625,7 +751,9 @@ export class ModelPicker {
625
751
  const keyText = keys.map((k) => t.fg("text", k)).join(t.fg("dim", " · "));
626
752
  out.push(truncateToWidth(` ${t.fg("dim", "Model key")} ${keyText}`, width - 1));
627
753
  }
628
- out.push(truncateToWidth(` ${description}`, width - 1));
754
+ for (const line of wrapTextWithAnsi(` ${description}`, Math.max(1, width - 1)).slice(0, 3)) {
755
+ out.push(truncateToWidth(line, width - 1));
756
+ }
629
757
  return out;
630
758
  }
631
759
 
@@ -633,13 +761,17 @@ export class ModelPicker {
633
761
  const t = this.theme;
634
762
  const parts: string[] = [];
635
763
  if (row?.kind === "fusion" && !this.fusionAvailable()) {
636
- parts.push("↑↓ select", "esc cancel");
764
+ parts.push("↑↓ select", `enter pick ${this.lead === undefined ? "lead" : "sidekick"}`, "esc cancel");
637
765
  } else if (row?.kind === "fusion" && this.focus !== "effort") {
638
- parts.push("↑↓ select", `tab ${this.focus === "lead" ? "sidekick" : "effort"}`, "enter apply", "esc collapse");
766
+ parts.push("↑↓ select", "type to search all models", `tab ${this.focus === "lead" ? "sidekick" : "effort"}`, "enter apply", "alt+enter set default", "esc collapse");
639
767
  } else {
640
768
  parts.push("↑↓ select");
641
- if (row?.kind === "fusion") parts.push("tab lead");
642
- parts.push("←→ effort", "enter/tab confirm", "space set lead", "esc cancel");
769
+ if (row?.kind === "fusion") {
770
+ const other = this.effortTarget === "lead" ? "sidekick" : "lead";
771
+ parts.push("tab lead", `←→ ${this.effortTarget} effort`, `space → ${other}`, "enter confirm", "alt+enter set default", "esc cancel");
772
+ } else {
773
+ parts.push("←→ effort", "enter/tab confirm", "alt+enter set default", "space set lead", "esc cancel");
774
+ }
643
775
  }
644
776
  return t.fg("dim", parts.join(" · "));
645
777
  }
@@ -675,6 +807,9 @@ export class ModelPicker {
675
807
  if (r === undefined) continue;
676
808
  const highlighted = i === this.selected;
677
809
  lines.push(this.renderRow(r, highlighted, width, nameCol));
810
+ if (r.kind === "fusion" && !this.fusionAvailable()) {
811
+ lines.push(truncateToWidth(` ${t.fg("dim", "pick a lead and sidekick — the catalogue below works too (open /unipi:settings → Fusion → Edit fusion presets… to curate)")}`, Math.max(1, width - 1)));
812
+ }
678
813
  if (highlighted && r.kind === "fusion" && this.focus !== "effort") {
679
814
  lines.push(...this.renderDropdown(width, nameCol));
680
815
  }
@@ -17,7 +17,8 @@
17
17
  import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync } from "node:fs";
18
18
  import { homedir } from "node:os";
19
19
  import { dirname, join } from "node:path";
20
- import { getSettings, registerSettings, setSettings } from "@pi-unipi/core";
20
+ import { getSettings, getSettingsScoped, registerSettings, setSettings } from "@pi-unipi/core";
21
+ import { persistDefaultModel, readDefaultModel } from "./pi-settings.js";
21
22
 
22
23
  export const PRESET_SCHEMA_VERSION = 1;
23
24
 
@@ -206,6 +207,48 @@ export interface LoadedPreset {
206
207
  hasProjectLayer: boolean;
207
208
  }
208
209
 
210
+ const STARTUP_THINKING = ["off", "minimal", "low", "medium", "high", "xhigh"] as const;
211
+
212
+ /**
213
+ * Hub → pi: a global write of `startup.*` becomes pi's own startup default
214
+ * (settings.json defaultProvider/defaultModel/defaultThinkingLevel — what
215
+ * pi's /model ctrl+s and the picker's alt+enter write). A single startup
216
+ * model also stops a remembered Fusion pair from overriding it at startup.
217
+ * Exported for tests.
218
+ */
219
+ export function applyStartupSettings(layer: Record<string, unknown>, home = homedir()): boolean {
220
+ const startup = layer.startup as { model?: unknown; thinking?: unknown } | undefined;
221
+ const model = typeof startup?.model === "string" ? startup.model.trim() : "";
222
+ const thinking = typeof startup?.thinking === "string" && (STARTUP_THINKING as readonly string[]).includes(startup.thinking) ? startup.thinking : undefined;
223
+ const parts = model ? splitModelKey(model) : undefined;
224
+ const current = readDefaultModel();
225
+ if (parts === undefined && thinking === undefined) return false;
226
+ const nextKey = parts ? model : current.key;
227
+ if (nextKey === current.key && (thinking === undefined || thinking === current.thinking)) return false; // mirror echo
228
+ const target = nextKey ? splitModelKey(nextKey) : undefined;
229
+ if (target === undefined) return false;
230
+ persistDefaultModel({ provider: target.provider, model: target.id, thinkingLevel: thinking });
231
+ if (parts !== undefined) {
232
+ const path = globalPresetPath(home);
233
+ const existing = mergePresets(emptyPreset(), parsePreset(readJson(path)));
234
+ if (existing.active?.kind === "fusion" && existing.active.lead !== model) {
235
+ saveRuntimeState(path, { effort: existing.effort, recent: existing.recent, active: { kind: "single", model } });
236
+ }
237
+ }
238
+ return true;
239
+ }
240
+
241
+ /** pi → hub: show pi's real startup default in /unipi:settings (it also
242
+ * changes through pi's /model ctrl+s and the picker's alt+enter). */
243
+ export function mirrorStartupFromPi(cwd: string): void {
244
+ const { key, thinking } = readDefaultModel();
245
+ const shown = (getSettingsScoped("fusion", "global", cwd)?.startup ?? {}) as { model?: string; thinking?: string };
246
+ const patch: Record<string, string> = {};
247
+ if (key !== undefined && shown.model !== key) patch.model = key;
248
+ if (thinking !== undefined && shown.thinking !== thinking && (STARTUP_THINKING as readonly string[]).includes(thinking)) patch.thinking = thinking;
249
+ if (Object.keys(patch).length > 0) setSettings("fusion", { startup: patch }, "global", cwd);
250
+ }
251
+
209
252
  // Registered with the unified settings hub: the DEFAULT PAIR is editable
210
253
  // there (model pickers, engine-layered). The curated arrays, effort, recent,
211
254
  // badges and prices remain in the preset files — the hub action "Edit fusion presets…" owns
@@ -213,8 +256,19 @@ export interface LoadedPreset {
213
256
  registerSettings({
214
257
  namespace: "fusion",
215
258
  label: "Fusion",
216
- defaults: { default: { lead: "", sidekick: "" } },
259
+ defaults: { default: { lead: "", sidekick: "" }, startup: { model: "" } },
260
+ onSet: (layer, scope) => {
261
+ if (scope === "global") applyStartupSettings(layer);
262
+ },
217
263
  schema: [
264
+ {
265
+ title: "Startup",
266
+ description: "The model pi opens with. /unipi:model → alt+enter sets the same thing (on the Fusion row it starts in Fusion).",
267
+ fields: [
268
+ { key: "startup.model", type: "model", label: "Startup model", capability: "text", emptyLabel: "pi default", scopes: ["global"] },
269
+ { key: "startup.thinking", type: "enum", label: "Startup thinking level", options: [...STARTUP_THINKING], scopes: ["global"] },
270
+ ],
271
+ },
218
272
  {
219
273
  title: "Default pair",
220
274
  description: "Used when the Fusion row is confirmed without editing",
@@ -52,7 +52,7 @@ You have a \`sidekick\` tool: a persistent subagent that works alongside you on
52
52
  - The sidekick remembers everything from previous handoffs (code it wrote, files it explored, your earlier instructions), so don't re-explain context it already has. Its runtime state also persists: background shells and processes it started (dev servers, DB connections, long-running commands) usually survive between handoffs. Every brief that involves servers or long-running processes must say what to do with them — e.g. "the server from the previous handoff may still be running; check and reuse it, restart only if it's gone or the code changed" — and, when a later handoff may need them, tell it to leave them running.
53
53
  - On each handoff give it: the goal, your plan, the constraints, the relevant files, and how to verify. Settle the consequential choices before handing off: the exact interface (signature, types, data shape, which existing helper to use) and the exact tests (cases, assertions, where to stub). Don't leave alternatives for it to pick; it will guess, and a wrong guess costs a whole extra round. A code snippet is fine when it is the clearest way to say it.
54
54
  - **Never make the sidekick redo work you already did.** Results you already derived go into the brief as settled inputs (the values, or the path to the file holding them), not as an invitation to recompute. Ask for re-derivation only when you have reason to doubt them.
55
- - Blocking dispatch is the default: a \`sidekick\` call waits and returns the report. Pass \`block: false\` only when you genuinely have parallel lead work to do meanwhile; don't start work redundant with what the sidekick is doing, and never poll \`read_subagent\` in a loop. When you have run out of parallel work and still need the report, wait with \`read_subagent\` (\`block: true\`) rather than ending your turn or guessing.
55
+ - Blocking dispatch is the default: a \`sidekick\` call waits and returns the report. Pass \`block: false\` only when you genuinely have parallel lead work to do meanwhile; don't start work redundant with what the sidekick is doing, and never poll \`read_subagent\` in a loop. When you have run out of parallel work and still need the report, wait with \`read_subagent\` (\`block: true\`) rather than ending your turn or guessing. A background handoff cannot ask the user for approval: any tool call that would need approval is automatically denied, so run approval-needing work with a blocking handoff.
56
56
  - Calling \`sidekick\` again while a handoff is running injects the new message into that handoff as an interrupt — it never starts a second sidekick. Use it to redirect with a corrected brief, tell it to wrap up and report what it has, or send a purely informational update.
57
57
  - If the user sends a message while a handoff is in flight, act on it before going back to waiting: handle lead-only work yourself, and for anything that concerns the running handoff, send an interrupt. Resume waiting only once you have deliberately decided the message changes nothing for the sidekick.
58
58
  - Track the lead-only actions you have promised the user (messages, review replies, commit/PR updates) — the sidekick cannot do these, so a brief that includes one silently drops it. Do each one the moment the handoff returns, before dispatching a follow-up.