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

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 (84) 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 +232 -0
  6. package/src/App.readonly.test.tsx +6 -21
  7. package/src/App.rendering.test.tsx +100 -0
  8. package/src/App.submit-confirm.test.tsx +165 -0
  9. package/src/App.test.tsx +20 -58
  10. package/src/App.tsx +0 -0
  11. package/src/App.walk.test.tsx +208 -0
  12. package/src/components/AnnotationCard.stories.tsx +75 -0
  13. package/src/components/AnnotationCard.tsx +136 -0
  14. package/src/components/Breadcrumb.stories.tsx +19 -0
  15. package/src/components/Breadcrumb.tsx +38 -0
  16. package/src/components/CodeBlock.stories.tsx +25 -0
  17. package/src/components/CodeBlock.tsx +68 -0
  18. package/src/components/CompletionOverlay.stories.tsx +23 -0
  19. package/src/components/CompletionOverlay.tsx +49 -0
  20. package/src/components/ComposeBar.stories.tsx +18 -0
  21. package/src/components/ComposeBar.tsx +54 -0
  22. package/src/components/ConfirmCard.stories.tsx +37 -0
  23. package/src/components/ConfirmCard.tsx +117 -0
  24. package/src/components/DiffSheet.stories.tsx +44 -0
  25. package/src/components/DiffSheet.tsx +243 -0
  26. package/src/components/InboxList.stories.tsx +18 -0
  27. package/src/components/InboxList.tsx +42 -0
  28. package/src/components/PlanSheet.stories.tsx +81 -0
  29. package/src/components/PlanSheet.tsx +309 -0
  30. package/src/components/ReviewRail.stories.tsx +103 -0
  31. package/src/components/ReviewRail.tsx +158 -0
  32. package/src/components/SettingsDialog.stories.tsx +63 -0
  33. package/src/components/SettingsDialog.tsx +132 -0
  34. package/src/components/SettingsRows.stories.tsx +18 -0
  35. package/src/components/SettingsRows.tsx +92 -0
  36. package/src/components/WalkWizard.stories.tsx +89 -0
  37. package/src/components/WalkWizard.tsx +146 -0
  38. package/src/components/__snapshots__/stories.test.tsx.snap +1319 -0
  39. package/src/components/primitives/Button.stories.tsx +32 -0
  40. package/src/components/primitives/Button.tsx +44 -0
  41. package/src/components/primitives/Card.stories.tsx +25 -0
  42. package/src/components/primitives/Card.tsx +58 -0
  43. package/src/components/primitives/Dialog.stories.tsx +24 -0
  44. package/src/components/primitives/Dialog.tsx +52 -0
  45. package/src/components/primitives/StatusBar.stories.tsx +11 -0
  46. package/src/components/primitives/StatusBar.tsx +22 -0
  47. package/src/components/primitives/Tabs.stories.tsx +29 -0
  48. package/src/components/primitives/Tabs.tsx +97 -0
  49. package/src/components/primitives/Toolbar.stories.tsx +17 -0
  50. package/src/components/primitives/Toolbar.tsx +14 -0
  51. package/src/components/stories-app.tsx +94 -0
  52. package/src/components/stories.test.tsx +51 -0
  53. package/src/components/story-fixtures.ts +90 -0
  54. package/src/components/story.ts +66 -0
  55. package/src/components/syntax-highlight.ts +57 -0
  56. package/src/components/theme-context.stories.tsx +41 -0
  57. package/src/components/theme-context.tsx +30 -0
  58. package/src/components/truncate-text.ts +6 -0
  59. package/src/config.test.ts +54 -29
  60. package/src/config.ts +53 -13
  61. package/src/editor.test.ts +110 -0
  62. package/src/editor.ts +101 -13
  63. package/src/herdr.test.ts +37 -0
  64. package/src/herdr.ts +21 -0
  65. package/src/integrations.test.ts +29 -0
  66. package/src/integrations.ts +28 -0
  67. package/src/key-bindings.test.ts +120 -0
  68. package/src/key-bindings.ts +353 -0
  69. package/src/keymap.test.ts +307 -0
  70. package/src/keymap.ts +204 -0
  71. package/src/run.ts +9 -5
  72. package/src/serve.test.ts +6 -6
  73. package/src/serve.ts +7 -7
  74. package/src/session-controller.ts +496 -0
  75. package/src/test-support.ts +87 -0
  76. package/src/theme.ts +23 -1
  77. package/src/view-diff.test.ts +8 -8
  78. package/src/view-diff.ts +22 -22
  79. package/src/view-plan.test.ts +179 -0
  80. package/src/view-plan.ts +362 -0
  81. package/src/walk.test.ts +110 -0
  82. package/src/walk.ts +65 -0
  83. package/src/view.test.ts +0 -157
  84. package/src/view.ts +0 -0
@@ -0,0 +1,243 @@
1
+ /**
2
+ * The diff review sheet: every row of the patch renders (no window cap)
3
+ * inside a scrollbox, with file headers, hunk headers, and contiguous line
4
+ * chunks. Line chunks draw through the native line-number gutter - custom
5
+ * numbers per row (old numbers on deletions), the cursor as a gutter sign,
6
+ * and annotation markers as line signs. Chunks split after an annotated row
7
+ * so the annotation body renders directly under its line.
8
+ */
9
+
10
+ import React, { useEffect, useMemo, useRef } from "react";
11
+ import type { LineNumberRenderable, LineSign, ScrollBoxRenderable } from "@opentui/core";
12
+ import type { Annotation } from "@cueloop/schema";
13
+ import type { DiffRow } from "../view-diff";
14
+ import type { Theme } from "../theme";
15
+ import { useComponentTheme } from "./theme-context";
16
+ import { truncateToSingleLine } from "./truncate-text";
17
+
18
+ export interface DiffSheetProps {
19
+ rows: DiffRow[];
20
+ cursor: number;
21
+ annotations: Annotation[];
22
+ focusedAnnotationId?: string;
23
+ theme?: Theme;
24
+ }
25
+
26
+ type DiffSegment =
27
+ | { kind: "header"; rowIndex: number; row: DiffRow }
28
+ | { kind: "chunk"; firstRowIndex: number; rows: DiffRow[]; annotation: Annotation | null };
29
+
30
+ /** Row text carries the patch's trailing newline; rendering strips it. */
31
+ function rowLine(row: DiffRow): string {
32
+ return row.text.replace(/\n$/, "");
33
+ }
34
+
35
+ /** Chunks split after an annotated row so its card can sit directly below. */
36
+ function segmentRows(rows: DiffRow[], annotatedByRow: Map<number, Annotation>): DiffSegment[] {
37
+ const segments: DiffSegment[] = [];
38
+ let chunk: DiffRow[] = [];
39
+ let chunkStart = 0;
40
+ const closeChunk = (annotation: Annotation | null): void => {
41
+ if (chunk.length) segments.push({ kind: "chunk", firstRowIndex: chunkStart, rows: chunk, annotation });
42
+ chunk = [];
43
+ };
44
+ for (let rowIndex = 0; rowIndex < rows.length; rowIndex++) {
45
+ const row = rows[rowIndex]!;
46
+ if (row.kind === "file" || row.kind === "hunk") {
47
+ closeChunk(null);
48
+ segments.push({ kind: "header", rowIndex, row });
49
+ chunkStart = rowIndex + 1;
50
+ continue;
51
+ }
52
+ if (!chunk.length) chunkStart = rowIndex;
53
+ chunk.push(row);
54
+ const annotation = annotatedByRow.get(rowIndex);
55
+ if (annotation) {
56
+ closeChunk(annotation);
57
+ chunkStart = rowIndex + 1;
58
+ }
59
+ }
60
+ closeChunk(null);
61
+ return segments;
62
+ }
63
+
64
+ function DiffChunk({
65
+ segment,
66
+ cursor,
67
+ focusedAnnotationId,
68
+ theme,
69
+ }: {
70
+ segment: Extract<DiffSegment, { kind: "chunk" }>;
71
+ cursor: number;
72
+ focusedAnnotationId?: string;
73
+ theme?: Theme;
74
+ }): React.ReactNode {
75
+ const tokens = useComponentTheme(theme);
76
+ const gutterRef = useRef<LineNumberRenderable | null>(null);
77
+ const cursorInChunk = cursor - segment.firstRowIndex;
78
+
79
+ const lineNumbers = useMemo(() => {
80
+ const numbers = new Map<number, number>();
81
+ segment.rows.forEach((row, lineIndex) => {
82
+ const gutterNumber = row.kind === "del" ? row.oldLine : row.newLine;
83
+ if (gutterNumber !== undefined) numbers.set(lineIndex, gutterNumber);
84
+ });
85
+ return numbers;
86
+ }, [segment]);
87
+
88
+ const lineSigns = useMemo(() => {
89
+ const signs = new Map<number, LineSign>();
90
+ if (cursorInChunk >= 0 && cursorInChunk < segment.rows.length) {
91
+ signs.set(cursorInChunk, { before: "▎", beforeColor: tokens.accent });
92
+ }
93
+ if (segment.annotation) {
94
+ signs.set(segment.rows.length - 1, { after: "◆", afterColor: tokens.accent });
95
+ }
96
+ return signs;
97
+ }, [segment, cursorInChunk, tokens]);
98
+
99
+ const lineColors = useMemo(() => {
100
+ // the renderable's config parser requires the gutter key to be present
101
+ const colors = new Map<number, { gutter: string | undefined; content: string }>();
102
+ if (segment.annotation) {
103
+ colors.set(segment.rows.length - 1, { gutter: undefined, content: tokens.markCommentBg });
104
+ }
105
+ if (cursorInChunk >= 0 && cursorInChunk < segment.rows.length) {
106
+ colors.set(cursorInChunk, { gutter: tokens.cursorBg, content: tokens.cursorBg });
107
+ }
108
+ return colors;
109
+ }, [segment, cursorInChunk, tokens]);
110
+
111
+ // gutter state flows through the imperative surface: the renderable keeps
112
+ // no prop setters for these maps, so updates land via effects
113
+ useEffect(() => {
114
+ const gutter = gutterRef.current;
115
+ if (!gutter) return;
116
+ gutter.setLineNumbers(lineNumbers);
117
+ gutter.setLineSigns(lineSigns);
118
+ gutter.clearAllLineColors();
119
+ for (const [line, colorConfig] of lineColors) gutter.setLineColor(line, colorConfig);
120
+ }, [lineNumbers, lineSigns, lineColors]);
121
+
122
+ return (
123
+ <box style={{ flexDirection: "column" }}>
124
+ <line-number
125
+ ref={gutterRef}
126
+ fg={tokens.textDim}
127
+ bg={tokens.bg}
128
+ minWidth={4}
129
+ paddingRight={1}
130
+ lineNumbers={lineNumbers}
131
+ lineSigns={lineSigns}
132
+ >
133
+ <text style={{ wrapMode: "none" }} selectable={false}>
134
+ {segment.rows.map((row, lineIndex) => {
135
+ const sign = row.kind === "add" ? "+" : row.kind === "del" ? "-" : " ";
136
+ const foreground = row.kind === "add" ? tokens.insFg : row.kind === "del" ? tokens.delFg : tokens.textMuted;
137
+ const isCursorRow = lineIndex === cursorInChunk;
138
+ const isAnnotatedRow = segment.annotation !== null && lineIndex === segment.rows.length - 1;
139
+ return (
140
+ <span
141
+ key={lineIndex}
142
+ fg={foreground}
143
+ bg={isCursorRow ? tokens.cursorBg : isAnnotatedRow ? tokens.markCommentBg : undefined}
144
+ >
145
+ {(lineIndex > 0 ? "\n" : "") + sign + rowLine(row)}
146
+ </span>
147
+ );
148
+ })}
149
+ </text>
150
+ </line-number>
151
+ {segment.annotation ? (
152
+ <text>
153
+ <span fg={tokens.textDim}>{" "}</span>
154
+ <span fg={segment.annotation.id === focusedAnnotationId ? tokens.text : tokens.accent}>
155
+ ◆ {truncateToSingleLine(segment.annotation.body, 70)}
156
+ </span>
157
+ </text>
158
+ ) : null}
159
+ </box>
160
+ );
161
+ }
162
+
163
+ export function DiffSheet({ rows, cursor, annotations, focusedAnnotationId, theme }: DiffSheetProps): React.ReactNode {
164
+ const tokens = useComponentTheme(theme);
165
+ const scrollRef = useRef<ScrollBoxRenderable | null>(null);
166
+
167
+ const annotatedByRow = useMemo(() => {
168
+ const byRow = new Map<number, Annotation>();
169
+ for (const annotation of annotations) {
170
+ const rowIndex = rows.findIndex(
171
+ (row) => row.text === annotation.anchor.quote && (row.kind === "ctx" || row.kind === "add" || row.kind === "del"),
172
+ );
173
+ if (rowIndex !== -1) byRow.set(rowIndex, annotation);
174
+ }
175
+ return byRow;
176
+ }, [rows, annotations]);
177
+
178
+ const segments = useMemo(() => segmentRows(rows, annotatedByRow), [rows, annotatedByRow]);
179
+
180
+ // content y offset per row index, for cursor-following scroll
181
+ const rowOffsets = useMemo(() => {
182
+ const offsets: number[] = [];
183
+ let contentY = 0;
184
+ for (const segment of segments) {
185
+ if (segment.kind === "header") {
186
+ offsets[segment.rowIndex] = contentY;
187
+ contentY += 1;
188
+ } else {
189
+ segment.rows.forEach((_, lineIndex) => {
190
+ offsets[segment.firstRowIndex + lineIndex] = contentY + lineIndex;
191
+ });
192
+ contentY += segment.rows.length + (segment.annotation ? 1 : 0);
193
+ }
194
+ }
195
+ return offsets;
196
+ }, [segments]);
197
+
198
+ useEffect(() => {
199
+ const scrollbox = scrollRef.current;
200
+ const cursorOffset = rowOffsets[cursor];
201
+ if (!scrollbox || cursorOffset === undefined) return;
202
+ const viewportHeight = Math.max(1, scrollbox.height);
203
+ if (cursorOffset < scrollbox.scrollTop + 2) {
204
+ scrollbox.scrollTo({ x: 0, y: Math.max(0, cursorOffset - 2) });
205
+ } else if (cursorOffset > scrollbox.scrollTop + viewportHeight - 3) {
206
+ scrollbox.scrollTo({ x: 0, y: cursorOffset - viewportHeight + 3 });
207
+ }
208
+ }, [cursor, rowOffsets]);
209
+
210
+ return (
211
+ <box style={{ flexGrow: 1, flexDirection: "column", paddingLeft: 1, paddingTop: 1 }}>
212
+ <scrollbox ref={scrollRef} style={{ flexGrow: 1 }} focused={false}>
213
+ {segments.map((segment, segmentIndex) => {
214
+ if (segment.kind === "header") {
215
+ const isCursor = segment.rowIndex === cursor;
216
+ if (segment.row.kind === "file") {
217
+ return (
218
+ <text key={segmentIndex} fg={tokens.text} bg={isCursor ? tokens.cursorBg : tokens.panel}>
219
+ {isCursor ? "▎" : " "}■ {rowLine(segment.row)}
220
+ </text>
221
+ );
222
+ }
223
+ return (
224
+ <text key={segmentIndex} fg={tokens.blue} bg={isCursor ? tokens.cursorBg : undefined}>
225
+ {isCursor ? "▎" : " "}
226
+ {rowLine(segment.row)}
227
+ </text>
228
+ );
229
+ }
230
+ return (
231
+ <DiffChunk
232
+ key={segmentIndex}
233
+ segment={segment}
234
+ cursor={cursor}
235
+ focusedAnnotationId={focusedAnnotationId}
236
+ theme={theme}
237
+ />
238
+ );
239
+ })}
240
+ </scrollbox>
241
+ </box>
242
+ );
243
+ }
@@ -0,0 +1,18 @@
1
+ import React from "react";
2
+ import { DARK } from "../theme";
3
+ import type { Story, StoryMeta } from "./story";
4
+ import { InboxList } from "./InboxList";
5
+ import { fixtureDiffSession, fixturePlanSession } from "./story-fixtures";
6
+
7
+ export const meta: StoryMeta = { title: "InboxList" };
8
+
9
+ export const TwoPending: Story = {
10
+ render: () => <InboxList inbox={[fixturePlanSession(), fixtureDiffSession()]} cursor={0} />,
11
+ expectedColors: [DARK.accent, DARK.cursorBg],
12
+ size: { width: 80, height: 12 },
13
+ };
14
+
15
+ export const Empty: Story = {
16
+ render: () => <InboxList inbox={[]} cursor={0} />,
17
+ size: { width: 80, height: 8 },
18
+ };
@@ -0,0 +1,42 @@
1
+ /**
2
+ * The pending-review inbox: a cursor-driven session list. Selection stays
3
+ * with the keyboard grammar; the list only renders the snapshot.
4
+ */
5
+
6
+ import React from "react";
7
+ import type { ReviewSession } from "@cueloop/schema";
8
+ import type { Theme } from "../theme";
9
+ import { useComponentTheme } from "./theme-context";
10
+ import { StatusBar } from "./primitives/StatusBar";
11
+
12
+ export interface InboxListProps {
13
+ inbox: ReviewSession[];
14
+ cursor: number;
15
+ theme?: Theme;
16
+ }
17
+
18
+ export function InboxList({ inbox, cursor, theme }: InboxListProps): React.ReactNode {
19
+ const tokens = useComponentTheme(theme);
20
+ return (
21
+ <box style={{ flexDirection: "column", width: "100%", height: "100%", backgroundColor: tokens.bg, padding: 1 }}>
22
+ <text fg={tokens.accent}>cueloop · inbox ({inbox.length} pending)</text>
23
+ <text> </text>
24
+ {inbox.length === 0 ? (
25
+ <text fg={tokens.textDim}>nothing waiting for review</text>
26
+ ) : (
27
+ inbox.map((session, index) => (
28
+ <text
29
+ key={session.id}
30
+ fg={index === cursor ? tokens.text : tokens.textMuted}
31
+ bg={index === cursor ? tokens.cursorBg : undefined}
32
+ >
33
+ {index === cursor ? "▸ " : " "}
34
+ {session.artifact.meta.title ?? session.id} · {session.workspace.branch} · {session.artifact.type}
35
+ </text>
36
+ ))
37
+ )}
38
+ <box style={{ flexGrow: 1 }} />
39
+ <StatusBar theme={theme}>j/k move · ⏎ open · q quit</StatusBar>
40
+ </box>
41
+ );
42
+ }
@@ -0,0 +1,81 @@
1
+ import React from "react";
2
+ import { DARK } from "../theme";
3
+ import type { Story, StoryMeta } from "./story";
4
+ import { PlanSheet } from "./PlanSheet";
5
+ import { fixtureDisplay, fixtureMarks, fixturePlanSession } from "./story-fixtures";
6
+
7
+ export const meta: StoryMeta = { title: "PlanSheet" };
8
+
9
+ const callbacks = { onLineActivate: () => {}, onEditRequest: () => {} };
10
+
11
+ export const AnnotatedPlan: Story = {
12
+ render: () => (
13
+ <PlanSheet
14
+ session={fixturePlanSession()}
15
+ display={fixtureDisplay()}
16
+ marks={fixtureMarks()}
17
+ cursor={2}
18
+ activeSpan={null}
19
+ compose={null}
20
+ editOrphanCount={0}
21
+ {...callbacks}
22
+ />
23
+ ),
24
+ expectedColors: [DARK.markCommentBg, DARK.markSuggestionBg, DARK.cursorBg],
25
+ size: { width: 90, height: 28 },
26
+ };
27
+
28
+ export const SpanSelection: Story = {
29
+ render: () => (
30
+ <PlanSheet
31
+ session={fixturePlanSession({ annotations: [] })}
32
+ display={fixtureDisplay()}
33
+ marks={new Map()}
34
+ cursor={2}
35
+ activeSpan={{ displayIndex: 2, start: 0, end: 10 }}
36
+ compose={null}
37
+ editOrphanCount={0}
38
+ {...callbacks}
39
+ />
40
+ ),
41
+ expectedColors: [DARK.accent],
42
+ size: { width: 90, height: 28 },
43
+ };
44
+
45
+ export const InlineCompose: Story = {
46
+ render: () => (
47
+ <PlanSheet
48
+ session={fixturePlanSession({ annotations: [] })}
49
+ display={fixtureDisplay()}
50
+ marks={new Map()}
51
+ cursor={2}
52
+ activeSpan={{ displayIndex: 2, start: 0, end: 10 }}
53
+ compose={{
54
+ kind: "comment",
55
+ displayIndex: 2,
56
+ quote: "The daemon",
57
+ draft: { text: "Which daemon?", onInput: () => {}, onSave: () => {}, onCancel: () => {} },
58
+ }}
59
+ editOrphanCount={0}
60
+ {...callbacks}
61
+ />
62
+ ),
63
+ size: { width: 90, height: 30 },
64
+ };
65
+
66
+ export const OrphanBanner: Story = {
67
+ render: () => (
68
+ <PlanSheet
69
+ session={fixturePlanSession()}
70
+ display={fixtureDisplay()}
71
+ marks={new Map()}
72
+ cursor={0}
73
+ activeSpan={null}
74
+ compose={null}
75
+ editOrphanCount={1}
76
+ {...callbacks}
77
+ />
78
+ ),
79
+ expectedColors: [DARK.red],
80
+ size: { width: 90, height: 28 },
81
+ };
@@ -0,0 +1,309 @@
1
+ /**
2
+ * The plan document sheet: header chrome, the reconciliation banner, and the
3
+ * scrollable block flow. Each prose block renders as one natively wrapped
4
+ * text (wrapMode "word") beside a marker gutter, so wrapping is the
5
+ * renderer's job; the sheet keeps the rendered/work offset mapping per block
6
+ * and exposes an imperative selection surface - the keyboard span drives the
7
+ * native selection, and a mouse drag reads back as an exact work-text range,
8
+ * which keeps quote anchors char-precise.
9
+ */
10
+
11
+ import React, { forwardRef, useEffect, useImperativeHandle, useRef } from "react";
12
+ import { useRenderer } from "@opentui/react";
13
+ import type { ScrollBoxRenderable, TextRenderable } from "@opentui/core";
14
+ import type { ReviewSession } from "@cueloop/schema";
15
+ import {
16
+ blockRuns,
17
+ displayText,
18
+ overlayMarks,
19
+ renderedOffsetFor,
20
+ revisionDelta,
21
+ workRangeForRendered,
22
+ type DisplayBlock,
23
+ type Mark,
24
+ type SpanState,
25
+ type StyleRun,
26
+ } from "../view-plan";
27
+ import type { Theme } from "../theme";
28
+ import { useComponentTheme } from "./theme-context";
29
+ import { CodeBlock } from "./CodeBlock";
30
+ import { AnnotationCard, type AnnotationDraft } from "./AnnotationCard";
31
+ import { Button } from "./primitives/Button";
32
+ import { Toolbar } from "./primitives/Toolbar";
33
+
34
+ export interface PlanSelection {
35
+ displayIndex: number;
36
+ start: number;
37
+ end: number;
38
+ }
39
+
40
+ export interface PlanSheetHandle {
41
+ /** Work-text range of the current native (mouse) selection, if any. */
42
+ readSelection(): PlanSelection | null;
43
+ /** Anchor/extend the renderer's native selection from keyboard span offsets. */
44
+ driveSpanSelection(span: SpanState): void;
45
+ clearSelection(): void;
46
+ revealBlock(displayIndex: number): void;
47
+ }
48
+
49
+ export interface PlanComposeState {
50
+ kind: "comment" | "suggestion";
51
+ displayIndex: number;
52
+ quote: string;
53
+ draft: AnnotationDraft;
54
+ }
55
+
56
+ export interface PlanSheetProps {
57
+ session: ReviewSession;
58
+ display: DisplayBlock[];
59
+ marks: Map<number, Mark[]>;
60
+ cursor: number;
61
+ /** Extra selection-style paint on one block (keyboard span or compose anchor). */
62
+ activeSpan: { displayIndex: number; start: number; end: number } | null;
63
+ compose: PlanComposeState | null;
64
+ editOrphanCount: number;
65
+ onLineActivate: (displayIndex: number) => void;
66
+ onEditRequest: () => void;
67
+ theme?: Theme;
68
+ }
69
+
70
+ interface BlockRef {
71
+ renderable: TextRenderable;
72
+ runs: StyleRun[];
73
+ }
74
+
75
+ /** Screen position of a rendered-text offset inside a wrapped text. */
76
+ function positionOfRenderedOffset(renderable: TextRenderable, renderedOffset: number): { x: number; y: number } {
77
+ const info = renderable.lineInfo;
78
+ let lineIndex = 0;
79
+ for (let index = 0; index < info.lineStartCols.length; index++) {
80
+ if (info.lineStartCols[index]! <= renderedOffset) lineIndex = index;
81
+ else break;
82
+ }
83
+ return {
84
+ x: renderable.x + renderedOffset - (info.lineStartCols[lineIndex] ?? 0),
85
+ y: renderable.y + lineIndex,
86
+ };
87
+ }
88
+
89
+ export const PlanSheet = forwardRef<PlanSheetHandle, PlanSheetProps>(function PlanSheet(
90
+ {
91
+ session,
92
+ display,
93
+ marks,
94
+ cursor,
95
+ activeSpan,
96
+ compose,
97
+ editOrphanCount,
98
+ onLineActivate,
99
+ onEditRequest,
100
+ theme,
101
+ }: PlanSheetProps,
102
+ handleRef,
103
+ ): React.ReactNode {
104
+ const tokens = useComponentTheme(theme);
105
+ const renderer = useRenderer();
106
+ const blockRefs = useRef(new Map<number, BlockRef>());
107
+ const scrollRef = useRef<ScrollBoxRenderable | null>(null);
108
+
109
+ useImperativeHandle(handleRef, () => ({
110
+ readSelection: (): PlanSelection | null => {
111
+ if (!renderer?.hasSelection) return null;
112
+ const ordered = [...blockRefs.current.entries()].sort(([a], [b]) => a - b);
113
+ for (const [displayIndex, blockRef] of ordered) {
114
+ const selection = blockRef.renderable.getSelection();
115
+ if (!selection || selection.end <= selection.start) continue;
116
+ const range = workRangeForRendered(blockRef.runs, selection.start, selection.end);
117
+ if (range) return { displayIndex, ...range };
118
+ }
119
+ return null;
120
+ },
121
+ driveSpanSelection: (span: SpanState): void => {
122
+ if (!renderer) return;
123
+ const blockRef = blockRefs.current.get(span.displayIndex);
124
+ if (!blockRef) return;
125
+ const renderedStart = renderedOffsetFor(blockRef.runs, span.start);
126
+ const renderedEnd = renderedOffsetFor(blockRef.runs, span.end - 1);
127
+ if (renderedStart === null || renderedEnd === null) return;
128
+ const startPosition = positionOfRenderedOffset(blockRef.renderable, renderedStart);
129
+ const endPosition = positionOfRenderedOffset(blockRef.renderable, renderedEnd);
130
+ renderer.startSelection(blockRef.renderable, startPosition.x, startPosition.y);
131
+ renderer.updateSelection(blockRef.renderable, endPosition.x + 1, endPosition.y);
132
+ },
133
+ clearSelection: (): void => {
134
+ renderer?.clearSelection();
135
+ },
136
+ revealBlock: (displayIndex: number): void => {
137
+ try {
138
+ scrollRef.current?.scrollChildIntoView(`plan-block-${displayIndex}`);
139
+ } catch {
140
+ // reveal is best-effort; selection state is already correct
141
+ }
142
+ },
143
+ }));
144
+
145
+ const registerBlock = (displayIndex: number, renderable: TextRenderable | null, runs: StyleRun[]): void => {
146
+ if (renderable) blockRefs.current.set(displayIndex, { renderable, runs });
147
+ else blockRefs.current.delete(displayIndex);
148
+ };
149
+ // stale refs must not survive a shrinking display list
150
+ useEffect(() => {
151
+ for (const displayIndex of blockRefs.current.keys()) {
152
+ if (displayIndex >= display.length) blockRefs.current.delete(displayIndex);
153
+ }
154
+ }, [display]);
155
+
156
+ const children: React.ReactNode[] = [];
157
+ for (let displayIndex = 0; displayIndex < display.length; displayIndex++) {
158
+ const block = display[displayIndex]!;
159
+ const isCursor = displayIndex === cursor;
160
+ const gap = topGap(display[displayIndex - 1], block);
161
+ if (block.kind === "code") {
162
+ children.push(
163
+ <CodeBlock
164
+ key={displayIndex}
165
+ id={`plan-block-${displayIndex}`}
166
+ language={(block.work ?? block.base)?.lang}
167
+ content={displayText(block)}
168
+ isCursor={isCursor}
169
+ marginTop={gap}
170
+ isAnnotated={(marks.get(displayIndex) ?? []).length > 0}
171
+ changeTag={block.type !== "same" ? tagLabel(block) : undefined}
172
+ theme={theme}
173
+ />,
174
+ );
175
+ } else {
176
+ const blockMarks = [...(marks.get(displayIndex) ?? [])];
177
+ if (activeSpan && activeSpan.displayIndex === displayIndex) {
178
+ blockMarks.push({ start: activeSpan.start, end: activeSpan.end, role: "kspan" });
179
+ }
180
+ const runs = overlayMarks(blockRuns(block, true), blockMarks);
181
+ // the change tag participates in the rendered text but carries no offsets
182
+ const mappedRuns: StyleRun[] =
183
+ block.type !== "same" ? [...runs, { text: ` [${tagLabel(block)}]`, role: "plain", start: null }] : runs;
184
+ children.push(
185
+ <box key={displayIndex} id={`plan-block-${displayIndex}`} style={{ flexDirection: "row", marginTop: gap }}>
186
+ <text selectable={false}>
187
+ <span fg={isCursor ? tokens.accent : tokens.textDim}>{isCursor ? "▎ " : " "}</span>
188
+ <span fg={tokens.textDim}>{marker(block)}</span>
189
+ </text>
190
+ <text
191
+ bg={isCursor ? tokens.cursorBg : undefined}
192
+ selectable
193
+ selectionBg={tokens.accent}
194
+ selectionFg={tokens.accentInk}
195
+ style={{ wrapMode: "word", flexGrow: 1, flexShrink: 1 }}
196
+ ref={(renderable: TextRenderable | null) => registerBlock(displayIndex, renderable, mappedRuns)}
197
+ onMouseUp={() => onLineActivate(displayIndex)}
198
+ >
199
+ {runs.map((run, runIndex) => (
200
+ <span key={runIndex} {...runStyle(run, block, tokens)}>
201
+ {run.text}
202
+ </span>
203
+ ))}
204
+ {block.type !== "same" ? <span fg={tagColor(block, tokens)}> [{tagLabel(block)}]</span> : null}
205
+ </text>
206
+ </box>,
207
+ );
208
+ }
209
+ if (compose && compose.displayIndex === displayIndex) {
210
+ children.push(
211
+ <AnnotationCard key={`compose-${displayIndex}`} kind={compose.kind} quote={compose.quote} draft={compose.draft} theme={theme} />,
212
+ );
213
+ }
214
+ }
215
+
216
+ return (
217
+ <box style={{ flexGrow: 1, flexDirection: "column" }}>
218
+ <SheetHeader session={session} onEditRequest={onEditRequest} theme={theme} />
219
+ {editOrphanCount > 0 ? (
220
+ <box style={{ height: 1, backgroundColor: tokens.markCommentBg, paddingLeft: 2 }}>
221
+ <text fg={tokens.red}>
222
+ {editOrphanCount} annotation{editOrphanCount === 1 ? "" : "s"} no longer match - the passage was removed.
223
+ </text>
224
+ </box>
225
+ ) : null}
226
+ <scrollbox ref={scrollRef} style={{ flexGrow: 1, paddingLeft: 2, paddingTop: 1 }} focused={false}>
227
+ {children}
228
+ </scrollbox>
229
+ </box>
230
+ );
231
+ });
232
+
233
+ /** Sheet chrome: submitted-by + revision delta left, the Edit word-button right. */
234
+ function SheetHeader({
235
+ session,
236
+ onEditRequest,
237
+ theme,
238
+ }: {
239
+ session: ReviewSession;
240
+ onEditRequest: () => void;
241
+ theme?: Theme;
242
+ }): React.ReactNode {
243
+ const tokens = useComponentTheme(theme);
244
+ const revisionCount = session.revisions.length;
245
+ const previous = revisionCount > 1 ? session.revisions[revisionCount - 2] : undefined;
246
+ const delta = previous ? revisionDelta(previous.content, session.artifact.content) : null;
247
+ return (
248
+ <box style={{ height: 1, flexDirection: "row", paddingLeft: 2, paddingRight: 1 }}>
249
+ <text fg={tokens.textDim}>
250
+ submitted by <span fg={tokens.textMuted}>{session.artifact.meta.agent ?? "unknown"}</span> · revision {revisionCount}
251
+ {delta ? (
252
+ <span fg={tokens.green}>
253
+ {" "}· v{revisionCount - 1}→v{revisionCount} +{delta.added} -{delta.removed}
254
+ </span>
255
+ ) : null}
256
+ </text>
257
+ <box style={{ flexGrow: 1 }} />
258
+ <Toolbar>
259
+ <Button onPress={onEditRequest} theme={theme}>
260
+ {" Edit "}
261
+ </Button>
262
+ </Toolbar>
263
+ </box>
264
+ );
265
+ }
266
+
267
+ /** Vertical rhythm: gaps live ABOVE blocks so boundaries never collapse. */
268
+ function topGap(previous: DisplayBlock | undefined, current: DisplayBlock): number {
269
+ if (!previous) return 0;
270
+ const tightPair =
271
+ (current.kind === "li" && previous.kind === "li") || (current.kind === "oli" && previous.kind === "oli");
272
+ return tightPair ? 0 : 1;
273
+ }
274
+
275
+ function marker(block: DisplayBlock): string {
276
+ if (block.kind === "li") return "- ";
277
+ if (block.kind === "oli") return `${block.orderedItemNumber ?? 1}. `;
278
+ if (block.kind === "quote") return "▏ ";
279
+ return "";
280
+ }
281
+
282
+ function tagLabel(block: DisplayBlock): "cut" | "new" | "edited" {
283
+ return block.type === "del" ? "cut" : block.type === "add" ? "new" : "edited";
284
+ }
285
+
286
+ function tagColor(block: DisplayBlock, tokens: Theme): string {
287
+ return block.type === "del" ? tokens.red : block.type === "add" ? tokens.green : tokens.accent;
288
+ }
289
+
290
+ function runStyle(run: StyleRun, block: DisplayBlock, tokens: Theme): { fg?: string; bg?: string } {
291
+ const headingFg = block.kind === "h1" ? tokens.text : block.kind === "h2" || block.kind === "h3" ? tokens.accent : undefined;
292
+ const struck = block.type === "del";
293
+ switch (run.role) {
294
+ case "ins":
295
+ return { fg: tokens.insFg };
296
+ case "del":
297
+ return { fg: tokens.delFg };
298
+ case "mark-comment":
299
+ return { fg: tokens.text, bg: tokens.markCommentBg };
300
+ case "mark-suggestion":
301
+ return { fg: tokens.text, bg: tokens.markSuggestionBg };
302
+ case "mark-focus":
303
+ return { fg: tokens.accentInk, bg: tokens.accent };
304
+ case "kspan":
305
+ return { fg: tokens.accentInk, bg: tokens.accent };
306
+ default:
307
+ return { fg: struck ? tokens.red : (headingFg ?? tokens.text) };
308
+ }
309
+ }