critique 0.1.41 → 0.1.42

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,17 @@
1
+ # 0.1.42
2
+
3
+ - New `--image` flag for all diff commands:
4
+ - Generates WebP images of terminal output (saved to /tmp)
5
+ - Splits long diffs into multiple images (70 lines per image)
6
+ - Uses takumi for high-performance image rendering
7
+ - `@takumi-rs/core` and `@takumi-rs/helpers` added as optional dependencies
8
+ - Library export: `import { renderTerminalToImages } from "critique/src/image.ts"`
9
+ - Web output: Use default theme to enable dark/light mode switching based on system preference
10
+ - `review` command:
11
+ - Improved AI prompt: order hunks by code flow, think upfront before writing, split heavy logic across sections
12
+ - Dependencies:
13
+ - Update opentui to `367a9408`
14
+
1
15
  # 0.1.41
2
16
 
3
17
  - `review` command:
package/bun.lock CHANGED
@@ -7,8 +7,8 @@
7
7
  "dependencies": {
8
8
  "@agentclientprotocol/sdk": "^0.12.0",
9
9
  "@clack/prompts": "1.0.0-alpha.9",
10
- "@opentui/core": "https://pkg.pr.new/anomalyco/opentui/@opentui/core@302acd5f2860aaeecf5a1a60325c195e3a0597e4",
11
- "@opentui/react": "https://pkg.pr.new/anomalyco/opentui/@opentui/react@302acd5f2860aaeecf5a1a60325c195e3a0597e4",
10
+ "@opentui/core": "https://pkg.pr.new/anomalyco/opentui/@opentui/core@367a94087821b3b5feedd35bbb57df43b10a286e",
11
+ "@opentui/react": "https://pkg.pr.new/anomalyco/opentui/@opentui/react@367a94087821b3b5feedd35bbb57df43b10a286e",
12
12
  "@parcel/watcher": "^2.5.1",
13
13
  "bun-pty": "^0.4.7",
14
14
  "cac": "^6.7.14",
@@ -27,6 +27,10 @@
27
27
  "typescript": "^5.9.3",
28
28
  "wrangler": "^4.19.1",
29
29
  },
30
+ "optionalDependencies": {
31
+ "@takumi-rs/core": "^0.65.0",
32
+ "@takumi-rs/helpers": "^0.65.0",
33
+ },
30
34
  },
31
35
  },
32
36
  "packages": {
@@ -222,21 +226,21 @@
222
226
 
223
227
  "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.9", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.0.3", "@jridgewell/sourcemap-codec": "^1.4.10" } }, "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ=="],
224
228
 
225
- "@opentui/core": ["@opentui/core@https://pkg.pr.new/anomalyco/opentui/@opentui/core@302acd5f2860aaeecf5a1a60325c195e3a0597e4", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "marked": "17.0.1", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "https://pkg.pr.new/anomalyco/opentui/@opentui/core-darwin-arm64@302acd5f2860aaeecf5a1a60325c195e3a0597e4", "@opentui/core-darwin-x64": "https://pkg.pr.new/anomalyco/opentui/@opentui/core-darwin-x64@302acd5f2860aaeecf5a1a60325c195e3a0597e4", "@opentui/core-linux-arm64": "https://pkg.pr.new/anomalyco/opentui/@opentui/core-linux-arm64@302acd5f2860aaeecf5a1a60325c195e3a0597e4", "@opentui/core-linux-x64": "https://pkg.pr.new/anomalyco/opentui/@opentui/core-linux-x64@302acd5f2860aaeecf5a1a60325c195e3a0597e4", "@opentui/core-win32-arm64": "https://pkg.pr.new/anomalyco/opentui/@opentui/core-win32-arm64@302acd5f2860aaeecf5a1a60325c195e3a0597e4", "@opentui/core-win32-x64": "https://pkg.pr.new/anomalyco/opentui/@opentui/core-win32-x64@302acd5f2860aaeecf5a1a60325c195e3a0597e4", "bun-webgpu": "0.1.4", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }],
229
+ "@opentui/core": ["@opentui/core@https://pkg.pr.new/anomalyco/opentui/@opentui/core@367a94087821b3b5feedd35bbb57df43b10a286e", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "marked": "17.0.1", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "https://pkg.pr.new/anomalyco/opentui/@opentui/core-darwin-arm64@367a94087821b3b5feedd35bbb57df43b10a286e", "@opentui/core-darwin-x64": "https://pkg.pr.new/anomalyco/opentui/@opentui/core-darwin-x64@367a94087821b3b5feedd35bbb57df43b10a286e", "@opentui/core-linux-arm64": "https://pkg.pr.new/anomalyco/opentui/@opentui/core-linux-arm64@367a94087821b3b5feedd35bbb57df43b10a286e", "@opentui/core-linux-x64": "https://pkg.pr.new/anomalyco/opentui/@opentui/core-linux-x64@367a94087821b3b5feedd35bbb57df43b10a286e", "@opentui/core-win32-arm64": "https://pkg.pr.new/anomalyco/opentui/@opentui/core-win32-arm64@367a94087821b3b5feedd35bbb57df43b10a286e", "@opentui/core-win32-x64": "https://pkg.pr.new/anomalyco/opentui/@opentui/core-win32-x64@367a94087821b3b5feedd35bbb57df43b10a286e", "bun-webgpu": "0.1.4", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }],
226
230
 
227
- "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@https://pkg.pr.new/anomalyco/opentui/@opentui/core-darwin-arm64@302acd5f2860aaeecf5a1a60325c195e3a0597e4", {}],
231
+ "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@https://pkg.pr.new/anomalyco/opentui/@opentui/core-darwin-arm64@367a94087821b3b5feedd35bbb57df43b10a286e", {}],
228
232
 
229
- "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@https://pkg.pr.new/anomalyco/opentui/@opentui/core-darwin-x64@302acd5f2860aaeecf5a1a60325c195e3a0597e4", {}],
233
+ "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@https://pkg.pr.new/anomalyco/opentui/@opentui/core-darwin-x64@367a94087821b3b5feedd35bbb57df43b10a286e", {}],
230
234
 
231
- "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@https://pkg.pr.new/anomalyco/opentui/@opentui/core-linux-arm64@302acd5f2860aaeecf5a1a60325c195e3a0597e4", {}],
235
+ "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@https://pkg.pr.new/anomalyco/opentui/@opentui/core-linux-arm64@367a94087821b3b5feedd35bbb57df43b10a286e", {}],
232
236
 
233
- "@opentui/core-linux-x64": ["@opentui/core-linux-x64@https://pkg.pr.new/anomalyco/opentui/@opentui/core-linux-x64@302acd5f2860aaeecf5a1a60325c195e3a0597e4", {}],
237
+ "@opentui/core-linux-x64": ["@opentui/core-linux-x64@https://pkg.pr.new/anomalyco/opentui/@opentui/core-linux-x64@367a94087821b3b5feedd35bbb57df43b10a286e", {}],
234
238
 
235
- "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@https://pkg.pr.new/anomalyco/opentui/@opentui/core-win32-arm64@302acd5f2860aaeecf5a1a60325c195e3a0597e4", {}],
239
+ "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@https://pkg.pr.new/anomalyco/opentui/@opentui/core-win32-arm64@367a94087821b3b5feedd35bbb57df43b10a286e", {}],
236
240
 
237
- "@opentui/core-win32-x64": ["@opentui/core-win32-x64@https://pkg.pr.new/anomalyco/opentui/@opentui/core-win32-x64@302acd5f2860aaeecf5a1a60325c195e3a0597e4", {}],
241
+ "@opentui/core-win32-x64": ["@opentui/core-win32-x64@https://pkg.pr.new/anomalyco/opentui/@opentui/core-win32-x64@367a94087821b3b5feedd35bbb57df43b10a286e", {}],
238
242
 
239
- "@opentui/react": ["@opentui/react@https://pkg.pr.new/anomalyco/opentui/@opentui/react@302acd5f2860aaeecf5a1a60325c195e3a0597e4", { "dependencies": { "@opentui/core": "https://pkg.pr.new/anomalyco/opentui/@opentui/core@302acd5f2860aaeecf5a1a60325c195e3a0597e4", "react-reconciler": "^0.32.0" }, "peerDependencies": { "react": ">=19.0.0", "react-devtools-core": "^7.0.1", "ws": "^8.18.0" } }],
243
+ "@opentui/react": ["@opentui/react@https://pkg.pr.new/anomalyco/opentui/@opentui/react@367a94087821b3b5feedd35bbb57df43b10a286e", { "dependencies": { "@opentui/core": "https://pkg.pr.new/anomalyco/opentui/@opentui/core@367a94087821b3b5feedd35bbb57df43b10a286e", "react-reconciler": "^0.32.0" }, "peerDependencies": { "react": ">=19.0.0", "react-devtools-core": "^7.0.1", "ws": "^8.18.0" } }],
240
244
 
241
245
  "@parcel/watcher": ["@parcel/watcher@2.5.4", "", { "dependencies": { "detect-libc": "^2.0.3", "is-glob": "^4.0.3", "node-addon-api": "^7.0.0", "picomatch": "^4.0.3" }, "optionalDependencies": { "@parcel/watcher-android-arm64": "2.5.4", "@parcel/watcher-darwin-arm64": "2.5.4", "@parcel/watcher-darwin-x64": "2.5.4", "@parcel/watcher-freebsd-x64": "2.5.4", "@parcel/watcher-linux-arm-glibc": "2.5.4", "@parcel/watcher-linux-arm-musl": "2.5.4", "@parcel/watcher-linux-arm64-glibc": "2.5.4", "@parcel/watcher-linux-arm64-musl": "2.5.4", "@parcel/watcher-linux-x64-glibc": "2.5.4", "@parcel/watcher-linux-x64-musl": "2.5.4", "@parcel/watcher-win32-arm64": "2.5.4", "@parcel/watcher-win32-ia32": "2.5.4", "@parcel/watcher-win32-x64": "2.5.4" } }, "sha512-WYa2tUVV5HiArWPB3ydlOc4R2ivq0IDrlqhMi3l7mVsFEXNcTfxYFPIHXHXIh/ca/y/V5N4E1zecyxdIBjYnkQ=="],
242
246
 
@@ -276,6 +280,26 @@
276
280
 
277
281
  "@speed-highlight/core": ["@speed-highlight/core@1.2.14", "", {}, "sha512-G4ewlBNhUtlLvrJTb88d2mdy2KRijzs4UhnlrOSRT4bmjh/IqNElZa3zkrZ+TC47TwtlDWzVLFADljF1Ijp5hA=="],
278
282
 
283
+ "@takumi-rs/core": ["@takumi-rs/core@0.65.0", "", { "optionalDependencies": { "@takumi-rs/core-darwin-arm64": "0.65.0", "@takumi-rs/core-darwin-x64": "0.65.0", "@takumi-rs/core-linux-arm64-gnu": "0.65.0", "@takumi-rs/core-linux-arm64-musl": "0.65.0", "@takumi-rs/core-linux-x64-gnu": "0.65.0", "@takumi-rs/core-linux-x64-musl": "0.65.0", "@takumi-rs/core-win32-arm64-msvc": "0.65.0", "@takumi-rs/core-win32-x64-msvc": "0.65.0" } }, "sha512-lBNG+NRc602ul7Kxy9UohlbnFLVyloQP/DTxQzyNH+8khpdaIQTxpdHouzRzxf6upRDVDIVX5TG8oT16jKUAvQ=="],
284
+
285
+ "@takumi-rs/core-darwin-arm64": ["@takumi-rs/core-darwin-arm64@0.65.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Ii6ILOANG6IeGThsuYjG5azlHXyHWWdVbM1ps3+SJyUg2g4Qn+nTruKGqHQOLNdZ5+37vpTg4PWh79XrUvjXpw=="],
286
+
287
+ "@takumi-rs/core-darwin-x64": ["@takumi-rs/core-darwin-x64@0.65.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-SJGeFVQ2foVHpYL6EiD+jNJnvQmegDLbbJ/SzbaYemJ/kjza2vNDg251urXne6daQ+HoGXLjhQtzW1C5Nyyf5g=="],
288
+
289
+ "@takumi-rs/core-linux-arm64-gnu": ["@takumi-rs/core-linux-arm64-gnu@0.65.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-YbwZzQTugFwvYErXs2W/QsngPSU5H6ZtYJNdbz9/qk8EEU6eB593XiBxE9/1vbq6Sb3mH84mPXElf1BmWJlvzw=="],
290
+
291
+ "@takumi-rs/core-linux-arm64-musl": ["@takumi-rs/core-linux-arm64-musl@0.65.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-iZpzfnD1/Gyof3p+fYUIBxNkDH9vQviOeihZ7yAtPbGmY4AbnOjoUBHDyJoKWE0el/x1W17efvNE7FuvzP0O5Q=="],
292
+
293
+ "@takumi-rs/core-linux-x64-gnu": ["@takumi-rs/core-linux-x64-gnu@0.65.0", "", { "os": "linux", "cpu": "x64" }, "sha512-kL4VclPYz7nmQuadCVSrAFvdy8LaJX5RPymG3upaHPcz0i7A8KmWY8jnQcDDkGQfPIi2lFncnt/sknCiaJ1t4Q=="],
294
+
295
+ "@takumi-rs/core-linux-x64-musl": ["@takumi-rs/core-linux-x64-musl@0.65.0", "", { "os": "linux", "cpu": "x64" }, "sha512-2way2sF3p5bMlhGsDMmQGTPxtc37241POzAJ0bPRKR1fumylEAaADuXvN95qM4x9751iTke/wVhJOL28nV7asw=="],
296
+
297
+ "@takumi-rs/core-win32-arm64-msvc": ["@takumi-rs/core-win32-arm64-msvc@0.65.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-yCZqiu1b3XXhDUE7OaA5RdVg/nYcZU1nzToY7ViE4fR8PqOK54Ad8qOAKYZCTr4FwCB4sWo88/R7K1n0nPFpfA=="],
298
+
299
+ "@takumi-rs/core-win32-x64-msvc": ["@takumi-rs/core-win32-x64-msvc@0.65.0", "", { "os": "win32", "cpu": "x64" }, "sha512-SAw224AhLcKa9+ttO4IxeTquOvyz8pnDeGugzmQZerYO18kOuOCiP1g4VRhM1drpEFN7bbnhSnIvrewg5m4akw=="],
300
+
301
+ "@takumi-rs/helpers": ["@takumi-rs/helpers@0.65.0", "", {}, "sha512-3BsiW0iP3Y7xUPmT/tJBClb59qTxtHEYHQUznXdtDA0qyN6YAWABA6U0DTd3la6XHMEDyKaHV263OL679FyPaA=="],
302
+
279
303
  "@tokenizer/token": ["@tokenizer/token@0.3.0", "", {}, "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="],
280
304
 
281
305
  "@types/bun": ["@types/bun@1.3.5", "", { "dependencies": { "bun-types": "1.3.5" } }, "sha512-RnygCqNrd3srIPEWBd5LFeUYG7plCoH2Yw9WaZGyNmdTEei+gWaHqydbaIRkIkcbXwhBT94q78QljxN0Sk838w=="],
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.41",
5
+ "version": "0.1.42",
6
6
  "private": false,
7
7
  "bin": "./src/cli.tsx",
8
8
  "scripts": {
@@ -24,8 +24,8 @@
24
24
  "dependencies": {
25
25
  "@agentclientprotocol/sdk": "^0.12.0",
26
26
  "@clack/prompts": "1.0.0-alpha.9",
27
- "@opentui/core": "https://pkg.pr.new/anomalyco/opentui/@opentui/core@302acd5f2860aaeecf5a1a60325c195e3a0597e4",
28
- "@opentui/react": "https://pkg.pr.new/anomalyco/opentui/@opentui/react@302acd5f2860aaeecf5a1a60325c195e3a0597e4",
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
29
  "@parcel/watcher": "^2.5.1",
30
30
  "bun-pty": "^0.4.7",
31
31
  "cac": "^6.7.14",
@@ -35,5 +35,9 @@
35
35
  "picocolors": "^1.1.1",
36
36
  "react": "^19.2.0",
37
37
  "zustand": "^5.0.8"
38
+ },
39
+ "optionalDependencies": {
40
+ "@takumi-rs/core": "^0.65.0",
41
+ "@takumi-rs/helpers": "^0.65.0"
38
42
  }
39
43
  }
package/src/cli.tsx CHANGED
@@ -504,7 +504,8 @@ async function runReviewMode(
504
504
 
505
505
  // Write hunks to temp file for the render command
506
506
  const hunksFile = writeTempFile(JSON.stringify(hunks), "critique-hunks", ".json");
507
- const themeName = persistedState.themeName ?? defaultThemeName;
507
+ // For web, always use default theme (with auto dark/light inversion) unless explicitly overridden
508
+ const themeName = defaultThemeName;
508
509
 
509
510
  // Calculate rows needed based on hunks
510
511
  const totalLines = hunks.reduce((sum, h) => sum + h.lines.length, 0);
@@ -834,7 +835,8 @@ async function runResumeMode(options: ResumeModeOptions) {
834
835
  }).join("\n");
835
836
  const yamlFile = writeTempFile(yamlContent, "critique-review", ".yaml");
836
837
 
837
- const themeName = persistedState.themeName ?? defaultThemeName;
838
+ // For web, always use default theme (with auto dark/light inversion) unless explicitly overridden
839
+ const themeName = defaultThemeName;
838
840
  const totalLines = review.hunks.reduce((sum, h) => sum + h.lines.length, 0);
839
841
  const baseRows = Math.max(200, totalLines * 2 + 100);
840
842
 
@@ -918,6 +920,16 @@ interface WebModeOptions {
918
920
  '--'?: string[];
919
921
  }
920
922
 
923
+ // Image mode handler
924
+ interface ImageModeOptions {
925
+ staged?: boolean;
926
+ commit?: string;
927
+ context?: string;
928
+ filter?: string;
929
+ theme?: string;
930
+ '--'?: string[];
931
+ }
932
+
921
933
  async function runWebMode(
922
934
  base: string | undefined,
923
935
  head: string | undefined,
@@ -943,9 +955,10 @@ async function runWebMode(
943
955
 
944
956
  const desktopCols = options.cols || 230;
945
957
  const mobileCols = options.mobileCols || 100;
958
+ // For web, always use default theme (with auto dark/light inversion) unless explicitly overridden via --theme
946
959
  const themeName = options.theme && themeNames.includes(options.theme)
947
960
  ? options.theme
948
- : persistedState.themeName ?? defaultThemeName;
961
+ : defaultThemeName;
949
962
 
950
963
  console.log("Capturing diff output...");
951
964
 
@@ -1009,6 +1022,118 @@ async function runWebMode(
1009
1022
  }
1010
1023
  }
1011
1024
 
1025
+ async function runImageMode(
1026
+ base: string | undefined,
1027
+ head: string | undefined,
1028
+ options: ImageModeOptions
1029
+ ) {
1030
+ const { renderTerminalToImages } = await import("./image.ts");
1031
+ const { writeTempFile, cleanupTempFile } = await import("./web-utils.ts");
1032
+
1033
+ const gitCommand = buildGitCommand({
1034
+ staged: options.staged,
1035
+ commit: options.commit,
1036
+ base,
1037
+ head,
1038
+ context: options.context,
1039
+ filter: options.filter,
1040
+ positionalFilters: options['--'],
1041
+ });
1042
+
1043
+ const themeName = options.theme && themeNames.includes(options.theme)
1044
+ ? options.theme
1045
+ : persistedState.themeName ?? defaultThemeName;
1046
+
1047
+ console.log("Capturing diff output...");
1048
+
1049
+ // Get the git diff
1050
+ const { stdout: gitDiff } = await execAsync(gitCommand, {
1051
+ encoding: "utf-8",
1052
+ });
1053
+
1054
+ if (!gitDiff.trim()) {
1055
+ console.log("No changes to display");
1056
+ process.exit(0);
1057
+ }
1058
+
1059
+ // Write diff to temp file
1060
+ const diffFile = writeTempFile(gitDiff, "critique-image-diff", ".patch");
1061
+
1062
+ // Build render command for image capture
1063
+ const renderCommand = [
1064
+ process.argv[1]!, // path to cli.tsx
1065
+ "web-render",
1066
+ diffFile,
1067
+ "--theme",
1068
+ themeName,
1069
+ "--cols",
1070
+ "120",
1071
+ "--rows",
1072
+ "10000",
1073
+ ];
1074
+
1075
+ console.log("Rendering to images...");
1076
+
1077
+ try {
1078
+ // Capture PTY output
1079
+ const decoder = new TextDecoder();
1080
+ let ansiOutput = "";
1081
+ const cols = 120;
1082
+ const rows = 10000;
1083
+
1084
+ const proc = Bun.spawn(["bun", ...renderCommand], {
1085
+ cwd: process.cwd(),
1086
+ env: {
1087
+ ...process.env,
1088
+ TERM: "xterm-256color",
1089
+ },
1090
+ terminal: {
1091
+ cols,
1092
+ rows,
1093
+ data(terminal, data) {
1094
+ ansiOutput += decoder.decode(data, { stream: true });
1095
+ },
1096
+ },
1097
+ });
1098
+
1099
+ await proc.exited;
1100
+ proc.terminal?.close();
1101
+ ansiOutput += decoder.decode();
1102
+
1103
+ // Clean up diff temp file
1104
+ cleanupTempFile(diffFile);
1105
+
1106
+ if (!ansiOutput.trim()) {
1107
+ console.error("No output captured");
1108
+ process.exit(1);
1109
+ }
1110
+
1111
+ // Strip terminal cleanup sequences
1112
+ const clearIdx = ansiOutput.lastIndexOf("\x1b[H\x1b[J");
1113
+ if (clearIdx > 0) {
1114
+ ansiOutput = ansiOutput.slice(0, clearIdx);
1115
+ }
1116
+
1117
+ // Render to images
1118
+ const result = await renderTerminalToImages(ansiOutput, {
1119
+ cols,
1120
+ themeName,
1121
+ });
1122
+
1123
+ console.log(`\nGenerated ${result.imageCount} image${result.imageCount === 1 ? "" : "s"}:`);
1124
+ for (const path of result.paths) {
1125
+ console.log(` ${path}`);
1126
+ }
1127
+
1128
+ process.exit(0);
1129
+ } catch (error: unknown) {
1130
+ cleanupTempFile(diffFile);
1131
+ const message = error instanceof Error ? error.message : String(error);
1132
+ console.error("Failed to generate images:", message);
1133
+ process.exit(1);
1134
+ }
1135
+ }
1136
+
1012
1137
  // Error boundary component
1013
1138
  interface ErrorBoundaryProps {
1014
1139
  children: React.ReactNode;
@@ -1354,6 +1479,7 @@ cli
1354
1479
  .option("--filter <pattern>", "Filter files by glob pattern (can be used multiple times)")
1355
1480
  .option("--theme <name>", "Theme to use for rendering")
1356
1481
  .option("--web [title]", "Generate web preview instead of TUI")
1482
+ .option("--image", "Generate images instead of TUI (saved to /tmp)")
1357
1483
  .option("--open", "Open in browser (with --web)")
1358
1484
  .option("--cols <cols>", "Desktop columns for web render", { default: 240 })
1359
1485
  .option("--mobile-cols <cols>", "Mobile columns for web render", { default: 100 })
@@ -1411,6 +1537,19 @@ cli
1411
1537
  return;
1412
1538
  }
1413
1539
 
1540
+ // If --image flag, delegate to image generation logic
1541
+ if (options.image) {
1542
+ await runImageMode(base, head, {
1543
+ staged: options.staged,
1544
+ commit: options.commit,
1545
+ context: options.context,
1546
+ filter: options.filter,
1547
+ theme: options.theme,
1548
+ '--': options['--'],
1549
+ });
1550
+ return;
1551
+ }
1552
+
1414
1553
  try {
1415
1554
  const gitCommand = buildGitCommand({
1416
1555
  staged: options.staged,
package/src/image.ts ADDED
@@ -0,0 +1,304 @@
1
+ // Terminal output to image conversion using takumi.
2
+ // Converts ANSI terminal output to images, splitting into multiple pages if needed.
3
+ // Exports renderTerminalToImages() for library and CLI use.
4
+
5
+ import { tmpdir } from "os"
6
+ import { join } from "path"
7
+ import fs from "fs"
8
+ import { ptyToJson, StyleFlags, type TerminalLine, type TerminalSpan } from "ghostty-opentui"
9
+ import { getResolvedTheme, rgbaToHex } from "./themes.ts"
10
+
11
+ export interface RenderToImagesOptions {
12
+ /** Terminal columns for parsing (default: 120) */
13
+ cols?: number
14
+ /** Terminal rows for parsing (default: 10000, effectively unlimited) */
15
+ rows?: number
16
+ /** Theme name for colors */
17
+ themeName?: string
18
+ /** Image width in pixels (default: 1200) */
19
+ imageWidth?: number
20
+ /** Font size in pixels (default: 14) */
21
+ fontSize?: number
22
+ /** Line height multiplier (default: 1.7) */
23
+ lineHeight?: number
24
+ /** Maximum lines per image before splitting (default: 70) */
25
+ maxLinesPerImage?: number
26
+ /** Output format: webp, png, or jpeg (default: webp) */
27
+ format?: "webp" | "png" | "jpeg"
28
+ /** Quality for lossy formats 0-100 (default: 85) */
29
+ quality?: number
30
+ }
31
+
32
+ export interface RenderResult {
33
+ /** Array of image buffers */
34
+ images: Buffer[]
35
+ /** Paths where images were saved */
36
+ paths: string[]
37
+ /** Total number of lines in the output */
38
+ totalLines: number
39
+ /** Number of images generated */
40
+ imageCount: number
41
+ }
42
+
43
+ /**
44
+ * Convert terminal spans to takumi text nodes.
45
+ * Following shiki-image pattern: only display: inline and color styles.
46
+ */
47
+ function spanToNode(
48
+ span: TerminalSpan,
49
+ text: typeof import("@takumi-rs/helpers").text
50
+ ) {
51
+ const style: Record<string, string | number> = {
52
+ display: "inline",
53
+ }
54
+
55
+ if (span.fg) {
56
+ style.color = span.fg
57
+ }
58
+ if (span.bg) {
59
+ style.backgroundColor = span.bg
60
+ }
61
+ if (span.flags & StyleFlags.BOLD) {
62
+ style.fontWeight = "bold"
63
+ }
64
+ if (span.flags & StyleFlags.ITALIC) {
65
+ style.fontStyle = "italic"
66
+ }
67
+ if (span.flags & StyleFlags.FAINT) {
68
+ style.opacity = 0.5
69
+ }
70
+
71
+ return text(span.text, style as any)
72
+ }
73
+
74
+ /**
75
+ * Convert a terminal line to a takumi container node.
76
+ * All lines treated the same - render spans with background.
77
+ * Uses negative margin to eliminate gaps (takumi doesn't respect container height for backgrounds).
78
+ */
79
+ function lineToNode(
80
+ line: TerminalLine,
81
+ container: typeof import("@takumi-rs/helpers").container,
82
+ text: typeof import("@takumi-rs/helpers").text,
83
+ backgroundColor: string,
84
+ lineHeight: number,
85
+ fontSize: number
86
+ ) {
87
+ const lineHeightPx = Math.round(fontSize * lineHeight)
88
+
89
+ // Calculate negative margin to eliminate gaps between lines
90
+ // The gap is proportional to the extra space from line-height (lineHeight - 1) * fontSize
91
+ // We use ~50% of that as overlap to close the gaps
92
+ const gapOverlap = Math.round((lineHeight - 1) * fontSize * 0.5)
93
+
94
+ // Get text children from spans, or use a visible character if empty
95
+ let textChildren = line.spans.map((span) => spanToNode(span, text))
96
+ if (textChildren.length === 0) {
97
+ textChildren = [text("X", { color: backgroundColor })]
98
+ }
99
+
100
+ return container({
101
+ style: {
102
+ display: "flex",
103
+ flexDirection: "row",
104
+ alignItems: "center",
105
+ width: "100%",
106
+ height: lineHeightPx,
107
+ marginBottom: -gapOverlap,
108
+ backgroundColor,
109
+ },
110
+ children: textChildren,
111
+ })
112
+ }
113
+
114
+ /**
115
+ * Check if a line is empty (no spans or only whitespace)
116
+ */
117
+ function isLineEmpty(line: TerminalLine): boolean {
118
+ if (line.spans.length === 0) return true
119
+ return line.spans.every((span) => span.text.trim() === "")
120
+ }
121
+
122
+ /**
123
+ * Trim empty lines from the end of the lines array
124
+ */
125
+ function trimEmptyLines(lines: TerminalLine[]): TerminalLine[] {
126
+ let result = [...lines]
127
+ while (result.length > 0 && isLineEmpty(result[result.length - 1]!)) {
128
+ result = result.slice(0, -1)
129
+ }
130
+ return result
131
+ }
132
+
133
+ /**
134
+ * Render terminal output to images.
135
+ * This is the main export for library use.
136
+ *
137
+ * @param ansiOutput - Raw ANSI terminal output string or Buffer
138
+ * @param options - Rendering options
139
+ * @returns Promise with image buffers and saved file paths
140
+ */
141
+ export async function renderTerminalToImages(
142
+ ansiOutput: string | Buffer,
143
+ options: RenderToImagesOptions = {}
144
+ ): Promise<RenderResult> {
145
+ // Try to import takumi - it's an optional dependency
146
+ let takumiCore: typeof import("@takumi-rs/core")
147
+ let takumiHelpers: typeof import("@takumi-rs/helpers")
148
+
149
+ try {
150
+ takumiCore = await import("@takumi-rs/core")
151
+ takumiHelpers = await import("@takumi-rs/helpers")
152
+ } catch {
153
+ throw new Error(
154
+ "takumi is not installed. Install it with: bun add @takumi-rs/core @takumi-rs/helpers"
155
+ )
156
+ }
157
+
158
+ const { Renderer } = takumiCore
159
+ const { container, text } = takumiHelpers
160
+
161
+ const {
162
+ cols = 120,
163
+ rows = 10000,
164
+ themeName = "tokyonight",
165
+ imageWidth = 1200,
166
+ fontSize = 14,
167
+ lineHeight = 1.9,
168
+ maxLinesPerImage = 70,
169
+ format = "webp",
170
+ quality = 85,
171
+ } = options
172
+
173
+ // Parse ANSI to terminal data
174
+ const data = ptyToJson(ansiOutput, { cols, rows })
175
+ let lines = trimEmptyLines(data.lines)
176
+
177
+ if (lines.length === 0) {
178
+ throw new Error("No content to render")
179
+ }
180
+
181
+ // Get theme colors
182
+ const theme = getResolvedTheme(themeName)
183
+ const backgroundColor = rgbaToHex(theme.background)
184
+ const textColor = rgbaToHex(theme.text)
185
+
186
+ // Calculate dimensions
187
+ const lineHeightPx = Math.round(fontSize * lineHeight)
188
+ const paddingY = 24
189
+ const paddingX = 32
190
+
191
+ // Split lines into chunks
192
+ const chunks: TerminalLine[][] = []
193
+ for (let i = 0; i < lines.length; i += maxLinesPerImage) {
194
+ chunks.push(lines.slice(i, i + maxLinesPerImage))
195
+ }
196
+
197
+ // Create renderer with bundled Geist font (included in takumi)
198
+ const renderer = new Renderer()
199
+
200
+ const images: Buffer[] = []
201
+ const paths: string[] = []
202
+ const timestamp = Date.now()
203
+
204
+ for (let chunkIndex = 0; chunkIndex < chunks.length; chunkIndex++) {
205
+ const chunk = chunks[chunkIndex]!
206
+ const imageHeight = chunk.length * lineHeightPx + paddingY * 2
207
+
208
+ // Build the root container for this chunk
209
+ // Explicit dimensions, lines stack with explicit heights
210
+ const contentHeight = chunk.length * lineHeightPx
211
+
212
+ const rootNode = container({
213
+ style: {
214
+ display: "flex",
215
+ flexDirection: "column",
216
+ gap: 0,
217
+ width: "100%",
218
+ height: "100%",
219
+ backgroundColor,
220
+ color: textColor,
221
+ fontFamily: "monospace",
222
+ fontSize,
223
+ whiteSpace: "pre",
224
+ paddingTop: paddingY,
225
+ paddingBottom: paddingY,
226
+ paddingLeft: paddingX,
227
+ paddingRight: paddingX,
228
+ },
229
+ children: chunk.map((line) => lineToNode(line, container, text, backgroundColor, lineHeight, fontSize)),
230
+ })
231
+
232
+ // Render to image
233
+ const imageBuffer = await renderer.render(rootNode, {
234
+ width: imageWidth,
235
+ height: imageHeight,
236
+ format,
237
+ quality,
238
+ })
239
+
240
+ images.push(Buffer.from(imageBuffer))
241
+
242
+ // Save to /tmp
243
+ const filename = `critique-${timestamp}-${chunkIndex + 1}.${format}`
244
+ const filepath = join(tmpdir(), filename)
245
+ fs.writeFileSync(filepath, imageBuffer)
246
+ paths.push(filepath)
247
+ }
248
+
249
+ return {
250
+ images,
251
+ paths,
252
+ totalLines: lines.length,
253
+ imageCount: chunks.length,
254
+ }
255
+ }
256
+
257
+ /**
258
+ * Capture PTY output and render to images.
259
+ * Similar to captureToHtml but outputs images instead.
260
+ */
261
+ export async function captureToImages(
262
+ renderCommand: string[],
263
+ options: RenderToImagesOptions & { themeName: string }
264
+ ): Promise<RenderResult> {
265
+ const decoder = new TextDecoder()
266
+ let ansiOutput = ""
267
+
268
+ const cols = options.cols ?? 120
269
+ const rows = options.rows ?? 10000
270
+
271
+ // Use Bun.Terminal for real PTY support
272
+ const proc = Bun.spawn(["bun", ...renderCommand], {
273
+ cwd: process.cwd(),
274
+ env: {
275
+ ...process.env,
276
+ TERM: "xterm-256color",
277
+ },
278
+ terminal: {
279
+ cols,
280
+ rows,
281
+ data(terminal, data) {
282
+ ansiOutput += decoder.decode(data, { stream: true })
283
+ },
284
+ },
285
+ })
286
+
287
+ await proc.exited
288
+
289
+ // Close terminal and flush decoder
290
+ proc.terminal?.close()
291
+ ansiOutput += decoder.decode()
292
+
293
+ if (!ansiOutput.trim()) {
294
+ throw new Error("No output captured")
295
+ }
296
+
297
+ // Strip terminal cleanup sequences
298
+ const clearIdx = ansiOutput.lastIndexOf("\x1b[H\x1b[J")
299
+ if (clearIdx > 0) {
300
+ ansiOutput = ansiOutput.slice(0, clearIdx)
301
+ }
302
+
303
+ return renderTerminalToImages(ansiOutput, options)
304
+ }
@@ -462,16 +462,30 @@ IMPORTANT: Never use emojis or non-ASCII characters except for box-drawing chara
462
462
  READING ORDER - The Guiding Principle
463
463
  ═══════════════════════════════════════════════════════════════════════════════
464
464
 
465
- The reader reads top to bottom. You control their mental model. Order groups so each builds on the previous:
465
+ THINK HARD BEFORE WRITING. Before you start editing the YAML file, carefully plan the full order of all hunks. Consider:
466
+ - What is the main story of this change?
467
+ - What should the reader understand first to make everything else click?
468
+ - Which hunks are essential vs supporting details?
466
469
 
467
- 1. Config/infrastructure (sets the stage)
468
- 2. Types and interfaces (vocabulary)
469
- 3. Core utilities (foundational pieces)
470
- 4. Main implementation (reader now has context)
471
- 5. Integration and usage (how it connects)
472
- 6. Tests and docs (validation)
470
+ The reader reads top to bottom. You control their mental model.
473
471
 
474
- Show "what" before "how". Show data structures before code that uses them.
472
+ ORDER BY CODE FLOW - Follow how the code actually executes in the app:
473
+ - Start with entry points (what gets called first)
474
+ - Then show what those call (the next layer down)
475
+ - Continue down to the dependencies
476
+
477
+ This creates an intuitive progression: dependants before dependencies. The reader follows the same path the code takes at runtime.
478
+
479
+ Practical ordering:
480
+ 1. Entry points and main implementation (routes, handlers, commands - where execution starts)
481
+ 2. Business logic (what the entry points call)
482
+ 3. Types and interfaces (if needed to understand the above)
483
+ 4. Integration points (how it connects to external systems)
484
+ 5. Tests and docs (validation)
485
+ 6. Utilities and helpers (supporting functions - reader already saw them being used)
486
+ 7. Config/infrastructure (setup, boilerplate - least essential)
487
+
488
+ Utils, infra, and config are supporting cast - put them last. The reader already saw them referenced above and can now understand their implementation.
475
489
 
476
490
  ═══════════════════════════════════════════════════════════════════════════════
477
491
  WHAT TO EXPLAIN - Why Over What
@@ -568,6 +582,12 @@ SPLITTING RULES - NEVER SHOW MORE THAN 10 LINES AT ONCE
568
582
  CRITICAL: Never show hunks larger than 10 lines. This is the most important rule.
569
583
  Split aggressively to reduce cognitive load. Readers absorb small chunks better.
570
584
 
585
+ For HEAVY LOGIC - split even one or two lines at a time:
586
+ - When code is dense or complex, show exactly what happens step by step
587
+ - Follow the same order the code executes - reader builds the mental model progressively
588
+ - You can interleave hunks from different files to follow the execution flow
589
+ - Example: show a function call, then the function it calls, then back to the caller
590
+
571
591
  For ADDED FILES (new files):
572
592
  - Split into logical parts: imports, types, then each function/method separately
573
593
  - Each function/class method gets its own chunk with a brief description
@@ -578,6 +598,8 @@ For MODIFIED FILES:
578
598
  - Split by logical boundaries (functions, concerns, before/after)
579
599
  - Use numbered headers for sequential parts: ## 1. Parse input ## 2. Validate ## 3. Execute
580
600
 
601
+ You control the order. Reorder and interleave hunks freely to tell the clearest story.
602
+
581
603
  Every chunk MUST have a description explaining its purpose, even just one line.
582
604
 
583
605
  Lines use cat -n format (1-based). Use lineRange to reference specific portions of large hunks.