@cueloop/client 0.1.0-alpha.2 → 0.1.0-alpha.21

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 (92) hide show
  1. package/package.json +17 -11
  2. package/src/App.autoclose.test.tsx +109 -0
  3. package/src/App.diff.test.tsx +5 -18
  4. package/src/App.export.test.tsx +8 -13
  5. package/src/App.plan-review.test.tsx +293 -0
  6. package/src/App.readonly.test.tsx +6 -21
  7. package/src/App.rendering.test.tsx +104 -0
  8. package/src/App.review-panel.test.tsx +97 -0
  9. package/src/App.submit-confirm.test.tsx +165 -0
  10. package/src/App.test.tsx +20 -58
  11. package/src/App.tsx +0 -0
  12. package/src/App.walk.test.tsx +208 -0
  13. package/src/components/AnnotationCard.compose-rows.test.ts +49 -0
  14. package/src/components/AnnotationCard.stories.tsx +75 -0
  15. package/src/components/AnnotationCard.tsx +189 -0
  16. package/src/components/Breadcrumb.stories.tsx +19 -0
  17. package/src/components/Breadcrumb.tsx +38 -0
  18. package/src/components/CodeBlock.stories.tsx +25 -0
  19. package/src/components/CodeBlock.tsx +68 -0
  20. package/src/components/CompletionOverlay.stories.tsx +23 -0
  21. package/src/components/CompletionOverlay.tsx +49 -0
  22. package/src/components/ComposeBar.stories.tsx +18 -0
  23. package/src/components/ComposeBar.tsx +54 -0
  24. package/src/components/ConfirmCard.stories.tsx +37 -0
  25. package/src/components/ConfirmCard.tsx +117 -0
  26. package/src/components/DiffSheet.stories.tsx +44 -0
  27. package/src/components/DiffSheet.tsx +243 -0
  28. package/src/components/InboxList.stories.tsx +18 -0
  29. package/src/components/InboxList.tsx +42 -0
  30. package/src/components/PlanSheet.stories.tsx +81 -0
  31. package/src/components/PlanSheet.tsx +309 -0
  32. package/src/components/ReviewPanel.stories.tsx +86 -0
  33. package/src/components/ReviewPanel.tsx +116 -0
  34. package/src/components/ReviewRail.stories.tsx +103 -0
  35. package/src/components/ReviewRail.tsx +171 -0
  36. package/src/components/SettingsDialog.stories.tsx +63 -0
  37. package/src/components/SettingsDialog.tsx +132 -0
  38. package/src/components/SettingsRows.stories.tsx +18 -0
  39. package/src/components/SettingsRows.tsx +92 -0
  40. package/src/components/WalkWizard.stories.tsx +89 -0
  41. package/src/components/WalkWizard.tsx +146 -0
  42. package/src/components/__snapshots__/stories.test.tsx.snap +1459 -0
  43. package/src/components/primitives/Button.stories.tsx +32 -0
  44. package/src/components/primitives/Button.tsx +44 -0
  45. package/src/components/primitives/Card.stories.tsx +25 -0
  46. package/src/components/primitives/Card.tsx +59 -0
  47. package/src/components/primitives/Dialog.stories.tsx +24 -0
  48. package/src/components/primitives/Dialog.tsx +53 -0
  49. package/src/components/primitives/StatusBar.stories.tsx +11 -0
  50. package/src/components/primitives/StatusBar.tsx +22 -0
  51. package/src/components/primitives/Tabs.stories.tsx +29 -0
  52. package/src/components/primitives/Tabs.tsx +97 -0
  53. package/src/components/primitives/Toolbar.stories.tsx +17 -0
  54. package/src/components/primitives/Toolbar.tsx +14 -0
  55. package/src/components/primitives/frame.test.ts +12 -0
  56. package/src/components/primitives/frame.ts +14 -0
  57. package/src/components/stories-app.tsx +95 -0
  58. package/src/components/stories.test.tsx +51 -0
  59. package/src/components/story-fixtures.ts +90 -0
  60. package/src/components/story.ts +66 -0
  61. package/src/components/syntax-highlight.ts +57 -0
  62. package/src/components/theme-context.stories.tsx +41 -0
  63. package/src/components/theme-context.tsx +30 -0
  64. package/src/components/truncate-text.ts +6 -0
  65. package/src/config.test.ts +105 -30
  66. package/src/config.ts +91 -16
  67. package/src/editor.test.ts +110 -0
  68. package/src/editor.ts +101 -13
  69. package/src/herdr.test.ts +37 -0
  70. package/src/herdr.ts +21 -0
  71. package/src/integrations.test.ts +29 -0
  72. package/src/integrations.ts +28 -0
  73. package/src/key-bindings.test.ts +120 -0
  74. package/src/key-bindings.ts +353 -0
  75. package/src/keymap.test.ts +347 -0
  76. package/src/keymap.ts +229 -0
  77. package/src/review-panel.test.ts +111 -0
  78. package/src/review-panel.ts +76 -0
  79. package/src/run.ts +9 -5
  80. package/src/serve.test.ts +6 -6
  81. package/src/serve.ts +7 -7
  82. package/src/session-controller.ts +512 -0
  83. package/src/test-support.ts +87 -0
  84. package/src/theme.ts +23 -1
  85. package/src/view-diff.test.ts +8 -8
  86. package/src/view-diff.ts +22 -22
  87. package/src/view-plan.test.ts +179 -0
  88. package/src/view-plan.ts +362 -0
  89. package/src/walk.test.ts +110 -0
  90. package/src/walk.ts +65 -0
  91. package/src/view.test.ts +0 -157
  92. package/src/view.ts +0 -0
@@ -0,0 +1,208 @@
1
+ /**
2
+ * The guided walk in the virtual terminal: the wizard steps every
3
+ * changed file with a plain progress title, ] marks viewed and persists with
4
+ * the session, esc keeps progress, w resumes at the first unviewed file
5
+ * (across App instances - the daemon round-trip), the agent-note block
6
+ * renders only for files carrying a note, and the end card hands over to the
7
+ * submit confirm with the honest viewed count.
8
+ */
9
+
10
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
11
+ import { mkdtempSync, rmSync } from "node:fs";
12
+ import { tmpdir } from "node:os";
13
+ import { join } from "node:path";
14
+ import React from "react";
15
+ import { testRender } from "@opentui/react/test-utils";
16
+ import { DaemonServer } from "@cueloop/daemon";
17
+ import type { ReviewSession } from "@cueloop/schema";
18
+ import { App } from "./App";
19
+ import { DARK } from "./theme";
20
+ import { press, waitForState, waitForText, waitForTextGone } from "./test-support";
21
+
22
+ const PATCH = `diff --git a/src/a.ts b/src/a.ts
23
+ index 111..222 100644
24
+ --- a/src/a.ts
25
+ +++ b/src/a.ts
26
+ @@ -1,2 +1,2 @@
27
+ export const a = 1;
28
+ -export const b = 2;
29
+ +export const b = 3;
30
+ diff --git a/src/b.ts b/src/b.ts
31
+ index 333..444 100644
32
+ --- a/src/b.ts
33
+ +++ b/src/b.ts
34
+ @@ -1,2 +1,2 @@
35
+ // header
36
+ -old line
37
+ +new line
38
+ diff --git a/src/c.ts b/src/c.ts
39
+ index 555..666 100644
40
+ --- a/src/c.ts
41
+ +++ b/src/c.ts
42
+ @@ -1,1 +1,2 @@
43
+ keep
44
+ +added tail
45
+ `;
46
+
47
+ let home: string;
48
+ let server: DaemonServer;
49
+ let session: ReviewSession;
50
+
51
+ beforeEach(() => {
52
+ home = mkdtempSync(join(tmpdir(), "cueloop-walk-"));
53
+ server = new DaemonServer({ home, idleExitMs: 0 });
54
+ server.start();
55
+ session = server.core.sessionCreate({
56
+ workspace: { repoRoot: "/repo", branch: "main" },
57
+ artifact: { type: "diff", content: PATCH, meta: { title: "working tree" } },
58
+ });
59
+ });
60
+ afterEach(() => {
61
+ server.stop();
62
+ rmSync(home, { recursive: true, force: true });
63
+ });
64
+
65
+ async function renderApp() {
66
+ const setup = await testRender(<App home={home} sessionId={session.id} />, { width: 120, height: 34 });
67
+ // Wait for session content, not just the chrome: pressing w before the diff
68
+ // loads drops the walk intent (no session, no diff view) and hangs the test.
69
+ await waitForText(setup, "src/a.ts");
70
+ return setup;
71
+ }
72
+
73
+ type Setup = Awaited<ReturnType<typeof renderApp>>;
74
+
75
+ /** Foreground colors (hex) of every styled span containing the needle. */
76
+ function foregroundsOf(setup: Setup, needle: string): string[] {
77
+ const foregrounds: string[] = [];
78
+ for (const line of setup.captureSpans().lines) {
79
+ for (const span of line.spans) {
80
+ if (!span.text.includes(needle)) continue;
81
+ const [red, green, blue] = span.fg.toInts();
82
+ foregrounds.push("#" + [red, green, blue].map((part) => part.toString(16).padStart(2, "0")).join(""));
83
+ }
84
+ }
85
+ return foregrounds;
86
+ }
87
+
88
+ describe("the guided walk", () => {
89
+ test("w walks all three files: progress title, persisted marks, end card, submit hand-over", async () => {
90
+ const setup = await renderApp();
91
+ await press(setup, "w");
92
+ await waitForText(setup, "file 1 of 3 · 0 viewed");
93
+ expect(setup.captureCharFrame()).toContain("src/a.ts");
94
+ expect(setup.captureCharFrame()).toContain("+1 -1");
95
+
96
+ await press(setup, "]");
97
+ await waitForText(setup, "file 2 of 3 · 1 viewed");
98
+ // the viewed mark rides the session record, not the client
99
+ await waitForState(setup, () => (server.core.sessionGet(session.id).viewedPaths ?? []).includes("src/a.ts"));
100
+
101
+ // [ steps back; the revisited card shows its viewed marker
102
+ await press(setup, "[");
103
+ await waitForText(setup, "file 1 of 3 · 1 viewed");
104
+ expect(setup.captureCharFrame()).toContain("· viewed");
105
+ await press(setup, "]");
106
+ await waitForText(setup, "file 2 of 3 · 1 viewed");
107
+
108
+ await press(setup, "]");
109
+ await waitForText(setup, "file 3 of 3 · 2 viewed");
110
+ await press(setup, "]");
111
+ await waitForText(setup, "walk complete");
112
+ expect(setup.captureCharFrame()).toContain("every file viewed (3/3)");
113
+ expect(server.core.sessionGet(session.id).viewedPaths).toEqual(["src/a.ts", "src/b.ts", "src/c.ts"]);
114
+
115
+ // the end card's return leaves the walk and opens the rail confirm
116
+ await press(setup, "enter");
117
+ await waitForText(setup, "submit review");
118
+ expect(setup.captureCharFrame()).toContain("3/3 files viewed");
119
+ await press(setup, "enter");
120
+ await waitForState(setup, () => server.core.sessionGet(session.id).status === "resolved");
121
+ });
122
+
123
+ test("esc keeps progress and w resumes at the first unviewed file", async () => {
124
+ const setup = await renderApp();
125
+ await press(setup, "w");
126
+ await waitForText(setup, "file 1 of 3 · 0 viewed");
127
+ await press(setup, "]");
128
+ await waitForText(setup, "file 2 of 3 · 1 viewed");
129
+
130
+ await press(setup, "escape");
131
+ await waitForTextGone(setup, "file 2 of 3");
132
+ // the sheet is back at full strength: no wizard card on screen
133
+ expect(setup.captureCharFrame()).not.toContain("· 1 viewed");
134
+
135
+ await press(setup, "w");
136
+ await waitForText(setup, "file 2 of 3 · 1 viewed");
137
+ });
138
+
139
+ test("a half-walked review resumes across App instances via the daemon", async () => {
140
+ const first = await renderApp();
141
+ await press(first, "w");
142
+ await waitForText(first, "file 1 of 3 · 0 viewed");
143
+ await press(first, "]");
144
+ await press(first, "]");
145
+ await waitForText(first, "file 3 of 3 · 2 viewed");
146
+ await waitForState(first, () => (server.core.sessionGet(session.id).viewedPaths ?? []).length === 2);
147
+ first.renderer.destroy();
148
+
149
+ // a fresh client reads the viewed set back from the session record
150
+ const second = await renderApp();
151
+ await press(second, "w");
152
+ await waitForText(second, "file 3 of 3 · 2 viewed");
153
+ expect(second.captureCharFrame()).toContain("src/c.ts");
154
+ // two full App boots with daemon round-trips need more than the default budget
155
+ }, 15_000);
156
+
157
+ test("the agent-note block renders only for files carrying a note", async () => {
158
+ server.core.sessionAnnotate(session.id, {
159
+ id: "note-b",
160
+ kind: "note",
161
+ anchor: { quote: "src/b.ts", prefix: "", suffix: "" },
162
+ body: "Swaps the stale line for the new constant.",
163
+ });
164
+ const setup = await renderApp();
165
+ await press(setup, "w");
166
+ await waitForText(setup, "file 1 of 3");
167
+ expect(setup.captureCharFrame()).not.toContain("agent note");
168
+
169
+ await press(setup, "]");
170
+ await waitForText(setup, "agent note");
171
+ expect(setup.captureCharFrame()).toContain("Swaps the stale line for the new constant.");
172
+
173
+ // a note is agent context, not reviewer feedback: it neither counts nor
174
+ // flips the default verdict, and never comes back in the feedback doc
175
+ await press(setup, "escape");
176
+ await waitForTextGone(setup, "agent note");
177
+ await press(setup, "enter");
178
+ await waitForText(setup, "0 annotations");
179
+ await press(setup, "enter");
180
+ await waitForState(setup, () => server.core.sessionGet(session.id).status === "resolved");
181
+ const resolved = server.core.sessionGet(session.id);
182
+ expect(resolved.verdict!.kind).toBe("approve");
183
+ expect(resolved.verdict!.feedback).not.toContain("Swaps the stale line");
184
+ });
185
+
186
+ test("the sheet dims behind the wizard; the preview keeps the diff colors", async () => {
187
+ const setup = await renderApp();
188
+ // before the walk the sheet's added line wears the insertion color
189
+ await waitForText(setup, "+new line");
190
+ expect(foregroundsOf(setup, "+new line")).toContain(DARK.insFg);
191
+
192
+ await press(setup, "w");
193
+ await waitForText(setup, "file 1 of 3 · 0 viewed");
194
+ // dimmed: the sheet line drops to the dim token...
195
+ expect(foregroundsOf(setup, "+new line")).toEqual([DARK.textDim]);
196
+ // ...while the wizard preview keeps the insertion color
197
+ expect(foregroundsOf(setup, "+export const b = 3;")).toContain(DARK.insFg);
198
+ });
199
+
200
+ test("walking a resolved review answers read-only", async () => {
201
+ server.core.sessionResolve(session.id, "approve", "");
202
+ const setup = await renderApp();
203
+ await waitForText(setup, "resolved");
204
+ await press(setup, "w");
205
+ await waitForText(setup, "review submitted - read-only");
206
+ expect(setup.captureCharFrame()).not.toContain("file 1 of 3");
207
+ });
208
+ });
@@ -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,75 @@
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 kind="comment" quote="persists sessions" draft={{ text: "Which daemon?", ...draftCallbacks }} />
13
+ ),
14
+ expectedColors: [DARK.accent, DARK.elevated],
15
+ };
16
+
17
+ export const SuggestionDraft: Story = {
18
+ render: () => (
19
+ <AnnotationCard kind="suggestion" quote="move the store" draft={{ text: "", ...draftCallbacks }} />
20
+ ),
21
+ expectedColors: [DARK.green],
22
+ };
23
+
24
+ export const SavedSelected: Story = {
25
+ render: () => (
26
+ <AnnotationCard
27
+ kind="comment"
28
+ quote="persists sessions"
29
+ saved={{
30
+ body: "Which daemon owns this?",
31
+ isSelected: true,
32
+ isOrphan: false,
33
+ isBlocking: false,
34
+ editing: null,
35
+ onPress: () => {},
36
+ }}
37
+ />
38
+ ),
39
+ expectedColors: [DARK.elevated],
40
+ };
41
+
42
+ export const SavedOrphanBlocking: Story = {
43
+ render: () => (
44
+ <AnnotationCard
45
+ kind="comment"
46
+ quote="a removed passage"
47
+ saved={{
48
+ body: "The passage is gone.",
49
+ isSelected: false,
50
+ isOrphan: true,
51
+ isBlocking: true,
52
+ editing: null,
53
+ onPress: () => {},
54
+ }}
55
+ />
56
+ ),
57
+ expectedColors: [DARK.red],
58
+ };
59
+
60
+ export const SavedEditing: Story = {
61
+ render: () => (
62
+ <AnnotationCard
63
+ kind="suggestion"
64
+ quote="move the store"
65
+ saved={{
66
+ body: "old body",
67
+ isSelected: true,
68
+ isOrphan: false,
69
+ isBlocking: false,
70
+ editing: { text: "new body", ...draftCallbacks },
71
+ onPress: () => {},
72
+ }}
73
+ />
74
+ ),
75
+ };
@@ -0,0 +1,189 @@
1
+ /**
2
+ * One annotation component in three modes: a draft composer (bordered card
3
+ * inline in the document flow), a saved rail card, and the rail card editing
4
+ * its body in place. Compose, saved, and re-edit share this rendering path,
5
+ * so the kind-color and quote treatment can never drift between them.
6
+ *
7
+ * Bodies are multiline: the composer is a textarea (wrap, undo/redo). Plain ⏎
8
+ * stays the save key while ⌥/Alt+⏎ and shift+⏎ insert a new line (the Slack
9
+ * convention). The box auto-grows with the wrapped text up to a cap, then
10
+ * scrolls internally.
11
+ */
12
+
13
+ import React, { useEffect, useRef, useState } from "react";
14
+ import type { KeyBinding, TextareaRenderable } from "@opentui/core";
15
+ import type { Theme } from "../theme";
16
+ import { useComponentTheme } from "./theme-context";
17
+ import { Card } from "./primitives/Card";
18
+ import { Button } from "./primitives/Button";
19
+ import { Toolbar } from "./primitives/Toolbar";
20
+ import { truncateToSingleLine } from "./truncate-text";
21
+
22
+ export interface AnnotationDraft {
23
+ text: string;
24
+ onInput: (text: string) => void;
25
+ onSave: () => void;
26
+ onCancel: () => void;
27
+ }
28
+
29
+ export interface AnnotationSaved {
30
+ body: string;
31
+ isSelected: boolean;
32
+ isOrphan: boolean;
33
+ isBlocking: boolean;
34
+ /** Non-null while the card body is being rewritten in place. */
35
+ editing: AnnotationDraft | null;
36
+ onPress: () => void;
37
+ }
38
+
39
+ export interface AnnotationCardProps {
40
+ id?: string;
41
+ kind: string;
42
+ quote: string;
43
+ draft?: AnnotationDraft;
44
+ saved?: AnnotationSaved;
45
+ theme?: Theme;
46
+ }
47
+
48
+ /**
49
+ * ⏎ submits (which keeps the textarea from inserting its default newline; the
50
+ * grammar owns the actual save). ⌥/Alt+⏎ and shift+⏎ insert a newline so the
51
+ * body can grow multiline without leaving the composer.
52
+ *
53
+ * Option/Alt reaches a binding as `meta`, not `option`: a binding key here is
54
+ * built from name+ctrl+shift+meta+super only, so the newline binding matches on
55
+ * `meta`. This also overrides the textarea default that maps meta+⏎ to submit.
56
+ */
57
+ const COMPOSE_KEY_BINDINGS: KeyBinding[] = [
58
+ { name: "return", action: "submit" },
59
+ { name: "return", meta: true, action: "newline" },
60
+ { name: "return", shift: true, action: "newline" },
61
+ ];
62
+
63
+ /** The composer never grows past this many rows; beyond it the textarea scrolls. */
64
+ export const COMPOSE_MAX_ROWS = 4;
65
+
66
+ /**
67
+ * Visible row count for the composer, so a soft-wrapped long line grows the box
68
+ * just like a hard newline does. Each logical line takes `ceil(length /
69
+ * contentWidth)` visual rows (at least one), summed over the hard-newline lines
70
+ * and capped. A non-positive width (before layout is known) counts hard
71
+ * newlines only. Beyond the cap the textarea scrolls internally and keeps the
72
+ * caret line in view.
73
+ */
74
+ export function composeRowCount(text: string, contentWidth: number, cap: number = COMPOSE_MAX_ROWS): number {
75
+ const usableWidth = contentWidth > 0 ? contentWidth : Number.MAX_SAFE_INTEGER;
76
+ let visualRowCount = 0;
77
+ for (const line of text.split("\n")) {
78
+ visualRowCount += Math.max(1, Math.ceil(line.length / usableWidth));
79
+ }
80
+ return Math.min(cap, Math.max(1, visualRowCount));
81
+ }
82
+
83
+ function DraftEditor({
84
+ draft,
85
+ rows,
86
+ onRowsChange,
87
+ theme,
88
+ }: {
89
+ draft: AnnotationDraft;
90
+ /** Current visible height in rows; owned by the parent so the card frame agrees. */
91
+ rows: number;
92
+ onRowsChange: (rows: number) => void;
93
+ theme?: Theme;
94
+ }): React.ReactNode {
95
+ const tokens = useComponentTheme(theme);
96
+ const editorRef = useRef<TextareaRenderable | null>(null);
97
+ // re-edit opens with the caret after the existing body, like a text field
98
+ const initialTextLength = useRef(draft.text.length);
99
+ useEffect(() => {
100
+ const editor = editorRef.current;
101
+ if (!editor) return;
102
+ editor.cursorOffset = initialTextLength.current;
103
+ // the box is laid out now, so its width is known: size to the wrapped body
104
+ onRowsChange(composeRowCount(editor.plainText, editor.width));
105
+ }, []);
106
+ return (
107
+ <>
108
+ <textarea
109
+ ref={editorRef}
110
+ focused
111
+ initialValue={draft.text}
112
+ placeholder="write a note..."
113
+ keyBindings={COMPOSE_KEY_BINDINGS}
114
+ onContentChange={() => {
115
+ const editor = editorRef.current;
116
+ if (!editor) return;
117
+ draft.onInput(editor.plainText);
118
+ onRowsChange(composeRowCount(editor.plainText, editor.width));
119
+ }}
120
+ style={{
121
+ height: rows,
122
+ backgroundColor: tokens.elevated,
123
+ focusedBackgroundColor: tokens.elevated,
124
+ textColor: tokens.text,
125
+ focusedTextColor: tokens.text,
126
+ }}
127
+ />
128
+ <Toolbar>
129
+ <Button variant="solid" marginRight={2} onPress={draft.onSave} theme={theme}>
130
+ {" Save ⏎ "}
131
+ </Button>
132
+ <Button onPress={draft.onCancel} theme={theme}>
133
+ {" Cancel esc "}
134
+ </Button>
135
+ </Toolbar>
136
+ </>
137
+ );
138
+ }
139
+
140
+ export function AnnotationCard({ id, kind, quote, draft, saved, theme }: AnnotationCardProps): React.ReactNode {
141
+ const tokens = useComponentTheme(theme);
142
+ const kindColor = kind === "suggestion" ? tokens.green : tokens.accent;
143
+ const activeDraft = draft ?? saved?.editing ?? null;
144
+ // The composer's visible height, grown from the wrapped text. It is shared
145
+ // with the card frame (contentRows) so the border never clips the textarea
146
+ // or the toolbar. Seeded from hard newlines, then corrected once the mounted
147
+ // textarea reports its measured width.
148
+ const [editorRowCount, setEditorRowCount] = useState(() =>
149
+ // Width 0 is the "layout not known yet" contract: count hard newlines only,
150
+ // until the mounted textarea reports its measured width below.
151
+ composeRowCount(activeDraft?.text ?? "", 0),
152
+ );
153
+ if (draft) {
154
+ const verb = kind === "suggestion" ? "suggest replacement for" : "comment on";
155
+ return (
156
+ <Card
157
+ title={` ${verb} "${truncateToSingleLine(quote, 40)}" `}
158
+ contentRows={editorRowCount + 1}
159
+ borderColor={kindColor}
160
+ marginLeft={2}
161
+ marginRight={2}
162
+ theme={theme}
163
+ >
164
+ <DraftEditor draft={draft} rows={editorRowCount} onRowsChange={setEditorRowCount} theme={theme} />
165
+ </Card>
166
+ );
167
+ }
168
+ const card = saved!;
169
+ return (
170
+ <box
171
+ id={id}
172
+ style={{ flexDirection: "column", marginBottom: 1, backgroundColor: card.isSelected ? tokens.elevated : undefined }}
173
+ onMouseUp={card.onPress}
174
+ >
175
+ <text fg={card.isSelected ? tokens.text : kindColor}>
176
+ {card.isSelected ? "▸ " : " "}
177
+ {kind.toUpperCase()}
178
+ {card.isBlocking ? <span fg={tokens.red}> · BLOCKING</span> : null}
179
+ <span fg={tokens.textDim}>{card.isOrphan ? " · ORPHANED" : " · pending"}</span>
180
+ </text>
181
+ <text fg={tokens.textDim}> "{truncateToSingleLine(quote, 26)}"</text>
182
+ {card.editing ? (
183
+ <DraftEditor draft={card.editing} rows={editorRowCount} onRowsChange={setEditorRowCount} theme={theme} />
184
+ ) : (
185
+ <text fg={card.isOrphan ? tokens.textDim : tokens.textMuted}> {truncateToSingleLine(card.body, 28)}</text>
186
+ )}
187
+ </box>
188
+ );
189
+ }
@@ -0,0 +1,19 @@
1
+ import React from "react";
2
+ import { DARK } from "../theme";
3
+ import type { Story, StoryMeta } from "./story";
4
+ import { Breadcrumb } from "./Breadcrumb";
5
+
6
+ export const meta: StoryMeta = { title: "Breadcrumb" };
7
+
8
+ export const SessionHeader: Story = {
9
+ render: () => (
10
+ <Breadcrumb
11
+ items={[
12
+ { label: "cueloop", tone: "accent" },
13
+ { label: "Migration Plan · rev 2", tone: "dim" },
14
+ { label: "resolved: approve", tone: "green" },
15
+ ]}
16
+ />
17
+ ),
18
+ expectedColors: [DARK.accent, DARK.green, DARK.panel],
19
+ };
@@ -0,0 +1,38 @@
1
+ /**
2
+ * One-row breadcrumb trail: toned segments joined by " · " on the panel
3
+ * surface. The app header is a breadcrumb (product · artifact · revision)
4
+ * followed by state badges; each item picks a semantic tone.
5
+ */
6
+
7
+ import React from "react";
8
+ import type { Theme } from "../theme";
9
+ import { useComponentTheme } from "./theme-context";
10
+
11
+ export type BreadcrumbTone = "accent" | "dim" | "text" | "green";
12
+
13
+ export interface BreadcrumbItem {
14
+ label: string;
15
+ tone?: BreadcrumbTone;
16
+ }
17
+
18
+ export interface BreadcrumbProps {
19
+ items: BreadcrumbItem[];
20
+ theme?: Theme;
21
+ }
22
+
23
+ export function Breadcrumb({ items, theme }: BreadcrumbProps): React.ReactNode {
24
+ const tokens = useComponentTheme(theme);
25
+ const toneColor = (tone?: BreadcrumbTone): string =>
26
+ tone === "accent" ? tokens.accent : tone === "green" ? tokens.green : tone === "text" ? tokens.text : tokens.textDim;
27
+ return (
28
+ <box style={{ height: 1, backgroundColor: tokens.panel, paddingLeft: 1, flexDirection: "row" }}>
29
+ <text fg={tokens.text}>
30
+ {items.map((item, index) => (
31
+ <span key={index} fg={toneColor(item.tone)}>
32
+ {index === 0 ? item.label : ` · ${item.label}`}
33
+ </span>
34
+ ))}
35
+ </text>
36
+ </box>
37
+ );
38
+ }
@@ -0,0 +1,25 @@
1
+ import React from "react";
2
+ import { DARK } from "../theme";
3
+ import type { Story, StoryMeta } from "./story";
4
+ import { CodeBlock } from "./CodeBlock";
5
+
6
+ export const meta: StoryMeta = { title: "CodeBlock" };
7
+
8
+ const SNIPPET = `export function gate(full: number) {
9
+ // threshold
10
+ return full < 0.6 ? "blocked" : "clear";
11
+ }`;
12
+
13
+ export const Typescript: Story = {
14
+ render: () => <CodeBlock language="ts" content={SNIPPET} />,
15
+ expectedColors: [DARK.elevated],
16
+ };
17
+
18
+ export const CursorAnnotatedEdited: Story = {
19
+ render: () => <CodeBlock language="ts" content={SNIPPET} isCursor isAnnotated changeTag="edited" marginTop={1} />,
20
+ expectedColors: [DARK.accent],
21
+ };
22
+
23
+ export const UnknownLanguage: Story = {
24
+ render: () => <CodeBlock language="brainfog" content={"plain verbatim lines\n with indentation"} />,
25
+ };
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Fenced code block: verbatim lines in a contained elevated box, highlighted
3
+ * by the native code renderable (tree-sitter). Unstyled text draws first, so
4
+ * the content never flashes empty while highlights resolve; unknown languages
5
+ * stay unstyled.
6
+ */
7
+
8
+ import React from "react";
9
+ import type { Theme } from "../theme";
10
+ import { useComponentTheme } from "./theme-context";
11
+ import { filetypeFor, syntaxStyleFor } from "./syntax-highlight";
12
+
13
+ export interface CodeBlockProps {
14
+ id?: string;
15
+ /** Markdown fence info string ("tsx", "python", ...). */
16
+ language?: string;
17
+ content: string;
18
+ isCursor?: boolean;
19
+ /** Vertical rhythm above the block. */
20
+ marginTop?: number;
21
+ isAnnotated?: boolean;
22
+ /** Working-copy change tag rendered after the language label. */
23
+ changeTag?: "cut" | "new" | "edited";
24
+ theme?: Theme;
25
+ }
26
+
27
+ export function CodeBlock({
28
+ id,
29
+ language,
30
+ content,
31
+ isCursor = false,
32
+ marginTop = 0,
33
+ isAnnotated = false,
34
+ changeTag,
35
+ theme,
36
+ }: CodeBlockProps): React.ReactNode {
37
+ const tokens = useComponentTheme(theme);
38
+ const tagColor = changeTag === "cut" ? tokens.red : changeTag === "new" ? tokens.green : tokens.accent;
39
+ return (
40
+ <box id={id} style={{ flexDirection: "column", marginTop }}>
41
+ <text>
42
+ <span fg={isCursor ? tokens.accent : tokens.textDim}>{isCursor ? "▎ " : " "}</span>
43
+ <span fg={tokens.textDim}>{language ?? "code"}</span>
44
+ {isAnnotated ? <span fg={tokens.accent}> ◆</span> : null}
45
+ {changeTag ? <span fg={tagColor}> [{changeTag}]</span> : null}
46
+ </text>
47
+ <box
48
+ style={{
49
+ flexDirection: "column",
50
+ backgroundColor: tokens.elevated,
51
+ paddingLeft: 2,
52
+ paddingRight: 2,
53
+ paddingTop: 1,
54
+ paddingBottom: 1,
55
+ marginLeft: 2,
56
+ }}
57
+ >
58
+ <code
59
+ content={content}
60
+ filetype={filetypeFor(language)}
61
+ syntaxStyle={syntaxStyleFor(tokens)}
62
+ selectable={false}
63
+ style={{ wrapMode: "none", fg: tokens.textMuted, bg: tokens.elevated }}
64
+ />
65
+ </box>
66
+ </box>
67
+ );
68
+ }