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

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 +1402 -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 +774 -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 +28 -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 +67 -0
  241. package/src/launch-layout.ts +47 -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,146 @@
1
+ /** The "/" palette in the composer: it reopens for each "/word" the caret writes, so skills chain. */
2
+
3
+ import { describe, expect, test } from "bun:test";
4
+ import React from "react";
5
+ import type { RGBA } from "@opentui/core";
6
+ import { testRender } from "@opentui/react/test-utils";
7
+ import type { QuickAction } from "../config";
8
+ import { settle, typeText } from "../test-support";
9
+ import { DARK } from "../theme";
10
+ import { buildDisplay, marksByDisplay } from "../view-plan";
11
+ import { fixturePlanSession } from "./story-fixtures";
12
+ import { ThreadView } from "./ThreadView";
13
+ import { mergeSlashItems, slashItemsFrom, type SlashItem } from "../slash-palette";
14
+ import { PaletteNamesContext, SlashSkillsContext } from "../skills";
15
+
16
+ const SKILLS: QuickAction[] = [
17
+ { prompt: "typescript magician" },
18
+ { prompt: "implement" },
19
+ { prompt: "write discoverable code" },
20
+ ];
21
+
22
+ const PLAN = "# Plan\n\nRefine the store before the rewrite lands.\n";
23
+
24
+ function colorToHex(color: RGBA): string {
25
+ const [red, green, blue] = color.toInts();
26
+
27
+ return "#" + [red, green, blue].map((part) => part.toString(16).padStart(2, "0")).join("");
28
+ }
29
+
30
+ /** Render a plan composer, wiring the palette names into context exactly as the app does. */
31
+ async function mountComposerWithSkills(skills: SlashItem[] = [], width = 72) {
32
+ const display = buildDisplay(PLAN, undefined);
33
+ const names = new Set(mergeSlashItems(slashItemsFrom(SKILLS), skills).map((item) => item.name));
34
+ const setup = await testRender(
35
+ <SlashSkillsContext.Provider value={skills}>
36
+ <PaletteNamesContext.Provider value={names}>
37
+ <ThreadView
38
+ session={fixturePlanSession({
39
+ artifact: { type: "plan", content: PLAN, meta: { title: "Plan" } },
40
+ })}
41
+ display={display}
42
+ marks={marksByDisplay([], display)}
43
+ quickActions={SKILLS}
44
+ observer={false}
45
+ onAnnotate={() => {}}
46
+ onReply={() => {}}
47
+ onUpdateAnnotation={() => {}}
48
+ onExit={() => {}}
49
+ />
50
+ </PaletteNamesContext.Provider>
51
+ </SlashSkillsContext.Provider>,
52
+ { width, height: 20 },
53
+ );
54
+
55
+ await settle(setup);
56
+ await settle(setup);
57
+
58
+ return setup;
59
+ }
60
+
61
+ const mountComposer = () => mountComposerWithSkills();
62
+
63
+ describe("skills in the palette", () => {
64
+ test("a skill from context lists in the / palette and a pick inserts its /name", async () => {
65
+ const setup = await mountComposerWithSkills([
66
+ { name: "vitest-patterns", description: "patterns for vitest", body: "" },
67
+ ]);
68
+
69
+ await typeText(setup, "/vitest");
70
+ expect(setup.captureCharFrame()).toContain("/vitest-patterns");
71
+
72
+ setup.mockInput.pressKey("RETURN");
73
+ await settle(setup);
74
+ // the reference lands in the draft, exactly like a quick action
75
+ expect(setup.captureCharFrame()).toContain("/vitest-patterns");
76
+ });
77
+ });
78
+
79
+ describe("the palette reopens per token and chains skills", () => {
80
+ test("a mid-sentence / opens the list, tab completes it, and a second / opens the list again", async () => {
81
+ const setup = await mountComposer();
82
+
83
+ // a trailing "/type" opens the full list under the composer, closest skill first
84
+ await typeText(setup, "run /type");
85
+ const firstList = setup.captureCharFrame();
86
+
87
+ expect(firstList).toContain("/typescript-magician");
88
+
89
+ // tab completes only that token, leaving the prose before it
90
+ setup.mockInput.pressKey("TAB");
91
+ await settle(setup);
92
+ const completed = setup.captureCharFrame();
93
+
94
+ expect(completed).toContain("run /typescript-magician");
95
+
96
+ // a second "/" reopens the palette so another skill chains onto the draft
97
+ await typeText(setup, "/impl");
98
+ const secondList = setup.captureCharFrame();
99
+
100
+ expect(secondList).toContain("/implement");
101
+
102
+ setup.mockInput.pressKey("TAB");
103
+ await settle(setup);
104
+ expect(setup.captureCharFrame()).toContain("run /typescript-magician /implement");
105
+ });
106
+
107
+ test("several skills picked on enter, with prose between, all persist in the draft", async () => {
108
+ const setup = await mountComposerWithSkills([], 120);
109
+
110
+ // pick the first skill, keep typing prose, pick the next, and so on
111
+ await typeText(setup, "start /type");
112
+ setup.mockInput.pressKey("RETURN");
113
+ await settle(setup);
114
+
115
+ await typeText(setup, "then run /impl");
116
+ setup.mockInput.pressKey("RETURN");
117
+ await settle(setup);
118
+
119
+ await typeText(setup, "and finally /write");
120
+ setup.mockInput.pressKey("RETURN");
121
+ await settle(setup);
122
+
123
+ // every pick landed as its full "/name", and the prose between them survived
124
+ expect(setup.captureCharFrame()).toContain(
125
+ "start /typescript-magician then run /implement and finally /write-discoverable-code",
126
+ );
127
+ });
128
+
129
+ test("a completed reference paints in the accent color", async () => {
130
+ const setup = await mountComposer();
131
+
132
+ await typeText(setup, "please /type");
133
+ setup.mockInput.pressKey("RETURN");
134
+ await settle(setup);
135
+
136
+ const accentText = setup
137
+ .captureSpans()
138
+ .lines.flatMap((line) => line.spans.filter((span) => colorToHex(span.fg) === DARK.accent))
139
+ .map((span) => span.text)
140
+ .join("");
141
+
142
+ expect(accentText).toContain("/typescript-magician");
143
+ // the prose around the reference is not accented
144
+ expect(accentText).not.toContain("please");
145
+ });
146
+ });
@@ -0,0 +1,26 @@
1
+ import React from "react";
2
+ import { DEFAULT_QUICK_ACTIONS } from "../config";
3
+ import { DARK } from "../theme";
4
+ import type { Story, StoryMeta } from "./story";
5
+ import { fixtureDisplay, fixtureMarks, fixturePlanSession } from "./story-fixtures";
6
+ import { ThreadView } from "./ThreadView";
7
+
8
+ export const meta: StoryMeta = { title: "Surfaces/ThreadView" };
9
+
10
+ export const InlineThreads: Story = {
11
+ render: () => (
12
+ <ThreadView
13
+ session={fixturePlanSession()}
14
+ display={fixtureDisplay()}
15
+ marks={fixtureMarks()}
16
+ quickActions={DEFAULT_QUICK_ACTIONS}
17
+ observer={false}
18
+ onAnnotate={() => {}}
19
+ onReply={() => {}}
20
+ onUpdateAnnotation={() => {}}
21
+ onExit={() => {}}
22
+ />
23
+ ),
24
+ expectedColors: [DARK.text],
25
+ size: { width: 100, height: 34 },
26
+ };
@@ -0,0 +1,525 @@
1
+ /**
2
+ * The thread view: the plan surface where discussions live inline in the
3
+ * document instead of the rail. The caret, marks, composer, palette, and cards
4
+ * come from the shared annotation surface (the diff sheet drives the same hook);
5
+ * this view paints the plan's blocks - headings, lists, quotes, tracked changes -
6
+ * and the right-edge scroll markers that navigate between discussions.
7
+ *
8
+ * Grammar: arrows move (caret flows across blocks) · shift+arrows select ·
9
+ * click/drag mark · typing comments (or edits my trailing comment / replies
10
+ * in a focused discussion) · "/" opens the quick-action palette · cmd+option+m
11
+ * comments on selection · cmd+enter sends, enter breaks the line · esc
12
+ * dismisses · tab folds · cmd+[ / cmd+] cycle discussions · blur-save on click.
13
+ */
14
+
15
+ import React, { useEffect, useRef, useState } from "react";
16
+ import { useTerminalDimensions } from "@opentui/react";
17
+ import type { KeyEvent, ScrollBoxRenderable } from "@opentui/core";
18
+ import type { Annotation, Thread } from "@cueloop/schema";
19
+ import {
20
+ displayText,
21
+ renderedText,
22
+ renderedStyleRuns,
23
+ styledRunsFor,
24
+ type DisplayBlock,
25
+ type Mark,
26
+ type RenderedRun,
27
+ type RunRole,
28
+ type StyledRun,
29
+ } from "../view-plan";
30
+ import type { TextSpan } from "../thread-selection";
31
+ import type { QuickAction } from "../config";
32
+ import type { CheatsheetSection } from "../key-bindings";
33
+ import type { Theme } from "../theme";
34
+ import { BOLD, CUT, ITALIC, UNDERLINE } from "../annotation-palette";
35
+ import { wrapLines, type MarkRange, type VisualLine } from "../mark-runs";
36
+ import { MarkdownGridBlock } from "./MarkdownGridBlock";
37
+ import { useFrameMeasure } from "../use-frame-measure";
38
+ import { scrollBoxDragViewport, useTerminalVirtualizer } from "../use-terminal-virtualizer";
39
+ import { useAnnotationSurface, type LineSource } from "../use-annotation-surface";
40
+ import { NavModeHint } from "./NavModeHint";
41
+ import { DiscussionMarkerRail } from "./DiscussionMarkerRail";
42
+ import { useComponentTheme } from "./theme-context";
43
+ import { colorForSyntaxGroup } from "./syntax-highlight";
44
+ import { highlightThreadCodeBlocks, type SyntaxSpan } from "../thread-code-syntax";
45
+
46
+ /** Blocks kept mounted beyond the viewport, so a scroll step or a drag past the edge never shows a gap. */
47
+ const OVERSCAN_BLOCKS = 8;
48
+
49
+ export { lighten } from "../annotation-palette";
50
+
51
+ interface CodeSyntaxState {
52
+ display: DisplayBlock[];
53
+ byBlock: Map<number, SyntaxSpan[]>;
54
+ }
55
+
56
+ function syntaxSpansForBlock(
57
+ block: DisplayBlock,
58
+ blockIndex: number,
59
+ display: DisplayBlock[],
60
+ codeSyntax: CodeSyntaxState | null,
61
+ ): readonly SyntaxSpan[] {
62
+ if (block.type === "del" || codeSyntax?.display !== display) return [];
63
+
64
+ return codeSyntax.byBlock.get(blockIndex) ?? [];
65
+ }
66
+
67
+ /**
68
+ * How a block's rows are painted: heading weight, muted kinds, and the list or
69
+ * quote marker. A cut block reads dim and struck through; other edits are
70
+ * tracked into the working copy but not tagged in the read-only view.
71
+ */
72
+ interface BlockStyle {
73
+ baseFg: string;
74
+ baseAttributes: number;
75
+ marker: string;
76
+ }
77
+
78
+ function blockStyle(block: DisplayBlock, tokens: Theme): BlockStyle {
79
+ const isHeading = block.kind === "h1" || block.kind === "h2" || block.kind === "h3";
80
+ const isCut = block.type === "del";
81
+ const muted = block.kind === "h2" || block.kind === "h3" || block.kind === "code";
82
+ const marker =
83
+ block.kind === "li"
84
+ ? "· "
85
+ : block.kind === "oli"
86
+ ? `${block.orderedItemNumber ?? 1}. `
87
+ : block.kind === "quote"
88
+ ? "▏ "
89
+ : "";
90
+
91
+ return {
92
+ baseFg: isCut ? tokens.textDim : muted ? tokens.textMuted : tokens.text,
93
+ baseAttributes: (isHeading ? BOLD : 0) | (isCut ? CUT : 0),
94
+ marker,
95
+ };
96
+ }
97
+
98
+ /** The read-only color of an inline markdown role: links accent, code and removals dim, insertions green. */
99
+ function roleForeground(role: RunRole, baseFg: string, tokens: Theme): string {
100
+ if (role === "link") return tokens.blue;
101
+ if (role === "ins") return tokens.green;
102
+ if (role === "code" || role === "del") return tokens.textDim;
103
+
104
+ return baseFg;
105
+ }
106
+
107
+ /** The text attributes an inline role adds over a block's base attributes. */
108
+ function roleAttributes(role: RunRole, baseAttributes: number): number {
109
+ if (role === "strong") return baseAttributes | BOLD;
110
+ if (role === "em") return baseAttributes | ITALIC;
111
+ if (role === "strike" || role === "del") return baseAttributes | CUT;
112
+
113
+ return baseAttributes;
114
+ }
115
+
116
+ function isGridKind(kind: DisplayBlock["kind"]): boolean {
117
+ return kind === "table" || kind === "frontmatter";
118
+ }
119
+
120
+ /* ------------------------------------------------------------------ view */
121
+
122
+ /** The grammar as the keybinds dialog lists it; the view owns these keys, so they are not rebindable. */
123
+ export const THREAD_VIEW_CHEATSHEET: CheatsheetSection[] = [
124
+ {
125
+ title: "Thread (type)",
126
+ entries: [
127
+ { keys: "click", label: "place the caret" },
128
+ { keys: "drag", label: "mark text, across blocks" },
129
+ { keys: "dbl-click", label: "mark the word" },
130
+ { keys: "← / →", label: "move by word" },
131
+ { keys: "⇧← / ⇧→", label: "hold a mark" },
132
+ { keys: "↑ / ↓", label: "move by block" },
133
+ { keys: "type", label: "comment on the mark" },
134
+ { keys: "enter", label: "reply to the comment" },
135
+ { keys: "esc", label: "nav mode" },
136
+ { keys: "tab", label: "cycle panes" },
137
+ { keys: "⌃q", label: "quit" },
138
+ ],
139
+ },
140
+ {
141
+ title: "Comment",
142
+ entries: [
143
+ { keys: "⌘enter", label: "send (⌃enter as well)" },
144
+ { keys: "enter", label: "new line" },
145
+ { keys: "backspace", label: "dismiss an empty draft" },
146
+ { keys: "/", label: "actions" },
147
+ { keys: "click away", label: "save" },
148
+ ],
149
+ },
150
+ ];
151
+
152
+ export interface ThreadViewProps {
153
+ session: Thread;
154
+ display: DisplayBlock[];
155
+ marks: Map<number, Mark[]>;
156
+ quickActions: QuickAction[];
157
+ observer: boolean;
158
+ /** True while a menu, dialog, or overlay owns the keyboard. */
159
+ suspended?: boolean;
160
+ /** Comments an edit orphaned: their passage is gone from the working copy. */
161
+ editOrphanCount?: number;
162
+ /** Reports whether a composer is open, so session chords can yield to typing. */
163
+ onComposingChange?: (composing: boolean) => void;
164
+ /** A message is in: no draft may open; the app answers with its read-only status. */
165
+ resolved?: boolean;
166
+ /** An observer or a resolved review refused a draft; the app shows why. */
167
+ onObserverBlocked?: (reason: "observer" | "resolved") => void;
168
+ /** Reports the caret's block, so block-level primitives (cut, restore) act where the caret is. */
169
+ onCursorChange?: (blockIndex: number) => void;
170
+ /** The rail's focused card; the discussion holding it takes focus here. */
171
+ focusedAnnotationId?: string;
172
+ /** Reports the focused discussion's root comment, so the rail follows. */
173
+ onFocusAnnotation?: (annotationId: string | undefined) => void;
174
+ onAnnotate: (span: TextSpan, body: string) => void;
175
+ onReply: (rootAnnotationId: string, body: string) => void;
176
+ onUpdateAnnotation: (id: string, body: string) => void;
177
+ /** The author's display name for a comment's hover tooltip. */
178
+ resolveAuthorLabel?: (annotation: Annotation) => string | undefined;
179
+ onNavCommand?: (key: KeyEvent, selection: TextSpan | null) => boolean;
180
+ onExit: () => void;
181
+ theme?: Theme;
182
+ }
183
+
184
+ export function ThreadView({
185
+ session,
186
+ display,
187
+ marks,
188
+ quickActions,
189
+ observer,
190
+ resolved = false,
191
+ suspended = false,
192
+ editOrphanCount = 0,
193
+ onComposingChange,
194
+ onObserverBlocked,
195
+ onCursorChange,
196
+ focusedAnnotationId,
197
+ onFocusAnnotation,
198
+ onAnnotate,
199
+ onReply,
200
+ onUpdateAnnotation,
201
+ resolveAuthorLabel,
202
+ onNavCommand,
203
+ onExit,
204
+ theme,
205
+ }: ThreadViewProps): React.ReactNode {
206
+ const tokens = useComponentTheme(theme);
207
+ const source: LineSource = {
208
+ count: display.length,
209
+ // the surface hit-tests and paints in rendered text (inline markers concealed); a grid anchors on its raw source
210
+ textAt: (blockIndex) => renderedText(display[blockIndex]!),
211
+ annotatable: () => true,
212
+ };
213
+ const scrollRef = useRef<ScrollBoxRenderable | null>(null);
214
+ const surface = useAnnotationSurface({
215
+ source,
216
+ session,
217
+ marks,
218
+ quickActions,
219
+ tokens,
220
+ observer,
221
+ resolved,
222
+ suspended,
223
+ onComposingChange,
224
+ onObserverBlocked,
225
+ onCursorChange,
226
+ focusedAnnotationId,
227
+ onFocusAnnotation,
228
+ onAnnotate,
229
+ onReply,
230
+ onUpdateAnnotation,
231
+ dragViewport: () => scrollBoxDragViewport(scrollRef.current),
232
+ resolveAuthorLabel,
233
+ onNavCommand,
234
+ onExit,
235
+ });
236
+ const { palette, discussions } = surface;
237
+ const { height: terminalHeight } = useTerminalDimensions();
238
+ const viewWidth = useFrameMeasure(
239
+ () => scrollRef.current?.content?.width ?? 0,
240
+ (left, right) => left === right,
241
+ 0,
242
+ );
243
+ const [codeSyntax, setCodeSyntax] = useState<CodeSyntaxState | null>(null);
244
+
245
+ useEffect(() => {
246
+ let active = true;
247
+
248
+ void highlightThreadCodeBlocks(display).then(
249
+ (byBlock) => {
250
+ if (active) setCodeSyntax({ display, byBlock });
251
+ },
252
+ (error) => {
253
+ if (active) console.error("thread code syntax highlighting failed", error);
254
+ },
255
+ );
256
+
257
+ return () => {
258
+ active = false;
259
+ };
260
+ }, [display]);
261
+
262
+ // only the viewport's blocks (plus an overscan) mount; a block is estimated at its wrapped-line
263
+ // count until its box, cards included, is measured. The initial viewport seeds the first render so a
264
+ // freshly mounted thread (a switch from a diff, another project) windows its first paint rather than
265
+ // building every block, and never flashes blank.
266
+ const estimateBlockRows = (index: number): number => {
267
+ const block = display[index];
268
+
269
+ if (!block) return 1;
270
+ const usable = viewWidth > 0 ? Math.max(1, viewWidth - 6) : 40;
271
+ const lines = Math.max(1, Math.ceil(displayText(block).length / usable));
272
+
273
+ return lines + (index === 0 ? 0 : 1);
274
+ };
275
+ const virtual = useTerminalVirtualizer({
276
+ scrollbox: scrollRef,
277
+ count: display.length,
278
+ estimateSize: estimateBlockRows,
279
+ overscan: OVERSCAN_BLOCKS,
280
+ });
281
+
282
+ useEffect(() => {
283
+ virtual.scrollToIndex(surface.revealBlockIndex, surface.compose ? "end" : "auto");
284
+ // eslint-disable-next-line react-hooks/exhaustive-deps
285
+ }, [surface.revealBlockIndex, surface.compose]);
286
+
287
+ interface LineContext {
288
+ blockIndex: number;
289
+ roleRuns: RenderedRun[];
290
+ line: VisualLine;
291
+ lineIndex: number;
292
+ ranges: MarkRange[];
293
+ marker: string;
294
+ baseFg: string;
295
+ baseAttributes: number;
296
+ syntaxSpans: readonly SyntaxSpan[];
297
+ }
298
+
299
+ const paintedSpan = (
300
+ run: StyledRun,
301
+ runIndex: number,
302
+ baseFg: string,
303
+ baseAttributes: number,
304
+ ) => (
305
+ <span
306
+ key={runIndex}
307
+ fg={
308
+ run.role === "plain" && run.syntaxGroup
309
+ ? (colorForSyntaxGroup(run.syntaxGroup, tokens) ?? baseFg)
310
+ : roleForeground(run.role, baseFg, tokens)
311
+ }
312
+ bg={run.caretOnly ? palette.caretCell : run.marked ? palette.markBackdrop : undefined}
313
+ attributes={roleAttributes(run.role, baseAttributes) | (run.marked ? UNDERLINE : 0)}
314
+ >
315
+ {run.text}
316
+ </span>
317
+ );
318
+
319
+ const lineRowFor = (context: LineContext): React.ReactNode => {
320
+ const {
321
+ blockIndex,
322
+ roleRuns,
323
+ line,
324
+ lineIndex,
325
+ ranges,
326
+ marker,
327
+ baseFg,
328
+ baseAttributes,
329
+ syntaxSpans,
330
+ } = context;
331
+
332
+ return (
333
+ <box key={`line-${lineIndex}`} style={{ flexDirection: "row" }}>
334
+ <text selectable={false}>
335
+ <span fg={tokens.textDim}>{" "}</span>
336
+ <span fg={tokens.textDim}>{lineIndex === 0 ? marker : " ".repeat(marker.length)}</span>
337
+ </text>
338
+ <text
339
+ selectable={false}
340
+ style={{ flexGrow: 1, flexShrink: 1 }}
341
+ ref={surface.registerLine(blockIndex, lineIndex, line)}
342
+ onMouseDown={surface.onLineMouseDown}
343
+ >
344
+ {styledRunsFor(roleRuns, line, ranges, syntaxSpans).map((run, runIndex) =>
345
+ paintedSpan(run, runIndex, baseFg, baseAttributes),
346
+ )}
347
+ </text>
348
+ </box>
349
+ );
350
+ };
351
+
352
+ // an h1 or h2 sits over a dim rule; h3 gets weight only. The row is
353
+ // always present for those kinds (only its width tracks the measured viewport) so a late width
354
+ // measurement never adds a row and shifts the blocks below it out from under a pending click
355
+ const headingRule = (block: DisplayBlock): React.ReactNode => {
356
+ if (block.kind !== "h1" && block.kind !== "h2") return null;
357
+ const width = Math.max(1, (viewWidth > 6 ? viewWidth : 46) - 6);
358
+
359
+ return (
360
+ <box key="heading-rule" style={{ flexDirection: "row" }}>
361
+ <text selectable={false} fg={tokens.textDim}>
362
+ {" " + "─".repeat(width)}
363
+ </text>
364
+ </box>
365
+ );
366
+ };
367
+
368
+ const blockNodeFor = (blockIndex: number): React.ReactNode => {
369
+ const block = display[blockIndex]!;
370
+ const { baseFg, baseAttributes, marker } = blockStyle(block, tokens);
371
+ // list items of one list stay tight; every other block sits a blank row below its neighbour
372
+ const previous = display[blockIndex - 1];
373
+ const tight =
374
+ previous !== undefined &&
375
+ block.kind === previous.kind &&
376
+ (block.kind === "li" || block.kind === "oli");
377
+ // the leading gap rides inside the measured box (not marginTop, which sits outside it) so the
378
+ // virtualizer's spacer math counts it
379
+ const leadingGap =
380
+ blockIndex === 0 || tight ? null : <box key="lead-gap" style={{ height: 1 }} />;
381
+ const measuredBox = (children: React.ReactNode): React.ReactNode => (
382
+ <box
383
+ key={`discussion-block-${blockIndex}`}
384
+ id={`discussion-block-${blockIndex}`}
385
+ ref={virtual.measureRef(blockIndex)}
386
+ style={{ flexDirection: "column" }}
387
+ >
388
+ {leadingGap}
389
+ {children}
390
+ </box>
391
+ );
392
+
393
+ if (isGridKind(block.kind)) {
394
+ // a grid annotates as one unit: every row hit-tests to the same block span, a mark tints the grid,
395
+ // and the block's discussion cards slot under it, so annotations resolving into a grid stay visible
396
+ const gridLine: VisualLine = { start: 0, end: renderedText(block).length };
397
+ const marked = surface.rangesFor(blockIndex).some((range) => !range.caretOnly);
398
+
399
+ return measuredBox(
400
+ <>
401
+ <MarkdownGridBlock
402
+ block={block}
403
+ theme={tokens}
404
+ contentWidth={viewWidth > 0 ? viewWidth - 6 : 40}
405
+ marked={marked}
406
+ markBackdrop={palette.markBackdrop}
407
+ registerRow={(rowIndex) => surface.registerLine(blockIndex, rowIndex, gridLine)}
408
+ onRowMouseDown={surface.onLineMouseDown}
409
+ />
410
+ {surface.cardsAfterLine(blockIndex, gridLine, true)}
411
+ </>,
412
+ );
413
+ }
414
+
415
+ const roleRuns = renderedStyleRuns(block);
416
+ const ranges = surface.rangesFor(blockIndex);
417
+ const lines = wrapLines(renderedText(block), viewWidth > 0 ? viewWidth - marker.length - 6 : 0);
418
+ const syntaxSpans = syntaxSpansForBlock(block, blockIndex, display, codeSyntax);
419
+ const lineRows: React.ReactNode[] = [];
420
+
421
+ for (let lineIndex = 0; lineIndex < lines.length; lineIndex++) {
422
+ const line = lines[lineIndex]!;
423
+ const isLastLine = lineIndex === lines.length - 1;
424
+
425
+ lineRows.push(
426
+ lineRowFor({
427
+ blockIndex,
428
+ roleRuns,
429
+ line,
430
+ lineIndex,
431
+ ranges,
432
+ marker,
433
+ baseFg,
434
+ baseAttributes,
435
+ syntaxSpans,
436
+ }),
437
+ );
438
+ if (isLastLine) lineRows.push(headingRule(block));
439
+ const cards = surface.cardsAfterLine(blockIndex, line, isLastLine);
440
+
441
+ lineRows.push(...cards);
442
+ if (cards.length > 0 && !isLastLine) {
443
+ lineRows.push(<box key={`gap-${lineIndex}`} style={{ height: 1 }} />);
444
+ }
445
+ }
446
+
447
+ // a code block announces its language first
448
+ const languageRow =
449
+ block.kind === "code" ? (
450
+ <text key="language" fg={tokens.textDim} selectable={false}>
451
+ {` ${block.work?.lang ?? block.base?.lang ?? "code"}`}
452
+ </text>
453
+ ) : null;
454
+
455
+ return measuredBox(
456
+ <>
457
+ {languageRow}
458
+ {lineRows}
459
+ </>,
460
+ );
461
+ };
462
+
463
+ const virtualBlocks = (): React.ReactNode[] => {
464
+ const nodes: React.ReactNode[] = [];
465
+ const firstItem = virtual.items[0];
466
+ const lastItem = virtual.items.at(-1);
467
+
468
+ // the very first render has no measured viewport yet, so the virtualizer's window is empty; paint a
469
+ // viewport-worth from the top rather than blank or the whole document, then it takes over next frame
470
+ if (!firstItem || !lastItem) {
471
+ let mountedRows = 0;
472
+
473
+ for (let index = 0; index < display.length; index++) {
474
+ nodes.push(blockNodeFor(index));
475
+ mountedRows += estimateBlockRows(index);
476
+ if (mountedRows > terminalHeight + OVERSCAN_BLOCKS) break;
477
+ }
478
+
479
+ return nodes;
480
+ }
481
+ if (firstItem.start > 0) {
482
+ nodes.push(<box key="spacer-above" style={{ height: firstItem.start }} />);
483
+ }
484
+ for (const item of virtual.items) nodes.push(blockNodeFor(item.index));
485
+ const below = virtual.totalSize - lastItem.end;
486
+
487
+ if (below > 0) nodes.push(<box key="spacer-below" style={{ height: below }} />);
488
+
489
+ return nodes;
490
+ };
491
+
492
+ return (
493
+ <box style={{ flexGrow: 1, flexDirection: "row" }} {...surface.rootMouseProps}>
494
+ <box style={{ flexGrow: 1, flexDirection: "column" }}>
495
+ {editOrphanCount > 0 ? (
496
+ <box style={{ height: 1, backgroundColor: palette.markBackdrop, paddingLeft: 2 }}>
497
+ <text fg={tokens.red}>
498
+ {`${editOrphanCount} annotation${editOrphanCount === 1 ? "" : "s"} no longer match - the passage was removed.`}
499
+ </text>
500
+ </box>
501
+ ) : null}
502
+ {/* the rail draws this box's scrollbar past the dots, so the built-in one stays hidden */}
503
+ <scrollbox
504
+ ref={scrollRef}
505
+ style={{ flexGrow: 1, paddingTop: 1 }}
506
+ focused={false}
507
+ verticalScrollbarOptions={{ visible: false }}
508
+ >
509
+ {virtualBlocks()}
510
+ </scrollbox>
511
+ {suspended ? null : (
512
+ <NavModeHint navMode={surface.navMode} surface="thread" theme={tokens} />
513
+ )}
514
+ </box>
515
+ <DiscussionMarkerRail
516
+ discussions={discussions}
517
+ spanQuote={surface.spanQuote}
518
+ focusedKey={surface.focusedDiscussion}
519
+ onJump={surface.jumpToDiscussion}
520
+ scrollbox={scrollRef}
521
+ theme={theme}
522
+ />
523
+ </box>
524
+ );
525
+ }