@assistant-ui/react-ink 0.0.6 → 0.0.7

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 (66) hide show
  1. package/README.md +1 -0
  2. package/dist/index.d.ts +2 -0
  3. package/dist/index.d.ts.map +1 -1
  4. package/dist/index.js +2 -0
  5. package/dist/index.js.map +1 -1
  6. package/dist/primitives/composer/ComposerSend.js +1 -1
  7. package/dist/primitives/composer/ComposerSend.js.map +1 -1
  8. package/dist/primitives/diff/DiffContent.d.ts +22 -0
  9. package/dist/primitives/diff/DiffContent.d.ts.map +1 -0
  10. package/dist/primitives/diff/DiffContent.js +40 -0
  11. package/dist/primitives/diff/DiffContent.js.map +1 -0
  12. package/dist/primitives/diff/DiffContext.d.ts +8 -0
  13. package/dist/primitives/diff/DiffContext.d.ts.map +1 -0
  14. package/dist/primitives/diff/DiffContext.js +11 -0
  15. package/dist/primitives/diff/DiffContext.js.map +1 -0
  16. package/dist/primitives/diff/DiffHeader.d.ts +10 -0
  17. package/dist/primitives/diff/DiffHeader.d.ts.map +1 -0
  18. package/dist/primitives/diff/DiffHeader.js +18 -0
  19. package/dist/primitives/diff/DiffHeader.js.map +1 -0
  20. package/dist/primitives/diff/DiffLine.d.ts +13 -0
  21. package/dist/primitives/diff/DiffLine.d.ts.map +1 -0
  22. package/dist/primitives/diff/DiffLine.js +20 -0
  23. package/dist/primitives/diff/DiffLine.js.map +1 -0
  24. package/dist/primitives/diff/DiffRoot.d.ts +12 -0
  25. package/dist/primitives/diff/DiffRoot.d.ts.map +1 -0
  26. package/dist/primitives/diff/DiffRoot.js +8 -0
  27. package/dist/primitives/diff/DiffRoot.js.map +1 -0
  28. package/dist/primitives/diff/DiffStats.d.ts +10 -0
  29. package/dist/primitives/diff/DiffStats.d.ts.map +1 -0
  30. package/dist/primitives/diff/DiffStats.js +12 -0
  31. package/dist/primitives/diff/DiffStats.js.map +1 -0
  32. package/dist/primitives/diff/DiffView.d.ts +13 -0
  33. package/dist/primitives/diff/DiffView.d.ts.map +1 -0
  34. package/dist/primitives/diff/DiffView.js +76 -0
  35. package/dist/primitives/diff/DiffView.js.map +1 -0
  36. package/dist/primitives/diff/diff-utils.d.ts +9 -0
  37. package/dist/primitives/diff/diff-utils.d.ts.map +1 -0
  38. package/dist/primitives/diff/diff-utils.js +125 -0
  39. package/dist/primitives/diff/diff-utils.js.map +1 -0
  40. package/dist/primitives/diff/index.d.ts +7 -0
  41. package/dist/primitives/diff/index.d.ts.map +1 -0
  42. package/dist/primitives/diff/index.js +6 -0
  43. package/dist/primitives/diff/index.js.map +1 -0
  44. package/dist/primitives/diff/types.d.ts +24 -0
  45. package/dist/primitives/diff/types.d.ts.map +1 -0
  46. package/dist/primitives/diff/types.js +2 -0
  47. package/dist/primitives/diff/types.js.map +1 -0
  48. package/dist/primitives/diff.d.ts +2 -0
  49. package/dist/primitives/diff.d.ts.map +1 -0
  50. package/dist/primitives/diff.js +2 -0
  51. package/dist/primitives/diff.js.map +1 -0
  52. package/package.json +9 -7
  53. package/src/index.ts +2 -0
  54. package/src/primitives/composer/ComposerSend.tsx +1 -1
  55. package/src/primitives/diff/DiffContent.tsx +77 -0
  56. package/src/primitives/diff/DiffContext.tsx +18 -0
  57. package/src/primitives/diff/DiffHeader.tsx +38 -0
  58. package/src/primitives/diff/DiffLine.tsx +42 -0
  59. package/src/primitives/diff/DiffRoot.tsx +25 -0
  60. package/src/primitives/diff/DiffStats.tsx +22 -0
  61. package/src/primitives/diff/DiffView.test.tsx +340 -0
  62. package/src/primitives/diff/DiffView.tsx +204 -0
  63. package/src/primitives/diff/diff-utils.ts +149 -0
  64. package/src/primitives/diff/index.ts +25 -0
  65. package/src/primitives/diff/types.ts +28 -0
  66. package/src/primitives/diff.ts +1 -0
@@ -0,0 +1,24 @@
1
+ export type DiffLineType = "add" | "del" | "normal";
2
+ export interface ParsedLine {
3
+ type: DiffLineType;
4
+ content: string;
5
+ oldLineNumber?: number;
6
+ newLineNumber?: number;
7
+ }
8
+ export interface ParsedFile {
9
+ oldName?: string | undefined;
10
+ newName?: string | undefined;
11
+ lines: ParsedLine[];
12
+ additions: number;
13
+ deletions: number;
14
+ }
15
+ export interface FoldedRegion {
16
+ type: "fold";
17
+ hiddenCount: number;
18
+ }
19
+ export type DisplayLine = ParsedLine | FoldedRegion;
20
+ export interface DiffFileInput {
21
+ content: string;
22
+ name?: string | undefined;
23
+ }
24
+ //# sourceMappingURL=types.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../../src/primitives/diff/types.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,YAAY,GAAG,KAAK,GAAG,KAAK,GAAG,QAAQ,CAAC;AAEpD,MAAM,WAAW,UAAU;IACzB,IAAI,EAAE,YAAY,CAAC;IACnB,OAAO,EAAE,MAAM,CAAC;IAChB,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,aAAa,CAAC,EAAE,MAAM,CAAC;CACxB;AAED,MAAM,WAAW,UAAU;IACzB,OAAO,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAC7B,OAAO,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAC7B,KAAK,EAAE,UAAU,EAAE,CAAC;IACpB,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,EAAE,MAAM,CAAC;CACnB;AAED,MAAM,WAAW,YAAY;IAC3B,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,EAAE,MAAM,CAAC;CACrB;AAED,MAAM,MAAM,WAAW,GAAG,UAAU,GAAG,YAAY,CAAC;AAEpD,MAAM,WAAW,aAAa;IAC5B,OAAO,EAAE,MAAM,CAAC;IAChB,IAAI,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;CAC3B"}
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=types.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.js","sourceRoot":"","sources":["../../../src/primitives/diff/types.ts"],"names":[],"mappings":""}
@@ -0,0 +1,2 @@
1
+ export * from "./diff/index.js";
2
+ //# sourceMappingURL=diff.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"diff.d.ts","sourceRoot":"","sources":["../../src/primitives/diff.ts"],"names":[],"mappings":"AAAA,gCAA6B"}
@@ -0,0 +1,2 @@
1
+ export * from "./diff/index.js";
2
+ //# sourceMappingURL=diff.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"diff.js","sourceRoot":"","sources":["../../src/primitives/diff.ts"],"names":[],"mappings":"AAAA,gCAA6B"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@assistant-ui/react-ink",
3
- "version": "0.0.6",
3
+ "version": "0.0.7",
4
4
  "description": "React Ink (terminal UI) bindings for assistant-ui",
5
5
  "keywords": [
6
6
  "assistant",
@@ -34,12 +34,14 @@
34
34
  ],
35
35
  "sideEffects": false,
36
36
  "dependencies": {
37
- "@assistant-ui/core": "^0.1.7",
38
- "@assistant-ui/store": "^0.2.3",
39
- "@assistant-ui/tap": "^0.5.3",
40
- "assistant-stream": "^0.3.6",
37
+ "@assistant-ui/core": "^0.1.8",
38
+ "@assistant-ui/store": "^0.2.4",
39
+ "@assistant-ui/tap": "^0.5.4",
40
+ "assistant-stream": "^0.3.7",
41
+ "diff": "^8.0.3",
41
42
  "ink-spinner": "^5.0.0",
42
- "zustand": "^5.0.11"
43
+ "parse-diff": "^0.11.1",
44
+ "zustand": "^5.0.12"
43
45
  },
44
46
  "peerDependencies": {
45
47
  "@types/react": "*",
@@ -55,7 +57,7 @@
55
57
  "@types/react": "^19.2.14",
56
58
  "ink": "^6.8.0",
57
59
  "ink-testing-library": "^4.0.0",
58
- "react": "^19.1.0",
60
+ "react": "^19.2.4",
59
61
  "vitest": "^4.1.0",
60
62
  "@assistant-ui/x-buildutils": "0.0.3"
61
63
  },
package/src/index.ts CHANGED
@@ -114,6 +114,8 @@ export * as ChainOfThoughtPrimitive from "./primitives/chainOfThought";
114
114
  export * as SuggestionPrimitive from "./primitives/suggestion";
115
115
  export * as ToolCallPrimitive from "./primitives/toolCall";
116
116
  export * as ErrorPrimitive from "./primitives/error";
117
+ export * as DiffPrimitive from "./primitives/diff";
118
+ export { DiffView, type DiffViewProps } from "./primitives/diff/DiffView";
117
119
 
118
120
  // Re-export shared providers from core/react
119
121
  export {
@@ -15,7 +15,7 @@ export const ComposerSend = ({
15
15
 
16
16
  return (
17
17
  <Pressable
18
- onPress={send}
18
+ onPress={() => send()}
19
19
  disabled={disabled ?? hookDisabled}
20
20
  {...pressableProps}
21
21
  >
@@ -0,0 +1,77 @@
1
+ import { type ComponentProps, type ReactNode, Fragment } from "react";
2
+ import { Box, Text } from "ink";
3
+ import { useMemo } from "react";
4
+ import { useDiffContext } from "./DiffContext";
5
+ import { DiffLine } from "./DiffLine";
6
+ import { foldContext } from "./diff-utils";
7
+ import type { ParsedLine, DisplayLine, FoldedRegion } from "./types";
8
+
9
+ export type DiffContentProps = ComponentProps<typeof Box> & {
10
+ fileIndex?: number | undefined;
11
+ showLineNumbers?: boolean | undefined;
12
+ contextLines?: number | undefined;
13
+ maxLines?: number | undefined;
14
+ renderLine?:
15
+ | ((props: { line: ParsedLine; index: number }) => ReactNode)
16
+ | undefined;
17
+ renderFold?:
18
+ | ((props: { region: FoldedRegion; index: number }) => ReactNode)
19
+ | undefined;
20
+ };
21
+
22
+ export const DiffContent = ({
23
+ fileIndex = 0,
24
+ showLineNumbers = true,
25
+ contextLines,
26
+ maxLines,
27
+ renderLine,
28
+ renderFold,
29
+ ...boxProps
30
+ }: DiffContentProps) => {
31
+ const { files } = useDiffContext();
32
+ const file = files[fileIndex];
33
+
34
+ const displayLines: DisplayLine[] = useMemo(() => {
35
+ if (!file) return [];
36
+ if (contextLines !== undefined) {
37
+ return foldContext(file.lines, contextLines);
38
+ }
39
+ return file.lines;
40
+ }, [file, contextLines]);
41
+
42
+ if (!file) return null;
43
+
44
+ const truncated = maxLines !== undefined && displayLines.length > maxLines;
45
+ const visibleLines = truncated
46
+ ? displayLines.slice(0, maxLines)
47
+ : displayLines;
48
+ const remainingCount = truncated ? displayLines.length - maxLines! : 0;
49
+
50
+ return (
51
+ <Box flexDirection="column" {...boxProps}>
52
+ {visibleLines.map((line, i) => {
53
+ if (line.type === "fold") {
54
+ if (renderFold) {
55
+ return (
56
+ <Fragment key={i}>
57
+ {renderFold({ region: line, index: i })}
58
+ </Fragment>
59
+ );
60
+ }
61
+ return (
62
+ <Text key={i}>{` --- ${line.hiddenCount} lines hidden ---`}</Text>
63
+ );
64
+ }
65
+ if (renderLine) {
66
+ return <Fragment key={i}>{renderLine({ line, index: i })}</Fragment>;
67
+ }
68
+ return (
69
+ <DiffLine key={i} line={line} showLineNumbers={showLineNumbers} />
70
+ );
71
+ })}
72
+ {truncated && <Text>{`... (${remainingCount} more lines)`}</Text>}
73
+ </Box>
74
+ );
75
+ };
76
+
77
+ DiffContent.displayName = "DiffPrimitive.Content";
@@ -0,0 +1,18 @@
1
+ import { createContext, useContext } from "react";
2
+ import type { ParsedFile } from "./types";
3
+
4
+ interface DiffContextValue {
5
+ files: ParsedFile[];
6
+ }
7
+
8
+ const DiffContext = createContext<DiffContextValue | null>(null);
9
+
10
+ export const useDiffContext = (): DiffContextValue => {
11
+ const ctx = useContext(DiffContext);
12
+ if (!ctx) {
13
+ throw new Error("useDiffContext must be used within a DiffRoot");
14
+ }
15
+ return ctx;
16
+ };
17
+
18
+ export const DiffContextProvider = DiffContext.Provider;
@@ -0,0 +1,38 @@
1
+ import type { ComponentProps } from "react";
2
+ import { Box, Text } from "ink";
3
+ import { useDiffContext } from "./DiffContext";
4
+ import { DiffStats } from "./DiffStats";
5
+
6
+ export type DiffHeaderProps = ComponentProps<typeof Box> & {
7
+ fileIndex?: number;
8
+ };
9
+
10
+ export const DiffHeader = ({ fileIndex = 0, ...boxProps }: DiffHeaderProps) => {
11
+ const { files } = useDiffContext();
12
+ const file = files[fileIndex];
13
+ if (!file) return null;
14
+
15
+ const isDevNull = (n: string | undefined) => !n || n === "/dev/null";
16
+ const renamed =
17
+ !isDevNull(file.oldName) &&
18
+ !isDevNull(file.newName) &&
19
+ file.oldName !== file.newName;
20
+ const displayName = isDevNull(file.newName) ? file.oldName : file.newName;
21
+
22
+ return (
23
+ <Box gap={1} {...boxProps}>
24
+ {renamed ? (
25
+ <>
26
+ <Text>{file.oldName}</Text>
27
+ <Text>{"->"}</Text>
28
+ <Text>{file.newName}</Text>
29
+ </>
30
+ ) : (
31
+ <Text>{displayName}</Text>
32
+ )}
33
+ <DiffStats fileIndex={fileIndex} />
34
+ </Box>
35
+ );
36
+ };
37
+
38
+ DiffHeader.displayName = "DiffPrimitive.Header";
@@ -0,0 +1,42 @@
1
+ import type { ComponentProps } from "react";
2
+ import { Box, Text } from "ink";
3
+ import type { ParsedLine } from "./types";
4
+
5
+ const INDICATOR: Record<ParsedLine["type"], string> = {
6
+ add: "+",
7
+ del: "-",
8
+ normal: " ",
9
+ };
10
+
11
+ export type DiffLineProps = ComponentProps<typeof Box> & {
12
+ line: ParsedLine;
13
+ showLineNumbers?: boolean;
14
+ lineNumberWidth?: number;
15
+ };
16
+
17
+ export const DiffLine = ({
18
+ line,
19
+ showLineNumbers = true,
20
+ lineNumberWidth = 4,
21
+ ...boxProps
22
+ }: DiffLineProps) => {
23
+ const lineNum =
24
+ line.type === "del"
25
+ ? line.oldLineNumber
26
+ : line.type === "add"
27
+ ? line.newLineNumber
28
+ : line.oldLineNumber;
29
+
30
+ const numStr = lineNum !== undefined ? String(lineNum) : "";
31
+ const padded = numStr.padStart(lineNumberWidth);
32
+ const content = `${INDICATOR[line.type]} ${line.content}`;
33
+
34
+ return (
35
+ <Box {...boxProps}>
36
+ {showLineNumbers && <Text>{padded} </Text>}
37
+ <Text>{content}</Text>
38
+ </Box>
39
+ );
40
+ };
41
+
42
+ DiffLine.displayName = "DiffPrimitive.Line";
@@ -0,0 +1,25 @@
1
+ import type { ComponentProps, ReactNode } from "react";
2
+ import { Box } from "ink";
3
+ import { DiffContextProvider } from "./DiffContext";
4
+ import type { ParsedFile } from "./types";
5
+
6
+ export type DiffRootProps = ComponentProps<typeof Box> & {
7
+ files?: ParsedFile[] | undefined;
8
+ children: ReactNode;
9
+ };
10
+
11
+ export const DiffRoot = ({
12
+ files = [],
13
+ children,
14
+ ...boxProps
15
+ }: DiffRootProps) => {
16
+ return (
17
+ <DiffContextProvider value={{ files }}>
18
+ <Box flexDirection="column" {...boxProps}>
19
+ {children}
20
+ </Box>
21
+ </DiffContextProvider>
22
+ );
23
+ };
24
+
25
+ DiffRoot.displayName = "DiffPrimitive.Root";
@@ -0,0 +1,22 @@
1
+ import type { ComponentProps } from "react";
2
+ import { Box, Text } from "ink";
3
+ import { useDiffContext } from "./DiffContext";
4
+
5
+ export type DiffStatsProps = ComponentProps<typeof Box> & {
6
+ fileIndex?: number;
7
+ };
8
+
9
+ export const DiffStats = ({ fileIndex = 0, ...boxProps }: DiffStatsProps) => {
10
+ const { files } = useDiffContext();
11
+ const file = files[fileIndex];
12
+ if (!file) return null;
13
+
14
+ return (
15
+ <Box gap={1} {...boxProps}>
16
+ <Text>+{file.additions}</Text>
17
+ <Text>-{file.deletions}</Text>
18
+ </Box>
19
+ );
20
+ };
21
+
22
+ DiffStats.displayName = "DiffPrimitive.Stats";
@@ -0,0 +1,340 @@
1
+ import type { ReactElement } from "react";
2
+ import { afterEach, describe, expect, it } from "vitest";
3
+ import { cleanup, render } from "ink-testing-library";
4
+ import { parsePatch, computeDiff, foldContext } from "./diff-utils";
5
+ import { DiffContent } from "./DiffContent";
6
+ import { DiffHeader } from "./DiffHeader";
7
+ import { DiffRoot } from "./DiffRoot";
8
+ import { DiffView } from "./DiffView";
9
+
10
+ const renderFrame = async (node: ReactElement) => {
11
+ const instance = render(node);
12
+ await new Promise((resolve) => setTimeout(resolve, 0));
13
+ return instance.lastFrame() ?? "";
14
+ };
15
+
16
+ afterEach(() => {
17
+ cleanup();
18
+ });
19
+
20
+ const SAMPLE_PATCH = `diff --git a/hello.txt b/hello.txt
21
+ index 1234567..abcdefg 100644
22
+ --- a/hello.txt
23
+ +++ b/hello.txt
24
+ @@ -1,3 +1,4 @@
25
+ line 1
26
+ -line 2
27
+ +line 2 modified
28
+ +line 2.5 added
29
+ line 3
30
+ `;
31
+
32
+ describe("parsePatch", () => {
33
+ it("parses a unified diff string", () => {
34
+ const files = parsePatch(SAMPLE_PATCH);
35
+ expect(files).toHaveLength(1);
36
+ const file = files[0]!;
37
+ expect(file.oldName).toBe("hello.txt");
38
+ expect(file.newName).toBe("hello.txt");
39
+ expect(file.additions).toBe(2);
40
+ expect(file.deletions).toBe(1);
41
+
42
+ const types = file.lines.map((l) => l.type);
43
+ expect(types).toContain("add");
44
+ expect(types).toContain("del");
45
+ expect(types).toContain("normal");
46
+ });
47
+
48
+ it("strips CRLF line endings from parsed patch lines", () => {
49
+ const patch = `diff --git a/x.txt b/x.txt
50
+ --- a/x.txt
51
+ +++ b/x.txt
52
+ @@ -1,2 +1,2 @@
53
+ a\r
54
+ -b\r
55
+ +c\r
56
+ `;
57
+
58
+ expect(parsePatch(patch)).toEqual([
59
+ {
60
+ oldName: "x.txt",
61
+ newName: "x.txt",
62
+ additions: 1,
63
+ deletions: 1,
64
+ lines: [
65
+ {
66
+ type: "normal",
67
+ content: "a",
68
+ oldLineNumber: 1,
69
+ newLineNumber: 1,
70
+ },
71
+ {
72
+ type: "del",
73
+ content: "b",
74
+ oldLineNumber: 2,
75
+ },
76
+ {
77
+ type: "add",
78
+ content: "c",
79
+ newLineNumber: 2,
80
+ },
81
+ ],
82
+ },
83
+ ]);
84
+ });
85
+
86
+ it("ignores no-newline markers in unified diff patches", () => {
87
+ const patch = `diff --git a/a.txt b/a.txt
88
+ --- a/a.txt
89
+ +++ b/a.txt
90
+ @@ -1 +1 @@
91
+ -old
92
+ \
93
+ +new
94
+ \
95
+ `;
96
+
97
+ expect(parsePatch(patch)).toEqual([
98
+ {
99
+ oldName: "a.txt",
100
+ newName: "a.txt",
101
+ additions: 1,
102
+ deletions: 1,
103
+ lines: [
104
+ {
105
+ type: "del",
106
+ content: "old",
107
+ oldLineNumber: 1,
108
+ },
109
+ {
110
+ type: "add",
111
+ content: "new",
112
+ newLineNumber: 1,
113
+ },
114
+ ],
115
+ },
116
+ ]);
117
+ });
118
+ });
119
+
120
+ describe("computeDiff", () => {
121
+ it("diffs two strings", () => {
122
+ const result = computeDiff("alpha\nbeta\n", "alpha\ngamma\n");
123
+ expect(result.additions).toBeGreaterThan(0);
124
+ expect(result.deletions).toBeGreaterThan(0);
125
+ const types = result.lines.map((l) => l.type);
126
+ expect(types).toContain("add");
127
+ expect(types).toContain("del");
128
+ expect(types).toContain("normal");
129
+ });
130
+
131
+ it("preserves blank-line additions and deletions", () => {
132
+ expect(computeDiff("a\n", "a\n\n")).toEqual({
133
+ additions: 1,
134
+ deletions: 0,
135
+ lines: [
136
+ {
137
+ type: "normal",
138
+ content: "a",
139
+ oldLineNumber: 1,
140
+ newLineNumber: 1,
141
+ },
142
+ {
143
+ type: "add",
144
+ content: "",
145
+ newLineNumber: 2,
146
+ },
147
+ ],
148
+ });
149
+
150
+ expect(computeDiff("a\n\n", "a\n")).toEqual({
151
+ additions: 0,
152
+ deletions: 1,
153
+ lines: [
154
+ {
155
+ type: "normal",
156
+ content: "a",
157
+ oldLineNumber: 1,
158
+ newLineNumber: 1,
159
+ },
160
+ {
161
+ type: "del",
162
+ content: "",
163
+ oldLineNumber: 2,
164
+ },
165
+ ],
166
+ });
167
+ });
168
+
169
+ it("strips CRLF line endings from computed diffs", () => {
170
+ expect(computeDiff("a\r\nb\r\n", "a\r\nc\r\n")).toEqual({
171
+ additions: 1,
172
+ deletions: 1,
173
+ lines: [
174
+ {
175
+ type: "normal",
176
+ content: "a",
177
+ oldLineNumber: 1,
178
+ newLineNumber: 1,
179
+ },
180
+ {
181
+ type: "del",
182
+ content: "b",
183
+ oldLineNumber: 2,
184
+ },
185
+ {
186
+ type: "add",
187
+ content: "c",
188
+ newLineNumber: 2,
189
+ },
190
+ ],
191
+ });
192
+ });
193
+ });
194
+
195
+ describe("foldContext", () => {
196
+ it("folds unchanged regions beyond contextLines", () => {
197
+ const lines = [
198
+ ...Array.from({ length: 10 }, (_, i) => ({
199
+ type: "normal" as const,
200
+ content: `line ${i}`,
201
+ oldLineNumber: i + 1,
202
+ newLineNumber: i + 1,
203
+ })),
204
+ { type: "add" as const, content: "new line", newLineNumber: 11 },
205
+ ...Array.from({ length: 10 }, (_, i) => ({
206
+ type: "normal" as const,
207
+ content: `line ${i + 11}`,
208
+ oldLineNumber: i + 11,
209
+ newLineNumber: i + 12,
210
+ })),
211
+ ];
212
+
213
+ const result = foldContext(lines, 2);
214
+ const folds = result.filter((l) => l.type === "fold");
215
+ expect(folds.length).toBeGreaterThan(0);
216
+ const totalHidden = folds.reduce(
217
+ (sum, f) => sum + (f.type === "fold" ? f.hiddenCount : 0),
218
+ 0,
219
+ );
220
+ expect(totalHidden).toBe(16);
221
+ });
222
+ });
223
+
224
+ describe("DiffView", () => {
225
+ it("supports composing primitives from prepared files", async () => {
226
+ const frame = await renderFrame(
227
+ <DiffRoot
228
+ files={[
229
+ {
230
+ oldName: "before.txt",
231
+ newName: "after.txt",
232
+ additions: 1,
233
+ deletions: 1,
234
+ lines: [
235
+ {
236
+ type: "del",
237
+ content: "before",
238
+ oldLineNumber: 1,
239
+ },
240
+ {
241
+ type: "add",
242
+ content: "after",
243
+ newLineNumber: 1,
244
+ },
245
+ ],
246
+ },
247
+ ]}
248
+ >
249
+ <DiffHeader />
250
+ <DiffContent />
251
+ </DiffRoot>,
252
+ );
253
+
254
+ expect(frame).toContain("before.txt");
255
+ expect(frame).toContain("after.txt");
256
+ expect(frame).toContain("+1");
257
+ expect(frame).toContain("-1");
258
+ });
259
+
260
+ it("renders a basic patch", async () => {
261
+ const frame = await renderFrame(<DiffView patch={SAMPLE_PATCH} />);
262
+ expect(frame).toContain("hello.txt");
263
+ expect(frame).toContain("+");
264
+ expect(frame).toContain("-");
265
+ });
266
+
267
+ it("renders from oldFile/newFile", async () => {
268
+ const frame = await renderFrame(
269
+ <DiffView
270
+ oldFile={{ content: "hello\nworld\n", name: "test.txt" }}
271
+ newFile={{ content: "hello\nearth\n", name: "test.txt" }}
272
+ />,
273
+ );
274
+ expect(frame).toContain("test.txt");
275
+ expect(frame).toContain("+");
276
+ expect(frame).toContain("-");
277
+ });
278
+
279
+ it("hides line numbers when showLineNumbers=false", async () => {
280
+ const withNumbers = await renderFrame(<DiffView patch={SAMPLE_PATCH} />);
281
+ const withoutNumbers = await renderFrame(
282
+ <DiffView patch={SAMPLE_PATCH} showLineNumbers={false} />,
283
+ );
284
+ expect(withNumbers.length).toBeGreaterThan(withoutNumbers.length);
285
+ });
286
+
287
+ it("truncates with maxLines", async () => {
288
+ const manyLines = Array.from({ length: 50 }, (_, i) => `+line${i}`).join(
289
+ "\n",
290
+ );
291
+ const patch = `diff --git a/big.txt b/big.txt
292
+ --- a/big.txt
293
+ +++ b/big.txt
294
+ @@ -0,0 +1,50 @@
295
+ ${manyLines}
296
+ `;
297
+ const frame = await renderFrame(<DiffView patch={patch} maxLines={5} />);
298
+ expect(frame).toContain("more lines");
299
+ });
300
+
301
+ it("folds context lines", async () => {
302
+ const normalBefore = Array.from({ length: 10 }, (_, i) => ` line${i}`).join(
303
+ "\n",
304
+ );
305
+ const normalAfter = Array.from({ length: 10 }, (_, i) => ` after${i}`).join(
306
+ "\n",
307
+ );
308
+ const patch = `diff --git a/ctx.txt b/ctx.txt
309
+ --- a/ctx.txt
310
+ +++ b/ctx.txt
311
+ @@ -1,21 +1,22 @@
312
+ ${normalBefore}
313
+ +inserted
314
+ ${normalAfter}
315
+ `;
316
+ const frame = await renderFrame(
317
+ <DiffView patch={patch} contextLines={2} />,
318
+ );
319
+ expect(frame).toContain("lines hidden");
320
+ });
321
+
322
+ it("renders multi-file patches", async () => {
323
+ const patch = `diff --git a/a.txt b/a.txt
324
+ --- a/a.txt
325
+ +++ b/a.txt
326
+ @@ -1 +1 @@
327
+ -old a
328
+ +new a
329
+ diff --git a/b.txt b/b.txt
330
+ --- a/b.txt
331
+ +++ b/b.txt
332
+ @@ -1 +1 @@
333
+ -old b
334
+ +new b
335
+ `;
336
+ const frame = await renderFrame(<DiffView patch={patch} />);
337
+ expect(frame).toContain("a.txt");
338
+ expect(frame).toContain("b.txt");
339
+ });
340
+ });