@cueloop/client 0.1.0-alpha.7 → 0.1.0-alpha.71

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 (248) 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 +39 -32
  8. package/src/App.appearance.test.tsx +53 -0
  9. package/src/App.autoclose.test.tsx +76 -50
  10. package/src/App.diff.test.tsx +156 -42
  11. package/src/App.export.test.tsx +58 -40
  12. package/src/App.plan-review.test.tsx +363 -0
  13. package/src/App.prototype-markdown.test.tsx +92 -0
  14. package/src/App.prototype.test.tsx +176 -0
  15. package/src/App.readonly.test.tsx +128 -67
  16. package/src/App.rendering.test.tsx +61 -35
  17. package/src/App.submit-confirm.test.tsx +191 -0
  18. package/src/App.test.tsx +310 -114
  19. package/src/App.toast.test.tsx +89 -0
  20. package/src/App.tsx +1043 -798
  21. package/src/App.walk.test.tsx +298 -0
  22. package/src/App.workbench.test.tsx +495 -0
  23. package/src/__snapshots__/view-plan.regression.test.ts.snap +100 -0
  24. package/src/annotation-palette.ts +61 -0
  25. package/src/app-key-state.ts +61 -0
  26. package/src/app-screens.tsx +432 -0
  27. package/src/app-view-model.ts +159 -0
  28. package/src/attribution.test.ts +31 -0
  29. package/src/attribution.ts +42 -0
  30. package/src/clipboard.test.ts +23 -0
  31. package/src/clipboard.ts +33 -0
  32. package/src/components/AnnotatableFileView.stories.tsx +42 -0
  33. package/src/components/AnnotatableFileView.test.tsx +102 -0
  34. package/src/components/AnnotatableFileView.tsx +126 -0
  35. package/src/components/AnnotationCards.stories.tsx +87 -0
  36. package/src/components/AnnotationCards.tsx +303 -0
  37. package/src/components/AppHeader.stories.tsx +50 -0
  38. package/src/components/AppHeader.tsx +83 -0
  39. package/src/components/AppShell.stories.tsx +89 -0
  40. package/src/components/AppShell.tsx +290 -0
  41. package/src/components/AppShell.zoom.test.tsx +135 -0
  42. package/src/components/BareWorkbenchFileView.stories.tsx +27 -0
  43. package/src/components/BareWorkbenchFileView.tsx +64 -0
  44. package/src/components/Breadcrumb.stories.tsx +19 -0
  45. package/src/components/Breadcrumb.tsx +45 -0
  46. package/src/components/ChangesColumn.stories.tsx +39 -0
  47. package/src/components/ChangesColumn.tsx +171 -0
  48. package/src/components/CodeBlock.stories.tsx +42 -0
  49. package/src/components/CodeBlock.tsx +84 -0
  50. package/src/components/CompletionOverlay.stories.tsx +23 -0
  51. package/src/components/CompletionOverlay.tsx +60 -0
  52. package/src/components/ConfirmCard.stories.tsx +35 -0
  53. package/src/components/ConfirmCard.tsx +128 -0
  54. package/src/components/ConfirmDialog.stories.tsx +21 -0
  55. package/src/components/ConfirmDialog.tsx +66 -0
  56. package/src/components/DiffContentView.scroll.test.tsx +121 -0
  57. package/src/components/DiffContentView.split.test.tsx +140 -0
  58. package/src/components/DiffContentView.stories.tsx +80 -0
  59. package/src/components/DiffContentView.syntax.test.tsx +82 -0
  60. package/src/components/DiffContentView.tsx +859 -0
  61. package/src/components/DiscussionMarkerRail.stories.tsx +54 -0
  62. package/src/components/DiscussionMarkerRail.tsx +181 -0
  63. package/src/components/EditorGrid.stories.tsx +59 -0
  64. package/src/components/EditorGrid.tabs.test.tsx +118 -0
  65. package/src/components/EditorGrid.tsx +368 -0
  66. package/src/components/EditorGrid.zoom.test.tsx +68 -0
  67. package/src/components/FileContentsView.stories.tsx +16 -0
  68. package/src/components/FileContentsView.test.tsx +29 -0
  69. package/src/components/FileContentsView.tsx +98 -0
  70. package/src/components/KeybindsDialog.stories.tsx +31 -0
  71. package/src/components/KeybindsDialog.tsx +103 -0
  72. package/src/components/MenuBar.stories.tsx +28 -0
  73. package/src/components/MenuBar.tsx +44 -0
  74. package/src/components/OverlayScrollbar.stories.tsx +33 -0
  75. package/src/components/OverlayScrollbar.test.tsx +139 -0
  76. package/src/components/OverlayScrollbar.tsx +129 -0
  77. package/src/components/PaneGrid.stories.tsx +84 -0
  78. package/src/components/PaneGrid.tsx +128 -0
  79. package/src/components/PanelColumn.stories.tsx +25 -0
  80. package/src/components/PanelColumn.tsx +95 -0
  81. package/src/components/ProjectTreeView.stories.tsx +25 -0
  82. package/src/components/ProjectTreeView.test.tsx +32 -0
  83. package/src/components/ProjectTreeView.tsx +83 -0
  84. package/src/components/PromptDialog.stories.tsx +36 -0
  85. package/src/components/PromptDialog.tsx +87 -0
  86. package/src/components/PrototypeContentView.stories.tsx +19 -0
  87. package/src/components/PrototypeContentView.tsx +446 -0
  88. package/src/components/RootOverlay.stories.tsx +44 -0
  89. package/src/components/RootOverlay.tsx +35 -0
  90. package/src/components/ScrollArea.stories.tsx +20 -0
  91. package/src/components/ScrollArea.tsx +44 -0
  92. package/src/components/SettingsDialog.stories.tsx +117 -0
  93. package/src/components/SettingsDialog.tsx +195 -0
  94. package/src/components/SettingsRows.stories.tsx +18 -0
  95. package/src/components/SettingsRows.tsx +112 -0
  96. package/src/components/ShellHeader.stories.tsx +50 -0
  97. package/src/components/ShellHeader.tsx +137 -0
  98. package/src/components/ThreadFooter.stories.tsx +30 -0
  99. package/src/components/ThreadFooter.test.tsx +31 -0
  100. package/src/components/ThreadFooter.tsx +54 -0
  101. package/src/components/ThreadTree.stories.tsx +46 -0
  102. package/src/components/ThreadTree.tsx +219 -0
  103. package/src/components/ThreadView.selection.test.tsx +812 -0
  104. package/src/components/ThreadView.slash.test.tsx +158 -0
  105. package/src/components/ThreadView.stories.tsx +26 -0
  106. package/src/components/ThreadView.tsx +330 -0
  107. package/src/components/ThreadsSidebar.stories.tsx +36 -0
  108. package/src/components/ThreadsSidebar.tsx +71 -0
  109. package/src/components/Toast.stories.tsx +12 -0
  110. package/src/components/Toast.tsx +45 -0
  111. package/src/components/Tooltip.stories.tsx +26 -0
  112. package/src/components/Tooltip.tsx +72 -0
  113. package/src/components/TreePane.stories.tsx +106 -0
  114. package/src/components/TreePane.tsx +157 -0
  115. package/src/components/WalkWizard.stories.tsx +100 -0
  116. package/src/components/WalkWizard.tsx +152 -0
  117. package/src/components/WelcomePlayground.stories.tsx +12 -0
  118. package/src/components/WelcomePlayground.test.tsx +47 -0
  119. package/src/components/WelcomePlayground.tsx +140 -0
  120. package/src/components/__snapshots__/stories.test.tsx.snap +1845 -0
  121. package/src/components/agent-launcher.stories.tsx +26 -0
  122. package/src/components/agent-launcher.test.tsx +50 -0
  123. package/src/components/agent-launcher.tsx +149 -0
  124. package/src/components/diff-content-view-layout.test.ts +198 -0
  125. package/src/components/diff-content-view-layout.ts +84 -0
  126. package/src/components/editor-grid.test.ts +86 -0
  127. package/src/components/editor-grid.ts +138 -0
  128. package/src/components/fade-title.test.ts +49 -0
  129. package/src/components/fade-title.ts +61 -0
  130. package/src/components/file-tree.test.ts +60 -0
  131. package/src/components/file-tree.ts +112 -0
  132. package/src/components/pages/Workspace.stories.tsx +181 -0
  133. package/src/components/primitives/Button.stories.tsx +32 -0
  134. package/src/components/primitives/Button.tsx +52 -0
  135. package/src/components/primitives/Card.stories.tsx +25 -0
  136. package/src/components/primitives/Card.tsx +60 -0
  137. package/src/components/primitives/Dialog.stories.tsx +24 -0
  138. package/src/components/primitives/Dialog.tsx +66 -0
  139. package/src/components/primitives/IconButton.stories.tsx +36 -0
  140. package/src/components/primitives/IconButton.tsx +62 -0
  141. package/src/components/primitives/StatusBar.stories.tsx +11 -0
  142. package/src/components/primitives/StatusBar.tsx +23 -0
  143. package/src/components/primitives/Tabs.stories.tsx +29 -0
  144. package/src/components/primitives/Tabs.tsx +102 -0
  145. package/src/components/primitives/Toolbar.stories.tsx +17 -0
  146. package/src/components/primitives/Toolbar.tsx +14 -0
  147. package/src/components/primitives/Tree.stories.tsx +124 -0
  148. package/src/components/primitives/Tree.tsx +96 -0
  149. package/src/components/primitives/frame.test.ts +12 -0
  150. package/src/components/primitives/frame.ts +14 -0
  151. package/src/components/primitives/icons.ts +61 -0
  152. package/src/components/primitives/tree-model.test.ts +99 -0
  153. package/src/components/primitives/tree-model.ts +137 -0
  154. package/src/components/quick-actions-editor.stories.tsx +54 -0
  155. package/src/components/quick-actions-editor.test.tsx +49 -0
  156. package/src/components/quick-actions-editor.tsx +150 -0
  157. package/src/components/session-tree.test.ts +134 -0
  158. package/src/components/session-tree.ts +108 -0
  159. package/src/components/stories-app.tsx +184 -0
  160. package/src/components/stories.test.tsx +66 -0
  161. package/src/components/story-fixtures.ts +102 -0
  162. package/src/components/story-tree.test.ts +100 -0
  163. package/src/components/story.ts +142 -0
  164. package/src/components/syntax-highlight.ts +110 -0
  165. package/src/components/terminal-pane.test.tsx +40 -0
  166. package/src/components/terminal-pane.ts +199 -0
  167. package/src/components/theme-context.stories.tsx +46 -0
  168. package/src/components/theme-context.tsx +37 -0
  169. package/src/components/truncate-text.ts +7 -0
  170. package/src/config.test.ts +461 -31
  171. package/src/config.ts +389 -57
  172. package/src/diff-fold.test.ts +78 -0
  173. package/src/diff-fold.ts +126 -0
  174. package/src/diff-hunk-curate.test.ts +315 -0
  175. package/src/diff-hunk-curate.ts +153 -0
  176. package/src/diff-intraline.test.ts +165 -0
  177. package/src/diff-intraline.ts +146 -0
  178. package/src/diff-syntax.test.ts +112 -0
  179. package/src/diff-syntax.ts +179 -0
  180. package/src/discussions.ts +79 -0
  181. package/src/editor.test.ts +149 -0
  182. package/src/editor.ts +139 -9
  183. package/src/ghostty-terminal.test.ts +85 -0
  184. package/src/ghostty-terminal.ts +199 -0
  185. package/src/herdr.test.ts +9 -34
  186. package/src/herdr.ts +25 -21
  187. package/src/index.ts +11 -0
  188. package/src/integrations.test.ts +41 -0
  189. package/src/integrations.ts +32 -0
  190. package/src/intent-dispatch.test.ts +698 -0
  191. package/src/intent-dispatch.ts +700 -0
  192. package/src/key-bindings.test.ts +156 -0
  193. package/src/key-bindings.ts +465 -0
  194. package/src/keymap.test.ts +611 -0
  195. package/src/keymap.ts +410 -0
  196. package/src/kitty-image.test.ts +66 -0
  197. package/src/kitty-image.ts +138 -0
  198. package/src/mark-runs.ts +120 -0
  199. package/src/prototype-browser.test.ts +56 -0
  200. package/src/prototype-browser.ts +307 -0
  201. package/src/prototype-pixels.tsx +35 -0
  202. package/src/pty.test.ts +81 -0
  203. package/src/pty.ts +245 -0
  204. package/src/ready-signal.test.ts +85 -0
  205. package/src/ready-signal.ts +52 -0
  206. package/src/run.ts +30 -9
  207. package/src/serve.test.ts +24 -12
  208. package/src/serve.ts +20 -9
  209. package/src/share.ts +215 -0
  210. package/src/skills.test.ts +74 -0
  211. package/src/skills.ts +68 -0
  212. package/src/slash-palette.test.ts +54 -0
  213. package/src/slash-palette.ts +104 -0
  214. package/src/split-diff.test.ts +110 -0
  215. package/src/split-diff.ts +149 -0
  216. package/src/test-support.test.ts +264 -0
  217. package/src/test-support.ts +355 -0
  218. package/src/theme-presets.test.ts +51 -0
  219. package/src/theme-presets.ts +214 -0
  220. package/src/theme.ts +84 -19
  221. package/src/thread-chords.test.ts +170 -0
  222. package/src/thread-chords.ts +169 -0
  223. package/src/thread-controller.curate.test.ts +438 -0
  224. package/src/thread-controller.live-diff.test.ts +199 -0
  225. package/src/thread-controller.share.test.ts +472 -0
  226. package/src/thread-controller.tree.test.ts +255 -0
  227. package/src/thread-controller.ts +1509 -0
  228. package/src/thread-selection.test.ts +210 -0
  229. package/src/thread-selection.ts +243 -0
  230. package/src/tree-view.test.ts +128 -0
  231. package/src/tree-view.ts +148 -0
  232. package/src/use-annotation-surface.tsx +851 -0
  233. package/src/use-changes-workbench.ts +153 -0
  234. package/src/use-frame-measure.ts +38 -0
  235. package/src/use-settings-dialog.tsx +274 -0
  236. package/src/use-terminal-virtualizer.ts +233 -0
  237. package/src/version.ts +5 -0
  238. package/src/view-diff.test.ts +66 -9
  239. package/src/view-diff.ts +231 -23
  240. package/src/view-plan.regression.test.ts +60 -0
  241. package/src/view-plan.test.ts +406 -0
  242. package/src/view-plan.ts +570 -0
  243. package/src/walk.test.ts +121 -0
  244. package/src/walk.ts +74 -0
  245. package/src/syntax.test.ts +0 -41
  246. package/src/syntax.ts +0 -88
  247. package/src/view.test.ts +0 -157
  248. package/src/view.ts +0 -312
@@ -0,0 +1,26 @@
1
+ import React from "react";
2
+ import { DARK } from "../theme";
3
+ import type { Story, StoryMeta } from "./story";
4
+ import { AgentLauncher } from "./agent-launcher";
5
+ import { fixturePlanSession } from "./story-fixtures";
6
+
7
+ export const meta: StoryMeta = { title: "Chrome/AgentLauncher" };
8
+
9
+ /** The launcher expects the rail's narrow, height-constrained column. */
10
+ function RailFrame({ children }: { children: React.ReactNode }): React.ReactNode {
11
+ return (
12
+ <box style={{ width: 40, height: 24, paddingLeft: 2, backgroundColor: DARK.panel }}>
13
+ {children}
14
+ </box>
15
+ );
16
+ }
17
+
18
+ export const Launcher: Story = {
19
+ render: () => (
20
+ <RailFrame>
21
+ <AgentLauncher session={fixturePlanSession()} onLaunchHarness={() => {}} theme={DARK} />
22
+ </RailFrame>
23
+ ),
24
+ expectedColors: [DARK.accent],
25
+ size: { width: 40, height: 24 },
26
+ };
@@ -0,0 +1,50 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import React from "react";
3
+ import { testRender } from "@opentui/react/test-utils";
4
+ import { DARK } from "../theme";
5
+ import { fixturePlanSession } from "./story-fixtures";
6
+ import { waitForText } from "../test-support";
7
+ import { AgentLauncher, HARNESS_LAUNCHERS, planHandoffBriefing } from "./agent-launcher";
8
+
9
+ describe("planHandoffBriefing", () => {
10
+ test("references the session by id and directs the agent to read then annotate, not rewrite", () => {
11
+ // Act
12
+ const briefing = planHandoffBriefing("ses_42");
13
+
14
+ // Assert
15
+ expect(briefing).toContain("cueloop session get ses_42");
16
+ expect(briefing).toContain("cueloop session annotate ses_42");
17
+ expect(briefing).toContain("Do not rewrite the plan");
18
+ });
19
+ });
20
+
21
+ describe("HARNESS_LAUNCHERS", () => {
22
+ test("carries the three harnesses with real binary commands (never the `cc` alias)", () => {
23
+ // Assert - `cc` resolves to the system C compiler when spawned on a PTY, so
24
+ // the Claude harness must run `claude`, the real binary, not the shell alias
25
+ expect(HARNESS_LAUNCHERS.map((harness) => harness.command)).toEqual(["claude", "pi", "codex"]);
26
+ });
27
+ });
28
+
29
+ describe("AgentLauncher", () => {
30
+ test("renders a button for every harness, in order", async () => {
31
+ // Arrange
32
+ const setup = await testRender(
33
+ <box style={{ width: 44, height: 20, flexDirection: "column" }}>
34
+ <AgentLauncher session={fixturePlanSession()} onLaunchHarness={() => {}} theme={DARK} />
35
+ </box>,
36
+ { width: 44, height: 20 },
37
+ );
38
+
39
+ // Act
40
+ await waitForText(setup, "Claude Code");
41
+ const frame = setup.captureCharFrame();
42
+
43
+ // Assert - all three launcher names present, in launch order
44
+ const names = HARNESS_LAUNCHERS.map((harness) => harness.name);
45
+ const positions = names.map((name) => frame.indexOf(name));
46
+
47
+ expect(positions.every((position) => position >= 0)).toBe(true);
48
+ expect(positions).toEqual(positions.toSorted((left, right) => left - right));
49
+ });
50
+ });
@@ -0,0 +1,149 @@
1
+ /**
2
+ * The Agent rail launcher: bring-your-own review harness. Branded claude code /
3
+ * pi / codex cards that, on click, run that harness in a herdr split beside the
4
+ * review (see launchHarnessInSplit) so a reviewer can ask an agent about the plan
5
+ * without leaving the tab. A plan-context toggle seeds a briefing into the split.
6
+ */
7
+
8
+ import React, { useEffect, useRef, useState } from "react";
9
+ import type { Thread } from "@cueloop/schema";
10
+ import type { Theme } from "../theme";
11
+ import { useComponentTheme } from "./theme-context";
12
+ import { FRAME_BORDER_STYLE } from "./primitives/frame";
13
+ import {
14
+ embeddedTerminalAvailable,
15
+ registerTerminalPane,
16
+ type TerminalPaneRenderable,
17
+ } from "./terminal-pane";
18
+
19
+ registerTerminalPane();
20
+
21
+ /** A running in-tab agent terminal, handed to the app so it can route keys and detach. */
22
+ export interface AgentTerminalHandle {
23
+ write: (data: string) => void;
24
+ detach: () => void;
25
+ }
26
+
27
+ /** One launchable harness: its display name and the rail command. */
28
+ export interface HarnessLauncher {
29
+ id: string;
30
+ name: string;
31
+ /** The shell command that starts the harness, run in the split. */
32
+ command: string;
33
+ }
34
+
35
+ /** Off-white for the running-terminal header, shared by every harness. */
36
+ const HARNESS_HEADER_COLOR = "#e4e6ec";
37
+
38
+ /** The bring-your-own harnesses, in launch order. The command is the real binary
39
+ * name spawned on a PTY (not a shell alias, so `claude` - never `cc`, which is
40
+ * the system C compiler). */
41
+ export const HARNESS_LAUNCHERS: HarnessLauncher[] = [
42
+ { id: "claude", name: "Claude Code", command: "claude" },
43
+ { id: "pi", name: "Pi", command: "pi" },
44
+ { id: "codex", name: "OpenAI Codex", command: "codex" },
45
+ ];
46
+
47
+ /** The briefing typed into a launched harness when the plan-context toggle is on. */
48
+ export function planHandoffBriefing(sessionId: string): string {
49
+ return `Review this cueloop plan: read it with 'cueloop session get ${sessionId}', then comment with 'cueloop session annotate ${sessionId} --author me --quote "<exact span>" --body "<comment>"'. Do not rewrite the plan.`;
50
+ }
51
+
52
+ /** Props for the Agent tab body: the session under review plus the launch callbacks. */
53
+ export interface AgentLauncherProps {
54
+ session: Thread;
55
+ /**
56
+ * Fallback launch when no embedded terminal ships for this platform: run the
57
+ * harness in a herdr split. seedText is the plan-context briefing.
58
+ */
59
+ onLaunchHarness: (command: string, seedText?: string) => void;
60
+ /** Notifies the app of the in-tab terminal handle (or null when detached) so it can route keys. */
61
+ onAgentTerminal?: (handle: AgentTerminalHandle | null) => void;
62
+ theme?: Theme;
63
+ }
64
+
65
+ /**
66
+ * The Agent tab body: branded launcher cards plus a plan-context toggle. Replaces
67
+ * the old dead agent/status/revision placeholder (now a dim footer line). When a
68
+ * prebuilt libghostty-vt ships for the platform, a picked harness runs embedded
69
+ * inside this tab; otherwise it falls back to a herdr split.
70
+ */
71
+ export function AgentLauncher({
72
+ session,
73
+ onLaunchHarness,
74
+ onAgentTerminal,
75
+ theme,
76
+ }: AgentLauncherProps): React.ReactNode {
77
+ const tokens = useComponentTheme(theme);
78
+ const [seedContext, setSeedContext] = useState(true);
79
+ const [running, setRunning] = useState<{ harness: HarnessLauncher; seed?: string } | null>(null);
80
+ const paneRef = useRef<TerminalPaneRenderable | null>(null);
81
+
82
+ const launch = (harness: HarnessLauncher): void => {
83
+ const seed = seedContext ? planHandoffBriefing(session.id) : undefined;
84
+
85
+ if (embeddedTerminalAvailable()) setRunning({ harness, seed });
86
+ else onLaunchHarness(harness.command, seed);
87
+ };
88
+ const detach = (): void => {
89
+ // The reconciler's removeChild detaches the pane without destroying it, so
90
+ // kill the child + free the VT here or they leak. shutdown() is idempotent.
91
+ paneRef.current?.shutdown();
92
+ setRunning(null);
93
+ };
94
+
95
+ useEffect(() => {
96
+ if (!running) return onAgentTerminal?.(null);
97
+ onAgentTerminal?.({ write: (data) => paneRef.current?.write(data), detach });
98
+
99
+ return () => onAgentTerminal?.(null);
100
+ // eslint-disable-next-line react-hooks/exhaustive-deps
101
+ }, [running]);
102
+
103
+ if (running) {
104
+ return (
105
+ <box style={{ flexDirection: "column", flexGrow: 1 }}>
106
+ <box style={{ flexDirection: "row" }} onMouseUp={detach}>
107
+ <text fg={HARNESS_HEADER_COLOR}>{running.harness.name}</text>
108
+ <box style={{ flexGrow: 1 }} />
109
+ <text fg={tokens.textDim}>✕ detach</text>
110
+ </box>
111
+ {React.createElement("terminalPane", {
112
+ ref: paneRef,
113
+ command: running.harness.command,
114
+ cwd: session.artifact.meta.cwd ?? process.cwd(),
115
+ seedText: running.seed,
116
+ onExit: detach,
117
+ style: { flexGrow: 1 },
118
+ })}
119
+ </box>
120
+ );
121
+ }
122
+
123
+ return (
124
+ <box style={{ flexDirection: "column", flexGrow: 1 }}>
125
+ {HARNESS_LAUNCHERS.map((harness) => (
126
+ <box
127
+ key={harness.id}
128
+ style={{
129
+ flexDirection: "row",
130
+ height: 3,
131
+ border: true,
132
+ borderStyle: FRAME_BORDER_STYLE,
133
+ backgroundColor: "transparent",
134
+ borderColor: tokens.text,
135
+ paddingLeft: 1,
136
+ paddingRight: 1,
137
+ }}
138
+ onMouseUp={() => launch(harness)}
139
+ >
140
+ <text fg={tokens.textMuted}>{harness.name}</text>
141
+ </box>
142
+ ))}
143
+ <box style={{ flexDirection: "row" }} onMouseUp={() => setSeedContext((on) => !on)}>
144
+ <text fg={tokens.textDim}>plan context: </text>
145
+ <text fg={tokens.accent}>{seedContext ? "seed the plan" : "none"}</text>
146
+ </box>
147
+ </box>
148
+ );
149
+ }
@@ -0,0 +1,198 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { Annotation } from "@cueloop/schema";
3
+ import {
4
+ commentCountsByFile,
5
+ diffRowText,
6
+ fileChangeCounts,
7
+ marksByRows,
8
+ type DiffRow,
9
+ } from "../view-diff";
10
+ import { fixtureDiffSession } from "./story-fixtures";
11
+ import type { IntralineRun } from "../diff-intraline";
12
+ import type { SyntaxSpan } from "../diff-syntax";
13
+ import { DARK } from "../theme";
14
+ import { coloredRowSpans } from "./diff-content-view-layout";
15
+
16
+ function row(kind: DiffRow["kind"], text: string, extra: Partial<DiffRow> = {}): DiffRow {
17
+ return { kind, text, file: "a.ts", ...extra };
18
+ }
19
+
20
+ function annotation(id: string, anchor: Annotation["anchor"]): Annotation {
21
+ return { id, kind: "comment", body: "note", anchor, createdAt: "2026-01-01T00:00:00Z" };
22
+ }
23
+
24
+ describe("diffRowText", () => {
25
+ test("strips the trailing newline the patch carries", () => {
26
+ // Arrange / Act / Assert
27
+ expect(diffRowText(row("add", "const x = 1;\n"))).toBe("const x = 1;");
28
+ });
29
+ });
30
+
31
+ describe("fileChangeCounts", () => {
32
+ test("tallies added and removed lines per file, ignoring context and headers", () => {
33
+ // Arrange
34
+ const rows = [
35
+ row("file", "a.ts"),
36
+ row("hunk", "@@"),
37
+ row("ctx", "unchanged"),
38
+ row("del", "gone"),
39
+ row("add", "one"),
40
+ row("add", "two"),
41
+ row("file", "b.ts", { file: "b.ts" }),
42
+ row("add", "only add", { file: "b.ts" }),
43
+ ];
44
+
45
+ // Act
46
+ const counts = fileChangeCounts(rows);
47
+
48
+ // Assert
49
+ expect(counts.get("a.ts")).toEqual({ additions: 2, deletions: 1 });
50
+ expect(counts.get("b.ts")).toEqual({ additions: 1, deletions: 0 });
51
+ });
52
+ });
53
+
54
+ describe("marksByRows", () => {
55
+ const rows = [
56
+ row("file", "a.ts"),
57
+ row("hunk", "@@"),
58
+ row("ctx", "keep\n"),
59
+ row("add", "const items = new Map();\n"),
60
+ row("add", "return items;\n"),
61
+ ];
62
+
63
+ test("a whole-row quote (the legacy row anchor) marks that row end to end", () => {
64
+ // Act
65
+ const marks = marksByRows(
66
+ [annotation("a1", { quote: "return items;", prefix: "", suffix: "" })],
67
+ rows,
68
+ );
69
+
70
+ // Assert
71
+ expect([...marks.keys()]).toEqual([4]);
72
+ expect(marks.get(4)![0]).toMatchObject({ start: 0, end: 13, annotationId: "a1" });
73
+ });
74
+
75
+ test("a word quote marks only its characters and carries the span", () => {
76
+ // Act
77
+ const marks = marksByRows(
78
+ [annotation("a1", { quote: "new Map()", prefix: "const items = ", suffix: ";" })],
79
+ rows,
80
+ );
81
+
82
+ // Assert
83
+ const mark = marks.get(3)![0]!;
84
+
85
+ expect(mark.start).toBe(14);
86
+ expect(mark.end).toBe(23);
87
+ expect(mark.span).toEqual({
88
+ start: { blockIndex: 3, char: 14 },
89
+ end: { blockIndex: 3, char: 23 },
90
+ });
91
+ });
92
+
93
+ test("a quote across two rows paints a mark on each row with one shared span", () => {
94
+ // Arrange - the anchor a two-row drag produces: rows joined by the block separator
95
+ const marks = marksByRows(
96
+ [
97
+ annotation("a1", {
98
+ quote: "Map();\n\nreturn",
99
+ prefix: "const items = new ",
100
+ suffix: " items;",
101
+ blockIndex: 3,
102
+ start: 18,
103
+ end: 6,
104
+ endBlockIndex: 4,
105
+ }),
106
+ ],
107
+ rows,
108
+ );
109
+
110
+ // Assert
111
+ expect([...marks.keys()].toSorted()).toEqual([3, 4]);
112
+ expect(marks.get(3)![0]!.span).toEqual(marks.get(4)![0]!.span);
113
+ expect(marks.get(3)![0]).toMatchObject({ start: 18, end: 24 });
114
+ expect(marks.get(4)![0]).toMatchObject({ start: 0, end: 6 });
115
+ });
116
+
117
+ test("the focused annotation wears the focus role", () => {
118
+ // Act
119
+ const marks = marksByRows(
120
+ [annotation("a1", { quote: "keep", prefix: "", suffix: "" })],
121
+ rows,
122
+ "a1",
123
+ );
124
+
125
+ // Assert
126
+ expect(marks.get(2)![0]!.role).toBe("mark-focus");
127
+ });
128
+ });
129
+
130
+ describe("commentCountsByFile", () => {
131
+ const rows = [
132
+ row("file", "a.ts"),
133
+ row("add", "const items = new Map();\n"),
134
+ row("file", "b.ts", { file: "b.ts" }),
135
+ row("add", "return other;\n", { file: "b.ts" }),
136
+ ];
137
+ const annotation = (id: string, quote: string, replyTo?: string): Annotation => {
138
+ const comment: Annotation = {
139
+ id,
140
+ kind: "comment",
141
+ body: id,
142
+ anchor: { quote, prefix: "", suffix: "" },
143
+ createdAt: "2026-01-01T00:00:00Z",
144
+ };
145
+
146
+ if (replyTo !== undefined) comment.replyTo = replyTo;
147
+
148
+ return comment;
149
+ };
150
+
151
+ test("counts a discussion's comments (root plus replies) toward its file", () => {
152
+ // Arrange - one thread on a.ts with a reply, one on b.ts
153
+ const session = fixtureDiffSession({
154
+ annotations: [
155
+ annotation("a1", "const items = new Map();"),
156
+ annotation("a1r", "const items = new Map();", "a1"),
157
+ annotation("b1", "return other;"),
158
+ ],
159
+ });
160
+
161
+ // Act
162
+ const counts = commentCountsByFile(session, rows);
163
+
164
+ // Assert
165
+ expect(counts.get("a.ts")).toBe(2);
166
+ expect(counts.get("b.ts")).toBe(1);
167
+ });
168
+ });
169
+
170
+ describe("coloredRowSpans", () => {
171
+ test("a changed word keeps the diff color while unchanged text dims", () => {
172
+ // Arrange - "old" is the change, " tail" unchanged, on a modified line
173
+ const runs: IntralineRun[] = [
174
+ { text: "old", changed: true },
175
+ { text: " tail", changed: false },
176
+ ];
177
+
178
+ // Act
179
+ const spans = coloredRowSpans("old tail", runs, undefined, DARK.deletedForeground, DARK);
180
+
181
+ // Assert
182
+ expect(spans).toEqual([
183
+ { text: "old", foreground: DARK.deletedForeground },
184
+ { text: " tail", foreground: DARK.textDim },
185
+ ]);
186
+ });
187
+
188
+ test("syntax color applies to unchanged text when there is no intra-line run", () => {
189
+ // Arrange - a keyword span over the whole word, no intra-line runs
190
+ const syntax: SyntaxSpan[] = [{ start: 0, end: 6, group: "keyword" }];
191
+
192
+ // Act
193
+ const spans = coloredRowSpans("return", undefined, syntax, DARK.textMuted, DARK);
194
+
195
+ // Assert
196
+ expect(spans).toEqual([{ text: "return", foreground: DARK.accent }]);
197
+ });
198
+ });
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Pure color logic for the diff review sheet, split out so it can be tested
3
+ * without rendering: a row's colored spans (syntax under the intra-line change
4
+ * color). DiffContentView.tsx paints marks and the caret over these per character.
5
+ */
6
+
7
+ import type { Theme } from "../theme";
8
+ import type { IntralineRun } from "../diff-intraline";
9
+ import type { SyntaxSpan } from "../diff-syntax";
10
+ import { colorForSyntaxGroup } from "./syntax-highlight";
11
+
12
+ /** One rendered span of a row: its text and its resolved foreground color. */
13
+ export interface ColoredSpan {
14
+ text: string;
15
+ foreground: string;
16
+ }
17
+
18
+ /**
19
+ * Colored spans for one row, resolved per character: an intra-line changed word
20
+ * keeps the diff color, otherwise the syntax color applies, falling back to the
21
+ * dim token on the unchanged part of a modified line or the row's base color.
22
+ * Adjacent same-color characters coalesce so a row stays a few spans.
23
+ */
24
+ export function coloredRowSpans(
25
+ text: string,
26
+ intralineRuns: IntralineRun[] | undefined,
27
+ syntaxSpans: SyntaxSpan[] | undefined,
28
+ baseColor: string,
29
+ tokens: Theme,
30
+ ): ColoredSpan[] {
31
+ const changed = changedColumns(text.length, intralineRuns);
32
+ const syntaxColorByColumn = syntaxColorColumns(text.length, syntaxSpans, tokens);
33
+ const isModifiedLine = intralineRuns !== undefined;
34
+
35
+ const spans: ColoredSpan[] = [];
36
+
37
+ for (let column = 0; column < text.length; column++) {
38
+ const color = changed[column]
39
+ ? baseColor
40
+ : (syntaxColorByColumn[column] ?? (isModifiedLine ? tokens.textDim : baseColor));
41
+ const previous = spans[spans.length - 1];
42
+
43
+ if (previous && previous.foreground === color) previous.text += text[column];
44
+ else spans.push({ text: text[column]!, foreground: color });
45
+ }
46
+
47
+ return spans;
48
+ }
49
+
50
+ /** Which columns are the intra-line change on a modified line. */
51
+ function changedColumns(length: number, intralineRuns: IntralineRun[] | undefined): boolean[] {
52
+ const changed = Array.from({ length }, () => false);
53
+
54
+ if (!intralineRuns) return changed;
55
+ let offset = 0;
56
+
57
+ for (const run of intralineRuns) {
58
+ if (run.changed)
59
+ for (let index = 0; index < run.text.length; index++) changed[offset + index] = true;
60
+ offset += run.text.length;
61
+ }
62
+
63
+ return changed;
64
+ }
65
+
66
+ /** The syntax color per column, or undefined where a column has no token. */
67
+ function syntaxColorColumns(
68
+ length: number,
69
+ syntaxSpans: SyntaxSpan[] | undefined,
70
+ tokens: Theme,
71
+ ): Array<string | undefined> {
72
+ const colors: Array<string | undefined> = Array.from({ length }, () => undefined);
73
+
74
+ for (const span of syntaxSpans ?? []) {
75
+ const color = colorForSyntaxGroup(span.group, tokens);
76
+
77
+ if (!color) continue;
78
+ for (let column = span.start; column < span.end && column < length; column++) {
79
+ colors[column] = color;
80
+ }
81
+ }
82
+
83
+ return colors;
84
+ }
@@ -0,0 +1,86 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ addTab,
4
+ changesTab,
5
+ closeTab,
6
+ containsGroup,
7
+ fileTab,
8
+ firstGroupId,
9
+ makeGroup,
10
+ splitGroup,
11
+ type EditorBranch,
12
+ type EditorGroup,
13
+ type EditorNode,
14
+ } from "./editor-grid";
15
+
16
+ function asBranch(node: EditorNode | null): EditorBranch {
17
+ if (node === null || node.type !== "branch") throw new Error("expected a branch node");
18
+ return node;
19
+ }
20
+ function asGroup(node: EditorNode | null): EditorGroup {
21
+ if (node === null || node.type !== "group") throw new Error("expected a group node");
22
+ return node;
23
+ }
24
+
25
+ describe("editor grid", () => {
26
+ test("a fresh group holds its tabs with the first active", () => {
27
+ const tab = changesTab();
28
+ const group = makeGroup([tab]);
29
+ expect(group.type).toBe("group");
30
+ expect(group.activeTabId).toBe(tab.id);
31
+ });
32
+
33
+ test("adding a tab focuses it", () => {
34
+ const group = makeGroup([changesTab()]);
35
+ const file = fileTab("App.tsx", "src/App.tsx", "diff");
36
+ const next = asGroup(addTab(group, group.id, file));
37
+ expect(next.tabs).toHaveLength(2);
38
+ expect(next.activeTabId).toBe(file.id);
39
+ });
40
+
41
+ test("splitting right makes a horizontal branch and focuses the new group", () => {
42
+ const group = makeGroup([fileTab("App.tsx", "src/App.tsx", "diff")]);
43
+ const { tree, focusGroupId } = splitGroup(group, group.id, "right");
44
+ expect(tree.type).toBe("branch");
45
+ const branch = asBranch(tree);
46
+ expect(branch.orientation).toBe("horizontal");
47
+ expect(branch.children).toHaveLength(2);
48
+ // the new group is the second child (right edge) and takes focus
49
+ expect(branch.children[1]!.id).toBe(focusGroupId);
50
+ expect(firstGroupId(tree)).toBe(group.id);
51
+ });
52
+
53
+ test("splitting up makes a vertical branch with the new group first", () => {
54
+ const group = makeGroup([fileTab("a.ts", "a.ts", "contents")]);
55
+ const { tree, focusGroupId } = splitGroup(group, group.id, "up");
56
+ const branch = asBranch(tree);
57
+ expect(branch.orientation).toBe("vertical");
58
+ expect(branch.children[0]!.id).toBe(focusGroupId);
59
+ });
60
+
61
+ test("closing the last tab of a split collapses the branch back to one group", () => {
62
+ const group = makeGroup([fileTab("a.ts", "a.ts", "diff")]);
63
+ const { tree } = splitGroup(group, group.id, "right");
64
+ const branch = asBranch(tree);
65
+ const rightGroup = asGroup(branch.children[1]!);
66
+ const collapsed = closeTab(tree, rightGroup.id, rightGroup.tabs[0]!.id);
67
+ expect(collapsed?.type).toBe("group");
68
+ expect(asGroup(collapsed).id).toBe(group.id);
69
+ });
70
+
71
+ test("closing the only tab of the whole grid returns null", () => {
72
+ const tab = changesTab();
73
+ const group = makeGroup([tab]);
74
+ expect(closeTab(group, group.id, tab.id)).toBeNull();
75
+ });
76
+
77
+ test("containsGroup finds a live group and misses a pruned one", () => {
78
+ const group = makeGroup([fileTab("a.ts", "a.ts", "diff")]);
79
+ const { tree } = splitGroup(group, group.id, "right");
80
+ const branch = asBranch(tree);
81
+ const rightGroup = asGroup(branch.children[1]!);
82
+ expect(containsGroup(tree, group.id)).toBe(true);
83
+ const collapsed = closeTab(tree, rightGroup.id, rightGroup.tabs[0]!.id);
84
+ expect(containsGroup(asGroup(collapsed), rightGroup.id)).toBe(false);
85
+ });
86
+ });