@cueloop/client 0.1.0-alpha.6 → 0.1.0-alpha.61

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 (161) hide show
  1. package/native/build-pty.sh +51 -0
  2. package/native/build.sh +82 -0
  3. package/native/darwin-arm64/libcuelooppty.dylib +0 -0
  4. package/native/darwin-arm64/libcueloopvt.dylib +0 -0
  5. package/native/src/pty.zig +193 -0
  6. package/native/src/shim.zig +114 -0
  7. package/package.json +37 -33
  8. package/src/App.appearance.test.tsx +51 -0
  9. package/src/App.autoclose.test.tsx +73 -47
  10. package/src/App.diff.test.tsx +169 -22
  11. package/src/App.export.test.tsx +49 -37
  12. package/src/App.plan-review.test.tsx +714 -0
  13. package/src/App.prototype.test.tsx +194 -0
  14. package/src/App.readonly.test.tsx +69 -35
  15. package/src/App.rendering.test.tsx +59 -31
  16. package/src/App.review-panel.test.tsx +143 -0
  17. package/src/App.submit-confirm.test.tsx +229 -0
  18. package/src/App.test.tsx +139 -68
  19. package/src/App.toast.test.tsx +77 -0
  20. package/src/App.tsx +0 -0
  21. package/src/App.walk.test.tsx +299 -0
  22. package/src/__snapshots__/view-plan.regression.test.ts.snap +100 -0
  23. package/src/app-key-state.ts +61 -0
  24. package/src/app-screens.tsx +270 -0
  25. package/src/app-view-model.ts +307 -0
  26. package/src/attribution.test.ts +31 -0
  27. package/src/attribution.ts +42 -0
  28. package/src/clipboard.test.ts +23 -0
  29. package/src/clipboard.ts +33 -0
  30. package/src/components/AnnotationCard.compose-rows.test.ts +49 -0
  31. package/src/components/AnnotationCard.stories.tsx +94 -0
  32. package/src/components/AnnotationCard.tsx +250 -0
  33. package/src/components/Breadcrumb.stories.tsx +19 -0
  34. package/src/components/Breadcrumb.tsx +45 -0
  35. package/src/components/CodeBlock.stories.tsx +42 -0
  36. package/src/components/CodeBlock.tsx +84 -0
  37. package/src/components/CompletionOverlay.stories.tsx +23 -0
  38. package/src/components/CompletionOverlay.tsx +60 -0
  39. package/src/components/ConfirmCard.stories.tsx +35 -0
  40. package/src/components/ConfirmCard.tsx +129 -0
  41. package/src/components/ConfirmDialog.stories.tsx +21 -0
  42. package/src/components/ConfirmDialog.tsx +66 -0
  43. package/src/components/DiffSheet.scroll.test.tsx +135 -0
  44. package/src/components/DiffSheet.stories.tsx +78 -0
  45. package/src/components/DiffSheet.syntax.test.tsx +65 -0
  46. package/src/components/DiffSheet.tsx +374 -0
  47. package/src/components/InboxList.stories.tsx +31 -0
  48. package/src/components/InboxList.tsx +64 -0
  49. package/src/components/KeybindsDialog.stories.tsx +31 -0
  50. package/src/components/KeybindsDialog.tsx +59 -0
  51. package/src/components/MarkerPopover.stories.tsx +58 -0
  52. package/src/components/MarkerPopover.tsx +109 -0
  53. package/src/components/MenuBar.stories.tsx +37 -0
  54. package/src/components/MenuBar.tsx +75 -0
  55. package/src/components/PlanSheet.stories.tsx +113 -0
  56. package/src/components/PlanSheet.tsx +422 -0
  57. package/src/components/PromptDialog.stories.tsx +36 -0
  58. package/src/components/PromptDialog.tsx +80 -0
  59. package/src/components/PrototypeSheet.stories.tsx +20 -0
  60. package/src/components/PrototypeSheet.tsx +450 -0
  61. package/src/components/ReviewPanel.stories.tsx +86 -0
  62. package/src/components/ReviewPanel.tsx +103 -0
  63. package/src/components/ReviewRail.stories.tsx +218 -0
  64. package/src/components/ReviewRail.tsx +318 -0
  65. package/src/components/SettingsDialog.stories.tsx +111 -0
  66. package/src/components/SettingsDialog.tsx +147 -0
  67. package/src/components/SettingsRows.stories.tsx +18 -0
  68. package/src/components/SettingsRows.tsx +112 -0
  69. package/src/components/Toast.stories.tsx +12 -0
  70. package/src/components/Toast.tsx +45 -0
  71. package/src/components/WalkWizard.stories.tsx +100 -0
  72. package/src/components/WalkWizard.tsx +152 -0
  73. package/src/components/__snapshots__/stories.test.tsx.snap +1857 -0
  74. package/src/components/agent-launcher.stories.tsx +26 -0
  75. package/src/components/agent-launcher.test.tsx +50 -0
  76. package/src/components/agent-launcher.tsx +149 -0
  77. package/src/components/diff-sheet-layout.test.ts +128 -0
  78. package/src/components/diff-sheet-layout.ts +184 -0
  79. package/src/components/plan-sheet-run-style.ts +90 -0
  80. package/src/components/primitives/Button.stories.tsx +32 -0
  81. package/src/components/primitives/Button.tsx +52 -0
  82. package/src/components/primitives/Card.stories.tsx +25 -0
  83. package/src/components/primitives/Card.tsx +60 -0
  84. package/src/components/primitives/Dialog.stories.tsx +24 -0
  85. package/src/components/primitives/Dialog.tsx +66 -0
  86. package/src/components/primitives/StatusBar.stories.tsx +11 -0
  87. package/src/components/primitives/StatusBar.tsx +23 -0
  88. package/src/components/primitives/Tabs.stories.tsx +29 -0
  89. package/src/components/primitives/Tabs.tsx +102 -0
  90. package/src/components/primitives/Toolbar.stories.tsx +17 -0
  91. package/src/components/primitives/Toolbar.tsx +14 -0
  92. package/src/components/primitives/frame.test.ts +12 -0
  93. package/src/components/primitives/frame.ts +14 -0
  94. package/src/components/quick-actions-editor.stories.tsx +54 -0
  95. package/src/components/quick-actions-editor.tsx +138 -0
  96. package/src/components/stories-app.tsx +124 -0
  97. package/src/components/stories.test.tsx +66 -0
  98. package/src/components/story-fixtures.ts +98 -0
  99. package/src/components/story.ts +75 -0
  100. package/src/components/syntax-highlight.ts +102 -0
  101. package/src/components/terminal-pane.test.tsx +40 -0
  102. package/src/components/terminal-pane.ts +193 -0
  103. package/src/components/theme-context.stories.tsx +46 -0
  104. package/src/components/theme-context.tsx +37 -0
  105. package/src/components/truncate-text.ts +7 -0
  106. package/src/config.test.ts +448 -31
  107. package/src/config.ts +339 -57
  108. package/src/diff-hunk-curate.test.ts +315 -0
  109. package/src/diff-hunk-curate.ts +253 -0
  110. package/src/diff-intraline.test.ts +165 -0
  111. package/src/diff-intraline.ts +146 -0
  112. package/src/diff-syntax.test.ts +96 -0
  113. package/src/diff-syntax.ts +176 -0
  114. package/src/editor.test.ts +149 -0
  115. package/src/editor.ts +135 -9
  116. package/src/ghostty-terminal.test.ts +84 -0
  117. package/src/ghostty-terminal.ts +176 -0
  118. package/src/herdr.test.ts +9 -34
  119. package/src/herdr.ts +17 -21
  120. package/src/index.ts +10 -0
  121. package/src/integrations.test.ts +41 -0
  122. package/src/integrations.ts +32 -0
  123. package/src/intent-dispatch.test.ts +499 -0
  124. package/src/intent-dispatch.ts +585 -0
  125. package/src/key-bindings.test.ts +156 -0
  126. package/src/key-bindings.ts +452 -0
  127. package/src/keymap.test.ts +635 -0
  128. package/src/keymap.ts +404 -0
  129. package/src/kitty-image.test.ts +66 -0
  130. package/src/kitty-image.ts +138 -0
  131. package/src/prototype-browser.test.ts +56 -0
  132. package/src/prototype-browser.ts +289 -0
  133. package/src/pty.test.ts +81 -0
  134. package/src/pty.ts +237 -0
  135. package/src/review-panel.test.ts +113 -0
  136. package/src/review-panel.ts +83 -0
  137. package/src/run.ts +16 -5
  138. package/src/serve.test.ts +25 -9
  139. package/src/serve.ts +19 -8
  140. package/src/session-controller.curate.test.ts +260 -0
  141. package/src/session-controller.share.test.ts +320 -0
  142. package/src/session-controller.ts +993 -0
  143. package/src/share.ts +120 -0
  144. package/src/test-support.test.ts +60 -0
  145. package/src/test-support.ts +197 -0
  146. package/src/theme-presets.test.ts +51 -0
  147. package/src/theme-presets.ts +187 -0
  148. package/src/theme.ts +64 -17
  149. package/src/use-settings-dialog.tsx +275 -0
  150. package/src/version.ts +5 -0
  151. package/src/view-diff.test.ts +22 -8
  152. package/src/view-diff.ts +39 -23
  153. package/src/view-plan.regression.test.ts +60 -0
  154. package/src/view-plan.test.ts +406 -0
  155. package/src/view-plan.ts +554 -0
  156. package/src/walk.test.ts +121 -0
  157. package/src/walk.ts +74 -0
  158. package/src/syntax.test.ts +0 -41
  159. package/src/syntax.ts +0 -88
  160. package/src/view.test.ts +0 -157
  161. package/src/view.ts +0 -0
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Post-submit hand-back overlay: the agent is unblocked, the reviewer either
3
+ * closes now, opts into auto-close, or dismisses back to the resolved view.
4
+ * The latest status line (e.g. the vault-export path) stays visible here.
5
+ */
6
+
7
+ import React from "react";
8
+ import type { VerdictKind } from "@cueloop/schema";
9
+ import type { Theme } from "../theme";
10
+ import { useComponentTheme } from "./theme-context";
11
+
12
+ export interface CompletionOverlayProps {
13
+ verdict: VerdictKind;
14
+ completion: { phase: "prompt" } | { phase: "counting"; remaining: number };
15
+ /** Latest status line (e.g. the vault-export path) stays visible here. */
16
+ status: string;
17
+ /** Where focus goes on close (the agent's pane), when known. */
18
+ returnsTo?: string;
19
+ theme?: Theme;
20
+ }
21
+
22
+ export function CompletionOverlay({
23
+ verdict,
24
+ completion,
25
+ status,
26
+ returnsTo,
27
+ theme,
28
+ }: CompletionOverlayProps): React.ReactNode {
29
+ const tokens = useComponentTheme(theme);
30
+ const approved = verdict === "approve";
31
+
32
+ return (
33
+ <box
34
+ style={{
35
+ width: "100%",
36
+ height: "100%",
37
+ backgroundColor: tokens.background,
38
+ flexDirection: "column",
39
+ justifyContent: "center",
40
+ alignItems: "center",
41
+ }}
42
+ >
43
+ <text fg={approved ? tokens.green : tokens.accent}>
44
+ {approved ? "review approved" : "feedback sent"}
45
+ </text>
46
+ <text> </text>
47
+ <text fg={tokens.text}>
48
+ The agent has your {approved ? "approval" : "feedback"} and is unblocked.
49
+ </text>
50
+ {returnsTo ? <text fg={tokens.textDim}>returning to {returnsTo} on close</text> : null}
51
+ {status ? <text fg={tokens.textDim}>{status}</text> : null}
52
+ <text> </text>
53
+ <text fg={tokens.textDim}>
54
+ close [return]
55
+ {completion.phase === "counting" ? ` · closing in ${completion.remaining}s` : ""}
56
+ {" · return to plan [esc] · always [a]"}
57
+ </text>
58
+ </box>
59
+ );
60
+ }
@@ -0,0 +1,35 @@
1
+ import React from "react";
2
+ import { DARK } from "../theme";
3
+ import type { Story, StoryMeta } from "./story";
4
+ import { ConfirmCard } from "./ConfirmCard";
5
+
6
+ export const meta: StoryMeta = { title: "ConfirmCard" };
7
+
8
+ const callbacks = {
9
+ onInput: () => {},
10
+ onSelectVerdict: () => {},
11
+ onSubmit: () => {},
12
+ onCancel: () => {},
13
+ };
14
+
15
+ export const ApproveDefault: Story = {
16
+ render: () => <ConfirmCard verdict="approve" summary="" {...callbacks} />,
17
+ expectedColors: [DARK.green, DARK.accent],
18
+ size: { width: 40, height: 14 },
19
+ };
20
+
21
+ export const RequestChanges: Story = {
22
+ render: () => (
23
+ <ConfirmCard verdict="request_changes" summary="Tighten the steps." {...callbacks} />
24
+ ),
25
+ expectedColors: [DARK.red],
26
+ size: { width: 40, height: 14 },
27
+ };
28
+
29
+ export const WithViewedSummary: Story = {
30
+ render: () => (
31
+ <ConfirmCard verdict="approve" summary="" viewedSummary="2/3 files viewed" {...callbacks} />
32
+ ),
33
+ expectedColors: [DARK.green, DARK.textDim],
34
+ size: { width: 40, height: 14 },
35
+ };
@@ -0,0 +1,129 @@
1
+ /**
2
+ * The Submit button expanded into a bordered confirm card at the rail bottom:
3
+ * the verdict selector (arrow keys or click), the optional summary, and plain
4
+ * word-buttons - key hints live in the status line only. The bordered height
5
+ * derives from the content rows through Card, so layout and render never drift.
6
+ */
7
+
8
+ import React from "react";
9
+ import type { VerdictKind } from "@cueloop/schema";
10
+ import type { Theme } from "../theme";
11
+ import { useComponentTheme } from "./theme-context";
12
+ import { Card } from "./primitives/Card";
13
+ import { Button } from "./primitives/Button";
14
+ import { Toolbar } from "./primitives/Toolbar";
15
+
16
+ export const VERDICTS: VerdictKind[] = ["comment", "approve", "request_changes"];
17
+
18
+ /** Selector words in the confirm card - one word per verdict. */
19
+ export const VERDICT_LABEL: Record<VerdictKind, string> = {
20
+ comment: "Comment",
21
+ approve: "Approve",
22
+ request_changes: "Changes",
23
+ };
24
+
25
+ export interface ConfirmCardProps {
26
+ verdict: VerdictKind;
27
+ summary: string;
28
+ /**
29
+ * The guided walk's honest coverage line for diff sessions, e.g.
30
+ * "2/3 files viewed". Undefined = no walk data, the row does not render.
31
+ */
32
+ viewedSummary?: string;
33
+ onInput: (summary: string) => void;
34
+ onSelectVerdict: (verdict: VerdictKind) => void;
35
+ onSubmit: () => void;
36
+ onCancel: () => void;
37
+ theme?: Theme;
38
+ }
39
+
40
+ /** 1-row verdict selector, spacer, summary input, spacer, buttons. */
41
+ const CONFIRM_CONTENT_ROWS = 5;
42
+
43
+ export function verdictColor(verdict: VerdictKind, tokens: Theme): string {
44
+ return verdict === "approve"
45
+ ? tokens.green
46
+ : verdict === "request_changes"
47
+ ? tokens.red
48
+ : tokens.blue;
49
+ }
50
+
51
+ /**
52
+ * The verdict selector: one row of pressable words, matching the reading
53
+ * direction of a choice between three peers. Selection stays controlled by
54
+ * the grammar (←/→ cycle the verdict); a click selects directly. The
55
+ * selected verdict wears brackets and its color.
56
+ */
57
+ function VerdictSelector({
58
+ verdict,
59
+ onSelectVerdict,
60
+ theme,
61
+ }: {
62
+ verdict: VerdictKind;
63
+ onSelectVerdict: (verdict: VerdictKind) => void;
64
+ theme?: Theme;
65
+ }): React.ReactNode {
66
+ const tokens = useComponentTheme(theme);
67
+
68
+ return (
69
+ <box style={{ flexDirection: "row", height: 1 }}>
70
+ {VERDICTS.map((candidate) => (
71
+ <box
72
+ key={candidate}
73
+ style={{ paddingRight: 1 }}
74
+ onMouseUp={() => onSelectVerdict(candidate)}
75
+ >
76
+ <text fg={candidate === verdict ? verdictColor(candidate, tokens) : tokens.textDim}>
77
+ {candidate === verdict
78
+ ? `[${VERDICT_LABEL[candidate]}]`
79
+ : ` ${VERDICT_LABEL[candidate]} `}
80
+ </text>
81
+ </box>
82
+ ))}
83
+ </box>
84
+ );
85
+ }
86
+
87
+ export function ConfirmCard({
88
+ verdict,
89
+ summary,
90
+ viewedSummary,
91
+ onInput,
92
+ onSelectVerdict,
93
+ onSubmit,
94
+ onCancel,
95
+ theme,
96
+ }: ConfirmCardProps): React.ReactNode {
97
+ const tokens = useComponentTheme(theme);
98
+
99
+ return (
100
+ <Card
101
+ title=" submit review "
102
+ contentRows={CONFIRM_CONTENT_ROWS + (viewedSummary !== undefined ? 2 : 0)}
103
+ borderColor={tokens.text}
104
+ backgroundColor="transparent"
105
+ marginRight={1}
106
+ theme={theme}
107
+ >
108
+ {viewedSummary !== undefined ? <text fg={tokens.textDim}>{viewedSummary}</text> : null}
109
+ {viewedSummary !== undefined ? <box style={{ height: 1 }} /> : null}
110
+ <VerdictSelector verdict={verdict} onSelectVerdict={onSelectVerdict} theme={theme} />
111
+ <box style={{ height: 1 }} />
112
+ <input
113
+ focused
114
+ value={summary}
115
+ onInput={onInput}
116
+ placeholder="summary for the agent (optional)"
117
+ />
118
+ <box style={{ height: 1 }} />
119
+ <Toolbar>
120
+ <Button variant="solid" marginRight={2} onPress={onSubmit} theme={theme}>
121
+ {" Submit "}
122
+ </Button>
123
+ <Button onPress={onCancel} theme={theme}>
124
+ {" Cancel "}
125
+ </Button>
126
+ </Toolbar>
127
+ </Card>
128
+ );
129
+ }
@@ -0,0 +1,21 @@
1
+ import React from "react";
2
+ import { DARK } from "../theme";
3
+ import type { Story, StoryMeta } from "./story";
4
+ import { ConfirmDialog } from "./ConfirmDialog";
5
+
6
+ export const meta: StoryMeta = { title: "ConfirmDialog" };
7
+
8
+ const callbacks = { onConfirm: () => {}, onCancel: () => {} };
9
+
10
+ export const DeletePlan: Story = {
11
+ render: () => (
12
+ <ConfirmDialog
13
+ isOpen
14
+ title=" Delete plan "
15
+ message={'Delete "Auth rollout"? This removes the plan and its review.'}
16
+ {...callbacks}
17
+ />
18
+ ),
19
+ expectedColors: [DARK.accent],
20
+ size: { width: 64, height: 16 },
21
+ };
@@ -0,0 +1,66 @@
1
+ /**
2
+ * A centered yes/no confirmation on the shared Dialog overlay. Presentational:
3
+ * the key grammar (⏎ confirm, esc cancel) is driven by the reducer; the buttons
4
+ * carry the same intents for the mouse. Used for destructive actions like the
5
+ * inbox delete.
6
+ */
7
+
8
+ import React from "react";
9
+ import { useTerminalDimensions } from "@opentui/react";
10
+ import type { Theme } from "../theme";
11
+ import { useComponentTheme } from "./theme-context";
12
+ import { Dialog } from "./primitives/Dialog";
13
+ import { Button } from "./primitives/Button";
14
+ import { Toolbar } from "./primitives/Toolbar";
15
+
16
+ export interface ConfirmDialogProps {
17
+ isOpen: boolean;
18
+ title?: string;
19
+ message: string;
20
+ confirmLabel?: string;
21
+ cancelLabel?: string;
22
+ onConfirm: () => void;
23
+ onCancel: () => void;
24
+ theme?: Theme;
25
+ }
26
+
27
+ export function ConfirmDialog({
28
+ isOpen,
29
+ title,
30
+ message,
31
+ confirmLabel = "Delete",
32
+ cancelLabel = "Cancel",
33
+ onConfirm,
34
+ onCancel,
35
+ theme,
36
+ }: ConfirmDialogProps): React.ReactNode {
37
+ const tokens = useComponentTheme(theme);
38
+ const { width: terminalWidth } = useTerminalDimensions();
39
+
40
+ if (!isOpen) return null;
41
+
42
+ return (
43
+ <Dialog isOpen title={title} width={Math.min(54, terminalWidth - 6)} height={7} theme={theme}>
44
+ <box
45
+ style={{
46
+ flexDirection: "column",
47
+ flexGrow: 1,
48
+ paddingLeft: 1,
49
+ paddingRight: 1,
50
+ paddingTop: 1,
51
+ }}
52
+ >
53
+ <text fg={tokens.text}>{message}</text>
54
+ <box style={{ flexGrow: 1 }} />
55
+ <Toolbar>
56
+ <Button variant="solid" onPress={onConfirm} theme={theme}>
57
+ {` ${confirmLabel} `}
58
+ </Button>
59
+ <Button onPress={onCancel} theme={theme}>
60
+ {` ${cancelLabel} esc `}
61
+ </Button>
62
+ </Toolbar>
63
+ </box>
64
+ </Dialog>
65
+ );
66
+ }
@@ -0,0 +1,135 @@
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
+
20
+ if (found) return found;
21
+ }
22
+
23
+ return undefined;
24
+ }
25
+
26
+ function hex(color: { toInts(): [number, number, number, number] }): string {
27
+ const [red, green, blue] = color.toInts();
28
+
29
+ return "#" + [red, green, blue].map((part) => part.toString(16).padStart(2, "0")).join("");
30
+ }
31
+
32
+ /** A single-hunk patch of `count` added lines, taller than any test viewport. */
33
+ function tallPatch(count: number): string {
34
+ const adds = Array.from(
35
+ { length: count },
36
+ (_, index) => `+line ${String(index).padStart(2, "0")}`,
37
+ );
38
+
39
+ return [
40
+ "diff --git a/f.ts b/f.ts",
41
+ "--- a/f.ts",
42
+ "+++ b/f.ts",
43
+ `@@ -0,0 +1,${count} @@`,
44
+ ...adds,
45
+ "",
46
+ ].join("\n");
47
+ }
48
+
49
+ /** DiffSheet with j-driven cursor, mirroring the App's down-navigation. */
50
+ function ScrollHarness({
51
+ rows,
52
+ annotations,
53
+ }: {
54
+ rows: DiffRow[];
55
+ annotations: Annotation[];
56
+ }): React.ReactNode {
57
+ const [cursor, setCursor] = useState(0);
58
+
59
+ useKeyboard((key) => {
60
+ if (key.name === "j") setCursor((current) => Math.min(rows.length - 1, current + 1));
61
+ });
62
+
63
+ return <DiffSheet rows={rows} cursor={cursor} annotations={annotations} />;
64
+ }
65
+
66
+ test("scrolling down past wrapped annotation cards keeps the cursor pinned and the scroll monotonic", async () => {
67
+ // Arrange - a tall diff whose annotation bodies would wrap at this width; the
68
+ // wrapped card is exactly the layout the cursor-follow model used to mis-measure
69
+ const rows = diffRows(tallPatch(40));
70
+ const longBody =
71
+ "this is a deliberately long annotation body that would wrap across the narrow test viewport";
72
+ const annotations: Annotation[] = [8, 16, 24].map((rowIndex) => ({
73
+ id: `a${rowIndex}`,
74
+ kind: "comment",
75
+ body: longBody,
76
+ anchor: { quote: rows[rowIndex]!.text, prefix: "", suffix: "" },
77
+ createdAt: "2026-01-01T00:00:00Z",
78
+ }));
79
+ const setup = await testRender(<ScrollHarness rows={rows} annotations={annotations} />, {
80
+ width: 60,
81
+ height: 12,
82
+ });
83
+
84
+ await setup.waitForVisualIdle();
85
+ const scrollbox = findById(setup.renderer.root, "diff-scroll") as ScrollBoxRenderable;
86
+
87
+ // the cursor row is the one painted with the cursor background
88
+ const cursorScreenRow = (): number => {
89
+ const lines = setup.captureSpans().lines;
90
+
91
+ for (let index = 0; index < lines.length; index++) {
92
+ if (lines[index]!.spans.some((span) => hex(span.bg) === DARK.cursorBackground)) return index;
93
+ }
94
+
95
+ return -1;
96
+ };
97
+
98
+ // Act - walk the cursor down through the whole diff, sampling the scroll each step
99
+ const scrollTops: number[] = [];
100
+ const screenRows: number[] = [];
101
+
102
+ for (let step = 0; step < rows.length; step++) {
103
+ scrollTops.push(scrollbox.scrollTop);
104
+ screenRows.push(cursorScreenRow());
105
+ await press(setup, "j");
106
+ }
107
+
108
+ // Assert - scrollTop only ever grows going down (never rebounds/jitters)
109
+ for (let step = 1; step < scrollTops.length; step++) {
110
+ expect(scrollTops[step]!).toBeGreaterThanOrEqual(scrollTops[step - 1]!);
111
+ }
112
+
113
+ // Assert - the cursor never scrolls off screen (the pre-fix bug walked it off
114
+ // the viewport as it passed each wrapped card)
115
+ expect(screenRows.every((row) => row >= 0)).toBe(true);
116
+
117
+ // Assert - through the steady scroll region (not the initial fill, not the
118
+ // final clamp at the content end) the cursor holds ONE stable row near the
119
+ // bottom; pre-fix it drifted down a row per wrapped card
120
+ const maxScroll = scrollbox.scrollHeight - scrollbox.height;
121
+ const steadyRows = screenRows.filter(
122
+ (_, step) => scrollTops[step]! > 0 && scrollTops[step]! < maxScroll,
123
+ );
124
+
125
+ expect(steadyRows.length).toBeGreaterThan(0);
126
+ expect(new Set(steadyRows).size).toBe(1);
127
+ const pinnedRow = steadyRows[0]!;
128
+ const viewportBottom = scrollbox.height;
129
+
130
+ // the cursor sits a couple of rows inside the bottom edge, per the follow rule
131
+ expect(viewportBottom - pinnedRow).toBeGreaterThanOrEqual(2);
132
+ expect(viewportBottom - pinnedRow).toBeLessThanOrEqual(3);
133
+
134
+ setup.renderer.destroy();
135
+ }, 25000);
@@ -0,0 +1,78 @@
1
+ import React from "react";
2
+ import { DARK } from "../theme";
3
+ import type { Story, StoryMeta } from "./story";
4
+ import { DiffSheet } from "./DiffSheet";
5
+ import { FIXTURE_PATCH, fixtureDiffRows } from "./story-fixtures";
6
+
7
+ export const meta: StoryMeta = { title: "DiffSheet" };
8
+
9
+ export const SignedRows: Story = {
10
+ render: () => <DiffSheet rows={fixtureDiffRows()} cursor={0} annotations={[]} />,
11
+ expectedColors: [DARK.insertedForeground, DARK.deletedForeground],
12
+ size: { width: 90, height: 16 },
13
+ };
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
+
23
+ export const CursorOnAddedLine: Story = {
24
+ render: () => <DiffSheet rows={fixtureDiffRows()} cursor={4} annotations={[]} />,
25
+ expectedColors: [DARK.cursorBackground],
26
+ size: { width: 90, height: 16 },
27
+ };
28
+
29
+ export const ComposingOnLine: Story = {
30
+ render: () => (
31
+ <DiffSheet
32
+ rows={fixtureDiffRows()}
33
+ cursor={4}
34
+ annotations={[]}
35
+ compose={{
36
+ kind: "comment",
37
+ rowIndex: 4,
38
+ quote: " private items = new Map();",
39
+ draft: {
40
+ text: "Map needs an eviction story.",
41
+ onInput: () => {},
42
+ onSave: () => {},
43
+ onCancel: () => {},
44
+ },
45
+ }}
46
+ />
47
+ ),
48
+ expectedColors: [DARK.accent],
49
+ size: { width: 90, height: 16 },
50
+ };
51
+
52
+ export const AnnotatedLine: Story = {
53
+ render: () => (
54
+ <DiffSheet
55
+ rows={fixtureDiffRows()}
56
+ cursor={0}
57
+ annotations={[
58
+ {
59
+ id: "a_story_diff",
60
+ kind: "comment",
61
+ anchor: {
62
+ // diff anchors quote the row text verbatim, trailing newline included
63
+ quote:
64
+ FIXTURE_PATCH.split("\n")
65
+ .find((line) => line.startsWith("+ "))!
66
+ .slice(1) + "\n",
67
+ prefix: "",
68
+ suffix: "",
69
+ },
70
+ body: "Map needs an eviction story.",
71
+ createdAt: "2026-01-01T00:00:00Z",
72
+ },
73
+ ]}
74
+ />
75
+ ),
76
+ expectedColors: [DARK.markCommentBackground],
77
+ size: { width: 90, height: 16 },
78
+ };
@@ -0,0 +1,65 @@
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
+
23
+ return "#" + [red, green, blue].map((part) => part.toString(16).padStart(2, "0")).join("");
24
+ }
25
+
26
+ beforeEach(() => allowEventLoopUpdates());
27
+ afterEach(() => allowEventLoopUpdates());
28
+
29
+ test("renders tree-sitter colors, with the changed word in the diff color on top", async () => {
30
+ // Arrange
31
+ const setup = await testRender(<DiffSheet rows={diffRows(PATCH)} cursor={0} annotations={[]} />, {
32
+ width: 60,
33
+ height: 12,
34
+ });
35
+
36
+ // Act
37
+ // syntax highlighting resolves off the render path; pump until a keyword paints
38
+ let keywordAccent = false;
39
+
40
+ for (let attempt = 0; attempt < 60 && !keywordAccent; attempt++) {
41
+ await setup.renderOnce();
42
+ await new Promise((resolve) => setTimeout(resolve, 20));
43
+ for (const line of setup.captureSpans().lines) {
44
+ for (const span of line.spans) {
45
+ if (span.text.includes("export") && hex(span.fg) === DARK.accent) keywordAccent = true;
46
+ }
47
+ }
48
+ }
49
+
50
+ // Assert
51
+ expect(keywordAccent).toBe(true);
52
+ // the intra-line changed number keeps the diff color over its syntax color
53
+ const changedColors = new Set<string>();
54
+
55
+ for (const line of setup.captureSpans().lines) {
56
+ for (const span of line.spans) {
57
+ if (span.text.includes("250")) changedColors.add(hex(span.fg));
58
+ if (span.text.includes("100")) changedColors.add(hex(span.fg));
59
+ }
60
+ }
61
+ expect(changedColors.has(DARK.insertedForeground)).toBe(true);
62
+ expect(changedColors.has(DARK.deletedForeground)).toBe(true);
63
+
64
+ setup.renderer.destroy();
65
+ }, 25000);