@cueloop/client 0.1.0-alpha.10 → 0.1.0-alpha.100

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 (355) hide show
  1. package/native/build-pty.sh +51 -0
  2. package/native/build.sh +82 -0
  3. package/native/darwin-arm64/libcuelooppty.dylib +0 -0
  4. package/native/darwin-arm64/libcueloopvt.dylib +0 -0
  5. package/native/src/pty.zig +193 -0
  6. package/native/src/shim.zig +114 -0
  7. package/package.json +42 -34
  8. package/src/annotations/annotation-palette.ts +62 -0
  9. package/src/annotations/components/AnnotationCards.stories.tsx +86 -0
  10. package/src/annotations/components/AnnotationCards.test.tsx +42 -0
  11. package/src/annotations/components/AnnotationCards.tsx +364 -0
  12. package/src/annotations/components/DiscussionMarkerRail.stories.tsx +54 -0
  13. package/src/annotations/components/DiscussionMarkerRail.tsx +186 -0
  14. package/src/annotations/discussions.ts +77 -0
  15. package/src/annotations/thread-selection.test.ts +210 -0
  16. package/src/annotations/thread-selection.ts +242 -0
  17. package/src/annotations/use-annotation-surface.tsx +1238 -0
  18. package/src/app/App.appearance.test.tsx +53 -0
  19. package/src/{App.autoclose.test.tsx → app/App.autoclose.test.tsx} +77 -51
  20. package/src/app/App.deleted-scroll.test.tsx +187 -0
  21. package/src/app/App.diff.test.tsx +313 -0
  22. package/src/app/App.export.test.tsx +105 -0
  23. package/src/app/App.focus.test.ts +89 -0
  24. package/src/app/App.identity.test.tsx +84 -0
  25. package/src/app/App.plan-render.test.tsx +209 -0
  26. package/src/app/App.plan-review.test.tsx +428 -0
  27. package/src/app/App.prototype-markdown.test.tsx +128 -0
  28. package/src/app/App.prototype.test.tsx +176 -0
  29. package/src/app/App.readonly.test.tsx +210 -0
  30. package/src/app/App.regression.test.tsx +99 -0
  31. package/src/app/App.rendering.test.tsx +134 -0
  32. package/src/app/App.share-dialog.test.tsx +177 -0
  33. package/src/app/App.submit-confirm.test.tsx +267 -0
  34. package/src/app/App.test.tsx +496 -0
  35. package/src/app/App.thread-agent.test.ts +12 -0
  36. package/src/app/App.toast.test.tsx +124 -0
  37. package/src/app/App.tsx +1650 -0
  38. package/src/app/App.visual.test.tsx +59 -0
  39. package/src/app/App.walk.test.tsx +299 -0
  40. package/src/app/App.workbench.test.tsx +1149 -0
  41. package/src/app/app-key-state.ts +61 -0
  42. package/src/app/app-screens.tsx +606 -0
  43. package/src/app/app-view-model.ts +168 -0
  44. package/src/app/components/AppHeader.stories.tsx +50 -0
  45. package/src/app/components/AppHeader.tsx +81 -0
  46. package/src/app/components/AppShell.brand.test.tsx +58 -0
  47. package/src/app/components/AppShell.extensions.test.tsx +191 -0
  48. package/src/app/components/AppShell.stories.tsx +89 -0
  49. package/src/app/components/AppShell.tsx +358 -0
  50. package/src/app/components/AppShell.zoom.test.tsx +149 -0
  51. package/src/app/components/Breadcrumb.stories.tsx +19 -0
  52. package/src/app/components/Breadcrumb.tsx +45 -0
  53. package/src/app/components/MenuBar.stories.tsx +28 -0
  54. package/src/app/components/MenuBar.tsx +44 -0
  55. package/src/app/components/PaneGrid.stories.tsx +84 -0
  56. package/src/app/components/PaneGrid.tsx +128 -0
  57. package/src/app/components/PanelColumn.stories.tsx +25 -0
  58. package/src/app/components/PanelColumn.tsx +93 -0
  59. package/src/app/components/ShellHeader.stories.tsx +50 -0
  60. package/src/app/components/ShellHeader.tsx +137 -0
  61. package/src/app/components/WorkspacePanels.stories.tsx +36 -0
  62. package/src/app/components/WorkspacePanels.tsx +120 -0
  63. package/src/app/components/pages/Workspace.stories.tsx +181 -0
  64. package/src/app/intent-dispatch.test.ts +728 -0
  65. package/src/app/intent-dispatch.ts +687 -0
  66. package/src/app/launch-layout.test.ts +78 -0
  67. package/src/app/launch-layout.ts +57 -0
  68. package/src/app/ready-signal.test.ts +85 -0
  69. package/src/app/ready-signal.ts +52 -0
  70. package/src/app/run.ts +109 -0
  71. package/src/{serve.test.ts → app/serve.test.ts} +24 -12
  72. package/src/app/serve.ts +111 -0
  73. package/src/app/version.ts +5 -0
  74. package/src/appearance/components/theme-context.stories.tsx +46 -0
  75. package/src/appearance/components/theme-context.tsx +37 -0
  76. package/src/appearance/theme-presets.test.ts +51 -0
  77. package/src/appearance/theme-presets.ts +219 -0
  78. package/src/appearance/theme.ts +112 -0
  79. package/src/appearance/visual/contrast.test.ts +35 -0
  80. package/src/appearance/visual/contrast.ts +33 -0
  81. package/src/diff/components/AnnotatableFileView.stories.tsx +42 -0
  82. package/src/diff/components/AnnotatableFileView.test.tsx +157 -0
  83. package/src/diff/components/AnnotatableFileView.tsx +141 -0
  84. package/src/diff/components/BareWorkbenchFileView.stories.tsx +27 -0
  85. package/src/diff/components/BareWorkbenchFileView.tsx +63 -0
  86. package/src/diff/components/DiffContentView.scroll.test.tsx +434 -0
  87. package/src/diff/components/DiffContentView.split.test.tsx +140 -0
  88. package/src/diff/components/DiffContentView.stories.tsx +80 -0
  89. package/src/diff/components/DiffContentView.syntax.test.tsx +82 -0
  90. package/src/diff/components/DiffContentView.tsx +1055 -0
  91. package/src/diff/components/WalkWizard.stories.tsx +100 -0
  92. package/src/diff/components/WalkWizard.tsx +146 -0
  93. package/src/diff/components/diff-content-view-layout.test.ts +198 -0
  94. package/src/diff/components/diff-content-view-layout.ts +84 -0
  95. package/src/diff/diff-fold.test.ts +78 -0
  96. package/src/diff/diff-fold.ts +126 -0
  97. package/src/diff/diff-hunk-curate.test.ts +315 -0
  98. package/src/diff/diff-hunk-curate.ts +152 -0
  99. package/src/diff/diff-intraline.test.ts +239 -0
  100. package/src/diff/diff-intraline.ts +238 -0
  101. package/src/diff/diff-syntax.test.ts +125 -0
  102. package/src/diff/diff-syntax.ts +186 -0
  103. package/src/diff/split-diff.test.ts +110 -0
  104. package/src/diff/split-diff.ts +144 -0
  105. package/src/diff/view-diff.test.ts +325 -0
  106. package/src/diff/view-diff.ts +522 -0
  107. package/src/diff/walk.test.ts +121 -0
  108. package/src/diff/walk.ts +73 -0
  109. package/src/index.ts +31 -2
  110. package/src/integrations/attribution.test.ts +31 -0
  111. package/src/integrations/attribution.ts +42 -0
  112. package/src/integrations/client-extension-registry.test.tsx +61 -0
  113. package/src/integrations/client-extension-registry.ts +159 -0
  114. package/src/integrations/clipboard.test.ts +23 -0
  115. package/src/integrations/clipboard.ts +33 -0
  116. package/src/integrations/components/ExtensionZone.stories.tsx +28 -0
  117. package/src/integrations/components/ExtensionZone.tsx +145 -0
  118. package/src/integrations/components/ShareDialog.stories.tsx +121 -0
  119. package/src/integrations/components/ShareDialog.tsx +550 -0
  120. package/src/integrations/components/share-dialog-store.ts +55 -0
  121. package/src/integrations/editor.test.ts +149 -0
  122. package/src/integrations/editor.ts +160 -0
  123. package/src/integrations/github-identity.test.ts +58 -0
  124. package/src/integrations/github-identity.ts +41 -0
  125. package/src/integrations/herdr.test.ts +43 -0
  126. package/src/integrations/herdr.ts +38 -0
  127. package/src/integrations/integrations.test.ts +41 -0
  128. package/src/integrations/integrations.ts +32 -0
  129. package/src/integrations/pasted-image.test.ts +67 -0
  130. package/src/integrations/pasted-image.ts +60 -0
  131. package/src/integrations/share.ts +238 -0
  132. package/src/keyboard/components/CompletionOverlay.stories.tsx +37 -0
  133. package/src/keyboard/components/CompletionOverlay.tsx +75 -0
  134. package/src/keyboard/components/HandleChipsInput.stories.tsx +24 -0
  135. package/src/keyboard/components/HandleChipsInput.test.tsx +35 -0
  136. package/src/keyboard/components/HandleChipsInput.tsx +143 -0
  137. package/src/keyboard/components/KeybindsDialog.stories.tsx +31 -0
  138. package/src/keyboard/components/KeybindsDialog.tsx +103 -0
  139. package/src/keyboard/components/NavModeHint.stories.tsx +40 -0
  140. package/src/keyboard/components/NavModeHint.tsx +31 -0
  141. package/src/keyboard/components/SlashComposer.stories.tsx +37 -0
  142. package/src/keyboard/components/SlashComposer.tsx +87 -0
  143. package/src/keyboard/key-bindings.test.ts +156 -0
  144. package/src/keyboard/key-bindings.ts +459 -0
  145. package/src/keyboard/keymap.test.ts +611 -0
  146. package/src/keyboard/keymap.ts +395 -0
  147. package/src/keyboard/skills.test.ts +74 -0
  148. package/src/keyboard/skills.ts +75 -0
  149. package/src/keyboard/slash-palette.test.ts +103 -0
  150. package/src/keyboard/slash-palette.ts +129 -0
  151. package/src/keyboard/thread-chords.test.ts +79 -0
  152. package/src/keyboard/thread-chords.ts +163 -0
  153. package/src/keyboard/use-shared-keyboard.ts +55 -0
  154. package/src/markdown/__snapshots__/view-plan.regression.test.ts.snap +100 -0
  155. package/src/markdown/components/MarkdownGridBlock.stories.tsx +33 -0
  156. package/src/markdown/components/MarkdownGridBlock.tsx +72 -0
  157. package/src/markdown/components/MarkdownThreadEditor.stories.tsx +15 -0
  158. package/src/markdown/components/MarkdownThreadEditor.test.tsx +98 -0
  159. package/src/markdown/components/MarkdownThreadEditor.tsx +160 -0
  160. package/src/markdown/components/ThreadView.selection.test.tsx +951 -0
  161. package/src/markdown/components/ThreadView.slash.test.tsx +146 -0
  162. package/src/markdown/components/ThreadView.stories.tsx +26 -0
  163. package/src/markdown/components/ThreadView.tsx +560 -0
  164. package/src/markdown/mark-runs.ts +121 -0
  165. package/src/markdown/markdown-editor-syntax-style.ts +50 -0
  166. package/src/markdown/markdown-grid.test.ts +101 -0
  167. package/src/markdown/markdown-grid.ts +203 -0
  168. package/src/markdown/markdown-highlight.test.ts +61 -0
  169. package/src/markdown/markdown-highlight.ts +124 -0
  170. package/src/markdown/thread-code-syntax.ts +45 -0
  171. package/src/markdown/use-thread-body-editing.tsx +54 -0
  172. package/src/markdown/view-plan.regression.test.ts +60 -0
  173. package/src/markdown/view-plan.test.ts +545 -0
  174. package/src/markdown/view-plan.ts +1009 -0
  175. package/src/performance/otlp-trace.test.ts +119 -0
  176. package/src/performance/otlp-trace.ts +134 -0
  177. package/src/performance/perf-report.ts +18 -0
  178. package/src/performance/perf-timings.test.ts +18 -0
  179. package/src/performance/perf-timings.ts +39 -0
  180. package/src/prototype/components/PrototypeContentView.stories.tsx +19 -0
  181. package/src/prototype/components/PrototypeContentView.tsx +442 -0
  182. package/src/prototype/prototype-browser.test.ts +81 -0
  183. package/src/prototype/prototype-browser.ts +321 -0
  184. package/src/prototype/prototype-pixels.tsx +35 -0
  185. package/src/settings/components/SettingsDialog.stories.tsx +117 -0
  186. package/src/settings/components/SettingsDialog.tsx +183 -0
  187. package/src/settings/components/SettingsRows.stories.tsx +18 -0
  188. package/src/settings/components/SettingsRows.tsx +112 -0
  189. package/src/settings/components/quick-actions-editor.stories.tsx +58 -0
  190. package/src/settings/components/quick-actions-editor.test.tsx +120 -0
  191. package/src/settings/components/quick-actions-editor.tsx +222 -0
  192. package/src/settings/config-identity.test.ts +78 -0
  193. package/src/settings/config.test.ts +676 -0
  194. package/src/settings/config.ts +628 -0
  195. package/src/settings/use-settings-dialog.tsx +351 -0
  196. package/src/stories/__snapshots__/stories.test.tsx.snap +2358 -0
  197. package/src/stories/stories-app.tsx +196 -0
  198. package/src/stories/stories.test.tsx +74 -0
  199. package/src/stories/story-fixtures.ts +102 -0
  200. package/src/stories/story-tree.test.ts +108 -0
  201. package/src/stories/story.ts +154 -0
  202. package/src/terminal/ansi-screen.test.ts +28 -0
  203. package/src/terminal/ansi-screen.ts +72 -0
  204. package/src/terminal/components/AnsiScreen.stories.tsx +12 -0
  205. package/src/terminal/components/AnsiScreen.tsx +28 -0
  206. package/src/terminal/components/terminal-pane.test.tsx +40 -0
  207. package/src/terminal/components/terminal-pane.ts +196 -0
  208. package/src/terminal/ghostty-terminal.test.ts +85 -0
  209. package/src/terminal/ghostty-terminal.ts +197 -0
  210. package/src/terminal/kitty-image.test.ts +66 -0
  211. package/src/terminal/kitty-image.ts +137 -0
  212. package/src/terminal/pty.test.ts +81 -0
  213. package/src/terminal/pty.ts +246 -0
  214. package/src/testing/test-support.test.ts +264 -0
  215. package/src/testing/test-support.ts +367 -0
  216. package/src/thread/agent-continuation.test.ts +32 -0
  217. package/src/thread/agent-continuation.ts +13 -0
  218. package/src/thread/agent-transcript.test.ts +161 -0
  219. package/src/thread/agent-transcript.ts +336 -0
  220. package/src/thread/components/AgentConfigControls.stories.tsx +39 -0
  221. package/src/thread/components/AgentConfigControls.test.tsx +112 -0
  222. package/src/thread/components/AgentConfigControls.tsx +114 -0
  223. package/src/thread/components/AgentThreadPane.stories.tsx +105 -0
  224. package/src/thread/components/AgentThreadPane.test.tsx +829 -0
  225. package/src/thread/components/AgentThreadPane.tsx +367 -0
  226. package/src/thread/components/ThreadFooter.stories.tsx +30 -0
  227. package/src/thread/components/ThreadFooter.test.tsx +49 -0
  228. package/src/thread/components/ThreadFooter.tsx +77 -0
  229. package/src/thread/components/ThreadTree.stories.tsx +46 -0
  230. package/src/thread/components/ThreadTree.test.tsx +41 -0
  231. package/src/thread/components/ThreadTree.tsx +267 -0
  232. package/src/thread/components/thread-tree.test.ts +137 -0
  233. package/src/thread/components/thread-tree.ts +112 -0
  234. package/src/thread/thread-controller.curate.test.ts +502 -0
  235. package/src/thread/thread-controller.derivation.test.ts +62 -0
  236. package/src/thread/thread-controller.live-diff.test.ts +357 -0
  237. package/src/thread/thread-controller.optimistic.test.ts +69 -0
  238. package/src/thread/thread-controller.share.test.ts +573 -0
  239. package/src/thread/thread-controller.switch-cache.test.ts +174 -0
  240. package/src/thread/thread-controller.tree.test.ts +263 -0
  241. package/src/thread/thread-controller.ts +2018 -0
  242. package/src/thread/tree-view.test.ts +128 -0
  243. package/src/thread/tree-view.ts +146 -0
  244. package/src/thread/use-thread-agent.test.tsx +208 -0
  245. package/src/thread/use-thread-agent.ts +111 -0
  246. package/src/ui/components/CodeBlock.stories.tsx +42 -0
  247. package/src/ui/components/CodeBlock.tsx +80 -0
  248. package/src/ui/components/ConfirmCard.stories.tsx +37 -0
  249. package/src/ui/components/ConfirmCard.tsx +151 -0
  250. package/src/ui/components/ConfirmDialog.stories.tsx +21 -0
  251. package/src/ui/components/ConfirmDialog.tsx +72 -0
  252. package/src/ui/components/OverlayScrollbar.stories.tsx +33 -0
  253. package/src/ui/components/OverlayScrollbar.test.tsx +139 -0
  254. package/src/ui/components/OverlayScrollbar.tsx +129 -0
  255. package/src/ui/components/PromptDialog.stories.tsx +36 -0
  256. package/src/ui/components/PromptDialog.test.tsx +65 -0
  257. package/src/ui/components/PromptDialog.tsx +99 -0
  258. package/src/ui/components/RootOverlay.stories.tsx +45 -0
  259. package/src/ui/components/RootOverlay.tsx +49 -0
  260. package/src/ui/components/ScrollArea.stories.tsx +20 -0
  261. package/src/ui/components/ScrollArea.tsx +61 -0
  262. package/src/ui/components/Toast.stories.tsx +19 -0
  263. package/src/ui/components/Toast.tsx +54 -0
  264. package/src/ui/components/Tooltip.stories.tsx +26 -0
  265. package/src/ui/components/Tooltip.tsx +72 -0
  266. package/src/ui/components/menu-control.stories.tsx +35 -0
  267. package/src/ui/components/menu-control.tsx +43 -0
  268. package/src/ui/components/primitives/Button.stories.tsx +32 -0
  269. package/src/ui/components/primitives/Button.tsx +57 -0
  270. package/src/ui/components/primitives/Card.stories.tsx +25 -0
  271. package/src/ui/components/primitives/Card.tsx +62 -0
  272. package/src/ui/components/primitives/Dialog.stories.tsx +24 -0
  273. package/src/ui/components/primitives/Dialog.test.tsx +38 -0
  274. package/src/ui/components/primitives/Dialog.tsx +69 -0
  275. package/src/ui/components/primitives/DialogActions.stories.tsx +20 -0
  276. package/src/ui/components/primitives/DialogActions.tsx +39 -0
  277. package/src/ui/components/primitives/IconButton.stories.tsx +36 -0
  278. package/src/ui/components/primitives/IconButton.tsx +58 -0
  279. package/src/ui/components/primitives/StatusBar.stories.tsx +11 -0
  280. package/src/ui/components/primitives/StatusBar.tsx +23 -0
  281. package/src/ui/components/primitives/Switch.stories.tsx +17 -0
  282. package/src/ui/components/primitives/Switch.tsx +32 -0
  283. package/src/ui/components/primitives/Tabs.stories.tsx +29 -0
  284. package/src/ui/components/primitives/Tabs.tsx +101 -0
  285. package/src/ui/components/primitives/Toolbar.stories.tsx +17 -0
  286. package/src/ui/components/primitives/Toolbar.tsx +14 -0
  287. package/src/ui/components/primitives/Tree.stories.tsx +124 -0
  288. package/src/ui/components/primitives/Tree.tsx +133 -0
  289. package/src/ui/components/primitives/frame.test.ts +12 -0
  290. package/src/ui/components/primitives/frame.ts +17 -0
  291. package/src/ui/components/primitives/icons.ts +61 -0
  292. package/src/ui/components/primitives/tree-model.test.ts +99 -0
  293. package/src/ui/components/primitives/tree-model.ts +137 -0
  294. package/src/ui/components/syntax-highlight.ts +133 -0
  295. package/src/ui/components/truncate-text.ts +7 -0
  296. package/src/ui/components/truncate-title.test.ts +31 -0
  297. package/src/ui/components/truncate-title.ts +15 -0
  298. package/src/ui/use-frame-measure.ts +74 -0
  299. package/src/ui/use-terminal-virtualizer.ts +259 -0
  300. package/src/workbench/components/ChangesColumn.stories.tsx +44 -0
  301. package/src/workbench/components/ChangesColumn.tsx +203 -0
  302. package/src/workbench/components/ChangesFileTree.test.tsx +181 -0
  303. package/src/workbench/components/EditorGrid.split.test.tsx +118 -0
  304. package/src/workbench/components/EditorGrid.stories.tsx +86 -0
  305. package/src/workbench/components/EditorGrid.tabs.test.tsx +118 -0
  306. package/src/workbench/components/EditorGrid.tsx +544 -0
  307. package/src/workbench/components/EditorGrid.zoom.test.tsx +132 -0
  308. package/src/workbench/components/FileContentsView.stories.tsx +16 -0
  309. package/src/workbench/components/FileContentsView.test.tsx +29 -0
  310. package/src/workbench/components/FileContentsView.tsx +100 -0
  311. package/src/workbench/components/GridTabContent.actions.test.tsx +151 -0
  312. package/src/workbench/components/GridTabContent.stories.tsx +59 -0
  313. package/src/workbench/components/GridTabContent.tsx +193 -0
  314. package/src/workbench/components/ProjectTreeView.stories.tsx +25 -0
  315. package/src/workbench/components/ProjectTreeView.test.tsx +236 -0
  316. package/src/workbench/components/ProjectTreeView.tsx +151 -0
  317. package/src/workbench/components/TreePane.stories.tsx +106 -0
  318. package/src/workbench/components/TreePane.tsx +155 -0
  319. package/src/workbench/components/WelcomePlayground.stories.tsx +12 -0
  320. package/src/workbench/components/WelcomePlayground.test.tsx +83 -0
  321. package/src/workbench/components/WelcomePlayground.tsx +138 -0
  322. package/src/workbench/components/editor-grid.test.ts +184 -0
  323. package/src/workbench/components/editor-grid.ts +281 -0
  324. package/src/workbench/components/file-tree.test.ts +60 -0
  325. package/src/workbench/components/file-tree.ts +112 -0
  326. package/src/workbench/use-changes-workbench.ts +205 -0
  327. package/src/workbench/use-remember-layout.ts +31 -0
  328. package/src/workbench/use-repo-changes.ts +91 -0
  329. package/src/workbench/workbench-snapshot.test.ts +99 -0
  330. package/src/workbench/workbench-snapshot.ts +59 -0
  331. package/src/App.diff.test.tsx +0 -99
  332. package/src/App.export.test.tsx +0 -88
  333. package/src/App.plan-review.test.tsx +0 -259
  334. package/src/App.readonly.test.tsx +0 -148
  335. package/src/App.rendering.test.tsx +0 -108
  336. package/src/App.submit-confirm.test.tsx +0 -183
  337. package/src/App.test.tsx +0 -218
  338. package/src/App.tsx +0 -1249
  339. package/src/config.test.ts +0 -116
  340. package/src/config.ts +0 -150
  341. package/src/editor.ts +0 -34
  342. package/src/herdr.test.ts +0 -68
  343. package/src/herdr.ts +0 -34
  344. package/src/keymap.test.ts +0 -270
  345. package/src/keymap.ts +0 -173
  346. package/src/run.ts +0 -30
  347. package/src/serve.ts +0 -75
  348. package/src/session-controller.ts +0 -398
  349. package/src/syntax.test.ts +0 -41
  350. package/src/syntax.ts +0 -88
  351. package/src/theme.ts +0 -46
  352. package/src/view-diff.test.ts +0 -55
  353. package/src/view-diff.ts +0 -86
  354. package/src/view.test.ts +0 -157
  355. package/src/view.ts +0 -364
@@ -0,0 +1,628 @@
1
+ /**
2
+ * Layered TOML config: built-in defaults → user config → trusted repo
3
+ * config → env. Sections: [keys] action = "combo" (every action rebindable),
4
+ * [theme] per-token overrides, [ui] auto_close + editor + theme (a named
5
+ * preset) + pins, [integrations.obsidian] notes-vault export.
6
+ */
7
+
8
+ import { existsSync, readFileSync } from "node:fs";
9
+ import { homedir } from "node:os";
10
+ import { join } from "node:path";
11
+ import { OBSIDIAN_DEFAULTS, type ObsidianConfig } from "@cueloop/integration-obsidian";
12
+ import { MESSAGE_OUTCOMES, type MessageOutcome } from "@cueloop/schema";
13
+ import { writeFileSync, mkdirSync } from "node:fs";
14
+ import { dirname } from "node:path";
15
+ import * as v from "valibot";
16
+ import { DARK, type Theme } from "../appearance/theme";
17
+ import {
18
+ DEFAULT_THEME_NAME,
19
+ isThemeName,
20
+ themeForName,
21
+ type ThemeName,
22
+ } from "../appearance/theme-presets";
23
+ import type { LaunchLayout } from "../app/launch-layout";
24
+
25
+ export interface KeymapConfig {
26
+ [action: string]: string | string[];
27
+ }
28
+
29
+ export interface IntegrationsConfig {
30
+ obsidian: ObsidianConfig;
31
+ }
32
+
33
+ /** Post-submit behavior: "off" prompts, 0 closes instantly, N counts down. */
34
+ export type AutoClose = "off" | number;
35
+
36
+ /** How the Changes diff renders when wide: old|new side by side, or one stacked column. A narrow
37
+ * (unzoomed) pane has no room for two columns, so it is always stacked regardless of this choice. */
38
+ export type DiffViewMode = "split" | "stacked";
39
+
40
+ export type ReviewWorkspaceMode = "worktree" | "current";
41
+
42
+ /** Agent review defaults shared by the slash workflow and Settings. */
43
+ export interface ReviewConfig {
44
+ skill: string;
45
+ workspace: ReviewWorkspaceMode;
46
+ }
47
+
48
+ /** One marker-popover quick action: a preset comment body, plus optional extra lines. */
49
+ export interface QuickAction {
50
+ prompt: string;
51
+ metadata?: string;
52
+ }
53
+
54
+ /** The built-in quick-actions for the marker popover when no `[[actions]]` are configured. */
55
+ export const DEFAULT_QUICK_ACTIONS: QuickAction[] = [
56
+ {
57
+ prompt: "Zoom out, research in depth",
58
+ metadata: "Research the broader problem, prior art, and alternatives before changing anything.",
59
+ },
60
+ {
61
+ prompt: "Restate simplified",
62
+ metadata: "Restate this as the simplest thing that works and cut the incidental complexity.",
63
+ },
64
+ {
65
+ prompt: "Out of scope",
66
+ metadata:
67
+ "This is out of scope for the task, so capture it as a follow-up instead of doing it now.",
68
+ },
69
+ {
70
+ prompt: "Let's chat about this",
71
+ metadata:
72
+ "Do not implement yet, and surface the open questions and trade-offs so we decide together.",
73
+ },
74
+ {
75
+ prompt: "Prototype this",
76
+ metadata: "Build a throwaway prototype to answer the question, skipping tests and polish.",
77
+ },
78
+ {
79
+ prompt: "Ensure 0 regressions",
80
+ metadata:
81
+ "Characterize the current behavior with tests first, then keep them green through the change.",
82
+ },
83
+ {
84
+ prompt: "Consider existing repo patterns",
85
+ metadata:
86
+ "Follow the nearest existing pattern in this codebase rather than introducing a new one.",
87
+ },
88
+ {
89
+ prompt: "LGTM",
90
+ metadata: "This looks good to me.",
91
+ },
92
+ ];
93
+
94
+ /** The comment body a quick action expands to: the prompt, then its system prompt when set. */
95
+ export function quickActionBody(action: QuickAction): string {
96
+ return action.metadata ? `${action.prompt}\n\n${action.metadata}` : action.prompt;
97
+ }
98
+
99
+ /** Resolve a quick action by 1-based index or case-insensitive prompt; undefined when no match. */
100
+ export function resolveQuickAction(
101
+ actions: QuickAction[],
102
+ actionRef: string,
103
+ ): QuickAction | undefined {
104
+ const index = Number(actionRef);
105
+
106
+ if (Number.isInteger(index) && index >= 1 && index <= actions.length) return actions[index - 1];
107
+ const wanted = actionRef.trim().toLowerCase();
108
+
109
+ return actions.find((action) => action.prompt.toLowerCase() === wanted);
110
+ }
111
+
112
+ export interface CueloopConfig {
113
+ keys: Record<string, string[]>;
114
+ theme: Theme;
115
+ themeOverrides: Partial<Theme>;
116
+ ui: {
117
+ autoClose: AutoClose;
118
+ editor?: string;
119
+ theme: ThemeName;
120
+ diffView: DiffViewMode;
121
+ defaultMessage: MessageOutcome;
122
+ pins: string[];
123
+ layout?: LaunchLayout;
124
+ };
125
+ authors: Record<string, string>;
126
+ identity: IdentityConfig;
127
+ actions: QuickAction[];
128
+ skillsPath: string;
129
+ review: ReviewConfig;
130
+ integrations: IntegrationsConfig;
131
+ experimental: ExperimentalConfig;
132
+ }
133
+
134
+ export interface ExperimentalConfig {
135
+ prototypePixels: boolean;
136
+ threadAgent: boolean;
137
+ }
138
+
139
+ /** Where a display name came from: typed by the user, or verified from a GitHub login. */
140
+ export type IdentityProvider = "typed" | "github";
141
+
142
+ export interface IdentityConfig {
143
+ name?: string;
144
+ provider: IdentityProvider;
145
+ }
146
+
147
+ /** Every action in the grammar, with its default binding(s). */
148
+ export const DEFAULT_KEYS: CueloopConfig["keys"] = {
149
+ down: ["j", "down"],
150
+ up: ["k", "up"],
151
+ top: ["g"],
152
+ bottom: ["G"],
153
+ span: ["v"],
154
+ comment: ["c"],
155
+ cut: ["x"],
156
+ reject_hunk: ["X"],
157
+ restore_curation: ["u"],
158
+ collapse_file: ["right"],
159
+ expand_file: ["left"],
160
+ split_diff: ["s"],
161
+ edit: ["e"],
162
+ next_annotation: ["n"],
163
+ prev_annotation: ["p"],
164
+ delete_annotation: ["backspace"],
165
+ rename: ["r"],
166
+ submit: ["return", "enter"],
167
+ share: ["S"],
168
+ quit: ["q"],
169
+ walk: ["w"],
170
+ };
171
+
172
+ const SkillsSectionSchema = v.object({ path: v.optional(v.string()) });
173
+
174
+ function skillsPathFrom(
175
+ raw: v.InferOutput<typeof SkillsSectionSchema> | undefined,
176
+ fallback: string,
177
+ ): string {
178
+ return raw?.path?.trim() ? raw.path.trim() : fallback;
179
+ }
180
+
181
+ const ConfigDocumentSchema = v.object({
182
+ actions: v.optional(v.array(v.unknown())),
183
+ authors: v.optional(v.unknown()),
184
+ identity: v.optional(v.unknown()),
185
+ integrations: v.optional(v.unknown()),
186
+ keys: v.optional(v.unknown()),
187
+ theme: v.optional(v.unknown()),
188
+ ui: v.optional(v.unknown()),
189
+ experimental: v.optional(v.unknown()),
190
+ skills: v.fallback(v.optional(SkillsSectionSchema), undefined),
191
+ review: v.optional(v.unknown()),
192
+ });
193
+
194
+ const IdentitySchema = v.object({
195
+ name: v.fallback(v.optional(v.string()), undefined),
196
+ provider: v.fallback(v.optional(v.picklist(["typed", "github"])), undefined),
197
+ });
198
+
199
+ const QuickActionSchema = v.object({
200
+ prompt: v.pipe(
201
+ v.string(),
202
+ v.check((prompt) => Boolean(prompt.trim())),
203
+ ),
204
+ metadata: v.optional(v.string()),
205
+ });
206
+
207
+ const AuthorsSchema = v.record(v.string(), v.unknown());
208
+ const KeysSchema = v.record(v.string(), v.unknown());
209
+ const KeyComboSchema = v.union([v.string(), v.array(v.string())]);
210
+ // Every field falls back to undefined on its own, so one bad value in a
211
+ // section never discards the section's other settings.
212
+ const UiSchema = v.object({
213
+ auto_close: v.fallback(
214
+ v.optional(v.union([v.literal("off"), v.pipe(v.number(), v.minValue(0))])),
215
+ undefined,
216
+ ),
217
+ editor: v.fallback(v.optional(v.string()), undefined),
218
+ theme: v.fallback(v.optional(v.string()), undefined),
219
+ diff_view: v.fallback(v.optional(v.picklist(["split", "stacked", "unified"])), undefined),
220
+ default_message: v.fallback(v.optional(v.picklist(MESSAGE_OUTCOMES)), undefined),
221
+ pins: v.fallback(v.optional(v.array(v.string())), undefined),
222
+ layout: v.fallback(
223
+ v.optional(
224
+ v.object({
225
+ threads: v.fallback(v.optional(v.boolean()), undefined),
226
+ right_sidebar: v.fallback(v.optional(v.picklist(["changes", "project", "off"])), undefined),
227
+ zoom_changes: v.fallback(v.optional(v.boolean()), undefined),
228
+ }),
229
+ ),
230
+ undefined,
231
+ ),
232
+ });
233
+ const ObsidianSchema = v.object({
234
+ vault: v.fallback(v.optional(v.string()), undefined),
235
+ folder: v.fallback(v.optional(v.string()), undefined),
236
+ filenameFormat: v.fallback(v.optional(v.string()), undefined),
237
+ separator: v.fallback(v.optional(v.picklist(["space", "dash", "underscore"])), undefined),
238
+ exportOn: v.fallback(v.optional(v.picklist(["approved", "message", "manual"])), undefined),
239
+ });
240
+ const IntegrationsSchema = v.object({ obsidian: v.optional(ObsidianSchema) });
241
+ const ExperimentalSchema = v.object({
242
+ prototype_pixels: v.fallback(v.optional(v.boolean()), undefined),
243
+ thread_agent: v.fallback(v.optional(v.boolean()), undefined),
244
+ });
245
+ const ReviewSchema = v.object({
246
+ skill: v.fallback(v.optional(v.string()), undefined),
247
+ workspace: v.fallback(v.optional(v.picklist(["worktree", "current"])), undefined),
248
+ });
249
+ const ThemeOverridesSchema = v.record(v.string(), v.unknown());
250
+
251
+ function isThemeToken(token: string): token is keyof Theme {
252
+ return token in DARK;
253
+ }
254
+
255
+ function parseToml(text: string) {
256
+ return v.parse(ConfigDocumentSchema, Bun.TOML.parse(text));
257
+ }
258
+
259
+ function parseActions(entries: readonly unknown[] | undefined): QuickAction[] | undefined {
260
+ if (!entries) return undefined;
261
+ const actions: QuickAction[] = [];
262
+
263
+ for (const entry of entries) {
264
+ const result = v.safeParse(QuickActionSchema, entry);
265
+
266
+ if (!result.success) continue;
267
+ const { prompt, metadata } = result.output;
268
+
269
+ actions.push(metadata?.trim() ? { prompt, metadata } : { prompt });
270
+ }
271
+
272
+ return actions.length ? actions : undefined;
273
+ }
274
+
275
+ function mergeObsidian(
276
+ target: ObsidianConfig,
277
+ obsidian: v.InferOutput<typeof ObsidianSchema>,
278
+ ): void {
279
+ if (obsidian.vault !== undefined) target.vault = obsidian.vault;
280
+ if (obsidian.folder !== undefined) target.folder = obsidian.folder;
281
+ if (obsidian.filenameFormat !== undefined) target.filenameFormat = obsidian.filenameFormat;
282
+ if (obsidian.separator !== undefined) target.separator = obsidian.separator;
283
+ if (obsidian.exportOn !== undefined) target.exportOn = obsidian.exportOn;
284
+ }
285
+
286
+ /** Fold a parsed `[ui]` table onto the accumulated ui config, field by present field. */
287
+ function applyUi(ui: CueloopConfig["ui"], parsed: v.InferOutput<typeof UiSchema>): void {
288
+ if (parsed.auto_close !== undefined) ui.autoClose = parsed.auto_close;
289
+ if (parsed.editor?.trim()) ui.editor = parsed.editor.trim();
290
+ // "unified" is the pre-rename spelling of "stacked"; keep loading it so an upgrade never flips the layout
291
+ if (parsed.diff_view !== undefined)
292
+ ui.diffView = parsed.diff_view === "unified" ? "stacked" : parsed.diff_view;
293
+ if (parsed.default_message !== undefined) ui.defaultMessage = parsed.default_message;
294
+ if (parsed.pins !== undefined) ui.pins = parsed.pins;
295
+ if (parsed.layout !== undefined) {
296
+ ui.layout = {
297
+ threads: parsed.layout.threads ?? true,
298
+ rightSidebar: parsed.layout.right_sidebar ?? "changes",
299
+ zoomChanges: parsed.layout.zoom_changes ?? false,
300
+ };
301
+ }
302
+ }
303
+
304
+ function applyReview(review: ReviewConfig, parsed: v.InferOutput<typeof ReviewSchema>): void {
305
+ if (parsed.skill?.trim()) review.skill = parsed.skill.trim();
306
+ if (parsed.workspace !== undefined) review.workspace = parsed.workspace;
307
+ }
308
+
309
+ function layer(
310
+ base: CueloopConfig,
311
+ raw: v.InferOutput<typeof ConfigDocumentSchema>,
312
+ allowIdentity: boolean,
313
+ ): CueloopConfig {
314
+ const out: CueloopConfig = {
315
+ keys: { ...base.keys },
316
+ theme: { ...base.theme },
317
+ themeOverrides: { ...base.themeOverrides },
318
+ ui: { ...base.ui },
319
+ authors: { ...base.authors },
320
+ identity: { ...base.identity },
321
+ actions: [...base.actions],
322
+ skillsPath: base.skillsPath,
323
+ review: { ...base.review },
324
+ integrations: { obsidian: { ...base.integrations.obsidian } },
325
+ experimental: { ...base.experimental },
326
+ };
327
+ const actions = parseActions(raw.actions);
328
+ const authors = v.safeParse(AuthorsSchema, raw.authors);
329
+ const identity = v.safeParse(IdentitySchema, raw.identity);
330
+ const keys = v.safeParse(KeysSchema, raw.keys);
331
+ const ui = v.safeParse(UiSchema, raw.ui);
332
+ const integrations = v.safeParse(IntegrationsSchema, raw.integrations);
333
+ const experimental = v.safeParse(ExperimentalSchema, raw.experimental);
334
+ const review = v.safeParse(ReviewSchema, raw.review);
335
+
336
+ if (actions) out.actions = actions;
337
+ out.skillsPath = skillsPathFrom(raw.skills, base.skillsPath);
338
+ if (authors.success) {
339
+ for (const [id, value] of Object.entries(authors.output)) {
340
+ const name = v.safeParse(v.string(), value);
341
+
342
+ if (name.success) out.authors[id] = name.output;
343
+ }
344
+ }
345
+ if (keys.success) {
346
+ for (const [action, value] of Object.entries(keys.output)) {
347
+ const combo = v.safeParse(KeyComboSchema, value);
348
+
349
+ if (combo.success)
350
+ out.keys[action] = Array.isArray(combo.output) ? combo.output : [combo.output];
351
+ }
352
+ }
353
+ // The reviewer identity is a personal credential; a repo must never forge a verified name.
354
+ if (allowIdentity && identity.success) {
355
+ if (identity.output.name !== undefined) out.identity.name = identity.output.name;
356
+ if (identity.output.provider !== undefined) out.identity.provider = identity.output.provider;
357
+ }
358
+ if (ui.success) applyUi(out.ui, ui.output);
359
+ if (integrations.success && integrations.output.obsidian) {
360
+ mergeObsidian(out.integrations.obsidian, integrations.output.obsidian);
361
+ }
362
+ if (experimental.success) applyExperimental(out.experimental, experimental.output);
363
+ if (review.success) applyReview(out.review, review.output);
364
+
365
+ return out;
366
+ }
367
+
368
+ export function loadConfig(
369
+ options: { repoRoot?: string; userConfigPath?: string } = {},
370
+ ): CueloopConfig {
371
+ let config: CueloopConfig = {
372
+ keys: { ...DEFAULT_KEYS },
373
+ theme: { ...DARK },
374
+ themeOverrides: {},
375
+ ui: {
376
+ autoClose: "off",
377
+ theme: DEFAULT_THEME_NAME,
378
+ diffView: "split",
379
+ defaultMessage: "approved",
380
+ pins: [],
381
+ },
382
+ authors: {},
383
+ identity: { provider: "typed" },
384
+ actions: [...DEFAULT_QUICK_ACTIONS],
385
+ skillsPath: join(homedir(), ".agents", "skills"),
386
+ review: { skill: "code-review", workspace: "worktree" },
387
+ integrations: { obsidian: { ...OBSIDIAN_DEFAULTS } },
388
+ experimental: { prototypePixels: false, threadAgent: false },
389
+ };
390
+ // Theme name and per-token overrides are separate concerns, composed once
391
+ // after all layers: the last file to set [ui] theme wins, and every [theme]
392
+ // override from every file lands on top - so a later preset never discards an
393
+ // earlier file's token overrides.
394
+ let themeName = DEFAULT_THEME_NAME;
395
+ const themeOverrides: Partial<Record<keyof Theme, string>> = {};
396
+ const userPath = userConfigPathFrom(options.userConfigPath);
397
+
398
+ for (const path of [
399
+ userPath,
400
+ options.repoRoot ? join(options.repoRoot, ".cueloop", "config.toml") : undefined,
401
+ ]) {
402
+ if (!path || !existsSync(path)) continue;
403
+ try {
404
+ const raw = parseToml(readFileSync(path, "utf8"));
405
+
406
+ config = layer(config, raw, path === userPath);
407
+ const ui = v.safeParse(UiSchema, raw.ui);
408
+ const rawTheme = ui.success ? ui.output.theme : undefined;
409
+ const parsedThemeOverrides = v.safeParse(ThemeOverridesSchema, raw.theme);
410
+
411
+ if (rawTheme && isThemeName(rawTheme)) themeName = rawTheme;
412
+ if (parsedThemeOverrides.success) {
413
+ for (const [token, value] of Object.entries(parsedThemeOverrides.output)) {
414
+ const override = v.safeParse(v.string(), value);
415
+
416
+ if (override.success && isThemeToken(token)) themeOverrides[token] = override.output;
417
+ }
418
+ }
419
+ } catch {
420
+ // a broken config never blocks a review; defaults win
421
+ }
422
+ }
423
+ config.ui.theme = themeName;
424
+ config.themeOverrides = themeOverrides;
425
+ config.theme = { ...themeForName(themeName), ...themeOverrides };
426
+
427
+ return config;
428
+ }
429
+
430
+ /** Reverse lookup: key name (+shift) → action, per the loaded keymap. */
431
+ export function actionFor(
432
+ keys: Record<string, string[]>,
433
+ name: string,
434
+ shift: boolean,
435
+ ): string | undefined {
436
+ const wanted = shift && name.length === 1 ? name.toUpperCase() : name;
437
+
438
+ for (const [action, combos] of Object.entries(keys)) {
439
+ if (combos.includes(wanted)) return action;
440
+ }
441
+
442
+ return undefined;
443
+ }
444
+
445
+ function userConfigPathFrom(userConfigPath?: string): string {
446
+ return (
447
+ userConfigPath ??
448
+ process.env.CUELOOP_CONFIG ??
449
+ join(process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config"), "cueloop", "config.toml")
450
+ );
451
+ }
452
+
453
+ /**
454
+ * Persist one `[ui]` key into the user config file. Line-level TOML surgery on
455
+ * the keys we own: replace the existing assignment, or drop it under an `[ui]`
456
+ * section (appending the section when absent) - a full TOML writer is not worth
457
+ * its weight here.
458
+ */
459
+ function persistUiSetting(key: string, rendered: string, userConfigPath?: string): void {
460
+ const path = userConfigPathFrom(userConfigPath);
461
+ let text = "";
462
+
463
+ if (existsSync(path)) text = readFileSync(path, "utf8");
464
+ const assignment = new RegExp(`^(\\s*)${key}\\s*=.*$`, "m");
465
+
466
+ if (assignment.test(text)) {
467
+ text = text.replace(assignment, `$1${key} = ${rendered}`);
468
+ } else if (/^\[ui\]/m.test(text)) {
469
+ text = text.replace(/^\[ui\]\s*$/m, `[ui]\n${key} = ${rendered}`);
470
+ } else {
471
+ text = text.trimEnd() + (text.trim() ? "\n\n" : "") + `[ui]\n${key} = ${rendered}\n`;
472
+ }
473
+ mkdirSync(dirname(path), { recursive: true });
474
+ writeFileSync(path, text);
475
+ }
476
+
477
+ function persistTableSetting(
478
+ table: string,
479
+ key: string,
480
+ rendered: string,
481
+ userConfigPath?: string,
482
+ ): void {
483
+ const path = userConfigPathFrom(userConfigPath);
484
+ let text = existsSync(path) ? readFileSync(path, "utf8") : "";
485
+ const escapedTable = escapeRegExp(table);
486
+ const block = new RegExp(
487
+ `(^\\[${escapedTable}\\][ \\t]*(?:#[^\\r\\n]*)?\\r?$)([\\s\\S]*?)(?=^\\[|(?![\\s\\S]))`,
488
+ "m",
489
+ );
490
+ const match = block.exec(text);
491
+
492
+ if (match) {
493
+ const assignment = new RegExp(`^(\\s*)${escapeRegExp(key)}\\s*=.*$`, "m");
494
+ const body = assignment.test(match[2]!)
495
+ ? match[2]!.replace(assignment, `$1${key} = ${rendered}`)
496
+ : `\n${key} = ${rendered}${match[2]!}`;
497
+
498
+ text =
499
+ text.slice(0, match.index) + match[1]! + body + text.slice(match.index + match[0].length);
500
+ } else {
501
+ text = `${text.trimEnd()}${text.trim() ? "\n\n" : ""}[${table}]\n${key} = ${rendered}\n`;
502
+ }
503
+ mkdirSync(dirname(path), { recursive: true });
504
+ writeFileSync(path, text);
505
+ }
506
+
507
+ /** A double-quoted TOML basic string with the quote-breaking characters escaped. */
508
+ function tomlString(value: string): string {
509
+ return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n")}"`;
510
+ }
511
+
512
+ /**
513
+ * Persist the whole quick-action set as `[[actions]]` tables, replacing any
514
+ * existing ones. The set is the user's entire vocabulary (not merged), so the
515
+ * old blocks are stripped and the new ones appended. An empty set clears them.
516
+ */
517
+ export function persistActions(actions: QuickAction[], userConfigPath?: string): void {
518
+ const path = userConfigPathFrom(userConfigPath);
519
+ let text = existsSync(path) ? readFileSync(path, "utf8") : "";
520
+
521
+ // strip every existing [[actions]] block (header through its key lines)
522
+ text = text.replace(/^\[\[actions\]\][^[]*/gm, "").trimEnd();
523
+ const blocks = actions
524
+ .map((action) => {
525
+ const lines = [`[[actions]]`, `prompt = ${tomlString(action.prompt)}`];
526
+
527
+ if (action.metadata) lines.push(`metadata = ${tomlString(action.metadata)}`);
528
+
529
+ return lines.join("\n");
530
+ })
531
+ .join("\n\n");
532
+
533
+ text = blocks ? `${text ? `${text}\n\n` : ""}${blocks}\n` : `${text}\n`;
534
+ mkdirSync(dirname(path), { recursive: true });
535
+ writeFileSync(path, text);
536
+ }
537
+
538
+ /** Persist the reviewer's own display name and its provider (`[identity]`) into the user config. */
539
+ export function persistIdentity(identity: IdentityConfig, userConfigPath?: string): void {
540
+ const path = userConfigPathFrom(userConfigPath);
541
+ let text = existsSync(path) ? readFileSync(path, "utf8") : "";
542
+ const nameLine = identity.name === undefined ? "" : `name = ${tomlString(identity.name)}\n`;
543
+ const block = `[identity]\n${nameLine}provider = ${tomlString(identity.provider)}\n`;
544
+
545
+ if (/^\[identity\]/m.test(text)) {
546
+ // Consume the header and its assignment lines only, so a bracket in a quoted name cannot truncate the block.
547
+ text = text.replace(/^\[identity\].*(?:\n(?![[\n]).*)*\n?/m, block);
548
+ } else {
549
+ text = text.trimEnd() + (text.trim() ? "\n\n" : "") + block;
550
+ }
551
+ mkdirSync(dirname(path), { recursive: true });
552
+ writeFileSync(path, text);
553
+ }
554
+
555
+ /** Persist the auto-close choice (`[ui] auto_close`) into the user config. */
556
+ export function persistAutoClose(value: AutoClose, userConfigPath?: string): void {
557
+ persistUiSetting("auto_close", value === "off" ? '"off"' : String(value), userConfigPath);
558
+ }
559
+
560
+ /** Persist the selected theme preset (`[ui] theme`) into the user config. */
561
+ export function persistTheme(name: ThemeName, userConfigPath?: string): void {
562
+ persistUiSetting("theme", `"${name}"`, userConfigPath);
563
+ }
564
+
565
+ /** Persist the diff-view choice (`[ui] diff_view`) into the user config. */
566
+ export function persistDiffView(mode: DiffViewMode, userConfigPath?: string): void {
567
+ persistUiSetting("diff_view", `"${mode}"`, userConfigPath);
568
+ }
569
+
570
+ /** Persist the configured agent review skill (`[review] skill`). */
571
+ export function persistReviewSkill(skill: string, userConfigPath?: string): void {
572
+ persistTableSetting("review", "skill", tomlString(skill), userConfigPath);
573
+ }
574
+
575
+ /** Persist where agent review work runs (`[review] workspace`). */
576
+ export function persistReviewWorkspace(
577
+ workspace: ReviewWorkspaceMode,
578
+ userConfigPath?: string,
579
+ ): void {
580
+ persistTableSetting("review", "workspace", tomlString(workspace), userConfigPath);
581
+ }
582
+
583
+ /** Persist the pinned-thread ids (`[ui] pins`) into the user config. */
584
+ export function persistPins(ids: readonly string[], userConfigPath?: string): void {
585
+ persistUiSetting("pins", `[${ids.map(tomlString).join(", ")}]`, userConfigPath);
586
+ }
587
+
588
+ /** Persist the last pane layout (`[ui] layout`) so a bare launch restores it. */
589
+ export function persistLayout(layout: LaunchLayout, userConfigPath?: string): void {
590
+ const table = `{ threads = ${layout.threads}, right_sidebar = "${layout.rightSidebar}", zoom_changes = ${layout.zoomChanges} }`;
591
+
592
+ persistUiSetting("layout", table, userConfigPath);
593
+ }
594
+
595
+ function escapeRegExp(text: string): string {
596
+ return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
597
+ }
598
+
599
+ /**
600
+ * Persist an author rename into the `[authors]` table. The identity id (an SSH
601
+ * fingerprint) is not a bare TOML key, so it is written quoted.
602
+ */
603
+ export function persistAuthorName(id: string, name: string, userConfigPath?: string): void {
604
+ const path = userConfigPathFrom(userConfigPath);
605
+ let text = existsSync(path) ? readFileSync(path, "utf8") : "";
606
+ const key = `"${id.replace(/"/g, '\\"')}"`;
607
+ const value = `"${name.replace(/"/g, '\\"')}"`;
608
+ const assignment = new RegExp(`^(\\s*)${escapeRegExp(key)}\\s*=.*$`, "m");
609
+
610
+ if (assignment.test(text)) {
611
+ text = text.replace(assignment, `$1${key} = ${value}`);
612
+ } else if (/^\[authors\]/m.test(text)) {
613
+ text = text.replace(/^\[authors\]\s*$/m, `[authors]\n${key} = ${value}`);
614
+ } else {
615
+ text = text.trimEnd() + (text.trim() ? "\n\n" : "") + `[authors]\n${key} = ${value}\n`;
616
+ }
617
+ mkdirSync(dirname(path), { recursive: true });
618
+ writeFileSync(path, text);
619
+ }
620
+
621
+ /** Only explicitly configured experimental switches override the preceding layer. */
622
+ function applyExperimental(
623
+ config: ExperimentalConfig,
624
+ raw: v.InferOutput<typeof ExperimentalSchema>,
625
+ ): void {
626
+ if (raw.prototype_pixels !== undefined) config.prototypePixels = raw.prototype_pixels;
627
+ if (raw.thread_agent !== undefined) config.threadAgent = raw.thread_agent;
628
+ }