@cueloop/client 0.1.0-alpha.0 → 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 (345) 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 +41 -27
  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/App.autoclose.test.tsx +144 -0
  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 -80
  333. package/src/App.readonly.test.tsx +0 -148
  334. package/src/App.test.tsx +0 -217
  335. package/src/App.tsx +0 -0
  336. package/src/config.test.ts +0 -116
  337. package/src/config.ts +0 -113
  338. package/src/editor.ts +0 -34
  339. package/src/run.ts +0 -28
  340. package/src/serve.ts +0 -75
  341. package/src/theme.ts +0 -46
  342. package/src/view-diff.test.ts +0 -55
  343. package/src/view-diff.ts +0 -86
  344. package/src/view.test.ts +0 -157
  345. package/src/view.ts +0 -0
@@ -0,0 +1,78 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ defaultLayout,
4
+ layoutFromPanes,
5
+ pairLayout,
6
+ planLayout,
7
+ pullRequestReviewLayout,
8
+ reviewLayout,
9
+ } from "./launch-layout";
10
+
11
+ describe("launch layout factories", () => {
12
+ test("review opens the diff zoomed with the Changes panel", () => {
13
+ // Assert
14
+ expect(reviewLayout()).toEqual({ threads: true, rightSidebar: "changes", zoomChanges: true });
15
+ });
16
+
17
+ test("pair opens the Project tree focused beside a zoomed Changes editor", () => {
18
+ expect(pairLayout()).toEqual({
19
+ threads: true,
20
+ rightSidebar: "changes",
21
+ zoomChanges: true,
22
+ projectMode: "tree",
23
+ focusPane: "project",
24
+ });
25
+ });
26
+
27
+ test("pull request review keeps its brief beside the Changes panel", () => {
28
+ // Assert
29
+ expect(pullRequestReviewLayout()).toEqual({
30
+ threads: true,
31
+ rightSidebar: "changes",
32
+ zoomChanges: false,
33
+ });
34
+ });
35
+
36
+ test("plan fills the middle with the thread pane and no right region", () => {
37
+ // Assert
38
+ expect(planLayout()).toEqual({ threads: true, rightSidebar: "off", zoomChanges: false });
39
+ });
40
+
41
+ test("the first-run default lands on the inbox with the diff zoomed", () => {
42
+ // Assert
43
+ expect(defaultLayout()).toEqual({ threads: true, rightSidebar: "changes", zoomChanges: true });
44
+ });
45
+ });
46
+
47
+ describe("layoutFromPanes", () => {
48
+ test("the Changes editor wins the right region when open", () => {
49
+ // Assert
50
+ expect(
51
+ layoutFromPanes({ threads: true, changesOpen: true, projectOpen: true, zoomChanges: true }),
52
+ ).toEqual({ threads: true, rightSidebar: "changes", zoomChanges: true });
53
+ });
54
+
55
+ test("the Project tree holds the right region when the Changes editor is closed", () => {
56
+ // Assert
57
+ expect(
58
+ layoutFromPanes({
59
+ threads: false,
60
+ changesOpen: false,
61
+ projectOpen: true,
62
+ zoomChanges: false,
63
+ }),
64
+ ).toEqual({ threads: false, rightSidebar: "project", zoomChanges: false });
65
+ });
66
+
67
+ test("the right region is off when neither pane is open", () => {
68
+ // Assert
69
+ expect(
70
+ layoutFromPanes({
71
+ threads: true,
72
+ changesOpen: false,
73
+ projectOpen: false,
74
+ zoomChanges: false,
75
+ }),
76
+ ).toEqual({ threads: true, rightSidebar: "off", zoomChanges: false });
77
+ });
78
+ });
@@ -0,0 +1,57 @@
1
+ /**
2
+ * The pane composition a launch opens with: whether the Threads sidebar is open,
3
+ * what the right region shows, and whether the Changes editor is zoomed. Each
4
+ * command picks a layout through a factory; a bare launch restores the last one
5
+ * the user left (persisted in config), falling back to the default.
6
+ */
7
+
8
+ export interface LaunchLayout {
9
+ threads: boolean;
10
+ rightSidebar: "changes" | "project" | "off";
11
+ zoomChanges: boolean;
12
+ projectMode?: "changes" | "tree";
13
+ focusPane?: "threads" | "thread" | "changes" | "project";
14
+ }
15
+
16
+ /** `cueloop diff`: the diff front and center. */
17
+ export function reviewLayout(): LaunchLayout {
18
+ return { threads: true, rightSidebar: "changes", zoomChanges: true };
19
+ }
20
+
21
+ /** `cueloop pair`: browse project files beside the zoomed Changes editor. */
22
+ export function pairLayout(): LaunchLayout {
23
+ return {
24
+ threads: true,
25
+ rightSidebar: "changes",
26
+ zoomChanges: true,
27
+ projectMode: "tree",
28
+ focusPane: "project",
29
+ };
30
+ }
31
+
32
+ /** `cueloop review <pr>`: PR context beside the Changes panel. */
33
+ export function pullRequestReviewLayout(): LaunchLayout {
34
+ return { threads: true, rightSidebar: "changes", zoomChanges: false };
35
+ }
36
+
37
+ /** `cueloop plan` / `cueloop reply`: the thread pane fills the middle; no Changes panel. */
38
+ export function planLayout(): LaunchLayout {
39
+ return { threads: true, rightSidebar: "off", zoomChanges: false };
40
+ }
41
+
42
+ /** The first-run default, before any layout has been remembered: the inbox with the diff zoomed. */
43
+ export function defaultLayout(): LaunchLayout {
44
+ return { threads: true, rightSidebar: "changes", zoomChanges: true };
45
+ }
46
+
47
+ /** Read back the layout the panes currently sit in, to remember it for the next bare launch. */
48
+ export function layoutFromPanes(panes: {
49
+ threads: boolean;
50
+ changesOpen: boolean;
51
+ projectOpen: boolean;
52
+ zoomChanges: boolean;
53
+ }): LaunchLayout {
54
+ const rightSidebar = panes.changesOpen ? "changes" : panes.projectOpen ? "project" : "off";
55
+
56
+ return { threads: panes.threads, rightSidebar, zoomChanges: panes.zoomChanges };
57
+ }
@@ -0,0 +1,85 @@
1
+ /** The ready signal fires once, after a frame, and writes the ready file when the env names one. */
2
+
3
+ import { afterEach, describe, expect, test } from "bun:test";
4
+ import { existsSync, mkdtempSync, rmSync } from "node:fs";
5
+ import { tmpdir } from "node:os";
6
+ import { join } from "node:path";
7
+ import { createElement, useState } from "react";
8
+ import { testRender } from "@opentui/react/test-utils";
9
+ import { READY_FILE_ENV, useReadySignal } from "./ready-signal";
10
+ import { settle } from "../testing/test-support";
11
+
12
+ const homes: string[] = [];
13
+
14
+ afterEach(() => {
15
+ for (const home of homes.splice(0)) rmSync(home, { recursive: true, force: true });
16
+ delete process.env[READY_FILE_ENV];
17
+ });
18
+
19
+ /** Hands its readiness setter to the test, so a frame can be rendered before and after the flip. */
20
+ function ReadyProbe(props: {
21
+ initiallyReady: boolean;
22
+ onReady: () => void;
23
+ exposeSetter?: (setReady: (ready: boolean) => void) => void;
24
+ }) {
25
+ const [ready, setReady] = useState(props.initiallyReady);
26
+
27
+ props.exposeSetter?.(setReady);
28
+ useReadySignal(ready, props.onReady);
29
+
30
+ return createElement("text", null, ready ? "ready" : "booting");
31
+ }
32
+
33
+ describe("useReadySignal", () => {
34
+ test("fires once on the first frame after ready turns true", async () => {
35
+ // Arrange - not ready yet
36
+ let fired = 0;
37
+ let setReady: ((ready: boolean) => void) | null = null;
38
+ const onReady = () => {
39
+ fired += 1;
40
+ };
41
+ const setup = await testRender(
42
+ createElement(ReadyProbe, {
43
+ initiallyReady: false,
44
+ onReady,
45
+ exposeSetter: (setter) => {
46
+ setReady = setter;
47
+ },
48
+ }),
49
+ { width: 20, height: 3 },
50
+ );
51
+
52
+ await settle(setup);
53
+ expect(fired).toBe(0);
54
+
55
+ // Act - ready, then let the commit, the effect, and more frames than one signal needs run
56
+ setReady!(true);
57
+ await settle(setup);
58
+ await settle(setup);
59
+
60
+ // Assert - exactly one signal, and the screen shows the ready tree
61
+ expect(fired).toBe(1);
62
+ expect(setup.captureCharFrame()).toContain("ready");
63
+ setup.renderer.destroy();
64
+ });
65
+
66
+ test("writes the ready file through the env channel", async () => {
67
+ // Arrange
68
+ const home = mkdtempSync(join(tmpdir(), "cueloop-ready-"));
69
+ const readyFile = join(home, "ready");
70
+
71
+ homes.push(home);
72
+ process.env[READY_FILE_ENV] = readyFile;
73
+ const setup = await testRender(
74
+ createElement(ReadyProbe, { initiallyReady: true, onReady: () => {} }),
75
+ { width: 20, height: 3 },
76
+ );
77
+
78
+ // Act
79
+ await settle(setup);
80
+
81
+ // Assert
82
+ expect(existsSync(readyFile)).toBe(true);
83
+ setup.renderer.destroy();
84
+ });
85
+ });
@@ -0,0 +1,52 @@
1
+ /**
2
+ * The app ready signal: fired once, after the first frame that paints a
3
+ * usable screen (a session, the no-thread shell, or an error) has been
4
+ * flushed. React runs child effects before parent effects, so by the time the
5
+ * App-level effect arms the frame listener every keyboard handler of that
6
+ * first screen is already subscribed - a key sent after the signal cannot be
7
+ * dropped. Surfaces that mount later (a composer, a lazily loaded tab) still
8
+ * need their own screen predicate. Tests wait on this instead of guessing
9
+ * from output silence. Two channels: an
10
+ * `onReady` callback for in-process suites, and the file named by
11
+ * CUELOOP_READY_FILE for subprocess and PTY suites. Users never see either.
12
+ */
13
+
14
+ import { writeFileSync } from "node:fs";
15
+ import { useEffect, useRef } from "react";
16
+ import { useRenderer } from "@opentui/react";
17
+
18
+ /** The env var naming the file the app writes once it is ready; unset in normal use. */
19
+ export const READY_FILE_ENV = "CUELOOP_READY_FILE";
20
+
21
+ /** Write the ready file when CUELOOP_READY_FILE names one; a no-op otherwise, so the signal costs nothing outside tests. */
22
+ function announceReady(): void {
23
+ const path = process.env[READY_FILE_ENV];
24
+
25
+ if (!path) return;
26
+ writeFileSync(path, "ready\n");
27
+ }
28
+
29
+ /**
30
+ * Fire the ready signal once, on the first rendered frame after `ready`
31
+ * turns true. Call it after every keyboard hook of the component so their
32
+ * subscriptions are in place when the signal goes out.
33
+ */
34
+ export function useReadySignal(ready: boolean, onReady?: () => void): void {
35
+ const renderer = useRenderer();
36
+ const fired = useRef(false);
37
+
38
+ useEffect(() => {
39
+ if (!ready || fired.current) return;
40
+ const emit = (): void => {
41
+ fired.current = true;
42
+ announceReady();
43
+ onReady?.();
44
+ };
45
+
46
+ renderer?.once("frame", emit);
47
+
48
+ return () => {
49
+ renderer?.off("frame", emit);
50
+ };
51
+ }, [ready, renderer, onReady]);
52
+ }
package/src/app/run.ts ADDED
@@ -0,0 +1,109 @@
1
+ /** Real-terminal entry: render the App and resolve when the user quits. */
2
+
3
+ import React from "react";
4
+ import { createCliRenderer } from "@opentui/core";
5
+ import { createRoot } from "@opentui/react";
6
+ import { App } from "./App";
7
+ import { closePrototypeBrowser } from "../prototype/prototype-browser";
8
+ import type { Appearance } from "../appearance/theme-presets";
9
+ import type { DiffFileContents, Thread } from "@cueloop/schema";
10
+ import type { ThreadClient } from "@cueloop/daemon/client";
11
+ import { loadConfig } from "../settings/config";
12
+ import { perfMark } from "../performance/perf-timings";
13
+ import { reportPerfMarks } from "../performance/perf-report";
14
+ import { defaultLayout, type LaunchLayout } from "./launch-layout";
15
+ import {
16
+ ClientExtensionRegistry,
17
+ loadInstalledClientExtensions,
18
+ } from "../integrations/client-extension-registry";
19
+
20
+ export interface RunClientOptions {
21
+ sessionId?: string;
22
+ initialReview?: { session: Thread; diff: { patch: string; files: DiffFileContents[] } };
23
+ initialClient?: ThreadClient;
24
+ home?: string;
25
+ layout?: LaunchLayout;
26
+ }
27
+
28
+ /** OSC background query budget: long enough for a terminal to answer, short enough that one that never
29
+ * does not stall the launch; a non-answering terminal falls back to dark after this. */
30
+ const THEME_QUERY_TIMEOUT_MS = 100;
31
+
32
+ export async function runClient(options: RunClientOptions): Promise<number> {
33
+ // mouse movement reporting makes multiplexers forward drags to the app,
34
+ // so the renderer's native selection is the drag driver
35
+ // a create-command dictates its layout; a bare inbox launch restores the remembered one, then the
36
+ // default; opening a specific thread carries no layout, so its own pane sync drives the composition
37
+ const layout =
38
+ options.layout ??
39
+ (options.sessionId === undefined ? (loadConfig().ui.layout ?? defaultLayout()) : undefined);
40
+ const renderer = await createCliRenderer({ enableMouseMovement: true });
41
+ const extensionRegistry = new ClientExtensionRegistry();
42
+
43
+ void loadInstalledClientExtensions(extensionRegistry).catch((error) => {
44
+ extensionRegistry.reportError(`Client extension discovery: ${String(error)}`);
45
+ });
46
+ // a full screen of measured elements each holds a frame listener; lift the default-10 ceiling so a
47
+ // busy view does not trip a false leak warning, while a runaway subscription still would
48
+ renderer.setMaxListeners(64);
49
+ perfMark("renderer");
50
+ // re-assert mouse reporting on focus-in: a multiplexer can drop it, stranding the pointer in native selection
51
+ renderer.on("focus", () => {
52
+ renderer.useMouse = false;
53
+ renderer.useMouse = true;
54
+ });
55
+
56
+ return new Promise<number>((resolve) => {
57
+ let exited = false;
58
+ const shutdown = async (code: number): Promise<void> => {
59
+ if (exited) return;
60
+ exited = true;
61
+ renderer.destroy();
62
+ // close the warm prototype Chromium before exit so it never orphans; the
63
+ // await also gives the renderer time to flush its terminal-restore bytes
64
+ await closePrototypeBrowser();
65
+ resolve(code);
66
+ process.exit(code);
67
+ };
68
+
69
+ // a closed pane/terminal hangs up (or the parent kills us); exit instead of
70
+ // lingering as an orphan whose pty read loop keeps burning CPU
71
+ process.once("SIGHUP", () => void shutdown(0));
72
+ process.once("SIGTERM", () => void shutdown(0));
73
+
74
+ const root = createRoot(renderer);
75
+ const renderApp = (appearance: Appearance): void => {
76
+ root.render(
77
+ React.createElement(App, {
78
+ home: options.home,
79
+ sessionId: options.sessionId,
80
+ initialReview: options.initialReview,
81
+ initialClient: options.initialClient,
82
+ appearance,
83
+ layout,
84
+ extensionRegistry,
85
+ onExit: shutdown,
86
+ onReady: () => {
87
+ perfMark("firstFrame");
88
+ reportPerfMarks("startup");
89
+ },
90
+ }),
91
+ );
92
+ };
93
+
94
+ // Paint immediately with the default appearance; the OSC theme query resolves off the
95
+ // first-paint path and upgrades the theme in place when the terminal answers, so launch
96
+ // never blocks on it. A non-answering terminal simply stays dark.
97
+ renderApp("dark");
98
+ perfMark("render");
99
+
100
+ void renderer
101
+ .waitForThemeMode(THEME_QUERY_TIMEOUT_MS)
102
+ .then((mode) => {
103
+ perfMark("themeQuery");
104
+ // the query can answer after a fast quit; never render onto a destroyed renderer
105
+ if (!exited && mode && mode !== "dark") renderApp(mode);
106
+ })
107
+ .catch(() => {});
108
+ });
109
+ }
@@ -10,14 +10,14 @@ import { tmpdir } from "node:os";
10
10
  import { join } from "node:path";
11
11
  import { Client } from "ssh2";
12
12
  import { DaemonServer } from "@cueloop/daemon";
13
- import type { ReviewSession } from "@cueloop/schema";
13
+ import type { Thread } from "@cueloop/schema";
14
14
  import { serveClient, type ServeHandle } from "./serve";
15
15
 
16
16
  const PLAN = "# Rollout Plan\n\nShip the store move behind a flag.\n";
17
17
 
18
18
  let home: string;
19
19
  let daemon: DaemonServer;
20
- let session: ReviewSession;
20
+ let session: Thread;
21
21
  let handle: ServeHandle;
22
22
 
23
23
  beforeEach(async () => {
@@ -38,7 +38,11 @@ afterEach(async () => {
38
38
  });
39
39
 
40
40
  /** Connect, open a PTY shell, resolve with the bytes seen until `until` matches. */
41
- function sshCapture(port: number, until: (s: string) => boolean, timeoutMs = 8000): Promise<string> {
41
+ function sshCapture(
42
+ port: number,
43
+ until: (frame: string) => boolean,
44
+ timeoutMs = 8000,
45
+ ): Promise<string> {
42
46
  return new Promise((resolve, reject) => {
43
47
  const conn = new Client();
44
48
  let out = "";
@@ -48,15 +52,17 @@ function sshCapture(port: number, until: (s: string) => boolean, timeoutMs = 800
48
52
  done = true;
49
53
  clearTimeout(timer);
50
54
  conn.end();
51
- err ? reject(err) : resolve(out);
55
+ if (err) reject(err);
56
+ else resolve(out);
52
57
  };
53
58
  const timer = setTimeout(() => finish(new Error(`timed out; captured:\n${out}`)), timeoutMs);
59
+
54
60
  conn
55
61
  .on("ready", () => {
56
62
  conn.shell({ term: "xterm-256color", cols: 100, rows: 30 }, (err, stream) => {
57
63
  if (err) return finish(err);
58
- stream.on("data", (d: Buffer) => {
59
- out += d.toString("utf8");
64
+ stream.on("data", (chunk: Buffer) => {
65
+ out += chunk.toString("utf8");
60
66
  if (until(out)) finish();
61
67
  });
62
68
  stream.on("close", () => finish(new Error(`stream closed early; captured:\n${out}`)));
@@ -70,6 +76,7 @@ function sshCapture(port: number, until: (s: string) => boolean, timeoutMs = 800
70
76
 
71
77
  describe("cueloop serve", () => {
72
78
  test("listens on localhost, persists a host key under CUELOOP_HOME/ssh", () => {
79
+ // Assert
73
80
  expect(handle.host).toBe("127.0.0.1");
74
81
  expect(handle.port).toBeGreaterThan(0);
75
82
  expect(handle.fingerprints.length).toBe(1);
@@ -77,19 +84,24 @@ describe("cueloop serve", () => {
77
84
  expect(existsSync(join(home, "ssh", "host_key"))).toBe(true);
78
85
  });
79
86
 
80
- test("an anonymous ssh client gets the observer TUI for the session", async () => {
81
- const bytes = await sshCapture(handle.port, (s) => s.includes("Rollout Plan") && s.includes("observer"));
87
+ test("an anonymous ssh client gets the read-only TUI for the session", async () => {
88
+ // Act
89
+ const bytes = await sshCapture(handle.port, (frame) => frame.includes("Rollout Plan"));
90
+
91
+ // Assert - the viewer sees the session; read-only is enforced behaviorally
92
+ // (covered by the observer-primitives tests), not by a header badge
82
93
  expect(bytes).toContain("cueloop");
83
94
  expect(bytes).toContain("Rollout Plan");
84
- // observer chrome, not the controller's mutating hint bar
85
- expect(bytes).toContain("observer - read-only");
86
95
  });
87
96
 
88
97
  test("two observers can watch at once", async () => {
98
+ // Act
89
99
  const [a, b] = await Promise.all([
90
- sshCapture(handle.port, (s) => s.includes("Rollout Plan")),
91
- sshCapture(handle.port, (s) => s.includes("Rollout Plan")),
100
+ sshCapture(handle.port, (frame) => frame.includes("Rollout Plan")),
101
+ sshCapture(handle.port, (frame) => frame.includes("Rollout Plan")),
92
102
  ]);
103
+
104
+ // Assert
93
105
  expect(a).toContain("Rollout Plan");
94
106
  expect(b).toContain("Rollout Plan");
95
107
  });
@@ -0,0 +1,111 @@
1
+ /**
2
+ * SSH-served TUI: `cueloop serve` lets teammates join a
3
+ * thread over plain ssh. Every SSH connection renders <App> in
4
+ * observer mode (readOnly) against the same local daemon; the one writable
5
+ * controller stays the local owner's own `cueloop` TUI.
6
+ *
7
+ * Trust model - tunnel of trust, no passwords, no keys:
8
+ * the server authenticates nobody ("open" auth). Access control is the act of
9
+ * sharing the address deliberately: it binds to 127.0.0.1 by default, so a
10
+ * remote teammate reaches it only through a channel the owner opened on
11
+ * purpose (an SSH tunnel, a tailnet address via --host). Anyone who can reach
12
+ * the port can watch - and only watch: observers cannot mutate the session.
13
+ * The host key is generated once under CUELOOP_HOME/ssh/host_key (0600) so
14
+ * fingerprints stay stable across restarts.
15
+ */
16
+
17
+ import { mkdirSync } from "node:fs";
18
+ import { join } from "node:path";
19
+ import React from "react";
20
+ import { createRoot } from "@opentui/react";
21
+ import { createServer } from "@opentui/ssh";
22
+ import { cueloopHome } from "@cueloop/daemon";
23
+ import { DaemonClient } from "@cueloop/daemon/client";
24
+ import type { Artifact } from "@cueloop/schema";
25
+ import { App } from "./App";
26
+ import { snapshotWorkbench } from "../workbench/workbench-snapshot";
27
+
28
+ /** OSC background query budget: brief so a terminal that never answers falls back to dark. */
29
+ const THEME_QUERY_TIMEOUT_MS = 200;
30
+
31
+ /**
32
+ * The frozen diff an observer reads for a served workbench thread: captured once at serve time, so a
33
+ * late local edit never shifts it. Undefined when nothing is served or the thread is not a workbench.
34
+ */
35
+ async function captureFrozenArtifact(
36
+ home: string,
37
+ sessionId: string | undefined,
38
+ ): Promise<Artifact | undefined> {
39
+ if (sessionId === undefined) return undefined;
40
+
41
+ const probe = await DaemonClient.connect({ home, autostart: true });
42
+
43
+ try {
44
+ const thread = await probe.sessionGet(sessionId).catch(() => undefined);
45
+
46
+ if (thread === undefined) return undefined;
47
+
48
+ const snapshot = await snapshotWorkbench(thread, (root, vcs) => probe.repoDiff(root, vcs));
49
+
50
+ return snapshot === thread ? undefined : snapshot.artifact;
51
+ } finally {
52
+ probe.close();
53
+ }
54
+ }
55
+
56
+ export interface ServeOptions {
57
+ port?: number;
58
+ host?: string;
59
+ sessionId?: string;
60
+ home?: string;
61
+ banner?: boolean;
62
+ onError?: (cause: unknown) => void;
63
+ }
64
+
65
+ export interface ServeHandle {
66
+ host: string;
67
+ port: number;
68
+ fingerprints: string[];
69
+ close(): Promise<void>;
70
+ }
71
+
72
+ export async function serveClient(options: ServeOptions = {}): Promise<ServeHandle> {
73
+ const home = options.home ?? cueloopHome();
74
+ const sshDir = join(home, "ssh");
75
+
76
+ mkdirSync(sshDir, { recursive: true, mode: 0o700 });
77
+
78
+ const servedArtifact = await captureFrozenArtifact(home, options.sessionId);
79
+
80
+ const server = createServer({
81
+ // password-less by design; see the trust model in the module comment
82
+ auth: "open",
83
+ hostKey: { path: join(sshDir, "host_key") },
84
+ startupBanner: options.banner ?? true,
85
+ idleTimeout: "2h",
86
+ onError: options.onError,
87
+ }).serve(async (session) => {
88
+ // the observer's terminal reports its own background, so a light-terminal
89
+ // watcher gets the readable variant too
90
+ const appearance =
91
+ (await session.renderer.waitForThemeMode(THEME_QUERY_TIMEOUT_MS).catch(() => null)) ?? "dark";
92
+ const root = createRoot(session.renderer);
93
+
94
+ root.render(
95
+ React.createElement(App, {
96
+ home,
97
+ sessionId: options.sessionId,
98
+ readOnly: true,
99
+ appearance,
100
+ servedArtifact,
101
+ // q disconnects only this observer, never the server
102
+ onExit: () => session.end(),
103
+ }),
104
+ );
105
+ session.onClose(() => root.unmount());
106
+ });
107
+
108
+ const info = await server.listen(options.port ?? 2222, options.host ?? "127.0.0.1");
109
+
110
+ return { ...info, close: () => server.close() };
111
+ }
@@ -0,0 +1,5 @@
1
+ /** The published client version, read from the package manifest. */
2
+
3
+ import manifest from "../../package.json" with { type: "json" };
4
+
5
+ export const CLIENT_VERSION: string = manifest.version;
@@ -0,0 +1,46 @@
1
+ import React from "react";
2
+ import { DARK, type Theme } from "../theme";
3
+ import type { Story, StoryMeta } from "../../stories/story";
4
+ import { ThemeProvider } from "./theme-context";
5
+ import { Card } from "../../ui/components/primitives/Card";
6
+
7
+ export const meta: StoryMeta = { title: "Foundations/Theme" };
8
+
9
+ /** A high-contrast variant to prove tokens flow through the provider. */
10
+ const HIGH_CONTRAST: Theme = { ...DARK, accent: "#00ffff", elevated: "#000000", border: "#ffffff" };
11
+
12
+ export const ContextDefault: Story = {
13
+ render: () => (
14
+ <Card title=" default dark theme " contentRows={1}>
15
+ <text>no provider needed</text>
16
+ </Card>
17
+ ),
18
+ expectedColors: [DARK.elevated],
19
+ };
20
+
21
+ export const ProviderSwap: Story = {
22
+ render: () => (
23
+ <ThemeProvider theme={HIGH_CONTRAST}>
24
+ <Card title=" provider theme " contentRows={1} borderColor={HIGH_CONTRAST.accent}>
25
+ <text>tokens come from the provider</text>
26
+ </Card>
27
+ </ThemeProvider>
28
+ ),
29
+ expectedColors: ["#00ffff", "#000000"],
30
+ };
31
+
32
+ export const PropOverride: Story = {
33
+ render: () => (
34
+ <ThemeProvider theme={DARK}>
35
+ <Card
36
+ title=" prop-overridden theme "
37
+ contentRows={1}
38
+ theme={HIGH_CONTRAST}
39
+ borderColor={HIGH_CONTRAST.accent}
40
+ >
41
+ <text>the theme prop beats the context</text>
42
+ </Card>
43
+ </ThemeProvider>
44
+ ),
45
+ expectedColors: ["#00ffff"],
46
+ };
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Provider theming for the component system. The context default is the
3
+ * built-in dark theme, so embedding a component (or rendering a story) needs
4
+ * no provider at all. One ThemeProvider swap switches the live theme; every
5
+ * component also accepts an optional `theme` prop that overrides the context
6
+ * for explicit control. Components consume named tokens only - a hardcoded
7
+ * color inside components/ is a review rejection.
8
+ */
9
+
10
+ import React, { createContext, useContext } from "react";
11
+ import { DARK, type Theme } from "../theme";
12
+
13
+ const ThemeContext = createContext<Theme>(DARK);
14
+
15
+ export function ThemeProvider({
16
+ theme,
17
+ children,
18
+ }: {
19
+ theme: Theme;
20
+ children: React.ReactNode;
21
+ }): React.ReactNode {
22
+ return <ThemeContext.Provider value={theme}>{children}</ThemeContext.Provider>;
23
+ }
24
+
25
+ export function useTheme(): Theme {
26
+ return useContext(ThemeContext);
27
+ }
28
+
29
+ /**
30
+ * Token resolution for a component: the explicit `theme` prop wins over the
31
+ * provider context, which defaults to the built-in dark theme.
32
+ */
33
+ export function useComponentTheme(override?: Theme): Theme {
34
+ const contextTheme = useContext(ThemeContext);
35
+
36
+ return override ?? contextTheme;
37
+ }