@cueloop/client 0.1.0-alpha.9 → 0.1.0-alpha.91

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 (316) 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/App.appearance.test.tsx +53 -0
  9. package/src/App.autoclose.test.tsx +77 -51
  10. package/src/App.diff.test.tsx +259 -45
  11. package/src/App.export.test.tsx +58 -41
  12. package/src/App.focus.test.ts +89 -0
  13. package/src/App.identity.test.tsx +78 -0
  14. package/src/App.plan-render.test.tsx +209 -0
  15. package/src/App.plan-review.test.tsx +330 -161
  16. package/src/App.prototype-markdown.test.tsx +128 -0
  17. package/src/App.prototype.test.tsx +176 -0
  18. package/src/App.readonly.test.tsx +129 -67
  19. package/src/App.regression.test.tsx +94 -0
  20. package/src/App.rendering.test.tsx +61 -35
  21. package/src/App.share-dialog.test.tsx +172 -0
  22. package/src/App.submit-confirm.test.tsx +264 -0
  23. package/src/App.test.tsx +402 -126
  24. package/src/App.toast.test.tsx +98 -0
  25. package/src/App.tsx +1406 -1060
  26. package/src/App.visual.test.tsx +57 -0
  27. package/src/App.walk.test.tsx +299 -0
  28. package/src/App.workbench.test.tsx +787 -0
  29. package/src/__snapshots__/view-plan.regression.test.ts.snap +100 -0
  30. package/src/annotation-palette.ts +62 -0
  31. package/src/ansi-screen.test.ts +28 -0
  32. package/src/ansi-screen.ts +72 -0
  33. package/src/app-key-state.ts +61 -0
  34. package/src/app-screens.tsx +577 -0
  35. package/src/app-view-model.ts +168 -0
  36. package/src/attribution.test.ts +31 -0
  37. package/src/attribution.ts +42 -0
  38. package/src/client-extension-registry.test.tsx +61 -0
  39. package/src/client-extension-registry.ts +159 -0
  40. package/src/clipboard.test.ts +23 -0
  41. package/src/clipboard.ts +33 -0
  42. package/src/components/AnnotatableFileView.stories.tsx +42 -0
  43. package/src/components/AnnotatableFileView.test.tsx +102 -0
  44. package/src/components/AnnotatableFileView.tsx +134 -0
  45. package/src/components/AnnotationCards.stories.tsx +86 -0
  46. package/src/components/AnnotationCards.tsx +341 -0
  47. package/src/components/AnsiScreen.stories.tsx +12 -0
  48. package/src/components/AnsiScreen.tsx +28 -0
  49. package/src/components/AppHeader.stories.tsx +50 -0
  50. package/src/components/AppHeader.tsx +83 -0
  51. package/src/components/AppShell.brand.test.tsx +58 -0
  52. package/src/components/AppShell.extensions.test.tsx +191 -0
  53. package/src/components/AppShell.stories.tsx +89 -0
  54. package/src/components/AppShell.tsx +362 -0
  55. package/src/components/AppShell.zoom.test.tsx +135 -0
  56. package/src/components/BareWorkbenchFileView.stories.tsx +27 -0
  57. package/src/components/BareWorkbenchFileView.tsx +64 -0
  58. package/src/components/Breadcrumb.stories.tsx +19 -0
  59. package/src/components/Breadcrumb.tsx +45 -0
  60. package/src/components/ChangesColumn.stories.tsx +44 -0
  61. package/src/components/ChangesColumn.tsx +199 -0
  62. package/src/components/ChangesFileTree.test.tsx +61 -0
  63. package/src/components/CodeBlock.stories.tsx +42 -0
  64. package/src/components/CodeBlock.tsx +84 -0
  65. package/src/components/CompletionOverlay.stories.tsx +37 -0
  66. package/src/components/CompletionOverlay.tsx +77 -0
  67. package/src/components/ConfirmCard.stories.tsx +37 -0
  68. package/src/components/ConfirmCard.tsx +150 -0
  69. package/src/components/ConfirmDialog.stories.tsx +21 -0
  70. package/src/components/ConfirmDialog.tsx +72 -0
  71. package/src/components/DiffContentView.scroll.test.tsx +149 -0
  72. package/src/components/DiffContentView.split.test.tsx +140 -0
  73. package/src/components/DiffContentView.stories.tsx +80 -0
  74. package/src/components/DiffContentView.syntax.test.tsx +82 -0
  75. package/src/components/DiffContentView.tsx +906 -0
  76. package/src/components/DiscussionMarkerRail.stories.tsx +54 -0
  77. package/src/components/DiscussionMarkerRail.tsx +186 -0
  78. package/src/components/EditorGrid.stories.tsx +59 -0
  79. package/src/components/EditorGrid.tabs.test.tsx +118 -0
  80. package/src/components/EditorGrid.tsx +426 -0
  81. package/src/components/EditorGrid.zoom.test.tsx +68 -0
  82. package/src/components/ExtensionZone.stories.tsx +28 -0
  83. package/src/components/ExtensionZone.tsx +145 -0
  84. package/src/components/FileContentsView.stories.tsx +16 -0
  85. package/src/components/FileContentsView.test.tsx +29 -0
  86. package/src/components/FileContentsView.tsx +98 -0
  87. package/src/components/GridTabContent.stories.tsx +59 -0
  88. package/src/components/GridTabContent.tsx +182 -0
  89. package/src/components/HandleChipsInput.stories.tsx +24 -0
  90. package/src/components/HandleChipsInput.test.tsx +35 -0
  91. package/src/components/HandleChipsInput.tsx +145 -0
  92. package/src/components/KeybindsDialog.stories.tsx +31 -0
  93. package/src/components/KeybindsDialog.tsx +103 -0
  94. package/src/components/MarkdownGridBlock.stories.tsx +33 -0
  95. package/src/components/MarkdownGridBlock.tsx +72 -0
  96. package/src/components/MarkdownThreadEditor.stories.tsx +15 -0
  97. package/src/components/MarkdownThreadEditor.test.tsx +98 -0
  98. package/src/components/MarkdownThreadEditor.tsx +162 -0
  99. package/src/components/MenuBar.stories.tsx +28 -0
  100. package/src/components/MenuBar.tsx +44 -0
  101. package/src/components/NavModeHint.stories.tsx +40 -0
  102. package/src/components/NavModeHint.tsx +31 -0
  103. package/src/components/OverlayScrollbar.stories.tsx +33 -0
  104. package/src/components/OverlayScrollbar.test.tsx +139 -0
  105. package/src/components/OverlayScrollbar.tsx +129 -0
  106. package/src/components/PaneGrid.stories.tsx +84 -0
  107. package/src/components/PaneGrid.tsx +128 -0
  108. package/src/components/PanelColumn.stories.tsx +25 -0
  109. package/src/components/PanelColumn.tsx +98 -0
  110. package/src/components/ProjectTreeView.stories.tsx +25 -0
  111. package/src/components/ProjectTreeView.test.tsx +75 -0
  112. package/src/components/ProjectTreeView.tsx +125 -0
  113. package/src/components/PromptDialog.stories.tsx +36 -0
  114. package/src/components/PromptDialog.test.tsx +61 -0
  115. package/src/components/PromptDialog.tsx +99 -0
  116. package/src/components/PrototypeContentView.stories.tsx +19 -0
  117. package/src/components/PrototypeContentView.tsx +446 -0
  118. package/src/components/RootOverlay.stories.tsx +45 -0
  119. package/src/components/RootOverlay.tsx +48 -0
  120. package/src/components/ScrollArea.stories.tsx +20 -0
  121. package/src/components/ScrollArea.tsx +44 -0
  122. package/src/components/SettingsDialog.stories.tsx +117 -0
  123. package/src/components/SettingsDialog.tsx +186 -0
  124. package/src/components/SettingsRows.stories.tsx +18 -0
  125. package/src/components/SettingsRows.tsx +112 -0
  126. package/src/components/ShareDialog.stories.tsx +121 -0
  127. package/src/components/ShareDialog.tsx +552 -0
  128. package/src/components/ShellHeader.stories.tsx +50 -0
  129. package/src/components/ShellHeader.tsx +137 -0
  130. package/src/components/SlashComposer.stories.tsx +37 -0
  131. package/src/components/SlashComposer.tsx +87 -0
  132. package/src/components/ThreadFooter.stories.tsx +30 -0
  133. package/src/components/ThreadFooter.test.tsx +47 -0
  134. package/src/components/ThreadFooter.tsx +76 -0
  135. package/src/components/ThreadTree.stories.tsx +46 -0
  136. package/src/components/ThreadTree.tsx +255 -0
  137. package/src/components/ThreadView.selection.test.tsx +951 -0
  138. package/src/components/ThreadView.slash.test.tsx +146 -0
  139. package/src/components/ThreadView.stories.tsx +26 -0
  140. package/src/components/ThreadView.tsx +525 -0
  141. package/src/components/Toast.stories.tsx +19 -0
  142. package/src/components/Toast.tsx +52 -0
  143. package/src/components/Tooltip.stories.tsx +26 -0
  144. package/src/components/Tooltip.tsx +72 -0
  145. package/src/components/TreePane.stories.tsx +106 -0
  146. package/src/components/TreePane.tsx +157 -0
  147. package/src/components/WalkWizard.stories.tsx +100 -0
  148. package/src/components/WalkWizard.tsx +150 -0
  149. package/src/components/WelcomePlayground.stories.tsx +12 -0
  150. package/src/components/WelcomePlayground.test.tsx +49 -0
  151. package/src/components/WelcomePlayground.tsx +139 -0
  152. package/src/components/WorkspacePanels.stories.tsx +36 -0
  153. package/src/components/WorkspacePanels.tsx +116 -0
  154. package/src/components/__snapshots__/stories.test.tsx.snap +2264 -0
  155. package/src/components/diff-content-view-layout.test.ts +198 -0
  156. package/src/components/diff-content-view-layout.ts +84 -0
  157. package/src/components/editor-grid.test.ts +86 -0
  158. package/src/components/editor-grid.ts +138 -0
  159. package/src/components/file-tree.test.ts +60 -0
  160. package/src/components/file-tree.ts +112 -0
  161. package/src/components/menu-control.stories.tsx +35 -0
  162. package/src/components/menu-control.tsx +43 -0
  163. package/src/components/pages/Workspace.stories.tsx +181 -0
  164. package/src/components/primitives/Button.stories.tsx +32 -0
  165. package/src/components/primitives/Button.tsx +59 -0
  166. package/src/components/primitives/Card.stories.tsx +25 -0
  167. package/src/components/primitives/Card.tsx +63 -0
  168. package/src/components/primitives/Dialog.stories.tsx +24 -0
  169. package/src/components/primitives/Dialog.test.tsx +37 -0
  170. package/src/components/primitives/Dialog.tsx +71 -0
  171. package/src/components/primitives/DialogActions.stories.tsx +20 -0
  172. package/src/components/primitives/DialogActions.tsx +39 -0
  173. package/src/components/primitives/IconButton.stories.tsx +36 -0
  174. package/src/components/primitives/IconButton.tsx +62 -0
  175. package/src/components/primitives/StatusBar.stories.tsx +11 -0
  176. package/src/components/primitives/StatusBar.tsx +23 -0
  177. package/src/components/primitives/Switch.stories.tsx +17 -0
  178. package/src/components/primitives/Switch.tsx +32 -0
  179. package/src/components/primitives/Tabs.stories.tsx +29 -0
  180. package/src/components/primitives/Tabs.tsx +102 -0
  181. package/src/components/primitives/Toolbar.stories.tsx +17 -0
  182. package/src/components/primitives/Toolbar.tsx +14 -0
  183. package/src/components/primitives/Tree.stories.tsx +124 -0
  184. package/src/components/primitives/Tree.tsx +100 -0
  185. package/src/components/primitives/frame.test.ts +12 -0
  186. package/src/components/primitives/frame.ts +17 -0
  187. package/src/components/primitives/icons.ts +61 -0
  188. package/src/components/primitives/tree-model.test.ts +99 -0
  189. package/src/components/primitives/tree-model.ts +137 -0
  190. package/src/components/quick-actions-editor.stories.tsx +58 -0
  191. package/src/components/quick-actions-editor.test.tsx +117 -0
  192. package/src/components/quick-actions-editor.tsx +226 -0
  193. package/src/components/share-dialog-store.ts +58 -0
  194. package/src/components/stories-app.tsx +193 -0
  195. package/src/components/stories.test.tsx +74 -0
  196. package/src/components/story-fixtures.ts +102 -0
  197. package/src/components/story-tree.test.ts +108 -0
  198. package/src/components/story.ts +159 -0
  199. package/src/components/syntax-highlight.ts +133 -0
  200. package/src/components/terminal-pane.test.tsx +40 -0
  201. package/src/components/terminal-pane.ts +199 -0
  202. package/src/components/theme-context.stories.tsx +46 -0
  203. package/src/components/theme-context.tsx +37 -0
  204. package/src/components/thread-tree.test.ts +134 -0
  205. package/src/components/thread-tree.ts +109 -0
  206. package/src/components/truncate-text.ts +7 -0
  207. package/src/components/truncate-title.test.ts +31 -0
  208. package/src/components/truncate-title.ts +15 -0
  209. package/src/config-identity.test.ts +78 -0
  210. package/src/config.test.ts +579 -34
  211. package/src/config.ts +533 -57
  212. package/src/diff-fold.test.ts +78 -0
  213. package/src/diff-fold.ts +126 -0
  214. package/src/diff-hunk-curate.test.ts +315 -0
  215. package/src/diff-hunk-curate.ts +153 -0
  216. package/src/diff-intraline.test.ts +238 -0
  217. package/src/diff-intraline.ts +236 -0
  218. package/src/diff-syntax.test.ts +112 -0
  219. package/src/diff-syntax.ts +179 -0
  220. package/src/discussions.ts +79 -0
  221. package/src/editor.test.ts +149 -0
  222. package/src/editor.ts +139 -9
  223. package/src/ghostty-terminal.test.ts +85 -0
  224. package/src/ghostty-terminal.ts +199 -0
  225. package/src/github-identity.test.ts +58 -0
  226. package/src/github-identity.ts +41 -0
  227. package/src/herdr.test.ts +9 -34
  228. package/src/herdr.ts +25 -21
  229. package/src/index.ts +29 -0
  230. package/src/integrations.test.ts +41 -0
  231. package/src/integrations.ts +32 -0
  232. package/src/intent-dispatch.test.ts +724 -0
  233. package/src/intent-dispatch.ts +690 -0
  234. package/src/key-bindings.test.ts +156 -0
  235. package/src/key-bindings.ts +459 -0
  236. package/src/keymap.test.ts +456 -115
  237. package/src/keymap.ts +315 -81
  238. package/src/kitty-image.test.ts +66 -0
  239. package/src/kitty-image.ts +138 -0
  240. package/src/launch-layout.test.ts +78 -0
  241. package/src/launch-layout.ts +62 -0
  242. package/src/mark-runs.ts +121 -0
  243. package/src/markdown-editor-syntax-style.ts +50 -0
  244. package/src/markdown-grid.test.ts +101 -0
  245. package/src/markdown-grid.ts +203 -0
  246. package/src/markdown-highlight.test.ts +61 -0
  247. package/src/markdown-highlight.ts +122 -0
  248. package/src/pasted-image.test.ts +67 -0
  249. package/src/pasted-image.ts +60 -0
  250. package/src/perf/otlp-trace.test.ts +116 -0
  251. package/src/perf/otlp-trace.ts +130 -0
  252. package/src/perf/perf-report.ts +17 -0
  253. package/src/perf/perf-timings.test.ts +18 -0
  254. package/src/perf/perf-timings.ts +38 -0
  255. package/src/prototype-browser.test.ts +81 -0
  256. package/src/prototype-browser.ts +329 -0
  257. package/src/prototype-pixels.tsx +35 -0
  258. package/src/pty.test.ts +81 -0
  259. package/src/pty.ts +245 -0
  260. package/src/ready-signal.test.ts +85 -0
  261. package/src/ready-signal.ts +52 -0
  262. package/src/run.ts +85 -12
  263. package/src/serve.test.ts +24 -12
  264. package/src/serve.ts +52 -10
  265. package/src/share.ts +237 -0
  266. package/src/skills.test.ts +74 -0
  267. package/src/skills.ts +75 -0
  268. package/src/slash-palette.test.ts +103 -0
  269. package/src/slash-palette.ts +129 -0
  270. package/src/split-diff.test.ts +110 -0
  271. package/src/split-diff.ts +149 -0
  272. package/src/test-support.test.ts +264 -0
  273. package/src/test-support.ts +369 -0
  274. package/src/theme-presets.test.ts +51 -0
  275. package/src/theme-presets.ts +219 -0
  276. package/src/theme.ts +88 -19
  277. package/src/thread-chords.test.ts +79 -0
  278. package/src/thread-chords.ts +163 -0
  279. package/src/thread-code-syntax.ts +45 -0
  280. package/src/thread-controller.curate.test.ts +476 -0
  281. package/src/thread-controller.derivation.test.ts +62 -0
  282. package/src/thread-controller.live-diff.test.ts +322 -0
  283. package/src/thread-controller.optimistic.test.ts +69 -0
  284. package/src/thread-controller.share.test.ts +561 -0
  285. package/src/thread-controller.switch-cache.test.ts +176 -0
  286. package/src/thread-controller.tree.test.ts +261 -0
  287. package/src/thread-controller.ts +2050 -0
  288. package/src/thread-selection.test.ts +210 -0
  289. package/src/thread-selection.ts +243 -0
  290. package/src/tree-view.test.ts +128 -0
  291. package/src/tree-view.ts +153 -0
  292. package/src/use-annotation-surface.tsx +975 -0
  293. package/src/use-changes-workbench.ts +190 -0
  294. package/src/use-frame-measure.ts +41 -0
  295. package/src/use-remember-layout.ts +31 -0
  296. package/src/use-repo-changes.ts +87 -0
  297. package/src/use-settings-dialog.tsx +364 -0
  298. package/src/use-terminal-virtualizer.ts +259 -0
  299. package/src/use-thread-body-editing.tsx +58 -0
  300. package/src/version.ts +5 -0
  301. package/src/view-diff.test.ts +279 -9
  302. package/src/view-diff.ts +460 -24
  303. package/src/view-plan.regression.test.ts +60 -0
  304. package/src/view-plan.test.ts +545 -0
  305. package/src/view-plan.ts +1019 -0
  306. package/src/visual/contrast.test.ts +35 -0
  307. package/src/visual/contrast.ts +33 -0
  308. package/src/walk.test.ts +121 -0
  309. package/src/walk.ts +74 -0
  310. package/src/workbench-snapshot.test.ts +99 -0
  311. package/src/workbench-snapshot.ts +59 -0
  312. package/src/session-controller.ts +0 -398
  313. package/src/syntax.test.ts +0 -41
  314. package/src/syntax.ts +0 -88
  315. package/src/view.test.ts +0 -157
  316. package/src/view.ts +0 -364
@@ -0,0 +1,369 @@
1
+ /**
2
+ * Shared driving helpers for the virtual-terminal App suites: an input settles
3
+ * when its painted frame stops changing across consecutive event-loop turns
4
+ * (macrotask yield, render pass, visual-idle wait per turn) - no fixed-duration
5
+ * sleeps. Waits on daemon round-trips go through waitForFrame/waitFor with
6
+ * generous pass budgets.
7
+ */
8
+
9
+ import { join } from "node:path";
10
+ import { cloneElement, isValidElement, type ReactNode } from "react";
11
+ import type { TestRendererOptions, TestRendererSetup } from "@opentui/core/testing";
12
+ import { testRender } from "@opentui/react/test-utils";
13
+ import type { AppProps } from "./App";
14
+
15
+ /**
16
+ * A probe for the App's ready signal in an in-process suite: pass `onReady`
17
+ * to the App and await `ready` before the first interaction, instead of
18
+ * waiting for a piece of text and hoping the keyboard is bound by then.
19
+ */
20
+ export interface AppReadyProbe {
21
+ /** Pass as the App's `onReady` prop. */
22
+ onReady: () => void;
23
+ /** Resolves once the App has fired its ready signal. */
24
+ ready: Promise<void>;
25
+ }
26
+
27
+ export function appReadyProbe(): AppReadyProbe {
28
+ let resolve: () => void = () => {};
29
+ const ready = new Promise<void>((done) => {
30
+ resolve = done;
31
+ });
32
+
33
+ return { onReady: () => resolve(), ready };
34
+ }
35
+
36
+ /**
37
+ * Boot an App element and resolve once it has fired its ready signal: the
38
+ * first usable screen is painted and its keyboard handlers are subscribed.
39
+ * Every App suite starts here so no boot waits on a piece of text and hopes.
40
+ */
41
+ export async function renderReadyApp(
42
+ element: ReactNode,
43
+ options: TestRendererOptions,
44
+ ): Promise<TestRendererSetup> {
45
+ if (!isValidElement<AppProps>(element)) throw new Error("renderReadyApp needs an App element");
46
+ const probe = appReadyProbe();
47
+ const setup = await testRender(cloneElement(element, { onReady: probe.onReady }), options);
48
+
49
+ await probe.ready;
50
+
51
+ return setup;
52
+ }
53
+
54
+ /**
55
+ * The CUELOOP_CONFIG path that isolates a test from the developer's real user
56
+ * config: a file inside the test home that does not exist unless the test
57
+ * writes it. Subprocess harnesses set this in the child env; in-process suites
58
+ * use `isolateUserConfig`.
59
+ */
60
+ export function isolatedUserConfigPath(home: string, fileName = "no-config.toml"): string {
61
+ return join(home, fileName);
62
+ }
63
+
64
+ /**
65
+ * Isolate the user config: point CUELOOP_CONFIG into the test home so
66
+ * loadConfig never reads the developer's real ~/.config/cueloop (a persisted
67
+ * review_state would change what char frames render locally while CI stays
68
+ * clean). The default file name does not exist, so loadConfig returns
69
+ * defaults; pass a file name for suites that write and assert a config of
70
+ * their own. Call in beforeEach; invoke the returned restore in afterEach.
71
+ */
72
+ export function isolateUserConfig(home: string, fileName?: string): () => void {
73
+ const priorUserConfig = process.env.CUELOOP_CONFIG;
74
+
75
+ process.env.CUELOOP_CONFIG = isolatedUserConfigPath(home, fileName);
76
+
77
+ return () => {
78
+ if (priorUserConfig === undefined) delete process.env.CUELOOP_CONFIG;
79
+ else process.env.CUELOOP_CONFIG = priorUserConfig;
80
+ };
81
+ }
82
+
83
+ declare global {
84
+ var IS_REACT_ACT_ENVIRONMENT: boolean | undefined;
85
+ }
86
+
87
+ /** The harness drives the real event loop by design; keep React's act warning off. */
88
+ export function allowEventLoopUpdates(): void {
89
+ globalThis.IS_REACT_ACT_ENVIRONMENT = false;
90
+ }
91
+
92
+ /** Turns of the event loop a frame must survive unchanged before an input counts as settled. */
93
+ const SETTLE_QUIET_TURNS = 2;
94
+
95
+ /** Upper bound on settle turns, so a surface that keeps changing never stalls a test. */
96
+ const SETTLE_MAX_TURNS = 40;
97
+
98
+ /** The painted frame with its colors, so a caret cell (background only) counts as a change. */
99
+ function paintedFrame(setup: TestRendererSetup): string {
100
+ return JSON.stringify(
101
+ setup
102
+ .captureSpans()
103
+ .lines.map((line) =>
104
+ line.spans.map((span) => [span.text, span.fg?.toInts(), span.bg?.toInts()]),
105
+ ),
106
+ );
107
+ }
108
+
109
+ /**
110
+ * Wait for an input to land: React commits its state on its own scheduler, which the renderer's
111
+ * idle check cannot see, so on a slow machine a single idle wait returns between the key and its
112
+ * paint. Instead, run turns of the loop until the painted frame is unchanged across
113
+ * `SETTLE_QUIET_TURNS` consecutive turns.
114
+ */
115
+ export async function settle(setup: TestRendererSetup): Promise<void> {
116
+ allowEventLoopUpdates();
117
+ let previous = "";
118
+ let quietTurns = 0;
119
+
120
+ for (let turn = 0; turn < SETTLE_MAX_TURNS; turn++) {
121
+ await new Promise((resolve) => setTimeout(resolve, 0));
122
+ await setup.renderOnce();
123
+ await setup.waitForVisualIdle();
124
+ const frame = paintedFrame(setup);
125
+
126
+ quietTurns = frame === previous ? quietTurns + 1 : 0;
127
+ if (quietTurns >= SETTLE_QUIET_TURNS) return;
128
+ previous = frame;
129
+ }
130
+ }
131
+
132
+ /** Drive one key press: letters type as text; named keys use KeyCodes ids. */
133
+ export async function press(setup: TestRendererSetup, key: string): Promise<void> {
134
+ if (key === "enter") setup.mockInput.pressKey("RETURN");
135
+ else if (key === "escape") setup.mockInput.pressKey("ESCAPE");
136
+ else if (key === "backspace") setup.mockInput.pressKey("BACKSPACE");
137
+ else if (key === "left") setup.mockInput.pressKey("ARROW_LEFT");
138
+ else if (key === "right") setup.mockInput.pressKey("ARROW_RIGHT");
139
+ else if (key === "up") setup.mockInput.pressKey("ARROW_UP");
140
+ else if (key === "down") setup.mockInput.pressKey("ARROW_DOWN");
141
+ else return typeText(setup, key);
142
+ await settle(setup);
143
+ }
144
+
145
+ export async function typeText(setup: TestRendererSetup, text: string): Promise<void> {
146
+ await setup.mockInput.typeText(text);
147
+ await settle(setup);
148
+ }
149
+
150
+ /** Drive one key press with modifiers: KeyCodes names ("ARROW_RIGHT") or single letters (ctrl+o). */
151
+ export async function pressKey(
152
+ setup: TestRendererSetup,
153
+ key: string,
154
+ modifiers?: { shift?: boolean; ctrl?: boolean; meta?: boolean },
155
+ ): Promise<void> {
156
+ setup.mockInput.pressKey(key, modifiers);
157
+ await settle(setup);
158
+ }
159
+
160
+ /**
161
+ * Run a thread nav-mode command: esc drops the surface into nav mode, then a bare
162
+ * key acts (a letter, or a named key like "enter"/"backspace"). The caret and any
163
+ * held selection carry into nav, so the command lands where the caret is.
164
+ */
165
+ export async function navCommand(setup: TestRendererSetup, key: string): Promise<void> {
166
+ await press(setup, "escape");
167
+ await new Promise((resolve) => setTimeout(resolve, 40));
168
+ await settle(setup);
169
+ // a single letter is a discrete keypress (nav command); named keys route through press
170
+ if (key.length === 1) await pressKey(setup, key);
171
+ else await press(setup, key);
172
+ }
173
+
174
+ /** A 0-based cell position within a captured char frame. */
175
+ export interface FrameLocation {
176
+ row: number;
177
+ column: number;
178
+ }
179
+
180
+ /** Locate the first line of a text frame containing the needle; null when absent. */
181
+ export function locateTextInFrame(frame: string, needle: string): FrameLocation | null {
182
+ for (const [row, line] of frame.split("\n").entries()) {
183
+ const column = line.indexOf(needle);
184
+
185
+ if (column !== -1) return { row, column };
186
+ }
187
+
188
+ return null;
189
+ }
190
+
191
+ /** Locate the first visual line containing the needle: 0-based row and start column. */
192
+ export function locateText(setup: TestRendererSetup, needle: string): FrameLocation {
193
+ const frame = setup.captureCharFrame();
194
+ const location = locateTextInFrame(frame, needle);
195
+
196
+ if (location === null) {
197
+ throw new Error(`locateText ${JSON.stringify(needle)} not found.\nframe:\n${frame}`);
198
+ }
199
+
200
+ return location;
201
+ }
202
+
203
+ /** The 0-based visual row of the needle - for relative vertical-layout assertions. */
204
+ export function frameRow(setup: TestRendererSetup, needle: string): number {
205
+ return locateText(setup, needle).row;
206
+ }
207
+
208
+ /** Click the first on-screen occurrence of the needle, offset in characters from its start. */
209
+ export async function clickText(
210
+ setup: TestRendererSetup,
211
+ needle: string,
212
+ charOffset = 0,
213
+ ): Promise<void> {
214
+ const { row, column } = locateText(setup, needle);
215
+
216
+ await setup.mockMouse.click(column + charOffset, row);
217
+ await settle(setup);
218
+ }
219
+
220
+ /**
221
+ * Drag from the first occurrence of one needle to the first occurrence of
222
+ * another, `toCharOffset` characters past the second needle's start - so a
223
+ * character-precise selection can end inside or right after a word.
224
+ */
225
+ export async function dragText(
226
+ setup: TestRendererSetup,
227
+ fromNeedle: string,
228
+ toNeedle: string,
229
+ toCharOffset = 0,
230
+ ): Promise<void> {
231
+ const from = locateText(setup, fromNeedle);
232
+ const to = locateText(setup, toNeedle);
233
+
234
+ await setup.mockMouse.drag(from.column, from.row, to.column + toCharOffset, to.row);
235
+ await settle(setup);
236
+ }
237
+
238
+ // Below the 60s per-test budget so a genuinely stuck wait throws its frame
239
+ // error (useful) before bun's bare "timed out" fires. Generous because a
240
+ // contended CI runner starves the render loop and daemon round-trips.
241
+ const WAIT_DEADLINE_MS = 45_000;
242
+
243
+ /** One macrotask turn: hands the event loop back so the in-process daemon can
244
+ * read its socket and apply the write before the next poll. Tight render-pass
245
+ * bursts starve that read on a contended CI runner - the round-trip then never
246
+ * lands and the wait hangs the whole budget. */
247
+ function yieldEventLoop(): Promise<void> {
248
+ return new Promise((resolve) => setTimeout(resolve, 2));
249
+ }
250
+
251
+ /** Log a still-waiting line at most this often, so only genuinely slow (i.e.
252
+ * flaking) waits are noisy; fast waits stay silent. */
253
+ const PROGRESS_LOG_MS = 4000;
254
+
255
+ /** The last render frame's tail, so a progress line shows what is on screen. */
256
+ function frameTail(frame: string, rows = 6): string {
257
+ return frame.split("\n").slice(-rows).join("\n");
258
+ }
259
+
260
+ /**
261
+ * Poll gently: one render pass, one frame check, one macrotask yield. No
262
+ * multi-pass bursts, so the daemon (same event loop) always gets a turn to
263
+ * process IO between checks. The deadline bounds the whole wait. Slow waits log
264
+ * progress (poll count + frame-change count) so a CI stall shows whether the
265
+ * render froze or the content simply never arrived.
266
+ */
267
+ async function waitForFramePredicate(
268
+ setup: TestRendererSetup,
269
+ predicate: (frame: string) => boolean,
270
+ label: string,
271
+ ): Promise<string> {
272
+ allowEventLoopUpdates();
273
+ const start = Date.now();
274
+ const deadline = start + WAIT_DEADLINE_MS;
275
+ let polls = 0;
276
+ let frameChanges = 0;
277
+ let lastFrame = "";
278
+ let lastLog = start;
279
+
280
+ for (;;) {
281
+ await setup.renderOnce();
282
+ const frame = setup.captureCharFrame();
283
+
284
+ polls++;
285
+ if (frame !== lastFrame) {
286
+ frameChanges++;
287
+ lastFrame = frame;
288
+ }
289
+ if (predicate(frame)) return frame;
290
+ const now = Date.now();
291
+
292
+ if (now > deadline) {
293
+ const frozen = frameChanges <= 1 ? " - render never advanced (frozen)" : "";
294
+
295
+ throw new Error(
296
+ `waitFor ${label} timed out after ${now - start}ms (${polls} polls, ${frameChanges} frame changes${frozen}).\nlast frame:\n${frame}`,
297
+ );
298
+ }
299
+ if (now - lastLog >= PROGRESS_LOG_MS) {
300
+ lastLog = now;
301
+ console.error(
302
+ `[waitFor] still waiting for ${label} - ${now - start}ms, ${polls} polls, ${frameChanges} frame changes\n${frameTail(frame)}`,
303
+ );
304
+ }
305
+ await yieldEventLoop();
306
+ }
307
+ }
308
+
309
+ /** Wait until a state predicate holds (daemon round-trips included). */
310
+ export async function waitForState(
311
+ setup: TestRendererSetup,
312
+ predicate: () => boolean,
313
+ label = "state",
314
+ ): Promise<void> {
315
+ allowEventLoopUpdates();
316
+ const start = Date.now();
317
+ const deadline = start + WAIT_DEADLINE_MS;
318
+ let polls = 0;
319
+ let frameChanges = 0;
320
+ let lastFrame = "";
321
+ let lastLog = start;
322
+
323
+ for (;;) {
324
+ if (predicate()) return;
325
+ await setup.renderOnce();
326
+ const frame = setup.captureCharFrame();
327
+
328
+ polls++;
329
+ if (frame !== lastFrame) {
330
+ frameChanges++;
331
+ lastFrame = frame;
332
+ }
333
+ if (predicate()) return;
334
+ const now = Date.now();
335
+
336
+ if (now > deadline) {
337
+ const frozen = frameChanges <= 1 ? " - render never advanced (frozen)" : "";
338
+
339
+ throw new Error(
340
+ `waitForState ${label} timed out after ${now - start}ms (${polls} polls, ${frameChanges} frame changes${frozen}).\nlast frame:\n${frame}`,
341
+ );
342
+ }
343
+ if (now - lastLog >= PROGRESS_LOG_MS) {
344
+ lastLog = now;
345
+ console.error(
346
+ `[waitForState] still waiting for ${label} - ${now - start}ms, ${polls} polls, ${frameChanges} frame changes`,
347
+ );
348
+ }
349
+ await yieldEventLoop();
350
+ }
351
+ }
352
+
353
+ /** Wait until the frame contains the needle (daemon round-trips included). */
354
+ export async function waitForText(setup: TestRendererSetup, needle: string): Promise<string> {
355
+ return waitForFramePredicate(
356
+ setup,
357
+ (frame) => frame.includes(needle),
358
+ `text ${JSON.stringify(needle)}`,
359
+ );
360
+ }
361
+
362
+ /** Wait until the frame no longer contains the needle. */
363
+ export async function waitForTextGone(setup: TestRendererSetup, needle: string): Promise<string> {
364
+ return waitForFramePredicate(
365
+ setup,
366
+ (frame) => !frame.includes(needle),
367
+ `text-gone ${JSON.stringify(needle)}`,
368
+ );
369
+ }
@@ -0,0 +1,51 @@
1
+ /** The branded transparent theme adapts to the terminal background - a light terminal gets dark text instead of light-on-light - while the opaque palette presets ignore appearance. */
2
+
3
+ import { describe, expect, test } from "bun:test";
4
+ import { DARK, LIGHT } from "./theme";
5
+ import { composeTheme, themeForName } from "./theme-presets";
6
+
7
+ describe("themeForName appearance", () => {
8
+ test("the branded cueloop theme picks the light variant on a light terminal", () => {
9
+ // Assert
10
+ expect(themeForName("cueloop", "dark")).toBe(DARK);
11
+ expect(themeForName("cueloop", "light")).toBe(LIGHT);
12
+ });
13
+
14
+ test("defaults to the dark variant when appearance is unspecified", () => {
15
+ // Assert
16
+ expect(themeForName("cueloop")).toBe(DARK);
17
+ });
18
+
19
+ test("an unknown name falls back to the branded theme, still appearance-aware", () => {
20
+ // Assert
21
+ expect(themeForName("nope", "light")).toBe(LIGHT);
22
+ expect(themeForName("nope", "dark")).toBe(DARK);
23
+ });
24
+
25
+ test("an explicit opaque preset ignores appearance (it paints its own background)", () => {
26
+ // Assert
27
+ expect(themeForName("nord", "light")).toBe(themeForName("nord", "dark"));
28
+ });
29
+ });
30
+
31
+ describe("the branded variants", () => {
32
+ test("both keep the transparent background but the light variant darkens its text", () => {
33
+ // Assert
34
+ expect(DARK.background).toBe("transparent");
35
+ expect(LIGHT.background).toBe("transparent");
36
+ expect(LIGHT.text).toBe("#1c1d24");
37
+ expect(LIGHT.text).not.toBe(DARK.text);
38
+ expect(LIGHT.elevated).not.toBe(DARK.elevated);
39
+ });
40
+ });
41
+
42
+ describe("composeTheme", () => {
43
+ test("resolves the appearance base, then layers per-token overrides on top", () => {
44
+ // Act
45
+ const themed = composeTheme("cueloop", { accent: "#123456" }, "light");
46
+
47
+ // Assert
48
+ expect(themed.text).toBe(LIGHT.text);
49
+ expect(themed.accent).toBe("#123456");
50
+ });
51
+ });
@@ -0,0 +1,219 @@
1
+ /**
2
+ * Named theme presets selectable from Settings and `[ui] theme`. The branded
3
+ * "cueloop" default keeps the transparent terminal-through look; each of the
4
+ * well-known palettes paints its own opaque background so it reads exactly like
5
+ * the published theme. Every preset fills the full Theme token contract, so a
6
+ * `[theme]` per-token override still layers cleanly on top of the chosen base.
7
+ *
8
+ * Each palette maps its first-party spec onto cueloop's semantic slots. Where a
9
+ * palette has no dedicated green (Rosé Pine), additions use its positive accent
10
+ * by that palette's own convention.
11
+ */
12
+
13
+ import { DARK, LIGHT, type Theme } from "./theme";
14
+
15
+ /** Terminal background appearance, reported by the terminal (OSC) or assumed dark. */
16
+ export type Appearance = "dark" | "light";
17
+
18
+ /** The branded transparent theme's variant for the detected terminal background. */
19
+ function brandedTheme(appearance: Appearance): Theme {
20
+ return appearance === "light" ? LIGHT : DARK;
21
+ }
22
+
23
+ /** Selectable theme name; the persisted `[ui] theme` value and cycle key. */
24
+ export type ThemeName =
25
+ | "cueloop"
26
+ | "rose-pine-moon"
27
+ | "catppuccin-mocha"
28
+ | "tokyo-night"
29
+ | "gruvbox-dark"
30
+ | "nord";
31
+
32
+ /** The branded default; the base every layered config starts from. */
33
+ export const DEFAULT_THEME_NAME: ThemeName = "cueloop";
34
+
35
+ /** Rosé Pine Moon - rosepinetheme.com. Foam stands in for additions (no green in the palette). */
36
+ const ROSE_PINE_MOON: Theme = {
37
+ background: "#232136",
38
+ panel: "#232136",
39
+ elevated: "#2a273f",
40
+ border: "#44415a",
41
+ text: "#e0def4",
42
+ textMuted: "#908caa",
43
+ textDim: "#6e6a86",
44
+ accent: "#ea9a97",
45
+ accentInk: "#232136",
46
+ warning: "#f6c177",
47
+ green: "#9ccfd8",
48
+ red: "#eb6f92",
49
+ blue: "#3e8fb0",
50
+ cursorBackground: "#382a2f",
51
+ markCommentBackground: "#382a37",
52
+ insertedForeground: "#9ccfd8",
53
+ deletedForeground: "#eb6f92",
54
+ insertedBackground: "#243a3f",
55
+ insertedEmphasisBackground: "#31555c",
56
+ deletedBackground: "#3a2530",
57
+ deletedEmphasisBackground: "#563643",
58
+ backdrop: "transparent",
59
+ };
60
+
61
+ /** Catppuccin Mocha - catppuccin.com. Mauve is the signature accent. */
62
+ const CATPPUCCIN_MOCHA: Theme = {
63
+ background: "#1e1e2e",
64
+ panel: "#1e1e2e",
65
+ elevated: "#313244",
66
+ border: "#45475a",
67
+ text: "#cdd6f4",
68
+ textMuted: "#a6adc8",
69
+ textDim: "#6c7086",
70
+ accent: "#cba6f7",
71
+ accentInk: "#1e1e2e",
72
+ warning: "#fab387",
73
+ green: "#a6e3a1",
74
+ red: "#f38ba8",
75
+ blue: "#89b4fa",
76
+ cursorBackground: "#2e2740",
77
+ markCommentBackground: "#33222b",
78
+ insertedForeground: "#a6e3a1",
79
+ deletedForeground: "#f38ba8",
80
+ insertedBackground: "#26372b",
81
+ insertedEmphasisBackground: "#375140",
82
+ deletedBackground: "#382530",
83
+ deletedEmphasisBackground: "#553844",
84
+ backdrop: "transparent",
85
+ };
86
+
87
+ /** Tokyo Night - the folke "night" variant. Magenta is the signature accent. */
88
+ const TOKYO_NIGHT: Theme = {
89
+ background: "#1a1b26",
90
+ panel: "#1a1b26",
91
+ elevated: "#292e42",
92
+ border: "#3b4261",
93
+ text: "#c0caf5",
94
+ textMuted: "#a9b1d6",
95
+ textDim: "#565f89",
96
+ accent: "#bb9af7",
97
+ accentInk: "#1a1b26",
98
+ warning: "#e0af68",
99
+ green: "#9ece6a",
100
+ red: "#f7768e",
101
+ blue: "#7aa2f7",
102
+ cursorBackground: "#2b2740",
103
+ markCommentBackground: "#2f2230",
104
+ insertedForeground: "#9ece6a",
105
+ deletedForeground: "#f7768e",
106
+ insertedBackground: "#233028",
107
+ insertedEmphasisBackground: "#33493a",
108
+ deletedBackground: "#32232e",
109
+ deletedEmphasisBackground: "#4d3543",
110
+ backdrop: "transparent",
111
+ };
112
+
113
+ /** Gruvbox Dark - morhetz/gruvbox, medium contrast. Orange is the warm signature accent. */
114
+ const GRUVBOX_DARK: Theme = {
115
+ background: "#282828",
116
+ panel: "#282828",
117
+ elevated: "#3c3836",
118
+ border: "#504945",
119
+ text: "#ebdbb2",
120
+ textMuted: "#d5c4a1",
121
+ textDim: "#928374",
122
+ accent: "#fe8019",
123
+ accentInk: "#282828",
124
+ warning: "#fe8019",
125
+ green: "#b8bb26",
126
+ red: "#fb4934",
127
+ blue: "#83a598",
128
+ cursorBackground: "#3a2f20",
129
+ markCommentBackground: "#3a2823",
130
+ insertedForeground: "#b8bb26",
131
+ deletedForeground: "#fb4934",
132
+ insertedBackground: "#333521",
133
+ insertedEmphasisBackground: "#4a4d2c",
134
+ deletedBackground: "#3c2a24",
135
+ deletedEmphasisBackground: "#5a3a30",
136
+ backdrop: "transparent",
137
+ };
138
+
139
+ /** Nord - nordtheme.com, the official 16-color spec. Frost cyan is the signature accent. */
140
+ const NORD: Theme = {
141
+ background: "#2e3440",
142
+ panel: "#2e3440",
143
+ elevated: "#3b4252",
144
+ border: "#434c5e",
145
+ text: "#eceff4",
146
+ textMuted: "#d8dee9",
147
+ textDim: "#4c566a",
148
+ accent: "#88c0d0",
149
+ accentInk: "#2e3440",
150
+ warning: "#d08770",
151
+ green: "#a3be8c",
152
+ red: "#bf616a",
153
+ blue: "#81a1c1",
154
+ cursorBackground: "#2a3540",
155
+ markCommentBackground: "#3a2f33",
156
+ insertedForeground: "#a3be8c",
157
+ deletedForeground: "#bf616a",
158
+ insertedBackground: "#333d33",
159
+ insertedEmphasisBackground: "#454f42",
160
+ deletedBackground: "#3c2f33",
161
+ deletedEmphasisBackground: "#544045",
162
+ backdrop: "transparent",
163
+ };
164
+
165
+ /** All selectable themes by name; "cueloop" is the branded transparent default. */
166
+ export const THEME_PRESETS: Record<ThemeName, Theme> = {
167
+ cueloop: DARK,
168
+ "rose-pine-moon": ROSE_PINE_MOON,
169
+ "catppuccin-mocha": CATPPUCCIN_MOCHA,
170
+ "tokyo-night": TOKYO_NIGHT,
171
+ "gruvbox-dark": GRUVBOX_DARK,
172
+ nord: NORD,
173
+ };
174
+
175
+ /** Cycle order for the Settings theme row; the registry's insertion order, branded default first. */
176
+ export const THEME_NAMES: ThemeName[] = [
177
+ "cueloop",
178
+ "rose-pine-moon",
179
+ "catppuccin-mocha",
180
+ "tokyo-night",
181
+ "gruvbox-dark",
182
+ "nord",
183
+ ];
184
+
185
+ /** Human labels for the Settings cycle row, keyed by the persisted name. */
186
+ export const THEME_LABELS: Record<ThemeName, string> = {
187
+ cueloop: "cueloop",
188
+ "rose-pine-moon": "Rosé Pine Moon",
189
+ "catppuccin-mocha": "Catppuccin Mocha",
190
+ "tokyo-night": "Tokyo Night",
191
+ "gruvbox-dark": "Gruvbox Dark",
192
+ nord: "Nord",
193
+ };
194
+
195
+ /**
196
+ * Resolve a persisted theme name to its token set. The branded "cueloop" theme
197
+ * (and any unknown name that falls back to it) adapts to the terminal
198
+ * background; the explicit palette presets paint their own opaque background, so
199
+ * they read the same on a light or dark terminal and ignore `appearance`.
200
+ */
201
+ export function themeForName(name: string, appearance: Appearance = "dark"): Theme {
202
+ if (name === "cueloop") return brandedTheme(appearance);
203
+
204
+ return isThemeName(name) ? THEME_PRESETS[name] : brandedTheme(appearance);
205
+ }
206
+
207
+ /** A preset with the user's `[theme]` per-token overrides layered on top - the live-switch equivalent of config load. */
208
+ export function composeTheme(
209
+ name: string,
210
+ overrides: Partial<Theme>,
211
+ appearance: Appearance = "dark",
212
+ ): Theme {
213
+ return { ...themeForName(name, appearance), ...overrides };
214
+ }
215
+
216
+ /** True when the name is a known preset (guards a persisted `[ui] theme` value). */
217
+ export function isThemeName(name: string): name is ThemeName {
218
+ return name in THEME_PRESETS;
219
+ }