@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,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: "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, "#cc785c"],
25
+ size: { width: 40, height: 24 },
26
+ };
@@ -0,0 +1,21 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { HARNESS_LAUNCHERS, planHandoffBriefing } from "./agent-launcher";
3
+
4
+ describe("planHandoffBriefing", () => {
5
+ test("references the session by id and directs the agent to read then annotate, not rewrite", () => {
6
+ // Act
7
+ const briefing = planHandoffBriefing("ses_42");
8
+
9
+ // Assert
10
+ expect(briefing).toContain("cueloop session get ses_42");
11
+ expect(briefing).toContain("cueloop session annotate ses_42");
12
+ expect(briefing).toContain("Do not rewrite the plan");
13
+ });
14
+ });
15
+
16
+ describe("HARNESS_LAUNCHERS", () => {
17
+ test("carries the three branded harnesses with their launch commands", () => {
18
+ // Assert
19
+ expect(HARNESS_LAUNCHERS.map((harness) => harness.command)).toEqual(["cc", "pi", "codex"]);
20
+ });
21
+ });
@@ -0,0 +1,123 @@
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, { useState } from "react";
9
+ import type { ReviewSession } from "@cueloop/schema";
10
+ import type { Theme } from "../theme";
11
+ import { useComponentTheme } from "./theme-context";
12
+ import { FRAME_BORDER_STYLE } from "./primitives/frame";
13
+
14
+ /** One launchable harness: its rail command and the real logo, rendered as colored rows. */
15
+ export interface HarnessLauncher {
16
+ id: string;
17
+ name: string;
18
+ /** The shell command that starts the harness, run in the split. */
19
+ command: string;
20
+ /** Brand color for the mark. */
21
+ color: string;
22
+ /** Multi-row mark rendered from the real logo. */
23
+ logo: string[];
24
+ }
25
+
26
+ /** Claude Code's brand coral - not a theme token, the mark keeps its own color. */
27
+ const CLAUDE_CORAL = "#CC785C";
28
+
29
+ /** The bring-your-own harnesses, with their real marks (finalized in the prototype). */
30
+ export const HARNESS_LAUNCHERS: HarnessLauncher[] = [
31
+ {
32
+ id: "claude",
33
+ name: "claude code",
34
+ command: "cc",
35
+ color: CLAUDE_CORAL,
36
+ logo: [" ▛████▜", "▜██████▛", " ▝▝ ▝▝"],
37
+ },
38
+ { id: "pi", name: "pi", command: "pi", color: "#e4e6ec", logo: ["███ ", "█ █ ", "██ █", "█ █"] },
39
+ {
40
+ id: "codex",
41
+ name: "codex",
42
+ command: "codex",
43
+ color: "#e4e6ec",
44
+ logo: ["┌───┐", "│>_ │", "└───┘"],
45
+ },
46
+ ];
47
+
48
+ /** The briefing typed into a launched harness when the plan-context toggle is on. */
49
+ export function planHandoffBriefing(sessionId: string): string {
50
+ 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.`;
51
+ }
52
+
53
+ function LogoMark({ harness }: { harness: HarnessLauncher }): React.ReactNode {
54
+ return (
55
+ <box style={{ width: 9, flexShrink: 0, flexDirection: "column", alignItems: "flex-start" }}>
56
+ {harness.logo.map((row) => (
57
+ <text key={row} fg={harness.color}>
58
+ {row}
59
+ </text>
60
+ ))}
61
+ </box>
62
+ );
63
+ }
64
+
65
+ export interface AgentLauncherProps {
66
+ session: ReviewSession;
67
+ /** Launch a harness in the rail; seedText is the plan-context briefing when the toggle is on. */
68
+ onLaunchHarness: (command: string, seedText?: string) => void;
69
+ theme?: Theme;
70
+ }
71
+
72
+ /**
73
+ * The Agent tab body: branded launcher cards plus a plan-context toggle. Replaces
74
+ * the old dead agent/status/revision placeholder (now a dim footer line).
75
+ */
76
+ export function AgentLauncher({
77
+ session,
78
+ onLaunchHarness,
79
+ theme,
80
+ }: AgentLauncherProps): React.ReactNode {
81
+ const tokens = useComponentTheme(theme);
82
+ const [seedContext, setSeedContext] = useState(true);
83
+ const launch = (harness: HarnessLauncher): void =>
84
+ onLaunchHarness(harness.command, seedContext ? planHandoffBriefing(session.id) : undefined);
85
+ return (
86
+ <box style={{ flexDirection: "column", flexGrow: 1 }}>
87
+ <text fg={tokens.text}>Ask an agent about this plan</text>
88
+ <box style={{ height: 1 }} />
89
+ {HARNESS_LAUNCHERS.map((harness) => (
90
+ <box
91
+ key={harness.id}
92
+ style={{
93
+ flexDirection: "row",
94
+ height: harness.logo.length + 2,
95
+ border: true,
96
+ borderStyle: FRAME_BORDER_STYLE,
97
+ backgroundColor: "transparent",
98
+ borderColor: tokens.border,
99
+ marginBottom: 1,
100
+ paddingLeft: 1,
101
+ paddingRight: 1,
102
+ }}
103
+ onMouseUp={() => launch(harness)}
104
+ >
105
+ <LogoMark harness={harness} />
106
+ <box style={{ flexDirection: "column", flexGrow: 1, paddingLeft: 1 }}>
107
+ <text fg={tokens.textMuted}>{harness.name}</text>
108
+ <text fg={tokens.textDim}>launch in rail ▸</text>
109
+ </box>
110
+ </box>
111
+ ))}
112
+ <box style={{ flexDirection: "row" }} onMouseUp={() => setSeedContext((on) => !on)}>
113
+ <text fg={tokens.textDim}>plan context: </text>
114
+ <text fg={tokens.accent}>{seedContext ? "seed the plan ▸" : "none ▸"}</text>
115
+ </box>
116
+ <box style={{ flexGrow: 1 }} />
117
+ <text fg={tokens.textDim}>
118
+ {session.artifact.meta.agent ?? "unknown"} · {session.status} · rev{" "}
119
+ {session.revisions.length}
120
+ </text>
121
+ </box>
122
+ );
123
+ }
@@ -0,0 +1,127 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { Annotation } from "@cueloop/schema";
3
+ import type { DiffRow } from "../view-diff";
4
+ import type { IntralineRun } from "../diff-intraline";
5
+ import type { SyntaxSpan } from "../diff-syntax";
6
+ import { DARK } from "../theme";
7
+ import {
8
+ annotatedRowsByIndex,
9
+ coloredRowSpans,
10
+ rowContentOffsets,
11
+ rowLine,
12
+ segmentRows,
13
+ } from "./diff-sheet-layout";
14
+
15
+ function row(kind: DiffRow["kind"], text: string, extra: Partial<DiffRow> = {}): DiffRow {
16
+ return { kind, text, file: "a.ts", ...extra };
17
+ }
18
+
19
+ function annotation(quote: string): Annotation {
20
+ return {
21
+ id: "a1",
22
+ kind: "comment",
23
+ body: "note",
24
+ anchor: { quote, prefix: "", suffix: "" },
25
+ createdAt: "2026-01-01T00:00:00Z",
26
+ };
27
+ }
28
+
29
+ describe("rowLine", () => {
30
+ test("strips the trailing newline the patch carries", () => {
31
+ // Arrange / Act / Assert
32
+ expect(rowLine(row("add", "const x = 1;\n"))).toBe("const x = 1;");
33
+ });
34
+ });
35
+
36
+ describe("annotatedRowsByIndex", () => {
37
+ test("maps an annotation to the code row whose text matches its quote", () => {
38
+ // Arrange
39
+ const rows = [row("file", "a.ts"), row("ctx", "keep"), row("add", "new line")];
40
+
41
+ // Act
42
+ const byRow = annotatedRowsByIndex(rows, [annotation("new line")]);
43
+
44
+ // Assert
45
+ expect([...byRow.keys()]).toEqual([2]);
46
+ });
47
+ });
48
+
49
+ describe("segmentRows", () => {
50
+ test("splits header rows out and closes a chunk after an annotated row", () => {
51
+ // Arrange
52
+ const rows = [
53
+ row("file", "a.ts"),
54
+ row("hunk", "@@"),
55
+ row("ctx", "keep"),
56
+ row("add", "changed"),
57
+ ];
58
+ const annotatedByRow = new Map<number, Annotation>([[3, annotation("changed")]]);
59
+
60
+ // Act
61
+ const segments = segmentRows(rows, annotatedByRow);
62
+
63
+ // Assert
64
+ expect(segments.map((segment) => segment.kind)).toEqual(["header", "header", "chunk"]);
65
+ const chunk = segments[2];
66
+ expect(chunk?.kind).toBe("chunk");
67
+ if (chunk?.kind === "chunk") expect(chunk.annotation?.id).toBe("a1");
68
+ });
69
+
70
+ test("closes a chunk at the compose row so a draft card can follow it", () => {
71
+ // Arrange - two code rows, composing on the first
72
+ const rows = [row("add", "one"), row("add", "two")];
73
+
74
+ // Act
75
+ const segments = segmentRows(rows, new Map(), 0);
76
+
77
+ // Assert - the first row is its own chunk, the second a separate chunk
78
+ expect(segments).toHaveLength(2);
79
+ expect(segments.every((segment) => segment.kind === "chunk")).toBe(true);
80
+ });
81
+ });
82
+
83
+ describe("rowContentOffsets", () => {
84
+ test("counts one row per header and an extra row for a chunk's annotation card", () => {
85
+ // Arrange
86
+ const rows = [row("file", "a.ts"), row("add", "changed")];
87
+ const annotatedByRow = new Map<number, Annotation>([[1, annotation("changed")]]);
88
+ const segments = segmentRows(rows, annotatedByRow);
89
+
90
+ // Act
91
+ const offsets = rowContentOffsets(segments);
92
+
93
+ // Assert - header at y0, its chunk row at y1; the card would sit at y2
94
+ expect(offsets[0]).toBe(0);
95
+ expect(offsets[1]).toBe(1);
96
+ });
97
+ });
98
+
99
+ describe("coloredRowSpans", () => {
100
+ test("a changed word keeps the diff color while unchanged text dims", () => {
101
+ // Arrange - "old" is the change, " tail" unchanged, on a modified line
102
+ const runs: IntralineRun[] = [
103
+ { text: "old", changed: true },
104
+ { text: " tail", changed: false },
105
+ ];
106
+
107
+ // Act
108
+ const spans = coloredRowSpans("old tail", runs, undefined, DARK.deletedForeground, DARK);
109
+
110
+ // Assert
111
+ expect(spans).toEqual([
112
+ { text: "old", foreground: DARK.deletedForeground },
113
+ { text: " tail", foreground: DARK.textDim },
114
+ ]);
115
+ });
116
+
117
+ test("syntax color applies to unchanged text when there is no intra-line run", () => {
118
+ // Arrange - a keyword span over the whole word, no intra-line runs
119
+ const syntax: SyntaxSpan[] = [{ start: 0, end: 6, group: "keyword" }];
120
+
121
+ // Act
122
+ const spans = coloredRowSpans("return", undefined, syntax, DARK.textMuted, DARK);
123
+
124
+ // Assert
125
+ expect(spans).toEqual([{ text: "return", foreground: DARK.accent }]);
126
+ });
127
+ });
@@ -0,0 +1,167 @@
1
+ /**
2
+ * Pure layout logic for the diff review sheet, split out so it can be tested
3
+ * without rendering: grouping rows into chunks around headers and annotations,
4
+ * resolving each row's colored spans (syntax under the intra-line change color),
5
+ * mapping annotations to rows, and the content-y offset of each row for scroll.
6
+ * DiffSheet.tsx is the thin renderer over these.
7
+ */
8
+
9
+ import type { Annotation } from "@cueloop/schema";
10
+ import type { DiffRow } from "../view-diff";
11
+ import type { Theme } from "../theme";
12
+ import type { IntralineRun } from "../diff-intraline";
13
+ import type { SyntaxSpan } from "../diff-syntax";
14
+ import { colorForSyntaxGroup } from "./syntax-highlight";
15
+
16
+ /** A header row rendered on its own line, or a contiguous run of code rows. */
17
+ export type DiffSegment =
18
+ | { kind: "header"; rowIndex: number; row: DiffRow }
19
+ | { kind: "chunk"; firstRowIndex: number; rows: DiffRow[]; annotation: Annotation | null };
20
+
21
+ /** One rendered span of a row: its text and its resolved foreground color. */
22
+ export interface ColoredSpan {
23
+ text: string;
24
+ foreground: string;
25
+ }
26
+
27
+ /** Row text carries the patch's trailing newline; rendering strips it. */
28
+ export function rowLine(row: DiffRow): string {
29
+ return row.text.replace(/\n$/, "");
30
+ }
31
+
32
+ /** The annotation anchored to each row, matched by its verbatim quote. */
33
+ export function annotatedRowsByIndex(
34
+ rows: DiffRow[],
35
+ annotations: Annotation[],
36
+ ): Map<number, Annotation> {
37
+ const byRow = new Map<number, Annotation>();
38
+ for (const annotation of annotations) {
39
+ const rowIndex = rows.findIndex(
40
+ (row) =>
41
+ row.text === annotation.anchor.quote &&
42
+ (row.kind === "ctx" || row.kind === "add" || row.kind === "del"),
43
+ );
44
+ if (rowIndex !== -1) byRow.set(rowIndex, annotation);
45
+ }
46
+ return byRow;
47
+ }
48
+
49
+ /** Group rows into header segments and code chunks; a chunk closes after an
50
+ * annotated or composed row so a card can render directly below that line. */
51
+ export function segmentRows(
52
+ rows: DiffRow[],
53
+ annotatedByRow: Map<number, Annotation>,
54
+ composeRowIndex?: number,
55
+ ): DiffSegment[] {
56
+ const segments: DiffSegment[] = [];
57
+ let chunk: DiffRow[] = [];
58
+ let chunkStart = 0;
59
+
60
+ const closeChunk = (annotation: Annotation | null): void => {
61
+ if (chunk.length) {
62
+ segments.push({ kind: "chunk", firstRowIndex: chunkStart, rows: chunk, annotation });
63
+ }
64
+ chunk = [];
65
+ };
66
+
67
+ for (let rowIndex = 0; rowIndex < rows.length; rowIndex++) {
68
+ const row = rows[rowIndex]!;
69
+ if (row.kind === "file" || row.kind === "hunk") {
70
+ closeChunk(null);
71
+ segments.push({ kind: "header", rowIndex, row });
72
+ chunkStart = rowIndex + 1;
73
+ continue;
74
+ }
75
+ if (!chunk.length) chunkStart = rowIndex;
76
+ chunk.push(row);
77
+
78
+ const annotation = annotatedByRow.get(rowIndex);
79
+ if (annotation) {
80
+ closeChunk(annotation);
81
+ chunkStart = rowIndex + 1;
82
+ } else if (rowIndex === composeRowIndex) {
83
+ closeChunk(null);
84
+ chunkStart = rowIndex + 1;
85
+ }
86
+ }
87
+ closeChunk(null);
88
+ return segments;
89
+ }
90
+
91
+ /** The content-y offset of each row index, so the cursor can be scrolled into
92
+ * view; a chunk's annotation adds one row for its card. */
93
+ export function rowContentOffsets(segments: DiffSegment[]): number[] {
94
+ const offsets: number[] = [];
95
+ let contentY = 0;
96
+ for (const segment of segments) {
97
+ if (segment.kind === "header") {
98
+ offsets[segment.rowIndex] = contentY;
99
+ contentY += 1;
100
+ } else {
101
+ segment.rows.forEach((_, lineIndex) => {
102
+ offsets[segment.firstRowIndex + lineIndex] = contentY + lineIndex;
103
+ });
104
+ contentY += segment.rows.length + (segment.annotation ? 1 : 0);
105
+ }
106
+ }
107
+ return offsets;
108
+ }
109
+
110
+ /**
111
+ * Colored spans for one row, resolved per character: an intra-line changed word
112
+ * keeps the diff color, otherwise the syntax color applies, falling back to the
113
+ * dim token on the unchanged part of a modified line or the row's base color.
114
+ * Adjacent same-color characters coalesce so a row stays a few spans.
115
+ */
116
+ export function coloredRowSpans(
117
+ text: string,
118
+ intralineRuns: IntralineRun[] | undefined,
119
+ syntaxSpans: SyntaxSpan[] | undefined,
120
+ baseColor: string,
121
+ tokens: Theme,
122
+ ): ColoredSpan[] {
123
+ const changed = changedColumns(text.length, intralineRuns);
124
+ const syntaxColorByColumn = syntaxColorColumns(text.length, syntaxSpans, tokens);
125
+ const isModifiedLine = intralineRuns !== undefined;
126
+
127
+ const spans: ColoredSpan[] = [];
128
+ for (let column = 0; column < text.length; column++) {
129
+ const color = changed[column]
130
+ ? baseColor
131
+ : (syntaxColorByColumn[column] ?? (isModifiedLine ? tokens.textDim : baseColor));
132
+ const previous = spans[spans.length - 1];
133
+ if (previous && previous.foreground === color) previous.text += text[column];
134
+ else spans.push({ text: text[column]!, foreground: color });
135
+ }
136
+ return spans;
137
+ }
138
+
139
+ /** Which columns are the intra-line change on a modified line. */
140
+ function changedColumns(length: number, intralineRuns: IntralineRun[] | undefined): boolean[] {
141
+ const changed = Array.from({ length }, () => false);
142
+ if (!intralineRuns) return changed;
143
+ let offset = 0;
144
+ for (const run of intralineRuns) {
145
+ if (run.changed)
146
+ for (let index = 0; index < run.text.length; index++) changed[offset + index] = true;
147
+ offset += run.text.length;
148
+ }
149
+ return changed;
150
+ }
151
+
152
+ /** The syntax color per column, or undefined where a column has no token. */
153
+ function syntaxColorColumns(
154
+ length: number,
155
+ syntaxSpans: SyntaxSpan[] | undefined,
156
+ tokens: Theme,
157
+ ): Array<string | undefined> {
158
+ const colors: Array<string | undefined> = Array.from({ length }, () => undefined);
159
+ for (const span of syntaxSpans ?? []) {
160
+ const color = colorForSyntaxGroup(span.group, tokens);
161
+ if (!color) continue;
162
+ for (let column = span.start; column < span.end && column < length; column++) {
163
+ colors[column] = color;
164
+ }
165
+ }
166
+ return colors;
167
+ }
@@ -0,0 +1,32 @@
1
+ import React from "react";
2
+ import { DARK } from "../../theme";
3
+ import type { Story, StoryMeta } from "../story";
4
+ import { Button } from "./Button";
5
+ import { Toolbar } from "./Toolbar";
6
+
7
+ export const meta: StoryMeta = { title: "primitives/Button" };
8
+
9
+ export const Variants: Story = {
10
+ render: () => (
11
+ <Toolbar>
12
+ <Button variant="solid" marginRight={2} onPress={() => {}}>
13
+ {" Save "}
14
+ </Button>
15
+ <Button marginRight={2} onPress={() => {}}>
16
+ {" Cancel esc "}
17
+ </Button>
18
+ <Button variant="accent-text" onPress={() => {}}>
19
+ {"Submit review"}
20
+ </Button>
21
+ </Toolbar>
22
+ ),
23
+ expectedColors: [DARK.accent, DARK.accentInk, DARK.textDim],
24
+ };
25
+
26
+ export const Disabled: Story = {
27
+ render: () => (
28
+ <Button isDisabled onPress={() => {}}>
29
+ {" Edit "}
30
+ </Button>
31
+ ),
32
+ };
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Word-button: a pressable label. `onPress` unifies mouse and (future)
3
+ * keyboard activation; `isDisabled` swallows presses without changing the
4
+ * rendered label, so read-only affordances keep their place in the layout.
5
+ */
6
+
7
+ import React from "react";
8
+ import type { Theme } from "../../theme";
9
+ import { useComponentTheme } from "../theme-context";
10
+
11
+ export interface ButtonProps {
12
+ /** Unified activation: mouse release today, keyboard activation tomorrow. */
13
+ onPress: () => void;
14
+ isDisabled?: boolean;
15
+ /** solid = accent-filled call to action; plain = quiet word-button. */
16
+ variant?: "solid" | "plain" | "accent-text";
17
+ marginRight?: number;
18
+ theme?: Theme;
19
+ children: string;
20
+ }
21
+
22
+ export function Button({
23
+ onPress,
24
+ isDisabled = false,
25
+ variant = "plain",
26
+ marginRight,
27
+ theme,
28
+ children,
29
+ }: ButtonProps): React.ReactNode {
30
+ const tokens = useComponentTheme(theme);
31
+ const backgroundColor = variant === "solid" ? tokens.accent : undefined;
32
+ const foreground =
33
+ variant === "solid"
34
+ ? tokens.accentInk
35
+ : variant === "accent-text"
36
+ ? tokens.accent
37
+ : tokens.textDim;
38
+ return (
39
+ <box
40
+ style={{ backgroundColor, marginRight }}
41
+ onMouseUp={() => {
42
+ if (!isDisabled) onPress();
43
+ }}
44
+ >
45
+ <text fg={foreground}>{children}</text>
46
+ </box>
47
+ );
48
+ }
@@ -0,0 +1,25 @@
1
+ import React from "react";
2
+ import { DARK } from "../../theme";
3
+ import type { Story, StoryMeta } from "../story";
4
+ import { Card } from "./Card";
5
+
6
+ export const meta: StoryMeta = { title: "primitives/Card" };
7
+
8
+ export const Titled: Story = {
9
+ render: () => (
10
+ <Card title=" a titled card " contentRows={2}>
11
+ <text>first content row</text>
12
+ <text>second content row</text>
13
+ </Card>
14
+ ),
15
+ expectedColors: [DARK.border, DARK.elevated],
16
+ };
17
+
18
+ export const AccentBorder: Story = {
19
+ render: () => (
20
+ <Card title=" accented " contentRows={1} borderColor={DARK.accent}>
21
+ <text>content</text>
22
+ </Card>
23
+ ),
24
+ expectedColors: [DARK.accent],
25
+ };
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Bordered rounded box with an optional title. The card owns the
3
+ * height-derivation rule: its height is always the declared content row
4
+ * count plus the two border rows, so layout math and the mounted box can
5
+ * never drift apart - border-collapse bugs die here.
6
+ */
7
+
8
+ import React from "react";
9
+ import type { Theme } from "../../theme";
10
+ import { useComponentTheme } from "../theme-context";
11
+ import { FRAME_BORDER_STYLE } from "./frame";
12
+
13
+ export interface CardProps {
14
+ title?: string;
15
+ /** Number of content rows inside the border; the box adds the two border rows. */
16
+ contentRows: number;
17
+ borderColor?: string;
18
+ backgroundColor?: string;
19
+ marginLeft?: number;
20
+ marginRight?: number;
21
+ theme?: Theme;
22
+ children: React.ReactNode;
23
+ }
24
+
25
+ /** The one height rule: content rows plus the top and bottom border rows. */
26
+ export function cardHeight(contentRows: number): number {
27
+ return contentRows + 2;
28
+ }
29
+
30
+ export function Card({
31
+ title,
32
+ contentRows,
33
+ borderColor,
34
+ backgroundColor,
35
+ marginLeft,
36
+ marginRight,
37
+ theme,
38
+ children,
39
+ }: CardProps): React.ReactNode {
40
+ const tokens = useComponentTheme(theme);
41
+ return (
42
+ <box
43
+ style={{
44
+ height: cardHeight(contentRows),
45
+ marginLeft,
46
+ marginRight,
47
+ border: true,
48
+ borderStyle: FRAME_BORDER_STYLE,
49
+ borderColor: borderColor ?? tokens.border,
50
+ backgroundColor: backgroundColor ?? tokens.elevated,
51
+ flexDirection: "column",
52
+ paddingLeft: 1,
53
+ }}
54
+ title={title}
55
+ >
56
+ {children}
57
+ </box>
58
+ );
59
+ }
@@ -0,0 +1,24 @@
1
+ import React from "react";
2
+ import { DARK } from "../../theme";
3
+ import type { Story, StoryMeta } from "../story";
4
+ import { Dialog } from "./Dialog";
5
+
6
+ export const meta: StoryMeta = { title: "primitives/Dialog" };
7
+
8
+ export const Open: Story = {
9
+ render: () => (
10
+ <Dialog isOpen title=" a modal " width={40} height={8}>
11
+ <text>dialog body content</text>
12
+ </Dialog>
13
+ ),
14
+ expectedColors: [DARK.accent],
15
+ size: { width: 60, height: 16 },
16
+ };
17
+
18
+ export const Closed: Story = {
19
+ render: () => (
20
+ <Dialog isOpen={false} title=" hidden " width={40} height={8}>
21
+ <text>never rendered</text>
22
+ </Dialog>
23
+ ),
24
+ };