@cueloop/client 0.1.0-alpha.33 → 0.1.0-alpha.34

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 (94) hide show
  1. package/package.json +27 -26
  2. package/src/App.autoclose.test.tsx +7 -4
  3. package/src/App.diff.test.tsx +130 -4
  4. package/src/App.export.test.tsx +22 -4
  5. package/src/App.plan-review.test.tsx +127 -27
  6. package/src/App.readonly.test.tsx +31 -8
  7. package/src/App.rendering.test.tsx +12 -3
  8. package/src/App.review-panel.test.tsx +11 -3
  9. package/src/App.submit-confirm.test.tsx +12 -7
  10. package/src/App.test.tsx +46 -9
  11. package/src/App.toast.test.tsx +10 -3
  12. package/src/App.tsx +400 -100
  13. package/src/App.walk.test.tsx +30 -10
  14. package/src/attribution.ts +8 -2
  15. package/src/clipboard.ts +5 -1
  16. package/src/components/AnnotationCard.stories.tsx +28 -9
  17. package/src/components/AnnotationCard.tsx +53 -38
  18. package/src/components/Breadcrumb.tsx +7 -1
  19. package/src/components/CodeBlock.stories.tsx +19 -2
  20. package/src/components/CodeBlock.tsx +24 -9
  21. package/src/components/CompletionOverlay.tsx +13 -3
  22. package/src/components/ConfirmCard.stories.tsx +22 -3
  23. package/src/components/ConfirmCard.tsx +22 -5
  24. package/src/components/ConfirmDialog.tsx +9 -1
  25. package/src/components/DiffSheet.scroll.test.tsx +123 -0
  26. package/src/components/DiffSheet.stories.tsx +18 -2
  27. package/src/components/DiffSheet.syntax.test.tsx +62 -0
  28. package/src/components/DiffSheet.tsx +184 -98
  29. package/src/components/InboxList.stories.tsx +16 -3
  30. package/src/components/InboxList.tsx +17 -10
  31. package/src/components/KeybindsDialog.tsx +19 -3
  32. package/src/components/MarkerPopover.stories.tsx +58 -0
  33. package/src/components/MarkerPopover.tsx +102 -0
  34. package/src/components/MenuBar.stories.tsx +5 -1
  35. package/src/components/MenuBar.tsx +18 -2
  36. package/src/components/PlanSheet.stories.tsx +34 -2
  37. package/src/components/PlanSheet.tsx +141 -32
  38. package/src/components/PromptDialog.tsx +18 -2
  39. package/src/components/ReviewPanel.stories.tsx +13 -3
  40. package/src/components/ReviewPanel.tsx +8 -4
  41. package/src/components/ReviewRail.stories.tsx +145 -72
  42. package/src/components/ReviewRail.tsx +177 -38
  43. package/src/components/SettingsDialog.stories.tsx +51 -3
  44. package/src/components/SettingsDialog.tsx +8 -3
  45. package/src/components/SettingsRows.tsx +19 -3
  46. package/src/components/Toast.tsx +17 -2
  47. package/src/components/WalkWizard.stories.tsx +13 -2
  48. package/src/components/WalkWizard.tsx +9 -3
  49. package/src/components/__snapshots__/stories.test.tsx.snap +281 -83
  50. package/src/components/diff-sheet-layout.test.ts +127 -0
  51. package/src/components/diff-sheet-layout.ts +167 -0
  52. package/src/components/primitives/Button.tsx +5 -1
  53. package/src/components/primitives/Dialog.tsx +9 -1
  54. package/src/components/primitives/Tabs.tsx +24 -4
  55. package/src/components/stories-app.tsx +34 -6
  56. package/src/components/story-fixtures.ts +11 -3
  57. package/src/components/story.ts +3 -1
  58. package/src/components/syntax-highlight.ts +47 -12
  59. package/src/components/theme-context.stories.tsx +6 -1
  60. package/src/components/theme-context.tsx +7 -1
  61. package/src/config.test.ts +178 -2
  62. package/src/config.ts +121 -23
  63. package/src/diff-hunk-curate.test.ts +315 -0
  64. package/src/diff-hunk-curate.ts +233 -0
  65. package/src/diff-intraline.test.ts +164 -0
  66. package/src/diff-intraline.ts +132 -0
  67. package/src/diff-syntax.test.ts +96 -0
  68. package/src/diff-syntax.ts +154 -0
  69. package/src/editor.test.ts +13 -4
  70. package/src/editor.ts +31 -5
  71. package/src/herdr.ts +8 -2
  72. package/src/index.ts +9 -1
  73. package/src/integrations.ts +3 -1
  74. package/src/intent-dispatch.test.ts +193 -9
  75. package/src/intent-dispatch.ts +151 -18
  76. package/src/key-bindings.test.ts +19 -9
  77. package/src/key-bindings.ts +36 -14
  78. package/src/keymap.test.ts +171 -26
  79. package/src/keymap.ts +83 -16
  80. package/src/review-panel.test.ts +3 -1
  81. package/src/review-panel.ts +9 -2
  82. package/src/serve.test.ts +11 -3
  83. package/src/session-controller.curate.test.ts +253 -0
  84. package/src/session-controller.share.test.ts +65 -17
  85. package/src/session-controller.ts +301 -26
  86. package/src/share.ts +41 -8
  87. package/src/test-support.ts +16 -6
  88. package/src/theme-presets.ts +168 -0
  89. package/src/theme.ts +1 -3
  90. package/src/view-diff.ts +15 -4
  91. package/src/view-plan.test.ts +40 -6
  92. package/src/view-plan.ts +71 -28
  93. package/src/walk.test.ts +7 -2
  94. package/src/walk.ts +7 -2
@@ -0,0 +1,123 @@
1
+ /** Keyboard scroll is smooth: cursor-follow never drifts past wrapped cards. */
2
+
3
+ import { expect, test } from "bun:test";
4
+ import React, { useState } from "react";
5
+ import { testRender } from "@opentui/react/test-utils";
6
+ import { useKeyboard } from "@opentui/react";
7
+ import type { Renderable, ScrollBoxRenderable } from "@opentui/core";
8
+ import type { Annotation } from "@cueloop/schema";
9
+ import { DiffSheet } from "./DiffSheet";
10
+ import { diffRows, type DiffRow } from "../view-diff";
11
+ import { DARK } from "../theme";
12
+ import { press } from "../test-support";
13
+
14
+ /** Find a renderable by id anywhere in the tree (getRenderable is not recursive). */
15
+ function findById(node: Renderable, id: string): Renderable | undefined {
16
+ if (node.id === id) return node;
17
+ for (const child of node.getChildren()) {
18
+ const found = findById(child, id);
19
+ if (found) return found;
20
+ }
21
+ return undefined;
22
+ }
23
+
24
+ function hex(color: { toInts(): [number, number, number, number] }): string {
25
+ const [red, green, blue] = color.toInts();
26
+ return "#" + [red, green, blue].map((part) => part.toString(16).padStart(2, "0")).join("");
27
+ }
28
+
29
+ /** A single-hunk patch of `count` added lines, taller than any test viewport. */
30
+ function tallPatch(count: number): string {
31
+ const adds = Array.from(
32
+ { length: count },
33
+ (_, index) => `+line ${String(index).padStart(2, "0")}`,
34
+ );
35
+ return [
36
+ "diff --git a/f.ts b/f.ts",
37
+ "--- a/f.ts",
38
+ "+++ b/f.ts",
39
+ `@@ -0,0 +1,${count} @@`,
40
+ ...adds,
41
+ "",
42
+ ].join("\n");
43
+ }
44
+
45
+ /** DiffSheet with j-driven cursor, mirroring the App's down-navigation. */
46
+ function ScrollHarness({
47
+ rows,
48
+ annotations,
49
+ }: {
50
+ rows: DiffRow[];
51
+ annotations: Annotation[];
52
+ }): React.ReactNode {
53
+ const [cursor, setCursor] = useState(0);
54
+ useKeyboard((key) => {
55
+ if (key.name === "j") setCursor((current) => Math.min(rows.length - 1, current + 1));
56
+ });
57
+ return <DiffSheet rows={rows} cursor={cursor} annotations={annotations} />;
58
+ }
59
+
60
+ test("scrolling down past wrapped annotation cards keeps the cursor pinned and the scroll monotonic", async () => {
61
+ // Arrange - a tall diff whose annotation bodies would wrap at this width; the
62
+ // wrapped card is exactly the layout the cursor-follow model used to mis-measure
63
+ const rows = diffRows(tallPatch(40));
64
+ const longBody =
65
+ "this is a deliberately long annotation body that would wrap across the narrow test viewport";
66
+ const annotations: Annotation[] = [8, 16, 24].map((rowIndex) => ({
67
+ id: `a${rowIndex}`,
68
+ kind: "comment",
69
+ body: longBody,
70
+ anchor: { quote: rows[rowIndex]!.text, prefix: "", suffix: "" },
71
+ createdAt: "2026-01-01T00:00:00Z",
72
+ }));
73
+ const setup = await testRender(<ScrollHarness rows={rows} annotations={annotations} />, {
74
+ width: 60,
75
+ height: 12,
76
+ });
77
+ await setup.waitForVisualIdle();
78
+ const scrollbox = findById(setup.renderer.root, "diff-scroll") as ScrollBoxRenderable;
79
+
80
+ // the cursor row is the one painted with the cursor background
81
+ const cursorScreenRow = (): number => {
82
+ const lines = setup.captureSpans().lines;
83
+ for (let index = 0; index < lines.length; index++) {
84
+ if (lines[index]!.spans.some((span) => hex(span.bg) === DARK.cursorBackground)) return index;
85
+ }
86
+ return -1;
87
+ };
88
+
89
+ // Act - walk the cursor down through the whole diff, sampling the scroll each step
90
+ const scrollTops: number[] = [];
91
+ const screenRows: number[] = [];
92
+ for (let step = 0; step < rows.length; step++) {
93
+ scrollTops.push(scrollbox.scrollTop);
94
+ screenRows.push(cursorScreenRow());
95
+ await press(setup, "j");
96
+ }
97
+
98
+ // Assert - scrollTop only ever grows going down (never rebounds/jitters)
99
+ for (let step = 1; step < scrollTops.length; step++) {
100
+ expect(scrollTops[step]!).toBeGreaterThanOrEqual(scrollTops[step - 1]!);
101
+ }
102
+
103
+ // Assert - the cursor never scrolls off screen (the pre-fix bug walked it off
104
+ // the viewport as it passed each wrapped card)
105
+ expect(screenRows.every((row) => row >= 0)).toBe(true);
106
+
107
+ // Assert - through the steady scroll region (not the initial fill, not the
108
+ // final clamp at the content end) the cursor holds ONE stable row near the
109
+ // bottom; pre-fix it drifted down a row per wrapped card
110
+ const maxScroll = scrollbox.scrollHeight - scrollbox.height;
111
+ const steadyRows = screenRows.filter(
112
+ (_, step) => scrollTops[step]! > 0 && scrollTops[step]! < maxScroll,
113
+ );
114
+ expect(steadyRows.length).toBeGreaterThan(0);
115
+ expect(new Set(steadyRows).size).toBe(1);
116
+ const pinnedRow = steadyRows[0]!;
117
+ const viewportBottom = scrollbox.height;
118
+ // the cursor sits a couple of rows inside the bottom edge, per the follow rule
119
+ expect(viewportBottom - pinnedRow).toBeGreaterThanOrEqual(2);
120
+ expect(viewportBottom - pinnedRow).toBeLessThanOrEqual(3);
121
+
122
+ setup.renderer.destroy();
123
+ }, 25000);
@@ -12,6 +12,14 @@ export const SignedRows: Story = {
12
12
  size: { width: 90, height: 16 },
13
13
  };
14
14
 
15
+ // A modified line keeps the diff color on the changed words and dims the
16
+ // unchanged prefix to the dim token, so the intra-line change stands out.
17
+ export const IntralineWordDiff: Story = {
18
+ render: () => <DiffSheet rows={fixtureDiffRows()} cursor={0} annotations={[]} />,
19
+ expectedColors: [DARK.insertedForeground, DARK.deletedForeground, DARK.textDim],
20
+ size: { width: 90, height: 16 },
21
+ };
22
+
15
23
  export const CursorOnAddedLine: Story = {
16
24
  render: () => <DiffSheet rows={fixtureDiffRows()} cursor={4} annotations={[]} />,
17
25
  expectedColors: [DARK.cursorBackground],
@@ -28,7 +36,12 @@ export const ComposingOnLine: Story = {
28
36
  kind: "comment",
29
37
  rowIndex: 4,
30
38
  quote: " private items = new Map();",
31
- draft: { text: "Map needs an eviction story.", onInput: () => {}, onSave: () => {}, onCancel: () => {} },
39
+ draft: {
40
+ text: "Map needs an eviction story.",
41
+ onInput: () => {},
42
+ onSave: () => {},
43
+ onCancel: () => {},
44
+ },
32
45
  }}
33
46
  />
34
47
  ),
@@ -47,7 +60,10 @@ export const AnnotatedLine: Story = {
47
60
  kind: "comment",
48
61
  anchor: {
49
62
  // diff anchors quote the row text verbatim, trailing newline included
50
- quote: FIXTURE_PATCH.split("\n").find((line) => line.startsWith("+ "))!.slice(1) + "\n",
63
+ quote:
64
+ FIXTURE_PATCH.split("\n")
65
+ .find((line) => line.startsWith("+ "))!
66
+ .slice(1) + "\n",
51
67
  prefix: "",
52
68
  suffix: "",
53
69
  },
@@ -0,0 +1,62 @@
1
+ import { afterEach, beforeEach, expect, test } from "bun:test";
2
+ import { testRender } from "@opentui/react/test-utils";
3
+ import type { RGBA } from "@opentui/core";
4
+ import React from "react";
5
+ import { DiffSheet } from "./DiffSheet";
6
+ import { diffRows } from "../view-diff";
7
+ import { DARK } from "../theme";
8
+ import { allowEventLoopUpdates } from "../test-support";
9
+
10
+ const PATCH = `diff --git a/src/x.ts b/src/x.ts
11
+ --- a/src/x.ts
12
+ +++ b/src/x.ts
13
+ @@ -1,3 +1,3 @@
14
+ export class Store {
15
+ - const limit = 100;
16
+ + const limit = 250;
17
+ }
18
+ `;
19
+
20
+ function hex(color: RGBA): string {
21
+ const [red, green, blue] = color.toInts();
22
+ return "#" + [red, green, blue].map((part) => part.toString(16).padStart(2, "0")).join("");
23
+ }
24
+
25
+ beforeEach(() => allowEventLoopUpdates());
26
+ afterEach(() => allowEventLoopUpdates());
27
+
28
+ test("renders tree-sitter colors, with the changed word in the diff color on top", async () => {
29
+ // Arrange
30
+ const setup = await testRender(<DiffSheet rows={diffRows(PATCH)} cursor={0} annotations={[]} />, {
31
+ width: 60,
32
+ height: 12,
33
+ });
34
+
35
+ // Act
36
+ // syntax highlighting resolves off the render path; pump until a keyword paints
37
+ let keywordAccent = false;
38
+ for (let attempt = 0; attempt < 60 && !keywordAccent; attempt++) {
39
+ await setup.renderOnce();
40
+ await new Promise((resolve) => setTimeout(resolve, 20));
41
+ for (const line of setup.captureSpans().lines) {
42
+ for (const span of line.spans) {
43
+ if (span.text.includes("export") && hex(span.fg) === DARK.accent) keywordAccent = true;
44
+ }
45
+ }
46
+ }
47
+
48
+ // Assert
49
+ expect(keywordAccent).toBe(true);
50
+ // the intra-line changed number keeps the diff color over its syntax color
51
+ const changedColors = new Set<string>();
52
+ for (const line of setup.captureSpans().lines) {
53
+ for (const span of line.spans) {
54
+ if (span.text.includes("250")) changedColors.add(hex(span.fg));
55
+ if (span.text.includes("100")) changedColors.add(hex(span.fg));
56
+ }
57
+ }
58
+ expect(changedColors.has(DARK.insertedForeground)).toBe(true);
59
+ expect(changedColors.has(DARK.deletedForeground)).toBe(true);
60
+
61
+ setup.renderer.destroy();
62
+ }, 25000);
@@ -1,14 +1,19 @@
1
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.
2
+ * The diff review sheet: a thin renderer over diff-sheet-layout. Rows render (no
3
+ * window cap) in a scrollbox with file/hunk headers and contiguous code chunks.
4
+ * A chunk draws through the native line-number gutter - custom numbers per row,
5
+ * the cursor as a gutter sign, annotation markers as line signs - and closes
6
+ * after an annotated row so the annotation body sits under its line. A
7
+ * curated-out row renders struck through.
8
8
  */
9
9
 
10
- import React, { useEffect, useMemo, useRef } from "react";
11
- import type { LineNumberRenderable, LineSign, ScrollBoxRenderable } from "@opentui/core";
10
+ import React, { useEffect, useMemo, useRef, useState } from "react";
11
+ import {
12
+ createTextAttributes,
13
+ type LineNumberRenderable,
14
+ type LineSign,
15
+ type ScrollBoxRenderable,
16
+ } from "@opentui/core";
12
17
  import type { Annotation } from "@cueloop/schema";
13
18
  import type { DiffRow } from "../view-diff";
14
19
  import type { Theme } from "../theme";
@@ -16,9 +21,28 @@ import { useComponentTheme } from "./theme-context";
16
21
  import { truncateToSingleLine } from "./truncate-text";
17
22
  import { AnnotationCard, type AnnotationDraft } from "./AnnotationCard";
18
23
  import { FRAME_BORDER_STYLE } from "./primitives/frame";
24
+ import { intralineRunsByRow, type IntralineRun } from "../diff-intraline";
25
+ import { highlightDiffRows, type SyntaxSpan } from "../diff-syntax";
26
+ import {
27
+ annotatedRowsByIndex,
28
+ coloredRowSpans,
29
+ rowContentOffsets,
30
+ rowLine,
31
+ segmentRows,
32
+ type DiffSegment,
33
+ } from "./diff-sheet-layout";
34
+
35
+ /** Shared empty map so an unresolved/stale highlight state is a stable value. */
36
+ const EMPTY_SYNTAX: Map<number, SyntaxSpan[]> = new Map();
37
+
38
+ /** Shared empty set so "nothing rejected" is a stable identity for renders. */
39
+ const EMPTY_REJECTED: Set<number> = new Set();
40
+
41
+ /** A rejected (curated-out) change row renders struck through and dimmed. */
42
+ const REJECTED_ATTRIBUTES = createTextAttributes({ strikethrough: true, dim: true });
19
43
 
20
44
  export interface DiffComposeState {
21
- kind: "comment" | "suggestion";
45
+ kind: "comment";
22
46
  rowIndex: number;
23
47
  quote: string;
24
48
  draft: AnnotationDraft;
@@ -29,60 +53,49 @@ export interface DiffSheetProps {
29
53
  cursor: number;
30
54
  annotations: Annotation[];
31
55
  focusedAnnotationId?: string;
56
+ /** Row indices the owner rejected during curation; drawn struck through. */
57
+ rejectedRows?: Set<number>;
32
58
  compose?: DiffComposeState | null;
33
59
  theme?: Theme;
34
60
  }
35
61
 
36
- type DiffSegment =
37
- | { kind: "header"; rowIndex: number; row: DiffRow }
38
- | { kind: "chunk"; firstRowIndex: number; rows: DiffRow[]; annotation: Annotation | null };
39
-
40
- /** Row text carries the patch's trailing newline; rendering strips it. */
41
- function rowLine(row: DiffRow): string {
42
- return row.text.replace(/\n$/, "");
43
- }
44
-
45
- /** Chunks split after an annotated or composed row so a card can sit below. */
46
- function segmentRows(rows: DiffRow[], annotatedByRow: Map<number, Annotation>, composeRowIndex?: number): DiffSegment[] {
47
- const segments: DiffSegment[] = [];
48
- let chunk: DiffRow[] = [];
49
- let chunkStart = 0;
50
- const closeChunk = (annotation: Annotation | null): void => {
51
- if (chunk.length) segments.push({ kind: "chunk", firstRowIndex: chunkStart, rows: chunk, annotation });
52
- chunk = [];
53
- };
54
- for (let rowIndex = 0; rowIndex < rows.length; rowIndex++) {
55
- const row = rows[rowIndex]!;
56
- if (row.kind === "file" || row.kind === "hunk") {
57
- closeChunk(null);
58
- segments.push({ kind: "header", rowIndex, row });
59
- chunkStart = rowIndex + 1;
60
- continue;
61
- }
62
- if (!chunk.length) chunkStart = rowIndex;
63
- chunk.push(row);
64
- const annotation = annotatedByRow.get(rowIndex);
65
- if (annotation) {
66
- closeChunk(annotation);
67
- chunkStart = rowIndex + 1;
68
- } else if (rowIndex === composeRowIndex) {
69
- closeChunk(null);
70
- chunkStart = rowIndex + 1;
71
- }
72
- }
73
- closeChunk(null);
74
- return segments;
62
+ /** The sign, base color, and background a row draws with. */
63
+ function rowStyle(
64
+ row: DiffRow,
65
+ isCursorRow: boolean,
66
+ isAnnotatedRow: boolean,
67
+ tokens: Theme,
68
+ ): { sign: string; baseColor: string; background: string | undefined } {
69
+ const sign = row.kind === "add" ? "+" : row.kind === "del" ? "-" : " ";
70
+ const baseColor =
71
+ row.kind === "add"
72
+ ? tokens.insertedForeground
73
+ : row.kind === "del"
74
+ ? tokens.deletedForeground
75
+ : tokens.textMuted;
76
+ const background = isCursorRow
77
+ ? tokens.cursorBackground
78
+ : isAnnotatedRow
79
+ ? tokens.markCommentBackground
80
+ : undefined;
81
+ return { sign, baseColor, background };
75
82
  }
76
83
 
77
84
  function DiffChunk({
78
85
  segment,
79
86
  cursor,
80
87
  focusedAnnotationId,
88
+ intralineByRow,
89
+ syntaxByRow,
90
+ rejectedRows,
81
91
  theme,
82
92
  }: {
83
93
  segment: Extract<DiffSegment, { kind: "chunk" }>;
84
94
  cursor: number;
85
95
  focusedAnnotationId?: string;
96
+ intralineByRow: Map<number, IntralineRun[]>;
97
+ syntaxByRow: Map<number, SyntaxSpan[]>;
98
+ rejectedRows: Set<number>;
86
99
  theme?: Theme;
87
100
  }): React.ReactNode {
88
101
  const tokens = useComponentTheme(theme);
@@ -113,16 +126,21 @@ function DiffChunk({
113
126
  // the renderable's config parser requires the gutter key to be present
114
127
  const colors = new Map<number, { gutter: string | undefined; content: string }>();
115
128
  if (segment.annotation) {
116
- colors.set(segment.rows.length - 1, { gutter: undefined, content: tokens.markCommentBackground });
129
+ colors.set(segment.rows.length - 1, {
130
+ gutter: undefined,
131
+ content: tokens.markCommentBackground,
132
+ });
117
133
  }
118
134
  if (cursorInChunk >= 0 && cursorInChunk < segment.rows.length) {
119
- colors.set(cursorInChunk, { gutter: tokens.cursorBackground, content: tokens.cursorBackground });
135
+ colors.set(cursorInChunk, {
136
+ gutter: tokens.cursorBackground,
137
+ content: tokens.cursorBackground,
138
+ });
120
139
  }
121
140
  return colors;
122
141
  }, [segment, cursorInChunk, tokens]);
123
142
 
124
- // gutter state flows through the imperative surface: the renderable keeps
125
- // no prop setters for these maps, so updates land via effects
143
+ // the gutter renderable has no prop setters for these maps, so they land via an effect
126
144
  useEffect(() => {
127
145
  const gutter = gutterRef.current;
128
146
  if (!gutter) return;
@@ -145,24 +163,60 @@ function DiffChunk({
145
163
  >
146
164
  <text style={{ wrapMode: "none" }} selectable={false}>
147
165
  {segment.rows.map((row, lineIndex) => {
148
- const sign = row.kind === "add" ? "+" : row.kind === "del" ? "-" : " ";
149
- const foreground = row.kind === "add" ? tokens.insertedForeground : row.kind === "del" ? tokens.deletedForeground : tokens.textMuted;
150
166
  const isCursorRow = lineIndex === cursorInChunk;
151
- const isAnnotatedRow = segment.annotation !== null && lineIndex === segment.rows.length - 1;
167
+ const isAnnotatedRow =
168
+ segment.annotation !== null && lineIndex === segment.rows.length - 1;
169
+ const { sign, baseColor, background } = rowStyle(
170
+ row,
171
+ isCursorRow,
172
+ isAnnotatedRow,
173
+ tokens,
174
+ );
175
+ const prefix = (lineIndex > 0 ? "\n" : "") + sign;
176
+ const absoluteRowIndex = segment.firstRowIndex + lineIndex;
177
+
178
+ // A rejected change collapses to one struck-through, dimmed span, so
179
+ // it reads as excluded regardless of its syntax or intra-line colors.
180
+ if (rejectedRows.has(absoluteRowIndex)) {
181
+ return (
182
+ <span
183
+ key={lineIndex}
184
+ fg={tokens.textDim}
185
+ bg={background}
186
+ attributes={REJECTED_ATTRIBUTES}
187
+ >
188
+ {prefix + rowLine(row)}
189
+ </span>
190
+ );
191
+ }
192
+
193
+ const spans = coloredRowSpans(
194
+ rowLine(row),
195
+ intralineByRow.get(absoluteRowIndex),
196
+ syntaxByRow.get(absoluteRowIndex),
197
+ baseColor,
198
+ tokens,
199
+ );
152
200
  return (
153
- <span
154
- key={lineIndex}
155
- fg={foreground}
156
- bg={isCursorRow ? tokens.cursorBackground : isAnnotatedRow ? tokens.markCommentBackground : undefined}
157
- >
158
- {(lineIndex > 0 ? "\n" : "") + sign + rowLine(row)}
159
- </span>
201
+ <React.Fragment key={lineIndex}>
202
+ <span fg={baseColor} bg={background}>
203
+ {prefix}
204
+ </span>
205
+ {spans.map((span, spanIndex) => (
206
+ <span key={spanIndex} fg={span.foreground} bg={background}>
207
+ {span.text}
208
+ </span>
209
+ ))}
210
+ </React.Fragment>
160
211
  );
161
212
  })}
162
213
  </text>
163
214
  </line-number>
215
+
164
216
  {segment.annotation ? (
165
- <text>
217
+ // one row exactly: wrapMode none keeps the model's 1-line-per-card offset
218
+ // honest, so the cursor-follow scroll never drifts past a wrapped card
219
+ <text style={{ wrapMode: "none" }}>
166
220
  <span fg={tokens.textDim}>{" "}</span>
167
221
  <span fg={segment.annotation.id === focusedAnnotationId ? tokens.text : tokens.accent}>
168
222
  ◆ {truncateToSingleLine(segment.annotation.body, 70)}
@@ -173,44 +227,52 @@ function DiffChunk({
173
227
  );
174
228
  }
175
229
 
176
- export function DiffSheet({ rows, cursor, annotations, focusedAnnotationId, compose, theme }: DiffSheetProps): React.ReactNode {
230
+ /** Async tree-sitter highlights, discarded when they belong to superseded rows. */
231
+ function useSyntaxHighlights(rows: DiffRow[]): Map<number, SyntaxSpan[]> {
232
+ const [highlighted, setHighlighted] = useState<{
233
+ rows: DiffRow[];
234
+ byRow: Map<number, SyntaxSpan[]>;
235
+ }>({
236
+ rows,
237
+ byRow: EMPTY_SYNTAX,
238
+ });
239
+ useEffect(() => {
240
+ let active = true;
241
+ void highlightDiffRows(rows).then((byRow) => {
242
+ if (active) setHighlighted({ rows, byRow });
243
+ });
244
+ return () => {
245
+ active = false;
246
+ };
247
+ }, [rows]);
248
+ return highlighted.rows === rows ? highlighted.byRow : EMPTY_SYNTAX;
249
+ }
250
+
251
+ export function DiffSheet({
252
+ rows,
253
+ cursor,
254
+ annotations,
255
+ focusedAnnotationId,
256
+ rejectedRows = EMPTY_REJECTED,
257
+ compose,
258
+ theme,
259
+ }: DiffSheetProps): React.ReactNode {
177
260
  const tokens = useComponentTheme(theme);
178
261
  const scrollRef = useRef<ScrollBoxRenderable | null>(null);
179
262
 
180
- const annotatedByRow = useMemo(() => {
181
- const byRow = new Map<number, Annotation>();
182
- for (const annotation of annotations) {
183
- const rowIndex = rows.findIndex(
184
- (row) => row.text === annotation.anchor.quote && (row.kind === "ctx" || row.kind === "add" || row.kind === "del"),
185
- );
186
- if (rowIndex !== -1) byRow.set(rowIndex, annotation);
187
- }
188
- return byRow;
189
- }, [rows, annotations]);
190
-
263
+ const annotatedByRow = useMemo(
264
+ () => annotatedRowsByIndex(rows, annotations),
265
+ [rows, annotations],
266
+ );
191
267
  const segments = useMemo(
192
268
  () => segmentRows(rows, annotatedByRow, compose?.rowIndex),
193
269
  [rows, annotatedByRow, compose?.rowIndex],
194
270
  );
271
+ const intralineByRow = useMemo(() => intralineRunsByRow(rows), [rows]);
272
+ const syntaxByRow = useSyntaxHighlights(rows);
273
+ const rowOffsets = useMemo(() => rowContentOffsets(segments), [segments]);
195
274
 
196
- // content y offset per row index, for cursor-following scroll
197
- const rowOffsets = useMemo(() => {
198
- const offsets: number[] = [];
199
- let contentY = 0;
200
- for (const segment of segments) {
201
- if (segment.kind === "header") {
202
- offsets[segment.rowIndex] = contentY;
203
- contentY += 1;
204
- } else {
205
- segment.rows.forEach((_, lineIndex) => {
206
- offsets[segment.firstRowIndex + lineIndex] = contentY + lineIndex;
207
- });
208
- contentY += segment.rows.length + (segment.annotation ? 1 : 0);
209
- }
210
- }
211
- return offsets;
212
- }, [segments]);
213
-
275
+ // follow the cursor: keep it a couple of rows inside the viewport
214
276
  useEffect(() => {
215
277
  const scrollbox = scrollRef.current;
216
278
  const cursorOffset = rowOffsets[cursor];
@@ -235,30 +297,54 @@ export function DiffSheet({ rows, cursor, annotations, focusedAnnotationId, comp
235
297
  paddingTop: 0,
236
298
  }}
237
299
  >
238
- <scrollbox ref={scrollRef} style={{ flexGrow: 1 }} focused={false}>
300
+ <scrollbox id="diff-scroll" ref={scrollRef} style={{ flexGrow: 1 }} focused={false}>
239
301
  {segments.map((segment, segmentIndex) => {
240
302
  if (segment.kind === "header") {
241
303
  const isCursor = segment.rowIndex === cursor;
242
304
  if (segment.row.kind === "file") {
243
305
  return (
244
- <text key={segmentIndex} fg={tokens.text} bg={isCursor ? tokens.cursorBackground : tokens.panel}>
306
+ <text
307
+ key={segmentIndex}
308
+ fg={tokens.text}
309
+ bg={isCursor ? tokens.cursorBackground : tokens.panel}
310
+ style={{ wrapMode: "none" }}
311
+ >
245
312
  {isCursor ? "▎" : " "}■ {rowLine(segment.row)}
246
313
  </text>
247
314
  );
248
315
  }
249
316
  return (
250
- <text key={segmentIndex} fg={tokens.blue} bg={isCursor ? tokens.cursorBackground : undefined}>
317
+ <text
318
+ key={segmentIndex}
319
+ fg={tokens.blue}
320
+ bg={isCursor ? tokens.cursorBackground : undefined}
321
+ style={{ wrapMode: "none" }}
322
+ >
251
323
  {isCursor ? "▎" : " "}
252
324
  {rowLine(segment.row)}
253
325
  </text>
254
326
  );
255
327
  }
328
+
256
329
  const lastRowIndex = segment.firstRowIndex + segment.rows.length - 1;
257
330
  return (
258
331
  <React.Fragment key={segmentIndex}>
259
- <DiffChunk segment={segment} cursor={cursor} focusedAnnotationId={focusedAnnotationId} theme={theme} />
332
+ <DiffChunk
333
+ segment={segment}
334
+ cursor={cursor}
335
+ focusedAnnotationId={focusedAnnotationId}
336
+ intralineByRow={intralineByRow}
337
+ syntaxByRow={syntaxByRow}
338
+ rejectedRows={rejectedRows}
339
+ theme={theme}
340
+ />
260
341
  {compose && compose.rowIndex === lastRowIndex ? (
261
- <AnnotationCard kind={compose.kind} quote={compose.quote} draft={compose.draft} theme={theme} />
342
+ <AnnotationCard
343
+ kind={compose.kind}
344
+ quote={compose.quote}
345
+ draft={compose.draft}
346
+ theme={theme}
347
+ />
262
348
  ) : null}
263
349
  </React.Fragment>
264
350
  );
@@ -6,13 +6,26 @@ import { fixtureDiffSession, fixturePlanSession } from "./story-fixtures";
6
6
 
7
7
  export const meta: StoryMeta = { title: "InboxList" };
8
8
 
9
+ /** InboxList is the body under App's header/menu chrome; a frame gives it height. */
10
+ function BodyFrame({ children }: { children: React.ReactNode }): React.ReactNode {
11
+ return <box style={{ flexDirection: "column", width: "100%", height: "100%" }}>{children}</box>;
12
+ }
13
+
9
14
  export const TwoPending: Story = {
10
- render: () => <InboxList inbox={[fixturePlanSession(), fixtureDiffSession()]} cursor={0} />,
11
- expectedColors: [DARK.accent, DARK.cursorBackground],
15
+ render: () => (
16
+ <BodyFrame>
17
+ <InboxList inbox={[fixturePlanSession(), fixtureDiffSession()]} cursor={0} />
18
+ </BodyFrame>
19
+ ),
20
+ expectedColors: [DARK.cursorBackground],
12
21
  size: { width: 80, height: 12 },
13
22
  };
14
23
 
15
24
  export const Empty: Story = {
16
- render: () => <InboxList inbox={[]} cursor={0} />,
25
+ render: () => (
26
+ <BodyFrame>
27
+ <InboxList inbox={[]} cursor={0} />
28
+ </BodyFrame>
29
+ ),
17
30
  size: { width: 80, height: 8 },
18
31
  };