@cueloop/client 0.1.0-alpha.4 → 0.1.0-alpha.40

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 (131) hide show
  1. package/package.json +32 -24
  2. package/src/App.autoclose.test.tsx +137 -0
  3. package/src/App.diff.test.tsx +158 -22
  4. package/src/App.export.test.tsx +44 -37
  5. package/src/App.plan-review.test.tsx +632 -0
  6. package/src/App.readonly.test.tsx +60 -36
  7. package/src/App.rendering.test.tsx +132 -0
  8. package/src/App.review-panel.test.tsx +136 -0
  9. package/src/App.submit-confirm.test.tsx +220 -0
  10. package/src/App.test.tsx +128 -69
  11. package/src/App.toast.test.tsx +75 -0
  12. package/src/App.tsx +0 -0
  13. package/src/App.walk.test.tsx +288 -0
  14. package/src/attribution.test.ts +31 -0
  15. package/src/attribution.ts +38 -0
  16. package/src/clipboard.test.ts +23 -0
  17. package/src/clipboard.ts +30 -0
  18. package/src/components/AnnotationCard.compose-rows.test.ts +49 -0
  19. package/src/components/AnnotationCard.stories.tsx +94 -0
  20. package/src/components/AnnotationCard.tsx +228 -0
  21. package/src/components/Breadcrumb.stories.tsx +19 -0
  22. package/src/components/Breadcrumb.tsx +44 -0
  23. package/src/components/CodeBlock.stories.tsx +42 -0
  24. package/src/components/CodeBlock.tsx +83 -0
  25. package/src/components/CompletionOverlay.stories.tsx +23 -0
  26. package/src/components/CompletionOverlay.tsx +59 -0
  27. package/src/components/ConfirmCard.stories.tsx +56 -0
  28. package/src/components/ConfirmCard.tsx +135 -0
  29. package/src/components/ConfirmDialog.stories.tsx +21 -0
  30. package/src/components/ConfirmDialog.tsx +64 -0
  31. package/src/components/DiffSheet.scroll.test.tsx +123 -0
  32. package/src/components/DiffSheet.stories.tsx +78 -0
  33. package/src/components/DiffSheet.syntax.test.tsx +62 -0
  34. package/src/components/DiffSheet.tsx +355 -0
  35. package/src/components/InboxList.stories.tsx +31 -0
  36. package/src/components/InboxList.tsx +62 -0
  37. package/src/components/KeybindsDialog.stories.tsx +31 -0
  38. package/src/components/KeybindsDialog.tsx +58 -0
  39. package/src/components/MarkerPopover.stories.tsx +58 -0
  40. package/src/components/MarkerPopover.tsx +102 -0
  41. package/src/components/MenuBar.stories.tsx +37 -0
  42. package/src/components/MenuBar.tsx +74 -0
  43. package/src/components/PlanSheet.stories.tsx +113 -0
  44. package/src/components/PlanSheet.tsx +389 -0
  45. package/src/components/PromptDialog.stories.tsx +36 -0
  46. package/src/components/PromptDialog.tsx +78 -0
  47. package/src/components/ReviewPanel.stories.tsx +86 -0
  48. package/src/components/ReviewPanel.tsx +101 -0
  49. package/src/components/ReviewRail.stories.tsx +220 -0
  50. package/src/components/ReviewRail.tsx +316 -0
  51. package/src/components/SettingsDialog.stories.tsx +111 -0
  52. package/src/components/SettingsDialog.tsx +141 -0
  53. package/src/components/SettingsRows.stories.tsx +18 -0
  54. package/src/components/SettingsRows.tsx +108 -0
  55. package/src/components/Toast.stories.tsx +12 -0
  56. package/src/components/Toast.tsx +44 -0
  57. package/src/components/WalkWizard.stories.tsx +100 -0
  58. package/src/components/WalkWizard.tsx +152 -0
  59. package/src/components/__snapshots__/stories.test.tsx.snap +1829 -0
  60. package/src/components/agent-launcher.stories.tsx +26 -0
  61. package/src/components/agent-launcher.test.tsx +21 -0
  62. package/src/components/agent-launcher.tsx +123 -0
  63. package/src/components/diff-sheet-layout.test.ts +127 -0
  64. package/src/components/diff-sheet-layout.ts +167 -0
  65. package/src/components/primitives/Button.stories.tsx +32 -0
  66. package/src/components/primitives/Button.tsx +48 -0
  67. package/src/components/primitives/Card.stories.tsx +25 -0
  68. package/src/components/primitives/Card.tsx +59 -0
  69. package/src/components/primitives/Dialog.stories.tsx +24 -0
  70. package/src/components/primitives/Dialog.tsx +64 -0
  71. package/src/components/primitives/StatusBar.stories.tsx +11 -0
  72. package/src/components/primitives/StatusBar.tsx +22 -0
  73. package/src/components/primitives/Tabs.stories.tsx +29 -0
  74. package/src/components/primitives/Tabs.tsx +98 -0
  75. package/src/components/primitives/Toolbar.stories.tsx +17 -0
  76. package/src/components/primitives/Toolbar.tsx +14 -0
  77. package/src/components/primitives/frame.test.ts +12 -0
  78. package/src/components/primitives/frame.ts +14 -0
  79. package/src/components/quick-actions-editor.stories.tsx +54 -0
  80. package/src/components/quick-actions-editor.tsx +136 -0
  81. package/src/components/stories-app.tsx +123 -0
  82. package/src/components/stories.test.tsx +62 -0
  83. package/src/components/story-fixtures.ts +98 -0
  84. package/src/components/story.ts +69 -0
  85. package/src/components/syntax-highlight.ts +92 -0
  86. package/src/components/theme-context.stories.tsx +46 -0
  87. package/src/components/theme-context.tsx +36 -0
  88. package/src/components/truncate-text.ts +6 -0
  89. package/src/config.test.ts +430 -31
  90. package/src/config.ts +304 -26
  91. package/src/diff-hunk-curate.test.ts +315 -0
  92. package/src/diff-hunk-curate.ts +233 -0
  93. package/src/diff-intraline.test.ts +164 -0
  94. package/src/diff-intraline.ts +132 -0
  95. package/src/diff-syntax.test.ts +96 -0
  96. package/src/diff-syntax.ts +154 -0
  97. package/src/editor.test.ts +143 -0
  98. package/src/editor.ts +123 -9
  99. package/src/herdr.test.ts +41 -0
  100. package/src/herdr.ts +27 -0
  101. package/src/index.ts +10 -0
  102. package/src/integrations.test.ts +41 -0
  103. package/src/integrations.ts +30 -0
  104. package/src/intent-dispatch.test.ts +492 -0
  105. package/src/intent-dispatch.ts +428 -0
  106. package/src/key-bindings.test.ts +152 -0
  107. package/src/key-bindings.ts +424 -0
  108. package/src/keymap.test.ts +622 -0
  109. package/src/keymap.ts +339 -0
  110. package/src/review-panel.test.ts +113 -0
  111. package/src/review-panel.ts +83 -0
  112. package/src/run.ts +9 -5
  113. package/src/serve.test.ts +24 -9
  114. package/src/serve.ts +7 -7
  115. package/src/session-controller.curate.test.ts +253 -0
  116. package/src/session-controller.share.test.ts +309 -0
  117. package/src/session-controller.ts +900 -0
  118. package/src/share.ts +113 -0
  119. package/src/test-support.test.ts +55 -0
  120. package/src/test-support.ts +187 -0
  121. package/src/theme-presets.ts +168 -0
  122. package/src/theme.ts +36 -15
  123. package/src/version.ts +5 -0
  124. package/src/view-diff.test.ts +19 -8
  125. package/src/view-diff.ts +34 -23
  126. package/src/view-plan.test.ts +294 -0
  127. package/src/view-plan.ts +408 -0
  128. package/src/walk.test.ts +121 -0
  129. package/src/walk.ts +70 -0
  130. package/src/view.test.ts +0 -157
  131. package/src/view.ts +0 -0
@@ -0,0 +1,288 @@
1
+ /** The guided walk in the virtual terminal: the wizard steps every changed file with a plain progress title, ] marks viewed and persists with the session, esc keeps progress, w resumes at the first unviewed file (across App instances via the daemon round-trip), the agent-note block renders only for files carrying a note, and the end card hands over to the submit confirm with the honest viewed count. */
2
+
3
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
4
+ import { mkdtempSync, rmSync } from "node:fs";
5
+ import { tmpdir } from "node:os";
6
+ import { join } from "node:path";
7
+ import React from "react";
8
+ import { testRender } from "@opentui/react/test-utils";
9
+ import { DaemonServer } from "@cueloop/daemon";
10
+ import type { ReviewSession } from "@cueloop/schema";
11
+ import { App } from "./App";
12
+ import { DARK } from "./theme";
13
+ import {
14
+ isolateUserConfig,
15
+ press,
16
+ waitForState,
17
+ waitForText,
18
+ waitForTextGone,
19
+ } from "./test-support";
20
+
21
+ const PATCH = `diff --git a/src/a.ts b/src/a.ts
22
+ index 111..222 100644
23
+ --- a/src/a.ts
24
+ +++ b/src/a.ts
25
+ @@ -1,2 +1,2 @@
26
+ export const a = 1;
27
+ -export const b = 2;
28
+ +export const b = 3;
29
+ diff --git a/src/b.ts b/src/b.ts
30
+ index 333..444 100644
31
+ --- a/src/b.ts
32
+ +++ b/src/b.ts
33
+ @@ -1,2 +1,2 @@
34
+ // header
35
+ -old line
36
+ +new line
37
+ diff --git a/src/c.ts b/src/c.ts
38
+ index 555..666 100644
39
+ --- a/src/c.ts
40
+ +++ b/src/c.ts
41
+ @@ -1,1 +1,2 @@
42
+ keep
43
+ +added tail
44
+ `;
45
+
46
+ let home: string;
47
+ let restoreUserConfig: () => void;
48
+ let server: DaemonServer;
49
+ let session: ReviewSession;
50
+
51
+ beforeEach(() => {
52
+ home = mkdtempSync(join(tmpdir(), "cueloop-walk-"));
53
+ restoreUserConfig = isolateUserConfig(home);
54
+ server = new DaemonServer({ home, idleExitMs: 0 });
55
+ server.start();
56
+ session = server.core.sessionCreate({
57
+ workspace: { repoRoot: "/repo", branch: "main" },
58
+ artifact: { type: "diff", content: PATCH, meta: { title: "working tree" } },
59
+ });
60
+ });
61
+ afterEach(() => {
62
+ restoreUserConfig();
63
+ server.stop();
64
+ rmSync(home, { recursive: true, force: true });
65
+ });
66
+
67
+ async function renderApp() {
68
+ const setup = await testRender(<App home={home} sessionId={session.id} />, {
69
+ width: 120,
70
+ height: 34,
71
+ });
72
+ // Wait for session content, not just the chrome: pressing w before the diff
73
+ // loads drops the walk intent (no session, no diff view) and hangs the test.
74
+ await waitForText(setup, "src/a.ts");
75
+ return setup;
76
+ }
77
+
78
+ type Setup = Awaited<ReturnType<typeof renderApp>>;
79
+
80
+ /** Foreground colors (hex) of every styled span containing the needle. */
81
+ function foregroundsOf(setup: Setup, needle: string): string[] {
82
+ const foregrounds: string[] = [];
83
+ for (const line of setup.captureSpans().lines) {
84
+ for (const span of line.spans) {
85
+ if (!span.text.includes(needle)) continue;
86
+ const [red, green, blue] = span.fg.toInts();
87
+ foregrounds.push(
88
+ "#" + [red, green, blue].map((part) => part.toString(16).padStart(2, "0")).join(""),
89
+ );
90
+ }
91
+ }
92
+ return foregrounds;
93
+ }
94
+
95
+ describe("the guided walk", () => {
96
+ test("w walks all three files: progress title, persisted marks, end card, submit hand-over", async () => {
97
+ // Arrange
98
+ const setup = await renderApp();
99
+
100
+ // Act
101
+ await press(setup, "w");
102
+
103
+ // Assert
104
+ await waitForText(setup, "file 1 of 3 · 0 viewed");
105
+ expect(setup.captureCharFrame()).toContain("src/a.ts");
106
+ expect(setup.captureCharFrame()).toContain("+1 -1");
107
+
108
+ // Act
109
+ await press(setup, "]");
110
+
111
+ // Assert
112
+ await waitForText(setup, "file 2 of 3 · 1 viewed");
113
+ // the viewed mark rides the session record, not the client
114
+ await waitForState(setup, () =>
115
+ (server.core.sessionGet(session.id).viewedPaths ?? []).includes("src/a.ts"),
116
+ );
117
+
118
+ // Act
119
+ // [ steps back; the revisited card shows its viewed marker
120
+ await press(setup, "[");
121
+
122
+ // Assert
123
+ await waitForText(setup, "file 1 of 3 · 1 viewed");
124
+ expect(setup.captureCharFrame()).toContain("· viewed");
125
+
126
+ // Act
127
+ await press(setup, "]");
128
+
129
+ // Assert
130
+ await waitForText(setup, "file 2 of 3 · 1 viewed");
131
+
132
+ // Act
133
+ await press(setup, "]");
134
+
135
+ // Assert
136
+ await waitForText(setup, "file 3 of 3 · 2 viewed");
137
+
138
+ // Act
139
+ await press(setup, "]");
140
+
141
+ // Assert
142
+ await waitForText(setup, "walk complete");
143
+ expect(setup.captureCharFrame()).toContain("every file viewed (3/3)");
144
+ expect(server.core.sessionGet(session.id).viewedPaths).toEqual([
145
+ "src/a.ts",
146
+ "src/b.ts",
147
+ "src/c.ts",
148
+ ]);
149
+
150
+ // Act
151
+ // the end card's return leaves the walk and opens the rail confirm
152
+ await press(setup, "enter");
153
+
154
+ // Assert
155
+ await waitForText(setup, "submit review");
156
+ expect(setup.captureCharFrame()).toContain("3/3 files viewed");
157
+
158
+ // Act
159
+ await press(setup, "enter");
160
+
161
+ // Assert
162
+ await waitForState(setup, () => server.core.sessionGet(session.id).status === "resolved");
163
+ });
164
+
165
+ test("esc keeps progress and w resumes at the first unviewed file", async () => {
166
+ // Arrange
167
+ const setup = await renderApp();
168
+ await press(setup, "w");
169
+ await waitForText(setup, "file 1 of 3 · 0 viewed");
170
+ await press(setup, "]");
171
+ await waitForText(setup, "file 2 of 3 · 1 viewed");
172
+
173
+ // Act
174
+ await press(setup, "escape");
175
+
176
+ // Assert
177
+ await waitForTextGone(setup, "file 2 of 3");
178
+ // the sheet is back at full strength: no wizard card on screen
179
+ expect(setup.captureCharFrame()).not.toContain("· 1 viewed");
180
+
181
+ // Act
182
+ await press(setup, "w");
183
+
184
+ // Assert
185
+ await waitForText(setup, "file 2 of 3 · 1 viewed");
186
+ });
187
+
188
+ test("a half-walked review resumes across App instances via the daemon", async () => {
189
+ // Arrange
190
+ const first = await renderApp();
191
+ await press(first, "w");
192
+ await waitForText(first, "file 1 of 3 · 0 viewed");
193
+ await press(first, "]");
194
+ await press(first, "]");
195
+ await waitForText(first, "file 3 of 3 · 2 viewed");
196
+ await waitForState(
197
+ first,
198
+ () => (server.core.sessionGet(session.id).viewedPaths ?? []).length === 2,
199
+ );
200
+ first.renderer.destroy();
201
+
202
+ // Act
203
+ // a fresh client reads the viewed set back from the session record
204
+ const second = await renderApp();
205
+ await press(second, "w");
206
+
207
+ // Assert
208
+ await waitForText(second, "file 3 of 3 · 2 viewed");
209
+ expect(second.captureCharFrame()).toContain("src/c.ts");
210
+ // two full App boots with daemon round-trips need more than the default
211
+ // budget, and the whole-suite publish lane has timed even 15s out; give the
212
+ // double-boot chain generous headroom so runner load cannot flake it.
213
+ }, 60_000);
214
+
215
+ test("the agent-note block renders only for files carrying a note", async () => {
216
+ // Arrange
217
+ server.core.sessionAnnotate(session.id, {
218
+ id: "note-b",
219
+ kind: "note",
220
+ anchor: { quote: "src/b.ts", prefix: "", suffix: "" },
221
+ body: "Swaps the stale line for the new constant.",
222
+ });
223
+ const setup = await renderApp();
224
+
225
+ // Act
226
+ await press(setup, "w");
227
+
228
+ // Assert
229
+ await waitForText(setup, "file 1 of 3");
230
+ expect(setup.captureCharFrame()).not.toContain("agent note");
231
+
232
+ // Act
233
+ await press(setup, "]");
234
+
235
+ // Assert
236
+ await waitForText(setup, "agent note");
237
+ expect(setup.captureCharFrame()).toContain("Swaps the stale line for the new constant.");
238
+
239
+ // Act
240
+ // a note is agent context, not reviewer feedback: it neither counts nor
241
+ // flips the default verdict, and never comes back in the feedback doc
242
+ await press(setup, "escape");
243
+ await waitForTextGone(setup, "agent note");
244
+ await press(setup, "enter");
245
+ await waitForText(setup, "0 annotations");
246
+ await press(setup, "enter");
247
+
248
+ // Assert
249
+ await waitForState(setup, () => server.core.sessionGet(session.id).status === "resolved");
250
+ const resolved = server.core.sessionGet(session.id);
251
+ expect(resolved.verdict!.kind).toBe("approve");
252
+ expect(resolved.verdict!.feedback).not.toContain("Swaps the stale line");
253
+ });
254
+
255
+ test("the sheet dims behind the wizard; the preview keeps the diff colors", async () => {
256
+ // Arrange
257
+ const setup = await renderApp();
258
+
259
+ // Assert
260
+ // before the walk the changed word on the added line wears the insertion color
261
+ await waitForText(setup, "+new line");
262
+ expect(foregroundsOf(setup, "new")).toContain(DARK.insertedForeground);
263
+
264
+ // Act
265
+ await press(setup, "w");
266
+
267
+ // Assert
268
+ await waitForText(setup, "file 1 of 3 · 0 viewed");
269
+ // dimmed: the sheet's changed word drops to the dim token...
270
+ expect(foregroundsOf(setup, "new")).toEqual([DARK.textDim]);
271
+ // ...while the wizard preview keeps the insertion color
272
+ expect(foregroundsOf(setup, "+export const b = 3;")).toContain(DARK.insertedForeground);
273
+ });
274
+
275
+ test("walking a resolved review answers read-only", async () => {
276
+ // Arrange
277
+ server.core.sessionResolve(session.id, "approve", "");
278
+ const setup = await renderApp();
279
+ await waitForText(setup, "resolved");
280
+
281
+ // Act
282
+ await press(setup, "w");
283
+
284
+ // Assert
285
+ await waitForText(setup, "review submitted - read-only");
286
+ expect(setup.captureCharFrame()).not.toContain("file 1 of 3");
287
+ });
288
+ });
@@ -0,0 +1,31 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { Identity } from "@cueloop/schema";
3
+ import { ANONYMOUS_LABEL, resolveDisplayName, shortHandle } from "./attribution";
4
+
5
+ describe(shortHandle, () => {
6
+ test("shortens an ssh fingerprint to a stable handle", () => {
7
+ // Arrange / Act / Assert
8
+ expect(shortHandle("SHA256:1a2b3c4d5e6f")).toBe("1a2b3c4d");
9
+ expect(shortHandle("SHA256:1a2b3c4d5e6f")).toBe(shortHandle("SHA256:1a2b3c4d5e6f"));
10
+ });
11
+ });
12
+
13
+ describe(resolveDisplayName, () => {
14
+ const named: Identity[] = [{ id: "SHA256:abc", provider: "ssh", name: "Al" }];
15
+ const unnamed: Identity[] = [{ id: "SHA256:abc", provider: "ssh" }];
16
+
17
+ test("a local rename override wins over everything", () => {
18
+ // Arrange / Act
19
+ const label = resolveDisplayName("SHA256:abc", named, { "SHA256:abc": "Alex" });
20
+
21
+ // Assert
22
+ expect(label).toBe("Alex");
23
+ });
24
+
25
+ test("falls back to name, then anonymous, then the short handle", () => {
26
+ // Arrange / Act / Assert
27
+ expect(resolveDisplayName("SHA256:abc", named, {})).toBe("Al");
28
+ expect(resolveDisplayName("SHA256:abc", unnamed, {})).toBe(ANONYMOUS_LABEL);
29
+ expect(resolveDisplayName("SHA256:1a2b3c4d5e", [], {})).toBe("1a2b3c4d");
30
+ });
31
+ });
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Attribution labels for annotations. An annotation's `author` is an identity
3
+ * id (an SSH fingerprint today). The rail resolves it against the share's
4
+ * participant registry and the planner's local rename overrides; ADR 0006.
5
+ */
6
+
7
+ import type { Identity } from "@cueloop/schema";
8
+
9
+ const FINGERPRINT_PREFIX = "SHA256:";
10
+
11
+ /** Shown for a known participant who chose not to give a name. */
12
+ export const ANONYMOUS_LABEL = "-- anonymous --";
13
+
14
+ /** A short, stable handle from a raw author id when no name is known. */
15
+ export function shortHandle(authorId: string): string {
16
+ const base = authorId.startsWith(FINGERPRINT_PREFIX)
17
+ ? authorId.slice(FINGERPRINT_PREFIX.length)
18
+ : authorId;
19
+ return base.slice(0, 8) || authorId;
20
+ }
21
+
22
+ /**
23
+ * The rail label for an author id: a local rename wins, then the collaborator's
24
+ * own name, then anonymous for a known-but-unnamed participant, then a short
25
+ * handle for an id we hold no identity for.
26
+ */
27
+ export function resolveDisplayName(
28
+ authorId: string,
29
+ participants: Identity[] | undefined,
30
+ overrides: Record<string, string>,
31
+ ): string {
32
+ const override = overrides[authorId];
33
+ if (override) return override;
34
+ const identity = participants?.find((participant) => participant.id === authorId);
35
+ if (identity?.name) return identity.name;
36
+ if (identity) return ANONYMOUS_LABEL;
37
+ return shortHandle(authorId);
38
+ }
@@ -0,0 +1,23 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { clipboardCommands } from "./clipboard";
3
+
4
+ describe(clipboardCommands, () => {
5
+ test("uses pbcopy on macOS", () => {
6
+ // Assert
7
+ expect(clipboardCommands("darwin")).toEqual([["pbcopy"]]);
8
+ });
9
+
10
+ test("uses clip.exe on Windows", () => {
11
+ // Assert
12
+ expect(clipboardCommands("win32")).toEqual([["clip.exe"]]);
13
+ });
14
+
15
+ test("prefers wayland, then X11 tools on Linux", () => {
16
+ // Act
17
+ const commands = clipboardCommands("linux");
18
+
19
+ // Assert
20
+ expect(commands[0]).toEqual(["wl-copy"]);
21
+ expect(commands.map((command) => command[0])).toEqual(["wl-copy", "xclip", "xsel"]);
22
+ });
23
+ });
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Best-effort system clipboard. Each platform has its own tool; we try them in
3
+ * order and report whether one worked, so the caller can fall back to printing
4
+ * the line (the over-SSH case, where no clipboard tool exists). Mirrors how
5
+ * herdr confirms a copy.
6
+ */
7
+
8
+ /** The clipboard-write commands to try, most-preferred first, for this platform. */
9
+ export function clipboardCommands(platform: NodeJS.Platform = process.platform): string[][] {
10
+ if (platform === "darwin") return [["pbcopy"]];
11
+ if (platform === "win32") return [["clip.exe"]];
12
+ return [["wl-copy"], ["xclip", "-selection", "clipboard"], ["xsel", "--clipboard", "--input"]];
13
+ }
14
+
15
+ /** Copy `text` to the clipboard; false when no tool is available or all fail. */
16
+ export async function copyToClipboard(text: string): Promise<boolean> {
17
+ for (const command of clipboardCommands()) {
18
+ try {
19
+ const proc = Bun.spawn(command, {
20
+ stdin: Buffer.from(text),
21
+ stdout: "ignore",
22
+ stderr: "ignore",
23
+ });
24
+ if ((await proc.exited) === 0) return true;
25
+ } catch {
26
+ // tool not installed - try the next one
27
+ }
28
+ }
29
+ return false;
30
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * The composer's auto-grow row math as a pure function: hard newlines and soft
3
+ * wrapping both add rows, growth stops at the cap, and a non-positive width
4
+ * (before layout is known) degrades to a hard-newline count. The live textarea
5
+ * scrolls internally past the cap, so this only needs to get the growth curve
6
+ * and the ceiling right.
7
+ */
8
+
9
+ import { describe, expect, test } from "bun:test";
10
+ import { COMPOSE_MAX_ROWS, composeRowCount } from "./AnnotationCard";
11
+
12
+ describe("composeRowCount", () => {
13
+ const width = 10;
14
+
15
+ test("empty and short text are one row", () => {
16
+ expect(composeRowCount("", width)).toBe(1);
17
+ expect(composeRowCount("hi", width)).toBe(1);
18
+ expect(composeRowCount("exactly-10", width)).toBe(1); // length 10 / width 10
19
+ });
20
+
21
+ test("hard newlines add rows", () => {
22
+ expect(composeRowCount("a\nb", width)).toBe(2);
23
+ expect(composeRowCount("a\nb\nc", width)).toBe(3);
24
+ // a blank trailing line still occupies a row
25
+ expect(composeRowCount("a\n", width)).toBe(2);
26
+ });
27
+
28
+ test("a soft-wrapped long line grows the box", () => {
29
+ expect(composeRowCount("a".repeat(11), width)).toBe(2); // ceil(11/10)
30
+ expect(composeRowCount("a".repeat(25), width)).toBe(3); // ceil(25/10)
31
+ });
32
+
33
+ test("wrapping and hard newlines combine", () => {
34
+ // 1 row for "short" plus ceil(21/10)=3 rows for the long line
35
+ expect(composeRowCount("short\n" + "b".repeat(21), width)).toBe(4);
36
+ });
37
+
38
+ test("growth is capped", () => {
39
+ expect(composeRowCount("a".repeat(100), width)).toBe(COMPOSE_MAX_ROWS);
40
+ expect(composeRowCount("l\ni\nn\ne\ns\nx", width)).toBe(COMPOSE_MAX_ROWS);
41
+ expect(composeRowCount("a\nb", width, 1)).toBe(1); // custom cap
42
+ });
43
+
44
+ test("a non-positive width counts hard newlines only, never divides by zero", () => {
45
+ expect(composeRowCount("a".repeat(100), 0)).toBe(1);
46
+ expect(composeRowCount("a\nb\nc", 0)).toBe(3);
47
+ expect(composeRowCount("a".repeat(100), -5)).toBe(1);
48
+ });
49
+ });
@@ -0,0 +1,94 @@
1
+ import React from "react";
2
+ import { DARK } from "../theme";
3
+ import type { Story, StoryMeta } from "./story";
4
+ import { AnnotationCard } from "./AnnotationCard";
5
+
6
+ export const meta: StoryMeta = { title: "AnnotationCard" };
7
+
8
+ const draftCallbacks = { onInput: () => {}, onSave: () => {}, onCancel: () => {} };
9
+
10
+ export const CommentDraft: Story = {
11
+ render: () => (
12
+ <AnnotationCard
13
+ kind="comment"
14
+ quote="persists sessions"
15
+ draft={{ text: "Which daemon?", ...draftCallbacks }}
16
+ />
17
+ ),
18
+ expectedColors: [DARK.accent],
19
+ };
20
+
21
+ export const SavedSelected: Story = {
22
+ render: () => (
23
+ <AnnotationCard
24
+ kind="comment"
25
+ quote="persists sessions"
26
+ saved={{
27
+ body: "Which daemon owns this?",
28
+ isSelected: true,
29
+ isOrphan: false,
30
+ isBlocking: false,
31
+ author: "me",
32
+ editing: null,
33
+ onPress: () => {},
34
+ }}
35
+ />
36
+ ),
37
+ expectedColors: [DARK.accent],
38
+ };
39
+
40
+ export const SavedCollaborator: Story = {
41
+ render: () => (
42
+ <AnnotationCard
43
+ kind="comment"
44
+ quote="persists sessions"
45
+ saved={{
46
+ body: "Which daemon owns this?",
47
+ isSelected: false,
48
+ isOrphan: false,
49
+ isBlocking: false,
50
+ author: "Priya",
51
+ editing: null,
52
+ onPress: () => {},
53
+ }}
54
+ />
55
+ ),
56
+ expectedColors: [DARK.border],
57
+ };
58
+
59
+ export const SavedOrphanBlocking: Story = {
60
+ render: () => (
61
+ <AnnotationCard
62
+ kind="comment"
63
+ quote="a removed passage"
64
+ saved={{
65
+ body: "The passage is gone.",
66
+ isSelected: false,
67
+ isOrphan: true,
68
+ isBlocking: true,
69
+ author: "me",
70
+ editing: null,
71
+ onPress: () => {},
72
+ }}
73
+ />
74
+ ),
75
+ expectedColors: [DARK.border],
76
+ };
77
+
78
+ export const SavedEditing: Story = {
79
+ render: () => (
80
+ <AnnotationCard
81
+ kind="comment"
82
+ quote="move the store"
83
+ saved={{
84
+ body: "old body",
85
+ isSelected: true,
86
+ isOrphan: false,
87
+ isBlocking: false,
88
+ author: "me",
89
+ editing: { text: "new body", ...draftCallbacks },
90
+ onPress: () => {},
91
+ }}
92
+ />
93
+ ),
94
+ };