@cueloop/client 0.1.0-alpha.33 → 0.1.0-alpha.35
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.
- package/package.json +27 -26
- package/src/App.autoclose.test.tsx +7 -4
- package/src/App.diff.test.tsx +130 -4
- package/src/App.export.test.tsx +22 -4
- package/src/App.plan-review.test.tsx +127 -27
- package/src/App.readonly.test.tsx +31 -8
- package/src/App.rendering.test.tsx +12 -3
- package/src/App.review-panel.test.tsx +11 -3
- package/src/App.submit-confirm.test.tsx +12 -7
- package/src/App.test.tsx +46 -9
- package/src/App.toast.test.tsx +10 -3
- package/src/App.tsx +400 -100
- package/src/App.walk.test.tsx +30 -10
- package/src/attribution.ts +8 -2
- package/src/clipboard.ts +5 -1
- package/src/components/AnnotationCard.stories.tsx +28 -9
- package/src/components/AnnotationCard.tsx +53 -38
- package/src/components/Breadcrumb.tsx +7 -1
- package/src/components/CodeBlock.stories.tsx +19 -2
- package/src/components/CodeBlock.tsx +24 -9
- package/src/components/CompletionOverlay.tsx +13 -3
- package/src/components/ConfirmCard.stories.tsx +22 -3
- package/src/components/ConfirmCard.tsx +22 -5
- package/src/components/ConfirmDialog.tsx +9 -1
- package/src/components/DiffSheet.scroll.test.tsx +123 -0
- package/src/components/DiffSheet.stories.tsx +18 -2
- package/src/components/DiffSheet.syntax.test.tsx +62 -0
- package/src/components/DiffSheet.tsx +184 -98
- package/src/components/InboxList.stories.tsx +16 -3
- package/src/components/InboxList.tsx +17 -10
- package/src/components/KeybindsDialog.tsx +19 -3
- package/src/components/MarkerPopover.stories.tsx +58 -0
- package/src/components/MarkerPopover.tsx +102 -0
- package/src/components/MenuBar.stories.tsx +5 -1
- package/src/components/MenuBar.tsx +18 -2
- package/src/components/PlanSheet.stories.tsx +34 -2
- package/src/components/PlanSheet.tsx +141 -32
- package/src/components/PromptDialog.tsx +18 -2
- package/src/components/ReviewPanel.stories.tsx +13 -3
- package/src/components/ReviewPanel.tsx +8 -4
- package/src/components/ReviewRail.stories.tsx +145 -72
- package/src/components/ReviewRail.tsx +177 -38
- package/src/components/SettingsDialog.stories.tsx +51 -3
- package/src/components/SettingsDialog.tsx +8 -3
- package/src/components/SettingsRows.tsx +19 -3
- package/src/components/Toast.tsx +17 -2
- package/src/components/WalkWizard.stories.tsx +13 -2
- package/src/components/WalkWizard.tsx +9 -3
- package/src/components/__snapshots__/stories.test.tsx.snap +281 -83
- package/src/components/diff-sheet-layout.test.ts +127 -0
- package/src/components/diff-sheet-layout.ts +167 -0
- package/src/components/primitives/Button.tsx +5 -1
- package/src/components/primitives/Dialog.tsx +9 -1
- package/src/components/primitives/Tabs.tsx +24 -4
- package/src/components/stories-app.tsx +34 -6
- package/src/components/story-fixtures.ts +11 -3
- package/src/components/story.ts +3 -1
- package/src/components/syntax-highlight.ts +47 -12
- package/src/components/theme-context.stories.tsx +6 -1
- package/src/components/theme-context.tsx +7 -1
- package/src/config.test.ts +178 -2
- package/src/config.ts +121 -23
- package/src/diff-hunk-curate.test.ts +315 -0
- package/src/diff-hunk-curate.ts +233 -0
- package/src/diff-intraline.test.ts +164 -0
- package/src/diff-intraline.ts +132 -0
- package/src/diff-syntax.test.ts +96 -0
- package/src/diff-syntax.ts +154 -0
- package/src/editor.test.ts +13 -4
- package/src/editor.ts +31 -5
- package/src/herdr.ts +8 -2
- package/src/index.ts +9 -1
- package/src/integrations.ts +3 -1
- package/src/intent-dispatch.test.ts +193 -9
- package/src/intent-dispatch.ts +151 -18
- package/src/key-bindings.test.ts +19 -9
- package/src/key-bindings.ts +36 -14
- package/src/keymap.test.ts +171 -26
- package/src/keymap.ts +83 -16
- package/src/review-panel.test.ts +3 -1
- package/src/review-panel.ts +9 -2
- package/src/serve.test.ts +11 -3
- package/src/session-controller.curate.test.ts +253 -0
- package/src/session-controller.share.test.ts +65 -17
- package/src/session-controller.ts +301 -26
- package/src/share.ts +41 -8
- package/src/test-support.ts +16 -6
- package/src/theme-presets.ts +168 -0
- package/src/theme.ts +1 -3
- package/src/view-diff.ts +15 -4
- package/src/view-plan.test.ts +40 -6
- package/src/view-plan.ts +71 -28
- package/src/walk.test.ts +7 -2
- package/src/walk.ts +7 -2
|
@@ -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
|
+
}
|
|
@@ -30,7 +30,11 @@ export function Button({
|
|
|
30
30
|
const tokens = useComponentTheme(theme);
|
|
31
31
|
const backgroundColor = variant === "solid" ? tokens.accent : undefined;
|
|
32
32
|
const foreground =
|
|
33
|
-
variant === "solid"
|
|
33
|
+
variant === "solid"
|
|
34
|
+
? tokens.accentInk
|
|
35
|
+
: variant === "accent-text"
|
|
36
|
+
? tokens.accent
|
|
37
|
+
: tokens.textDim;
|
|
34
38
|
return (
|
|
35
39
|
<box
|
|
36
40
|
style={{ backgroundColor, marginRight }}
|
|
@@ -21,7 +21,15 @@ export interface DialogProps {
|
|
|
21
21
|
children: React.ReactNode;
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
-
export function Dialog({
|
|
24
|
+
export function Dialog({
|
|
25
|
+
isOpen,
|
|
26
|
+
title,
|
|
27
|
+
width,
|
|
28
|
+
height,
|
|
29
|
+
background,
|
|
30
|
+
theme,
|
|
31
|
+
children,
|
|
32
|
+
}: DialogProps): React.ReactNode {
|
|
25
33
|
const tokens = useComponentTheme(theme);
|
|
26
34
|
if (!isOpen) return null;
|
|
27
35
|
return (
|
|
@@ -29,7 +29,13 @@ export interface TabsProps {
|
|
|
29
29
|
children: React.ReactNode;
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
-
export function Tabs({
|
|
32
|
+
export function Tabs({
|
|
33
|
+
selectedKey,
|
|
34
|
+
onSelectionChange,
|
|
35
|
+
selectedColor,
|
|
36
|
+
theme,
|
|
37
|
+
children,
|
|
38
|
+
}: TabsProps): React.ReactNode {
|
|
33
39
|
return (
|
|
34
40
|
<TabsContext.Provider value={{ selectedKey, onSelectionChange, selectedColor, theme }}>
|
|
35
41
|
{children}
|
|
@@ -63,13 +69,27 @@ export function TabList({ children }: TabListProps): React.ReactNode {
|
|
|
63
69
|
});
|
|
64
70
|
return (
|
|
65
71
|
<box
|
|
66
|
-
style={{
|
|
72
|
+
style={{
|
|
73
|
+
width: "100%",
|
|
74
|
+
height: 3,
|
|
75
|
+
border: true,
|
|
76
|
+
borderStyle: FRAME_BORDER_STYLE,
|
|
77
|
+
borderColor: tokens.text,
|
|
78
|
+
flexDirection: "row",
|
|
79
|
+
paddingLeft: 1,
|
|
80
|
+
}}
|
|
67
81
|
>
|
|
68
82
|
{items.map((item) => {
|
|
69
83
|
const selected = item.id === tabs.selectedKey;
|
|
70
84
|
return (
|
|
71
|
-
<box
|
|
72
|
-
|
|
85
|
+
<box
|
|
86
|
+
key={item.id}
|
|
87
|
+
style={{ marginRight: 2 }}
|
|
88
|
+
onMouseUp={() => tabs.onSelectionChange(item.id)}
|
|
89
|
+
>
|
|
90
|
+
<text fg={selected ? (tabs.selectedColor ?? tokens.accent) : tokens.textDim}>
|
|
91
|
+
{item.label}
|
|
92
|
+
</text>
|
|
73
93
|
</box>
|
|
74
94
|
);
|
|
75
95
|
})}
|
|
@@ -13,8 +13,17 @@ import { ThemeProvider } from "./theme-context";
|
|
|
13
13
|
import { loadStories, type LoadedStory } from "./story";
|
|
14
14
|
import { FRAME_BORDER_STYLE } from "./primitives/frame";
|
|
15
15
|
|
|
16
|
-
function StoriesApp({
|
|
17
|
-
|
|
16
|
+
function StoriesApp({
|
|
17
|
+
stories,
|
|
18
|
+
onExit,
|
|
19
|
+
}: {
|
|
20
|
+
stories: LoadedStory[];
|
|
21
|
+
onExit: () => void;
|
|
22
|
+
}): React.ReactNode {
|
|
23
|
+
const moduleTitles = useMemo(
|
|
24
|
+
() => [...new Set(stories.map((story) => story.moduleTitle))],
|
|
25
|
+
[stories],
|
|
26
|
+
);
|
|
18
27
|
const [moduleIndex, setModuleIndex] = useState(0);
|
|
19
28
|
const [storyIndex, setStoryIndex] = useState(0);
|
|
20
29
|
const moduleTitle = moduleTitles[moduleIndex]!;
|
|
@@ -38,17 +47,33 @@ function StoriesApp({ stories, onExit }: { stories: LoadedStory[]; onExit: () =>
|
|
|
38
47
|
|
|
39
48
|
return (
|
|
40
49
|
<ThemeProvider theme={DARK}>
|
|
41
|
-
<box
|
|
50
|
+
<box
|
|
51
|
+
style={{
|
|
52
|
+
flexDirection: "column",
|
|
53
|
+
width: "100%",
|
|
54
|
+
height: "100%",
|
|
55
|
+
backgroundColor: DARK.background,
|
|
56
|
+
}}
|
|
57
|
+
>
|
|
42
58
|
<box style={{ height: 1, backgroundColor: DARK.panel, paddingLeft: 1 }}>
|
|
43
59
|
<text fg={DARK.text}>
|
|
44
60
|
<span fg={DARK.accent}>cueloop stories</span>
|
|
45
61
|
<span fg={DARK.textDim}>
|
|
46
|
-
{" "}
|
|
62
|
+
{" "}
|
|
63
|
+
· {moduleTitle} / {current.storyName}
|
|
47
64
|
</span>
|
|
48
65
|
</text>
|
|
49
66
|
</box>
|
|
50
67
|
<box style={{ flexDirection: "row", flexGrow: 1 }}>
|
|
51
|
-
<box
|
|
68
|
+
<box
|
|
69
|
+
style={{
|
|
70
|
+
width: 28,
|
|
71
|
+
backgroundColor: DARK.panel,
|
|
72
|
+
flexDirection: "column",
|
|
73
|
+
paddingLeft: 1,
|
|
74
|
+
paddingTop: 1,
|
|
75
|
+
}}
|
|
76
|
+
>
|
|
52
77
|
{moduleTitles.map((title, index) => (
|
|
53
78
|
<text key={title} fg={index === moduleIndex ? DARK.accent : DARK.textMuted}>
|
|
54
79
|
{index === moduleIndex ? "▸ " : " "}
|
|
@@ -67,7 +92,10 @@ function StoriesApp({ stories, onExit }: { stories: LoadedStory[]; onExit: () =>
|
|
|
67
92
|
title={` ${current.storyName} (${moduleStories.indexOf(current) + 1}/${moduleStories.length}) `}
|
|
68
93
|
>
|
|
69
94
|
{/* remount per story so component-local state never leaks across */}
|
|
70
|
-
<box
|
|
95
|
+
<box
|
|
96
|
+
key={`${moduleTitle}/${current.storyName}`}
|
|
97
|
+
style={{ flexGrow: 1, flexDirection: "column" }}
|
|
98
|
+
>
|
|
71
99
|
{current.story.render()}
|
|
72
100
|
</box>
|
|
73
101
|
</box>
|
|
@@ -44,7 +44,7 @@ export const FIXTURE_ANNOTATIONS: Annotation[] = [
|
|
|
44
44
|
},
|
|
45
45
|
{
|
|
46
46
|
id: "a_story_2",
|
|
47
|
-
kind: "
|
|
47
|
+
kind: "comment",
|
|
48
48
|
anchor: { quote: "move the store", prefix: "", suffix: "" },
|
|
49
49
|
body: "move the store behind one interface",
|
|
50
50
|
createdAt: "2026-01-01T00:00:01Z",
|
|
@@ -56,7 +56,11 @@ export function fixturePlanSession(overrides: Partial<ReviewSession> = {}): Revi
|
|
|
56
56
|
schemaVersion: "1",
|
|
57
57
|
id: "s_story_plan",
|
|
58
58
|
workspace: { repoRoot: "/repo", branch: "main" },
|
|
59
|
-
artifact: {
|
|
59
|
+
artifact: {
|
|
60
|
+
type: "plan",
|
|
61
|
+
content: FIXTURE_PLAN,
|
|
62
|
+
meta: { title: "Migration Plan", agent: "agent/worker-3" },
|
|
63
|
+
},
|
|
60
64
|
revisions: [{ revision: 1, content: FIXTURE_PLAN, submittedAt: "2026-01-01T00:00:00Z" }],
|
|
61
65
|
annotations: FIXTURE_ANNOTATIONS,
|
|
62
66
|
status: "pending",
|
|
@@ -69,7 +73,11 @@ export function fixtureDiffSession(overrides: Partial<ReviewSession> = {}): Revi
|
|
|
69
73
|
schemaVersion: "1",
|
|
70
74
|
id: "s_story_diff",
|
|
71
75
|
workspace: { repoRoot: "/repo", branch: "main" },
|
|
72
|
-
artifact: {
|
|
76
|
+
artifact: {
|
|
77
|
+
type: "diff",
|
|
78
|
+
content: FIXTURE_PATCH,
|
|
79
|
+
meta: { title: "working tree", agent: "agent/worker-3" },
|
|
80
|
+
},
|
|
73
81
|
revisions: [{ revision: 1, content: FIXTURE_PATCH, submittedAt: "2026-01-01T00:00:00Z" }],
|
|
74
82
|
annotations: [],
|
|
75
83
|
status: "pending",
|
package/src/components/story.ts
CHANGED
|
@@ -31,7 +31,9 @@ export interface LoadedStory {
|
|
|
31
31
|
}
|
|
32
32
|
|
|
33
33
|
export function isStory(value: unknown): value is Story {
|
|
34
|
-
return
|
|
34
|
+
return (
|
|
35
|
+
typeof value === "object" && value !== null && typeof (value as Story).render === "function"
|
|
36
|
+
);
|
|
35
37
|
}
|
|
36
38
|
|
|
37
39
|
/** Import every *.stories.tsx next to the components and flatten the exports. */
|
|
@@ -1,12 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Code highlighting tokens: the theme's named
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
* Unknown languages simply render unstyled -
|
|
6
|
-
* never a blocker.
|
|
2
|
+
* Code highlighting tokens: the theme's named colors projected onto tree-sitter
|
|
3
|
+
* capture names. One group -> color map is the single source of truth, feeding
|
|
4
|
+
* both the native code renderable (via a cached SyntaxStyle per theme) and the
|
|
5
|
+
* diff sheet's per-row spans. Unknown languages simply render unstyled -
|
|
6
|
+
* highlighting is an enhancement, never a blocker.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
SyntaxStyle,
|
|
11
|
+
infoStringToFiletype,
|
|
12
|
+
extensionToFiletype,
|
|
13
|
+
basenameToFiletype,
|
|
14
|
+
} from "@opentui/core";
|
|
10
15
|
import type { Theme } from "../theme";
|
|
11
16
|
|
|
12
17
|
/** Markdown fence info -> tree-sitter filetype, with the aliases we accept. */
|
|
@@ -27,12 +32,24 @@ export function filetypeFor(language?: string): string | undefined {
|
|
|
27
32
|
return infoStringToFiletype(aliases[info] ?? info) ?? aliases[info] ?? info;
|
|
28
33
|
}
|
|
29
34
|
|
|
30
|
-
|
|
35
|
+
/** A diff/code file path -> tree-sitter filetype, by basename then extension. */
|
|
36
|
+
export function filetypeForPath(path: string): string | undefined {
|
|
37
|
+
const basename = path.split("/").pop() ?? path;
|
|
38
|
+
const byBasename = basenameToFiletype.get(basename);
|
|
39
|
+
if (byBasename) return byBasename;
|
|
40
|
+
const dotIndex = basename.lastIndexOf(".");
|
|
41
|
+
if (dotIndex <= 0) return undefined;
|
|
42
|
+
return extensionToFiletype.get(basename.slice(dotIndex + 1).toLowerCase());
|
|
43
|
+
}
|
|
31
44
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
45
|
+
interface SyntaxGroupStyle {
|
|
46
|
+
fg: string;
|
|
47
|
+
italic?: boolean;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The one group -> style map, keyed by tree-sitter capture name. */
|
|
51
|
+
function syntaxGroupStyles(theme: Theme): Record<string, SyntaxGroupStyle> {
|
|
52
|
+
return {
|
|
36
53
|
default: { fg: theme.textMuted },
|
|
37
54
|
comment: { fg: theme.textDim, italic: true },
|
|
38
55
|
string: { fg: theme.green },
|
|
@@ -51,7 +68,25 @@ export function syntaxStyleFor(theme: Theme): SyntaxStyle {
|
|
|
51
68
|
parameter: { fg: theme.textMuted },
|
|
52
69
|
tag: { fg: theme.accent },
|
|
53
70
|
attribute: { fg: theme.blue },
|
|
54
|
-
}
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const syntaxStyleCache = new WeakMap<Theme, SyntaxStyle>();
|
|
75
|
+
|
|
76
|
+
export function syntaxStyleFor(theme: Theme): SyntaxStyle {
|
|
77
|
+
const cached = syntaxStyleCache.get(theme);
|
|
78
|
+
if (cached) return cached;
|
|
79
|
+
const style = SyntaxStyle.fromStyles(syntaxGroupStyles(theme));
|
|
55
80
|
syntaxStyleCache.set(theme, style);
|
|
56
81
|
return style;
|
|
57
82
|
}
|
|
83
|
+
|
|
84
|
+
/** The foreground for a tree-sitter capture group; undefined when unstyled. */
|
|
85
|
+
export function colorForSyntaxGroup(group: string, theme: Theme): string | undefined {
|
|
86
|
+
const styles = syntaxGroupStyles(theme);
|
|
87
|
+
const direct = styles[group];
|
|
88
|
+
if (direct) return direct.fg;
|
|
89
|
+
// capture names are dotted (e.g. "keyword.control"); match the broadest prefix
|
|
90
|
+
const base = group.split(".")[0]!;
|
|
91
|
+
return styles[base]?.fg;
|
|
92
|
+
}
|
|
@@ -32,7 +32,12 @@ export const ProviderSwap: Story = {
|
|
|
32
32
|
export const PropOverride: Story = {
|
|
33
33
|
render: () => (
|
|
34
34
|
<ThemeProvider theme={DARK}>
|
|
35
|
-
<Card
|
|
35
|
+
<Card
|
|
36
|
+
title=" prop-overridden theme "
|
|
37
|
+
contentRows={1}
|
|
38
|
+
theme={HIGH_CONTRAST}
|
|
39
|
+
borderColor={HIGH_CONTRAST.accent}
|
|
40
|
+
>
|
|
36
41
|
<text>the theme prop beats the context</text>
|
|
37
42
|
</Card>
|
|
38
43
|
</ThemeProvider>
|
|
@@ -12,7 +12,13 @@ import { DARK, type Theme } from "../theme";
|
|
|
12
12
|
|
|
13
13
|
const ThemeContext = createContext<Theme>(DARK);
|
|
14
14
|
|
|
15
|
-
export function ThemeProvider({
|
|
15
|
+
export function ThemeProvider({
|
|
16
|
+
theme,
|
|
17
|
+
children,
|
|
18
|
+
}: {
|
|
19
|
+
theme: Theme;
|
|
20
|
+
children: React.ReactNode;
|
|
21
|
+
}): React.ReactNode {
|
|
16
22
|
return <ThemeContext.Provider value={theme}>{children}</ThemeContext.Provider>;
|
|
17
23
|
}
|
|
18
24
|
|