critique 0.1.43 → 0.1.47

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,31 @@
1
+ # 0.1.47
2
+
3
+ - Add vim-style keyboard navigation:
4
+ - `G` (Shift+g) - scroll to bottom
5
+ - `gg` (double-tap g) - scroll to top
6
+ - `Ctrl+D` - half page down
7
+ - `Ctrl+U` - half page up
8
+ - `review` command: change debug console toggle from `Ctrl+D` to `Ctrl+Z` (consistent with main viewer)
9
+ - Fix theme loading on Windows by using `fileURLToPath` for proper path conversion
10
+
11
+ # 0.1.46
12
+
13
+ - Add directory tree view at top of diff TUIs (default, review, web commands)
14
+ - Switch opentui packages to npm releases (from pkg.pr.new preview URLs)
15
+ - Add missing `marked` dependency
16
+ - Fix Q and Escape keys not working to exit when there are no changes to display (fixes #16)
17
+
18
+ # 0.1.45
19
+
20
+ - Support git range syntax in single argument: `critique origin/main...HEAD` or `critique main..feature`
21
+
22
+ # 0.1.44
23
+
24
+ - Fix parsing error with submodule status lines:
25
+ - Handle "Submodule name contains modified content" lines
26
+ - Handle "Submodule name contains untracked content" lines
27
+ - Handle "Submodule name (new commits)" and similar status lines
28
+
1
29
  # 0.1.43
2
30
 
3
31
  - Show full submodule diffs instead of just commit hashes:
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "critique",
3
3
  "module": "src/diff.tsx",
4
4
  "type": "module",
5
- "version": "0.1.43",
5
+ "version": "0.1.47",
6
6
  "private": false,
7
7
  "bin": "./src/cli.tsx",
8
8
  "scripts": {
@@ -22,15 +22,16 @@
22
22
  "wrangler": "^4.19.1"
23
23
  },
24
24
  "dependencies": {
25
- "@agentclientprotocol/sdk": "^0.12.0",
25
+ "@agentclientprotocol/sdk": "^0.13.1",
26
26
  "@clack/prompts": "1.0.0-alpha.9",
27
- "@opentui/core": "https://pkg.pr.new/anomalyco/opentui/@opentui/core@367a94087821b3b5feedd35bbb57df43b10a286e",
28
- "@opentui/react": "https://pkg.pr.new/anomalyco/opentui/@opentui/react@367a94087821b3b5feedd35bbb57df43b10a286e",
29
- "@parcel/watcher": "^2.5.1",
27
+ "@opentui/core": "^0",
28
+ "@opentui/react": "^0",
29
+ "@parcel/watcher": "^2.5.6",
30
30
  "cac": "^6.7.14",
31
31
  "diff": "^8.0.2",
32
32
  "ghostty-opentui": "^1.3.12",
33
33
  "js-yaml": "^4.1.1",
34
+ "marked": "^17.0.1",
34
35
  "picocolors": "^1.1.1",
35
36
  "react": "^19.2.0",
36
37
  "zustand": "^5.0.8"
package/src/cli.tsx CHANGED
@@ -27,11 +27,12 @@ import { join } from "path";
27
27
  import { create } from "zustand";
28
28
  import Dropdown from "./dropdown.tsx";
29
29
  import { debounce } from "./utils.ts";
30
- import { DiffView } from "./components/diff-view.tsx";
30
+ import { DiffView, DirectoryTreeView } from "./components/index.ts";
31
31
  import { logger } from "./logger.ts";
32
32
  import {
33
33
  buildGitCommand,
34
34
  getFileName,
35
+ getFileStatus,
35
36
  countChanges,
36
37
  getViewMode,
37
38
  processFiles,
@@ -40,6 +41,7 @@ import {
40
41
  IGNORED_FILES,
41
42
  type ParsedFile,
42
43
  } from "./diff-utils.ts";
44
+ import type { TreeFileInfo } from "./directory-tree.ts";
43
45
 
44
46
  // Lazy-load watcher only when --watch is used
45
47
  let watcherModule: typeof import("@parcel/watcher") | null = null;
@@ -1258,6 +1260,9 @@ function App({ parsedFiles }: AppProps) {
1258
1260
  const scrollboxRef = React.useRef<ScrollBoxRenderable | null>(null);
1259
1261
  const fileRefs = React.useRef<Map<number, BoxRenderable>>(new Map());
1260
1262
 
1263
+ // Ref for double-tap detection (gg)
1264
+ const lastKeyRef = React.useRef<{ key: string; time: number } | null>(null);
1265
+
1261
1266
  useOnResize(
1262
1267
  React.useCallback((newWidth: number) => {
1263
1268
  setWidth(newWidth);
@@ -1293,7 +1298,43 @@ function App({ parsedFiles }: AppProps) {
1293
1298
 
1294
1299
  if (key.name === "z" && key.ctrl) {
1295
1300
  renderer.console.toggle();
1301
+ return;
1296
1302
  }
1303
+
1304
+ // Vim-style scroll navigation
1305
+ const scrollbox = scrollboxRef.current;
1306
+ if (scrollbox) {
1307
+ // G - go to bottom
1308
+ if (key.name === "g" && key.shift) {
1309
+ scrollbox.scrollBy(1, "content");
1310
+ return;
1311
+ }
1312
+
1313
+ // gg - go to top (double-tap within 300ms)
1314
+ if (key.name === "g" && !key.shift && !key.ctrl) {
1315
+ const now = Date.now();
1316
+ if (lastKeyRef.current?.key === "g" && now - lastKeyRef.current.time < 300) {
1317
+ scrollbox.scrollTo(0);
1318
+ lastKeyRef.current = null;
1319
+ } else {
1320
+ lastKeyRef.current = { key: "g", time: now };
1321
+ }
1322
+ return;
1323
+ }
1324
+
1325
+ // Ctrl+D - half page down
1326
+ if (key.ctrl && key.name === "d") {
1327
+ scrollbox.scrollBy(0.5, "viewport");
1328
+ return;
1329
+ }
1330
+
1331
+ // Ctrl+U - half page up
1332
+ if (key.ctrl && key.name === "u") {
1333
+ scrollbox.scrollBy(-0.5, "viewport");
1334
+ return;
1335
+ }
1336
+ }
1337
+
1297
1338
  if (key.option) {
1298
1339
  if (key.eventType === "release") {
1299
1340
  scrollAcceleration.multiplier = 1;
@@ -1332,10 +1373,20 @@ function App({ parsedFiles }: AppProps) {
1332
1373
  };
1333
1374
  });
1334
1375
 
1335
- const handleFileSelect = (value: string) => {
1336
- const index = parseInt(value, 10);
1337
-
1338
- // Scroll to file (scrollbox is always mounted)
1376
+ // Build tree data for directory tree view
1377
+ const treeFiles: TreeFileInfo[] = parsedFiles.map((file, idx) => {
1378
+ const { additions, deletions } = countChanges(file.hunks);
1379
+ return {
1380
+ path: getFileName(file),
1381
+ status: getFileStatus(file),
1382
+ additions,
1383
+ deletions,
1384
+ fileIndex: idx,
1385
+ };
1386
+ });
1387
+
1388
+ // Scroll to file by index
1389
+ const scrollToFile = (index: number) => {
1339
1390
  const scrollbox = scrollboxRef.current;
1340
1391
  const fileRef = fileRefs.current.get(index);
1341
1392
  if (scrollbox && fileRef) {
@@ -1343,10 +1394,18 @@ function App({ parsedFiles }: AppProps) {
1343
1394
  const targetY = fileRef.y - contentY;
1344
1395
  scrollbox.scrollTo(Math.max(0, targetY));
1345
1396
  }
1346
-
1397
+ };
1398
+
1399
+ const handleFileSelect = (value: string) => {
1400
+ const index = parseInt(value, 10);
1401
+ scrollToFile(index);
1347
1402
  setShowDropdown(false);
1348
1403
  };
1349
1404
 
1405
+ const handleTreeFileSelect = (fileIndex: number) => {
1406
+ scrollToFile(fileIndex);
1407
+ };
1408
+
1350
1409
  const themeOptions = themeNames.map((name) => ({
1351
1410
  title: name,
1352
1411
  value: name,
@@ -1365,6 +1424,15 @@ function App({ parsedFiles }: AppProps) {
1365
1424
  // Render all files content (used in both theme picker preview and main view)
1366
1425
  const renderAllFiles = () => (
1367
1426
  <box style={{ flexDirection: "column" }}>
1427
+ {/* Directory tree at the top */}
1428
+ <box style={{ marginBottom: 2 }}>
1429
+ <DirectoryTreeView
1430
+ files={treeFiles}
1431
+ onFileSelect={handleTreeFileSelect}
1432
+ themeName={activeTheme}
1433
+ />
1434
+ </box>
1435
+
1368
1436
  {parsedFiles.map((file, idx) => {
1369
1437
  const fileName = getFileName(file);
1370
1438
  const filetype = detectFiletype(fileName);
@@ -1612,6 +1680,15 @@ cli
1612
1680
  ParsedFile[] | null
1613
1681
  >(null);
1614
1682
 
1683
+ const watchRenderer = useRenderer();
1684
+
1685
+ // Handle exit keys (Q, Escape) for loading and empty states
1686
+ useKeyboard((key) => {
1687
+ if (key.name === "escape" || key.name === "q") {
1688
+ watchRenderer.destroy();
1689
+ }
1690
+ });
1691
+
1615
1692
  React.useEffect(() => {
1616
1693
  const fetchDiff = async () => {
1617
1694
  try {
@@ -0,0 +1,133 @@
1
+ // DirectoryTreeView - Renders a directory tree with file status colors and change counts.
2
+ // Shows added files in green, modified in orange, deleted in red.
3
+ // Change counts (+n,-n) use green/red for the numbers, brackets are muted.
4
+ // Supports click-to-scroll via onFileSelect callback.
5
+
6
+ import * as React from "react"
7
+ import { buildDirectoryTree, type TreeFileInfo, type TreeNode } from "../directory-tree.ts"
8
+ import { getResolvedTheme, rgbaToHex } from "../themes.ts"
9
+
10
+ export interface DirectoryTreeViewProps {
11
+ /** Files to display in the tree */
12
+ files: TreeFileInfo[]
13
+ /** Callback when a file is clicked (receives fileIndex) */
14
+ onFileSelect?: (fileIndex: number) => void
15
+ /** Theme name for colors */
16
+ themeName: string
17
+ }
18
+
19
+ /**
20
+ * Get the color for a file based on its status
21
+ * Uses diff colors from theme: green (added), red (deleted), default text (modified)
22
+ */
23
+ function getStatusColor(status: "added" | "modified" | "deleted", theme: ReturnType<typeof getResolvedTheme>): string {
24
+ switch (status) {
25
+ case "added":
26
+ return rgbaToHex(theme.diffAdded) // green
27
+ case "deleted":
28
+ return rgbaToHex(theme.diffRemoved) // red
29
+ case "modified":
30
+ return rgbaToHex(theme.text) // default text color, same as folders
31
+ }
32
+ }
33
+
34
+ interface TreeNodeLineProps {
35
+ node: TreeNode
36
+ theme: ReturnType<typeof getResolvedTheme>
37
+ mutedColor: string
38
+ textColor: string
39
+ onSelect?: () => void
40
+ }
41
+
42
+ /**
43
+ * Render a single tree node line with proper colors
44
+ */
45
+ const TreeNodeLine: React.FC<TreeNodeLineProps> = ({
46
+ node,
47
+ theme,
48
+ mutedColor,
49
+ textColor,
50
+ onSelect,
51
+ }) => {
52
+ const [isHovered, setIsHovered] = React.useState(false)
53
+
54
+ if (node.isFile) {
55
+ // File node - colorize based on status
56
+ const pathColor = node.status ? getStatusColor(node.status, theme) : textColor
57
+ const addColor = rgbaToHex(theme.diffAdded) // green
58
+ const delColor = rgbaToHex(theme.diffRemoved) // red
59
+ const hasAdditions = (node.additions ?? 0) > 0
60
+ const hasDeletions = (node.deletions ?? 0) > 0
61
+
62
+ return (
63
+ <box
64
+ style={{
65
+ flexDirection: "row",
66
+ backgroundColor: isHovered ? rgbaToHex(theme.backgroundPanel) : undefined,
67
+ }}
68
+ onMouseMove={() => setIsHovered(true)}
69
+ onMouseOut={() => setIsHovered(false)}
70
+ onMouseDown={onSelect}
71
+ >
72
+ <text fg={mutedColor}>{node.prefix}{node.connector}</text>
73
+ <text fg={pathColor}>{node.displayPath}</text>
74
+ <text fg={mutedColor}> (</text>
75
+ {hasAdditions && <text fg={addColor}>+{node.additions}</text>}
76
+ {hasAdditions && hasDeletions && <text fg={mutedColor}>,</text>}
77
+ {hasDeletions && <text fg={delColor}>-{node.deletions}</text>}
78
+ <text fg={mutedColor}>)</text>
79
+ </box>
80
+ )
81
+ }
82
+
83
+ // Directory node - use muted color for everything
84
+ return (
85
+ <box style={{ flexDirection: "row" }}>
86
+ <text fg={mutedColor}>{node.prefix}{node.connector}</text>
87
+ <text fg={textColor}>{node.displayPath}</text>
88
+ </box>
89
+ )
90
+ }
91
+
92
+ /**
93
+ * DirectoryTreeView component
94
+ * Renders a directory tree with file status colors and click-to-scroll support
95
+ */
96
+ export function DirectoryTreeView({
97
+ files,
98
+ onFileSelect,
99
+ themeName,
100
+ }: DirectoryTreeViewProps) {
101
+ const nodes = React.useMemo(() => buildDirectoryTree(files), [files])
102
+ const resolvedTheme = getResolvedTheme(themeName)
103
+ const mutedColor = rgbaToHex(resolvedTheme.textMuted)
104
+ const textColor = rgbaToHex(resolvedTheme.text)
105
+
106
+ if (nodes.length === 0) {
107
+ return null
108
+ }
109
+
110
+ return (
111
+ <box
112
+ style={{
113
+ alignSelf: "center",
114
+ flexDirection: "column",
115
+ }}
116
+ >
117
+ {nodes.map((node, idx) => (
118
+ <TreeNodeLine
119
+ key={idx}
120
+ node={node}
121
+ theme={resolvedTheme}
122
+ mutedColor={mutedColor}
123
+ textColor={textColor}
124
+ onSelect={
125
+ node.isFile && node.fileIndex !== undefined && onFileSelect
126
+ ? () => onFileSelect(node.fileIndex!)
127
+ : undefined
128
+ }
129
+ />
130
+ ))}
131
+ </box>
132
+ )
133
+ }
@@ -2,3 +2,4 @@
2
2
  // Exports reusable components used across main diff view and review mode.
3
3
 
4
4
  export { DiffView, type DiffViewProps } from "./diff-view.tsx"
5
+ export { DirectoryTreeView, type DirectoryTreeViewProps } from "./directory-tree-view.tsx"
package/src/diff-utils.ts CHANGED
@@ -3,15 +3,27 @@
3
3
  // and provides helpers for unified/split view mode selection.
4
4
 
5
5
  /**
6
- * Strip submodule header lines from git diff output.
7
- * git diff --submodule=diff adds "Submodule name hash1..hash2:" lines
8
- * that the diff parser doesn't understand.
6
+ * Strip submodule status lines from git diff output.
7
+ * git diff --submodule=diff adds various status lines that the diff parser doesn't understand:
8
+ * - "Submodule name hash1..hash2:" (header before submodule diff)
9
+ * - "Submodule name contains modified content"
10
+ * - "Submodule name contains untracked content"
11
+ * - "Submodule name (new commits)"
12
+ * - "Submodule name (commits not present)"
9
13
  */
10
14
  export function stripSubmoduleHeaders(diffOutput: string): string {
11
- // Match lines like "Submodule errore 1bf6fc8..d746b25:"
12
15
  return diffOutput
13
16
  .split("\n")
14
- .filter((line) => !line.match(/^Submodule \S+ [a-f0-9]+\.\.[a-f0-9]+:$/))
17
+ .filter((line) => {
18
+ // Match lines like "Submodule errore 1bf6fc8..d746b25:"
19
+ if (line.match(/^Submodule \S+ [a-f0-9]+\.\.[a-f0-9]+:?$/)) return false;
20
+ // Match lines like "Submodule unframer contains modified content"
21
+ if (line.match(/^Submodule \S+ contains (modified|untracked) content$/))
22
+ return false;
23
+ // Match lines like "Submodule name (new commits)" or "(commits not present)"
24
+ if (line.match(/^Submodule \S+ \(.*\)$/)) return false;
25
+ return true;
26
+ })
15
27
  .join("\n");
16
28
  }
17
29
 
@@ -76,6 +88,22 @@ export function buildGitCommand(options: GitCommandOptions): string {
76
88
  if (options.base && options.head) {
77
89
  return `git diff ${options.base}...${options.head} --no-prefix ${submoduleArg} ${contextArg} ${filterArg}`.trim();
78
90
  }
91
+ // Detect range syntax in single base argument (e.g., "origin/main...HEAD" or "main..feature")
92
+ if (options.base && !options.head) {
93
+ // Three-dot syntax: A...B (merge-base to B, like GitHub PRs)
94
+ const threeDotsMatch = options.base.match(/^(.+)\.\.\.(.+)$/);
95
+ if (threeDotsMatch) {
96
+ const [, rangeBase, rangeHead] = threeDotsMatch;
97
+ return `git diff ${rangeBase}...${rangeHead} --no-prefix ${submoduleArg} ${contextArg} ${filterArg}`.trim();
98
+ }
99
+
100
+ // Two-dot syntax: A..B (commits in B not in A)
101
+ const twoDotsMatch = options.base.match(/^(.+)\.\.(.+)$/);
102
+ if (twoDotsMatch) {
103
+ const [, rangeBase, rangeHead] = twoDotsMatch;
104
+ return `git diff ${rangeBase}..${rangeHead} --no-prefix ${submoduleArg} ${contextArg} ${filterArg}`.trim();
105
+ }
106
+ }
79
107
  // Single ref: show that commit's changes
80
108
  if (options.base) {
81
109
  return `git show ${options.base} --no-prefix ${submoduleArg} ${contextArg} ${filterArg}`.trim();
@@ -83,6 +111,24 @@ export function buildGitCommand(options: GitCommandOptions): string {
83
111
  return `git add -N . && git diff --no-prefix ${submoduleArg} ${contextArg} ${filterArg}`.trim();
84
112
  }
85
113
 
114
+ /**
115
+ * Get file status from parsed diff file based on /dev/null presence
116
+ * - added: oldFileName is /dev/null (new file)
117
+ * - deleted: newFileName is /dev/null (removed file)
118
+ * - modified: both files exist (changed file)
119
+ */
120
+ export function getFileStatus(file: {
121
+ oldFileName?: string;
122
+ newFileName?: string;
123
+ }): "added" | "modified" | "deleted" {
124
+ const oldName = file.oldFileName;
125
+ const newName = file.newFileName;
126
+
127
+ if (!oldName || oldName === "/dev/null") return "added";
128
+ if (!newName || newName === "/dev/null") return "deleted";
129
+ return "modified";
130
+ }
131
+
86
132
  /**
87
133
  * Get filename from parsed diff file, handling /dev/null for new/deleted files
88
134
  */
@@ -0,0 +1,332 @@
1
+ // Tests for directory tree building and rendering
2
+ // Uses opentui test renderer with captureCharFrame() for visual testing
3
+
4
+ import { afterEach, describe, expect, it } from "bun:test"
5
+ import { testRender } from "@opentui/react/test-utils"
6
+ import { buildDirectoryTree, type TreeFileInfo, type TreeNode } from "./directory-tree.ts"
7
+ import { DirectoryTreeView } from "./components/directory-tree-view.tsx"
8
+
9
+ /**
10
+ * Simple component to render tree nodes as text for testing
11
+ */
12
+ function TreeRenderer({ nodes }: { nodes: TreeNode[] }) {
13
+ return (
14
+ <box style={{ flexDirection: "column" }}>
15
+ {nodes.map((node, idx) => {
16
+ // Build the line: prefix + connector + path + optional stats
17
+ const statsStr = node.isFile
18
+ ? ` (+${node.additions},-${node.deletions})`
19
+ : ""
20
+
21
+ return (
22
+ <text key={idx}>
23
+ {node.prefix}
24
+ {node.connector}
25
+ {node.displayPath}
26
+ {statsStr}
27
+ </text>
28
+ )
29
+ })}
30
+ </box>
31
+ )
32
+ }
33
+
34
+ describe("buildDirectoryTree", () => {
35
+ it("should return empty array for no files", () => {
36
+ const result = buildDirectoryTree([])
37
+ expect(result).toEqual([])
38
+ })
39
+
40
+ it("should handle single file at root", () => {
41
+ const files: TreeFileInfo[] = [
42
+ { path: "README.md", status: "modified", additions: 5, deletions: 2 },
43
+ ]
44
+ const result = buildDirectoryTree(files)
45
+ expect(result).toHaveLength(1)
46
+ expect(result[0]!.displayPath).toBe("README.md")
47
+ expect(result[0]!.isFile).toBe(true)
48
+ expect(result[0]!.status).toBe("modified")
49
+ })
50
+
51
+ it("should collapse single-child directories", () => {
52
+ const files: TreeFileInfo[] = [
53
+ { path: "src/components/Button.tsx", status: "added", additions: 50, deletions: 0 },
54
+ ]
55
+ const result = buildDirectoryTree(files)
56
+ // Should collapse src/components into one directory node
57
+ expect(result).toHaveLength(2)
58
+ expect(result[0]!.displayPath).toBe("src/components")
59
+ expect(result[0]!.isFile).toBe(false)
60
+ expect(result[1]!.displayPath).toBe("Button.tsx")
61
+ expect(result[1]!.isFile).toBe(true)
62
+ })
63
+ })
64
+
65
+ describe("TreeRenderer visual tests", () => {
66
+ let testSetup: Awaited<ReturnType<typeof testRender>>
67
+
68
+ afterEach(() => {
69
+ if (testSetup) {
70
+ testSetup.renderer.destroy()
71
+ }
72
+ })
73
+
74
+ it("should render single file", async () => {
75
+ const files: TreeFileInfo[] = [
76
+ { path: "package.json", status: "modified", additions: 1, deletions: 1 },
77
+ ]
78
+ const nodes = buildDirectoryTree(files)
79
+
80
+ testSetup = await testRender(<TreeRenderer nodes={nodes} />, {
81
+ width: 50,
82
+ height: 5,
83
+ })
84
+
85
+ await testSetup.renderOnce()
86
+ const frame = testSetup.captureCharFrame()
87
+ expect(frame).toMatchInlineSnapshot(`
88
+ "└── package.json (+1,-1)
89
+
90
+
91
+
92
+
93
+ "
94
+ `)
95
+ })
96
+
97
+ it("should render multiple root files", async () => {
98
+ const files: TreeFileInfo[] = [
99
+ { path: "package.json", status: "modified", additions: 1, deletions: 1 },
100
+ { path: "README.md", status: "added", additions: 20, deletions: 0 },
101
+ { path: "tsconfig.json", status: "deleted", additions: 0, deletions: 15 },
102
+ ]
103
+ const nodes = buildDirectoryTree(files)
104
+
105
+ testSetup = await testRender(<TreeRenderer nodes={nodes} />, {
106
+ width: 50,
107
+ height: 7,
108
+ })
109
+
110
+ await testSetup.renderOnce()
111
+ const frame = testSetup.captureCharFrame()
112
+ expect(frame).toMatchInlineSnapshot(`
113
+ "├── package.json (+1,-1)
114
+ ├── README.md (+20,-0)
115
+ └── tsconfig.json (+0,-15)
116
+
117
+
118
+
119
+
120
+ "
121
+ `)
122
+ })
123
+
124
+ it("should render nested directories with proper connectors", async () => {
125
+ const files: TreeFileInfo[] = [
126
+ { path: "src/index.ts", status: "modified", additions: 5, deletions: 2 },
127
+ { path: "src/utils.ts", status: "added", additions: 30, deletions: 0 },
128
+ ]
129
+ const nodes = buildDirectoryTree(files)
130
+
131
+ testSetup = await testRender(<TreeRenderer nodes={nodes} />, {
132
+ width: 50,
133
+ height: 7,
134
+ })
135
+
136
+ await testSetup.renderOnce()
137
+ const frame = testSetup.captureCharFrame()
138
+ expect(frame).toMatchInlineSnapshot(`
139
+ "└── src
140
+ ├── index.ts (+5,-2)
141
+ └── utils.ts (+30,-0)
142
+
143
+
144
+
145
+
146
+ "
147
+ `)
148
+ })
149
+
150
+ it("should collapse single-child directories", async () => {
151
+ const files: TreeFileInfo[] = [
152
+ { path: "src/components/Button.tsx", status: "added", additions: 50, deletions: 0 },
153
+ { path: "src/components/Input.tsx", status: "added", additions: 40, deletions: 0 },
154
+ ]
155
+ const nodes = buildDirectoryTree(files)
156
+
157
+ testSetup = await testRender(<TreeRenderer nodes={nodes} />, {
158
+ width: 50,
159
+ height: 7,
160
+ })
161
+
162
+ await testSetup.renderOnce()
163
+ const frame = testSetup.captureCharFrame()
164
+ expect(frame).toMatchInlineSnapshot(`
165
+ "└── src/components
166
+ ├── Button.tsx (+50,-0)
167
+ └── Input.tsx (+40,-0)
168
+
169
+
170
+
171
+
172
+ "
173
+ `)
174
+ })
175
+
176
+ it("should render complex nested structure", async () => {
177
+ const files: TreeFileInfo[] = [
178
+ { path: "package.json", status: "modified", additions: 2, deletions: 1, fileIndex: 0 },
179
+ { path: "src/index.ts", status: "modified", additions: 10, deletions: 5, fileIndex: 1 },
180
+ { path: "src/components/Button.tsx", status: "added", additions: 50, deletions: 0, fileIndex: 2 },
181
+ { path: "src/components/Input.tsx", status: "modified", additions: 15, deletions: 8, fileIndex: 3 },
182
+ { path: "src/utils/helpers.ts", status: "deleted", additions: 0, deletions: 30, fileIndex: 4 },
183
+ { path: "tests/index.test.ts", status: "added", additions: 25, deletions: 0, fileIndex: 5 },
184
+ ]
185
+ const nodes = buildDirectoryTree(files)
186
+
187
+ testSetup = await testRender(<TreeRenderer nodes={nodes} />, {
188
+ width: 60,
189
+ height: 15,
190
+ })
191
+
192
+ await testSetup.renderOnce()
193
+ const frame = testSetup.captureCharFrame()
194
+ expect(frame).toMatchInlineSnapshot(`
195
+ "├── package.json (+2,-1)
196
+ ├── src
197
+ │ ├── index.ts (+10,-5)
198
+ │ ├── components
199
+ │ │ ├── Button.tsx (+50,-0)
200
+ │ │ └── Input.tsx (+15,-8)
201
+ │ └── utils
202
+ │ └── helpers.ts (+0,-30)
203
+ └── tests
204
+ └── index.test.ts (+25,-0)
205
+
206
+
207
+
208
+
209
+
210
+ "
211
+ `)
212
+ })
213
+
214
+ it("should handle deeply nested paths with collapse", async () => {
215
+ const files: TreeFileInfo[] = [
216
+ { path: "packages/core/src/lib/utils/helpers.ts", status: "modified", additions: 5, deletions: 3 },
217
+ { path: "packages/core/src/lib/utils/format.ts", status: "added", additions: 20, deletions: 0 },
218
+ ]
219
+ const nodes = buildDirectoryTree(files)
220
+
221
+ testSetup = await testRender(<TreeRenderer nodes={nodes} />, {
222
+ width: 60,
223
+ height: 7,
224
+ })
225
+
226
+ await testSetup.renderOnce()
227
+ const frame = testSetup.captureCharFrame()
228
+ expect(frame).toMatchInlineSnapshot(`
229
+ "└── packages/core/src/lib/utils
230
+ ├── helpers.ts (+5,-3)
231
+ └── format.ts (+20,-0)
232
+
233
+
234
+
235
+
236
+ "
237
+ `)
238
+ })
239
+
240
+ it("should handle sibling directories at different levels", async () => {
241
+ const files: TreeFileInfo[] = [
242
+ { path: "src/api/routes.ts", status: "modified", additions: 10, deletions: 5 },
243
+ { path: "src/api/handlers.ts", status: "added", additions: 30, deletions: 0 },
244
+ { path: "src/db/models.ts", status: "modified", additions: 8, deletions: 2 },
245
+ { path: "lib/utils.ts", status: "added", additions: 15, deletions: 0 },
246
+ ]
247
+ const nodes = buildDirectoryTree(files)
248
+
249
+ testSetup = await testRender(<TreeRenderer nodes={nodes} />, {
250
+ width: 60,
251
+ height: 12,
252
+ })
253
+
254
+ await testSetup.renderOnce()
255
+ const frame = testSetup.captureCharFrame()
256
+ expect(frame).toMatchInlineSnapshot(`
257
+ "├── src
258
+ │ ├── api
259
+ │ │ ├── routes.ts (+10,-5)
260
+ │ │ └── handlers.ts (+30,-0)
261
+ │ └── db
262
+ │ └── models.ts (+8,-2)
263
+ └── lib
264
+ └── utils.ts (+15,-0)
265
+
266
+
267
+
268
+
269
+ "
270
+ `)
271
+ })
272
+ })
273
+
274
+ describe("DirectoryTreeView component", () => {
275
+ let testSetup: Awaited<ReturnType<typeof testRender>>
276
+
277
+ afterEach(() => {
278
+ if (testSetup) {
279
+ testSetup.renderer.destroy()
280
+ }
281
+ })
282
+
283
+ it("should render tree without border", async () => {
284
+ const files: TreeFileInfo[] = [
285
+ { path: "src/index.ts", status: "modified", additions: 5, deletions: 2, fileIndex: 0 },
286
+ { path: "src/utils.ts", status: "added", additions: 30, deletions: 0, fileIndex: 1 },
287
+ { path: "README.md", status: "deleted", additions: 0, deletions: 15, fileIndex: 2 },
288
+ ]
289
+
290
+ testSetup = await testRender(
291
+ <DirectoryTreeView files={files} themeName="github" />,
292
+ { width: 60, height: 12 },
293
+ )
294
+
295
+ await testSetup.renderOnce()
296
+ const frame = testSetup.captureCharFrame()
297
+ expect(frame).toMatchInlineSnapshot(`
298
+ " ├── src
299
+ │ ├── index.ts (+5,-2)
300
+ │ └── utils.ts (+30)
301
+ └── README.md (-15)
302
+
303
+
304
+
305
+
306
+
307
+
308
+
309
+
310
+ "
311
+ `)
312
+ })
313
+
314
+ it("should render empty when no files", async () => {
315
+ testSetup = await testRender(
316
+ <DirectoryTreeView files={[]} themeName="github" />,
317
+ { width: 40, height: 5 },
318
+ )
319
+
320
+ await testSetup.renderOnce()
321
+ const frame = testSetup.captureCharFrame()
322
+ // Should render nothing (DirectoryTreeView returns null for empty)
323
+ expect(frame).toMatchInlineSnapshot(`
324
+ "
325
+
326
+
327
+
328
+
329
+ "
330
+ `)
331
+ })
332
+ })
@@ -0,0 +1,200 @@
1
+ // Directory tree builder for displaying file changes in a tree structure.
2
+ // Builds a collapsible tree from file paths with status colors and change counts.
3
+ // Returns structured nodes that can be rendered by DirectoryTreeView component.
4
+
5
+ /**
6
+ * File status based on git diff
7
+ */
8
+ export type FileStatus = "added" | "modified" | "deleted"
9
+
10
+ /**
11
+ * Input file info for tree building
12
+ */
13
+ export interface TreeFileInfo {
14
+ path: string
15
+ status: FileStatus
16
+ additions: number
17
+ deletions: number
18
+ /** Optional index for scroll-to functionality */
19
+ fileIndex?: number
20
+ }
21
+
22
+ /**
23
+ * A single node in the rendered tree output
24
+ */
25
+ export interface TreeNode {
26
+ /** Display path (may be collapsed, e.g., "src/components") */
27
+ displayPath: string
28
+ /** Whether this is a file (true) or directory (false) */
29
+ isFile: boolean
30
+ /** File index for scroll-to (only for files) */
31
+ fileIndex?: number
32
+ /** File status (only for files) */
33
+ status?: FileStatus
34
+ /** Number of added lines (only for files) */
35
+ additions?: number
36
+ /** Number of deleted lines (only for files) */
37
+ deletions?: number
38
+ /** Tree connector character: "├── " or "└── " */
39
+ connector: string
40
+ /** Prefix string for tree lines, e.g., "│ " */
41
+ prefix: string
42
+ }
43
+
44
+ interface InternalTreeNode {
45
+ path: string
46
+ title?: string
47
+ fileIndex?: number
48
+ status?: FileStatus
49
+ additions?: number
50
+ deletions?: number
51
+ children: InternalTreeNode[]
52
+ }
53
+
54
+ /**
55
+ * Build a directory tree from file paths
56
+ * @param files Array of file info objects
57
+ * @returns Array of TreeNode objects ready for rendering
58
+ */
59
+ export function buildDirectoryTree(files: TreeFileInfo[]): TreeNode[] {
60
+ if (files.length === 0) {
61
+ return []
62
+ }
63
+
64
+ const tree = buildInternalTree(files)
65
+ return flattenTree(tree)
66
+ }
67
+
68
+ /**
69
+ * Build internal tree structure from file paths
70
+ */
71
+ function buildInternalTree(files: TreeFileInfo[]): InternalTreeNode[] {
72
+ const root: InternalTreeNode[] = []
73
+ const nodeMap = new Map<string, InternalTreeNode>()
74
+
75
+ for (const file of files) {
76
+ const parts = file.path.split("/").filter((part) => part !== "")
77
+ let currentPath = ""
78
+ let currentLevel = root
79
+
80
+ for (let i = 0; i < parts.length; i++) {
81
+ const part = parts[i]!
82
+ currentPath = currentPath ? `${currentPath}/${part}` : part
83
+
84
+ let node = nodeMap.get(currentPath)
85
+ if (!node) {
86
+ node = {
87
+ path: currentPath,
88
+ children: [],
89
+ }
90
+ nodeMap.set(currentPath, node)
91
+ currentLevel.push(node)
92
+ }
93
+
94
+ // On the final part, assign file info
95
+ if (i === parts.length - 1) {
96
+ node.title = part
97
+ node.fileIndex = file.fileIndex
98
+ node.status = file.status
99
+ node.additions = file.additions
100
+ node.deletions = file.deletions
101
+ }
102
+
103
+ currentLevel = node.children
104
+ }
105
+ }
106
+
107
+ return root
108
+ }
109
+
110
+ /**
111
+ * Get the base name from a path
112
+ */
113
+ function getName(node: InternalTreeNode): string {
114
+ const parts = node.path.split("/")
115
+ return parts[parts.length - 1] || node.path
116
+ }
117
+
118
+ /**
119
+ * Collapse directories that only contain a single subdirectory (no files)
120
+ */
121
+ function collapseNode(node: InternalTreeNode): {
122
+ path: string
123
+ collapsed: boolean
124
+ children: InternalTreeNode[]
125
+ originalNode: InternalTreeNode
126
+ } {
127
+ let currentNode = node
128
+ let collapsedPath = getName(currentNode)
129
+
130
+ // Keep collapsing while:
131
+ // - Current node has exactly one child
132
+ // - Current node is not a file (no status/title means it's a directory)
133
+ // - The single child is also a directory (no status)
134
+ while (
135
+ currentNode.children.length === 1 &&
136
+ currentNode.status === undefined &&
137
+ currentNode.children[0]!.status === undefined &&
138
+ currentNode.children[0]!.children.length > 0
139
+ ) {
140
+ currentNode = currentNode.children[0]!
141
+ collapsedPath = collapsedPath + "/" + getName(currentNode)
142
+ }
143
+
144
+ return {
145
+ path: collapsedPath,
146
+ collapsed: collapsedPath !== getName(node),
147
+ children: currentNode.children,
148
+ originalNode: currentNode,
149
+ }
150
+ }
151
+
152
+ /**
153
+ * Flatten the tree into a linear array of TreeNode objects
154
+ */
155
+ function flattenTree(tree: InternalTreeNode[]): TreeNode[] {
156
+ const result: TreeNode[] = []
157
+
158
+ function processNode(
159
+ node: InternalTreeNode,
160
+ prefix: string,
161
+ isLast: boolean,
162
+ isRoot: boolean,
163
+ ): void {
164
+ const collapsed = collapseNode(node)
165
+ const displayPath = collapsed.path
166
+ const connector = isLast ? "\u2514\u2500\u2500 " : "\u251c\u2500\u2500 "
167
+
168
+ // Determine if this is a file (has status) or directory
169
+ const isFile = collapsed.originalNode.status !== undefined
170
+
171
+ result.push({
172
+ displayPath,
173
+ isFile,
174
+ fileIndex: collapsed.originalNode.fileIndex,
175
+ status: collapsed.originalNode.status,
176
+ additions: collapsed.originalNode.additions,
177
+ deletions: collapsed.originalNode.deletions,
178
+ connector,
179
+ prefix,
180
+ })
181
+
182
+ // Process children
183
+ if (collapsed.children.length > 0) {
184
+ const childPrefix = prefix + (isLast ? " " : "\u2502 ")
185
+
186
+ collapsed.children.forEach((child, idx) => {
187
+ const childIsLast = idx === collapsed.children.length - 1
188
+ processNode(child, childPrefix, childIsLast, false)
189
+ })
190
+ }
191
+ }
192
+
193
+ // Process root level nodes
194
+ tree.forEach((node, idx) => {
195
+ const isLast = idx === tree.length - 1
196
+ processNode(node, "", isLast, true)
197
+ })
198
+
199
+ return result
200
+ }
@@ -4,11 +4,12 @@
4
4
 
5
5
  import * as React from "react"
6
6
  import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/react"
7
- import { MacOSScrollAccel, SyntaxStyle, BoxRenderable, CodeRenderable, TextRenderable } from "@opentui/core"
7
+ import { MacOSScrollAccel, SyntaxStyle, BoxRenderable, CodeRenderable, TextRenderable, ScrollBoxRenderable } from "@opentui/core"
8
8
  import type { Token } from "marked"
9
9
  import { getResolvedTheme, getSyntaxTheme, defaultThemeName, themeNames, rgbaToHex } from "../themes.ts"
10
10
  import { detectFiletype, countChanges, getViewMode } from "../diff-utils.ts"
11
- import { DiffView } from "../components/diff-view.tsx"
11
+ import { DiffView, DirectoryTreeView } from "../components/index.ts"
12
+ import type { TreeFileInfo } from "../directory-tree.ts"
12
13
  import { watchReviewYaml } from "./yaml-watcher.ts"
13
14
  import { createSubHunk } from "./hunk-parser.ts"
14
15
  import { parseDiagram } from "./diagram-parser.ts"
@@ -52,6 +53,10 @@ export function ReviewApp({
52
53
  const [showThemePicker, setShowThemePicker] = React.useState(false)
53
54
  const [previewTheme, setPreviewTheme] = React.useState<string | null>(null)
54
55
 
56
+ // Refs for vim-style scroll navigation
57
+ const scrollboxRef = React.useRef<ScrollBoxRenderable | null>(null)
58
+ const lastKeyRef = React.useRef<{ key: string; time: number } | null>(null)
59
+
55
60
  // Get theme from store
56
61
  const themeName = useAppStore((s) => s.themeName)
57
62
  // Use preview theme if hovering, otherwise use selected theme
@@ -75,8 +80,8 @@ export function ReviewApp({
75
80
 
76
81
  // Keyboard navigation
77
82
  useKeyboard((key) => {
78
- // Ctrl+D toggles debug console
79
- if (key.ctrl && key.name === "d") {
83
+ // Ctrl+Z toggles debug console
84
+ if (key.ctrl && key.name === "z") {
80
85
  renderer.console.toggle()
81
86
  return
82
87
  }
@@ -98,6 +103,40 @@ export function ReviewApp({
98
103
  setShowThemePicker(true)
99
104
  return
100
105
  }
106
+
107
+ // Vim-style scroll navigation
108
+ const scrollbox = scrollboxRef.current
109
+ if (scrollbox) {
110
+ // G - go to bottom
111
+ if (key.name === "g" && key.shift) {
112
+ scrollbox.scrollBy(1, "content")
113
+ return
114
+ }
115
+
116
+ // gg - go to top (double-tap within 300ms)
117
+ if (key.name === "g" && !key.shift && !key.ctrl) {
118
+ const now = Date.now()
119
+ if (lastKeyRef.current?.key === "g" && now - lastKeyRef.current.time < 300) {
120
+ scrollbox.scrollTo(0)
121
+ lastKeyRef.current = null
122
+ } else {
123
+ lastKeyRef.current = { key: "g", time: now }
124
+ }
125
+ return
126
+ }
127
+
128
+ // Ctrl+D - half page down
129
+ if (key.ctrl && key.name === "d") {
130
+ scrollbox.scrollBy(0.5, "viewport")
131
+ return
132
+ }
133
+
134
+ // Ctrl+U - half page up
135
+ if (key.ctrl && key.name === "u") {
136
+ scrollbox.scrollBy(-0.5, "viewport")
137
+ return
138
+ }
139
+ }
101
140
  })
102
141
 
103
142
  const themeOptions = themeNames.map((name) => ({
@@ -175,6 +214,7 @@ export function ReviewApp({
175
214
  themeName={activeTheme}
176
215
  width={width}
177
216
  renderer={renderer}
217
+ scrollboxRef={scrollboxRef}
178
218
  />
179
219
  )
180
220
  }
@@ -232,6 +272,7 @@ export interface ReviewAppViewProps {
232
272
  showFooter?: boolean // defaults to true, set false for web rendering
233
273
  renderer?: any // Optional renderer for variable-width markdown
234
274
  gap?: number // Gap between markdown descriptions and hunks (default: 2)
275
+ scrollboxRef?: React.RefObject<ScrollBoxRenderable | null> // For vim-style navigation
235
276
  }
236
277
 
237
278
  /**
@@ -247,12 +288,50 @@ export function ReviewAppView({
247
288
  showFooter = true,
248
289
  renderer,
249
290
  gap = 2,
291
+ scrollboxRef,
250
292
  }: ReviewAppViewProps) {
251
293
  const [scrollAcceleration] = React.useState(() => new ScrollAcceleration())
252
294
 
253
295
  // Create a map of hunk ID to hunk for quick lookup
254
296
  const hunkMap = React.useMemo(() => new Map(hunks.map((h) => [h.id, h])), [hunks])
255
297
 
298
+ // Build tree data from all hunks, grouped by filename
299
+ // Aggregates additions/deletions per file
300
+ const treeFiles: TreeFileInfo[] = React.useMemo(() => {
301
+ const fileMap = new Map<string, { additions: number; deletions: number; firstIndex: number }>()
302
+
303
+ for (let i = 0; i < hunks.length; i++) {
304
+ const hunk = hunks[i]!
305
+ const existing = fileMap.get(hunk.filename)
306
+ const { additions, deletions } = countChanges([{ lines: hunk.lines }])
307
+
308
+ if (existing) {
309
+ existing.additions += additions
310
+ existing.deletions += deletions
311
+ } else {
312
+ fileMap.set(hunk.filename, { additions, deletions, firstIndex: i })
313
+ }
314
+ }
315
+
316
+ // Determine status for each file (simplified: all are modified in review context)
317
+ // In practice, we could look at the hunk headers for more accurate status
318
+ const result: TreeFileInfo[] = []
319
+ for (const [filename, data] of fileMap) {
320
+ let status: "added" | "modified" | "deleted" = "modified"
321
+ if (data.deletions === 0 && data.additions > 0) status = "added"
322
+ else if (data.additions === 0 && data.deletions > 0) status = "deleted"
323
+
324
+ result.push({
325
+ path: filename,
326
+ status,
327
+ additions: data.additions,
328
+ deletions: data.deletions,
329
+ fileIndex: data.firstIndex,
330
+ })
331
+ }
332
+ return result
333
+ }, [hunks])
334
+
256
335
  const resolvedTheme = getResolvedTheme(themeName)
257
336
  const bgColor = rgbaToHex(resolvedTheme.background)
258
337
 
@@ -325,6 +404,7 @@ export function ReviewAppView({
325
404
  >
326
405
  {/* Scrollable content - shows ALL groups */}
327
406
  <scrollbox
407
+ ref={scrollboxRef}
328
408
  scrollAcceleration={scrollAcceleration}
329
409
  style={{
330
410
  flexGrow: 1,
@@ -347,6 +427,16 @@ export function ReviewAppView({
347
427
  focused
348
428
  >
349
429
  <box style={{ flexDirection: "column" }}>
430
+ {/* Directory tree at the top - updates as hunks arrive */}
431
+ {treeFiles.length > 0 && (
432
+ <box style={{ marginBottom: gap }}>
433
+ <DirectoryTreeView
434
+ files={treeFiles}
435
+ themeName={themeName}
436
+ />
437
+ </box>
438
+ )}
439
+
350
440
  {groups.map((group, groupIdx) => {
351
441
  // Resolve hunks from group - supports both hunkIds and hunkId with lineRange
352
442
  const groupHunks = resolveGroupHunks(group, hunkMap)
package/src/themes.ts CHANGED
@@ -3,6 +3,7 @@
3
3
  // and provides both UI colors and Tree-sitter compatible syntax styles.
4
4
 
5
5
  import { parseColor, RGBA } from "@opentui/core";
6
+ import { fileURLToPath } from "url";
6
7
 
7
8
  // Only import the default theme statically for fast startup
8
9
  // Other themes are loaded on-demand when selected
@@ -25,6 +26,11 @@ interface ThemeJson {
25
26
  export interface ResolvedTheme {
26
27
  // UI colors
27
28
  primary: RGBA;
29
+ // Status colors
30
+ success: RGBA;
31
+ error: RGBA;
32
+ warning: RGBA;
33
+ info: RGBA;
28
34
  // Syntax colors
29
35
  syntaxComment: RGBA;
30
36
  syntaxKeyword: RGBA;
@@ -39,7 +45,10 @@ export interface ResolvedTheme {
39
45
  text: RGBA;
40
46
  textMuted: RGBA;
41
47
  conceal: RGBA;
42
- // Diff colors
48
+ // Diff colors (foreground)
49
+ diffAdded: RGBA;
50
+ diffRemoved: RGBA;
51
+ // Diff colors (background)
43
52
  diffAddedBg: RGBA;
44
53
  diffRemovedBg: RGBA;
45
54
  diffContextBg: RGBA;
@@ -133,8 +142,9 @@ function loadTheme(name: string): ThemeJson {
133
142
  try {
134
143
  // Use dynamic import with synchronous pattern for JSON
135
144
  // This works because JSON imports are resolved at bundle time by Bun
136
- const themePath = new URL(`./themes/${fileName}`, import.meta.url).pathname;
145
+ const themeUrl = new URL(`./themes/${fileName}`, import.meta.url);
137
146
  // Read file synchronously using Node fs (works in Bun)
147
+ const themePath = fileURLToPath(themeUrl);
138
148
  const fs = require("fs");
139
149
  const content = fs.readFileSync(themePath, "utf-8");
140
150
  const themeJson = JSON.parse(content) as ThemeJson;
@@ -177,8 +187,20 @@ function resolveTheme(
177
187
 
178
188
  const text = resolveColor(t.text ?? fallbackText);
179
189
 
190
+ // Fallback colors for status (green, red, yellow, orange)
191
+ const fallbackGreen: ColorValue = "#3fb950";
192
+ const fallbackRed: ColorValue = "#f85149";
193
+ const fallbackYellow: ColorValue = "#e3b341";
194
+ const fallbackOrange: ColorValue = "#d29922";
195
+
180
196
  return {
181
197
  primary: resolveColor(t.primary ?? t.syntaxFunction ?? fallbackGray),
198
+ // Status colors
199
+ success: resolveColor(t.success ?? fallbackGreen),
200
+ error: resolveColor(t.error ?? fallbackRed),
201
+ warning: resolveColor(t.warning ?? fallbackYellow),
202
+ info: resolveColor(t.info ?? fallbackOrange),
203
+ // Syntax colors
182
204
  syntaxComment: resolveColor(t.syntaxComment ?? fallbackGray),
183
205
  syntaxKeyword: resolveColor(t.syntaxKeyword ?? fallbackGray),
184
206
  syntaxFunction: resolveColor(t.syntaxFunction ?? fallbackGray),
@@ -191,6 +213,10 @@ function resolveTheme(
191
213
  text,
192
214
  textMuted: resolveColor(t.textMuted ?? fallbackGray),
193
215
  conceal: resolveColor(t.conceal ?? t.textMuted ?? fallbackGray),
216
+ // Diff foreground colors
217
+ diffAdded: resolveColor(t.diffAdded ?? t.success ?? fallbackGreen),
218
+ diffRemoved: resolveColor(t.diffRemoved ?? t.error ?? fallbackRed),
219
+ // Diff background colors
194
220
  diffAddedBg: resolveColor(t.diffAddedBg ?? "#1e3a1e"),
195
221
  diffRemovedBg: resolveColor(t.diffRemovedBg ?? "#3a1e1e"),
196
222
  diffContextBg: resolveColor(t.diffContextBg ?? fallbackBg),