critique 0.1.103 → 0.1.105

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/AGENTS.md CHANGED
@@ -17,8 +17,8 @@ gh pr view 536 -R anomalyco/opentui --json commits --jq '.commits[-1].oid[:40]'
17
17
  then use it in package.json:
18
18
 
19
19
  ```
20
- https://pkg.pr.new/anomalyco/opentui/@opentui/core@<hash>
21
- https://pkg.pr.new/anomalyco/opentui/@opentui/react@<hash>
20
+ https://pkg.pr.new/anomalyco/opentui/@opentuah/core@<hash>
21
+ https://pkg.pr.new/anomalyco/opentui/@opentuah/react@<hash>
22
22
  ```
23
23
 
24
24
  YOU MUST ALWAYS use the commit hash 40 characters long when changing the pkg.pr.new url! not the pr number!
@@ -134,10 +134,7 @@ npx opensrc <owner>/<repo> # GitHub repo (e.g., npx opensrc vercel/ai)
134
134
 
135
135
  ## opentui fork
136
136
 
137
- we are using an opentui fork with name opentuah with my personal PRs merged
138
-
139
- "@opentui/core": "npm:@opentuah/core@^0.1.80",
140
- "@opentui/react": "npm:@opentuah/react@^0.1.80",
137
+ we are using an opentui fork published as `@opentuah`. imports use `@opentuah/core` and `@opentuah/react` directly (no npm alias remapping).
141
138
 
142
139
  To find my opentui folder with that fork see kimaki projects via kimaki cli, the one named opentui.
143
140
 
package/CHANGELOG.md CHANGED
@@ -1,3 +1,20 @@
1
+ # 0.1.105
2
+
3
+ - `critique --stdin` / `critique --web`:
4
+ - Only show 'URL is private' notice when generating with --web (fix notice appearing in scrollback/pager output like lazygit where it makes no sense)
5
+ - Tests:
6
+ - Add comprehensive integration tests for --stdin pager mode using tuistory (10 test cases covering empty diffs, multiple files, renames, binary files, narrow terminals, etc.)
7
+ - Rewrite lazygit pager test as real integration test that launches critique in a PTY and verifies scrollback output
8
+
9
+ # 0.1.104
10
+
11
+ - `critique --stdin` (pager mode):
12
+ - Force scrollback mode for `--stdin` pager usage to fix lazygit integration (#25)
13
+ - When used as a pager (e.g. `critique --stdin` in lazygit), critique now correctly outputs static colored text instead of interactive TUI escape sequences
14
+ - `critique`:
15
+ - Fix crash in Bun compiled binaries where terminal TTY `columns`/`rows` report as 0, causing NaN framebuffer dimensions
16
+ - Replace `@opentui/*` npm alias remapping with direct `@opentuah/*` imports
17
+
1
18
  # 0.1.103
2
19
 
3
20
  - Syntax highlighting:
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.103",
5
+ "version": "0.1.105",
6
6
  "license": "MIT",
7
7
  "private": false,
8
8
  "bin": "./src/cli.tsx",
@@ -22,17 +22,18 @@
22
22
  "hono": "^4.7.10",
23
23
  "sharp": "^0.34.5",
24
24
  "stripe": "^20.2.0",
25
+ "tuistory": "^0.0.13",
25
26
  "typescript": "^5.9.3",
26
27
  "wrangler": "^4.19.1"
27
28
  },
28
29
  "dependencies": {
29
30
  "@agentclientprotocol/sdk": "^0.13.1",
30
31
  "@clack/prompts": "1.0.0-alpha.9",
31
- "@opentui/core": "npm:@opentuah/core@0.1.88",
32
- "@opentui/react": "npm:@opentuah/react@0.1.88",
32
+ "@opentuah/core": "0.1.95",
33
+ "@opentuah/react": "0.1.95",
33
34
  "@parcel/watcher": "^2.5.6",
34
- "goke": "^6.1.3",
35
35
  "diff": "^8.0.2",
36
+ "goke": "^6.1.3",
36
37
  "js-yaml": "^4.1.1",
37
38
  "marked": "^17.0.1",
38
39
  "picocolors": "^1.1.1",
@@ -3,12 +3,12 @@
3
3
  // Renders example hunks and review data to preview TUI appearance.
4
4
  // Run with: bun run scripts/preview-review.tsx (TUI) or --web (HTML upload).
5
5
 
6
- import { createCliRenderer, addDefaultParsers } from "@opentui/core"
6
+ import { createCliRenderer, addDefaultParsers } from "@opentuah/core"
7
7
  import parsersConfig from "../parsers-config.ts"
8
8
 
9
9
  // Register custom syntax highlighting parsers
10
10
  addDefaultParsers(parsersConfig.parsers)
11
- import { createRoot } from "@opentui/react"
11
+ import { createRoot } from "@opentuah/react"
12
12
  import * as React from "react"
13
13
  import { ReviewApp, ReviewAppView } from "../src/review/review-app.tsx"
14
14
  import { createHunk } from "../src/review/hunk-parser.ts"
package/src/ansi-html.ts CHANGED
@@ -2,8 +2,8 @@
2
2
  // Uses opentui's test renderer to capture structured span data and generates responsive HTML documents
3
3
  // with proper font scaling to fit terminal content within viewport width.
4
4
 
5
- import { TextAttributes, rgbToHex, type RGBA } from "@opentui/core"
6
- import type { CapturedFrame, CapturedLine, CapturedSpan } from "@opentui/core"
5
+ import { TextAttributes, rgbToHex, type RGBA } from "@opentuah/core"
6
+ import type { CapturedFrame, CapturedLine, CapturedSpan } from "@opentuah/core"
7
7
 
8
8
  export interface ToHtmlOptions {
9
9
  /** Background color for the container */
@@ -1,7 +1,7 @@
1
1
  import { describe, test, expect } from "bun:test"
2
2
  import { spanToAnsi, frameToAnsi } from "./ansi-output.ts"
3
- import { RGBA } from "@opentui/core"
4
- import type { CapturedFrame, CapturedSpan, CapturedLine } from "@opentui/core"
3
+ import { RGBA } from "@opentuah/core"
4
+ import type { CapturedFrame, CapturedSpan, CapturedLine } from "@opentuah/core"
5
5
 
6
6
  const themeBg = RGBA.fromValues(0, 0, 0, 1) // Black background
7
7
 
@@ -3,8 +3,8 @@
3
3
  // Falls back gracefully: truecolor → 256 → 16 → plain text.
4
4
 
5
5
  import supportsColor from "supports-color"
6
- import { TextAttributes, type RGBA } from "@opentui/core"
7
- import type { CapturedFrame, CapturedSpan, CapturedLine } from "@opentui/core"
6
+ import { TextAttributes, type RGBA } from "@opentuah/core"
7
+ import type { CapturedFrame, CapturedSpan, CapturedLine } from "@opentuah/core"
8
8
 
9
9
  // Color support levels: 0=none, 1=16 colors, 2=256 colors, 3=truecolor
10
10
  type ColorLevel = 0 | 1 | 2 | 3
@@ -3,7 +3,7 @@
3
3
  import * as React from "react"
4
4
  import { afterEach, describe, expect, it } from "bun:test"
5
5
  import { act } from "react"
6
- import { testRender } from "@opentui/react/test-utils"
6
+ import { testRender } from "@opentuah/react/test-utils"
7
7
  import { App } from "./cli.tsx"
8
8
  import type { ParsedFile } from "./diff-utils.ts"
9
9
 
package/src/cli.tsx CHANGED
@@ -3,6 +3,10 @@
3
3
  // Provides TUI diff viewing, AI-powered review generation, and web preview upload.
4
4
  // Commands: default (diff), review (AI analysis), web (HTML upload), pick (cherry-pick files).
5
5
 
6
+ // Must be first import: patches process.stdout.columns/rows for Bun compiled binaries
7
+ // where they incorrectly return 0 instead of actual terminal dimensions.
8
+ import "./patch-terminal-dimensions.ts";
9
+
6
10
  import { goke, wrapJsonSchema } from "goke";
7
11
  import {
8
12
  createRoot,
@@ -11,7 +15,7 @@ import {
11
15
  useOnResize,
12
16
  useRenderer,
13
17
  useTerminalDimensions,
14
- } from "@opentui/react";
18
+ } from "@opentuah/react";
15
19
  import { useCopySelection } from "./hooks/use-copy-selection.ts";
16
20
  import * as React from "react";
17
21
  import { exec, execSync } from "child_process";
@@ -22,7 +26,7 @@ import {
22
26
  ScrollBoxRenderable,
23
27
  BoxRenderable,
24
28
  addDefaultParsers,
25
- } from "@opentui/core";
29
+ } from "@opentuah/core";
26
30
  import parsersConfig from "../parsers-config.ts";
27
31
 
28
32
  // Register custom syntax highlighting parsers
@@ -1826,7 +1830,7 @@ cli
1826
1830
  return;
1827
1831
  }
1828
1832
 
1829
- if (options.scrollback || !process.stdout.isTTY) {
1833
+ if (options.scrollback || options.stdin || !process.stdout.isTTY) {
1830
1834
  // For scrollback, prefer terminal width over --cols default (240 is for web)
1831
1835
  const scrollbackCols = process.stdout.columns || parseInt(options.cols) || 120;
1832
1836
  await runScrollbackMode(cleanedDiff, {
@@ -3,7 +3,7 @@
3
3
  import * as React from "react"
4
4
  import { afterEach, describe, expect, it } from "bun:test"
5
5
  import { act } from "react"
6
- import { testRender } from "@opentui/react/test-utils"
6
+ import { testRender } from "@opentuah/react/test-utils"
7
7
  import { DiffView } from "./diff-view.tsx"
8
8
  import { useAppStore } from "../store.ts"
9
9
 
@@ -3,7 +3,7 @@
3
3
  // Supports split and unified view modes with line numbers.
4
4
 
5
5
  import * as React from "react"
6
- import { SyntaxStyle } from "@opentui/core"
6
+ import { SyntaxStyle } from "@opentuah/core"
7
7
  import { getSyntaxTheme, getResolvedTheme, rgbaToHex } from "../themes.ts"
8
8
 
9
9
  export interface DiffViewProps {
package/src/diff-utils.ts CHANGED
@@ -459,7 +459,7 @@ export function processFiles<T extends ParsedFile>(
459
459
 
460
460
  /**
461
461
  * Detect filetype from filename for syntax highlighting
462
- * Maps to tree-sitter parsers available in @opentui/core and parsers-config.ts
462
+ * Maps to tree-sitter parsers available in @opentuah/core and parsers-config.ts
463
463
  */
464
464
  export function detectFiletype(filePath: string): string | undefined {
465
465
  const ext = filePath.split(".").pop()?.toLowerCase();
@@ -2,7 +2,7 @@
2
2
  // Uses opentui test renderer with captureCharFrame() for visual testing
3
3
 
4
4
  import { afterEach, describe, expect, it } from "bun:test"
5
- import { testRender } from "@opentui/react/test-utils"
5
+ import { testRender } from "@opentuah/react/test-utils"
6
6
  import { buildDirectoryTree, type TreeFileInfo, type TreeNode } from "./directory-tree.ts"
7
7
  import { DirectoryTreeView } from "./components/directory-tree-view.tsx"
8
8
 
@@ -1,7 +1,7 @@
1
1
  import * as React from "react"
2
2
  import { afterEach, describe, expect, it } from "bun:test"
3
3
  import { act } from "react"
4
- import { testRender } from "@opentui/react/test-utils"
4
+ import { testRender } from "@opentuah/react/test-utils"
5
5
  import Dropdown, { filterDropdownOptions } from "./dropdown.tsx"
6
6
  import { getResolvedTheme } from "./themes.ts"
7
7
 
package/src/dropdown.tsx CHANGED
@@ -3,8 +3,8 @@
3
3
  // Used by main diff view for file picker and theme picker overlays.
4
4
 
5
5
  import React, { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
6
- import { useKeyboard } from "@opentui/react";
7
- import { TextAttributes, TextareaRenderable } from "@opentui/core";
6
+ import { useKeyboard } from "@opentuah/react";
7
+ import { TextAttributes, TextareaRenderable } from "@opentuah/core";
8
8
  import { type ResolvedTheme, rgbaToHex } from "./themes";
9
9
 
10
10
  export interface DropdownOption {
@@ -2,7 +2,7 @@
2
2
  // Automatically copies selected text to clipboard when user releases mouse button.
3
3
  // Uses native clipboard commands (pbcopy, xclip, etc.) with OSC52 fallback.
4
4
 
5
- import { useRenderer } from "@opentui/react"
5
+ import { useRenderer } from "@opentuah/react"
6
6
  import { spawn } from "child_process"
7
7
 
8
8
  /**
package/src/image.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  // Uses opentui-image.ts for generic frame-to-image conversion.
3
3
  // Adds theme resolution and diff/review-specific rendering.
4
4
 
5
- import type { CapturedFrame } from "@opentui/core"
5
+ import type { CapturedFrame } from "@opentuah/core"
6
6
  import { getResolvedTheme, rgbaToHex } from "./themes.ts"
7
7
  import {
8
8
  renderFrameToImage,
@@ -334,12 +334,11 @@ export async function renderDiffToOgImage(
334
334
  const charWidth = fontSize * 0.6
335
335
  const cols = options.cols ?? Math.floor(contentWidth / charWidth)
336
336
 
337
- // Render diff to captured frame (no notice block for OG images)
337
+ // Render diff to captured frame
338
338
  const frame = await renderDiffToFrame(diffContent, {
339
339
  cols,
340
340
  maxRows: 200,
341
341
  themeName,
342
- showNotice: false,
343
342
  })
344
343
 
345
344
  // Convert frame to OG image
@@ -4,8 +4,8 @@
4
4
  import { tmpdir } from "os"
5
5
  import { join } from "path"
6
6
  import fs from "fs"
7
- import { TextAttributes, rgbToHex, type RGBA } from "@opentui/core"
8
- import type { CapturedFrame, CapturedLine, CapturedSpan } from "@opentui/core"
7
+ import { TextAttributes, rgbToHex, type RGBA } from "@opentuah/core"
8
+ import type { CapturedFrame, CapturedLine, CapturedSpan } from "@opentuah/core"
9
9
 
10
10
  // ─────────────────────────────────────────────────────────────
11
11
  // Types
@@ -0,0 +1,51 @@
1
+ // Polyfill for terminal dimensions in Bun compiled binaries.
2
+ // Bun's `--compile` produces binaries where process.stdout.columns/rows are 0
3
+ // instead of the actual terminal size (even when isTTY is true).
4
+ // This must be imported before any opentui imports, since opentui reads
5
+ // stdout.columns at init time and uses `?? fallback` which doesn't catch 0.
6
+
7
+ import { execSync } from "child_process"
8
+
9
+ function getTerminalSize(): { cols: number; rows: number } | null {
10
+ try {
11
+ const cols = parseInt(
12
+ execSync("tput cols", {
13
+ encoding: "utf-8",
14
+ stdio: ["inherit", "pipe", "pipe"],
15
+ }).trim(),
16
+ )
17
+ const rows = parseInt(
18
+ execSync("tput lines", {
19
+ encoding: "utf-8",
20
+ stdio: ["inherit", "pipe", "pipe"],
21
+ }).trim(),
22
+ )
23
+ if (cols > 0 && rows > 0) return { cols, rows }
24
+ } catch {}
25
+ return null
26
+ }
27
+
28
+ if (process.stdout.isTTY && process.stdout.columns === 0) {
29
+ const size = getTerminalSize()
30
+ if (size) {
31
+ Object.defineProperty(process.stdout, "columns", {
32
+ value: size.cols,
33
+ writable: true,
34
+ configurable: true,
35
+ })
36
+ Object.defineProperty(process.stdout, "rows", {
37
+ value: size.rows,
38
+ writable: true,
39
+ configurable: true,
40
+ })
41
+
42
+ // Keep patched values updated on terminal resize
43
+ process.on("SIGWINCH", () => {
44
+ const newSize = getTerminalSize()
45
+ if (newSize) {
46
+ process.stdout.columns = newSize.cols
47
+ process.stdout.rows = newSize.rows
48
+ }
49
+ })
50
+ }
51
+ }
@@ -1,7 +1,7 @@
1
1
  // Test for ReviewAppView rendering with example YAML data
2
2
 
3
3
  import { afterEach, describe, expect, it } from "bun:test"
4
- import { testRender } from "@opentui/react/test-utils"
4
+ import { testRender } from "@opentuah/react/test-utils"
5
5
  import { ReviewAppView } from "./review-app.tsx"
6
6
  import { createHunk } from "./hunk-parser.ts"
7
7
  import type { ReviewYaml } from "./types.ts"
@@ -3,8 +3,8 @@
3
3
  // Supports live generation updates, theme switching, and resume from saved reviews.
4
4
 
5
5
  import * as React from "react"
6
- import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/react"
7
- import { MacOSScrollAccel, SyntaxStyle, BoxRenderable, CodeRenderable, TextRenderable, ScrollBoxRenderable } from "@opentui/core"
6
+ import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentuah/react"
7
+ import { MacOSScrollAccel, SyntaxStyle, BoxRenderable, CodeRenderable, TextRenderable, ScrollBoxRenderable } from "@opentuah/core"
8
8
  import { useCopySelection } from "../hooks/use-copy-selection.ts"
9
9
  import type { Token } from "marked"
10
10
  import { getResolvedTheme, getSyntaxTheme, defaultThemeName, themeNames, rgbaToHex } from "../themes.ts"
@@ -1,7 +1,7 @@
1
1
  // Tests for StreamDisplay component
2
2
 
3
3
  import { afterEach, describe, expect, it } from "bun:test"
4
- import { testRender } from "@opentui/react/test-utils"
4
+ import { testRender } from "@opentuah/react/test-utils"
5
5
  import type { SessionNotification } from "@agentclientprotocol/sdk"
6
6
  import { StreamDisplay } from "./stream-display.tsx"
7
7
 
@@ -3,7 +3,7 @@
3
3
  // Shows formatted tool operations with file names and edit statistics.
4
4
 
5
5
  import * as React from "react"
6
- import { SyntaxStyle } from "@opentui/core"
6
+ import { SyntaxStyle } from "@opentuah/core"
7
7
  import type { SessionNotification } from "@agentclientprotocol/sdk"
8
8
  import { formatNotifications, SYMBOLS, COLORS, isEditTool, type StreamLine } from "./acp-stream-display.ts"
9
9
  import { getSyntaxTheme, getResolvedTheme, rgbaToHex } from "../themes.ts"
@@ -0,0 +1,420 @@
1
+ // Integration tests for --stdin pager mode (lazygit integration).
2
+ // Reproduces https://github.com/remorses/critique/issues/25
3
+ //
4
+ // Uses tuistory to launch critique in a PTY (exactly like lazygit does),
5
+ // pipes a real diff via stdin, and verifies the output is plain scrollback
6
+ // text — not interactive TUI escape sequences.
7
+ //
8
+ // tuistory spawns a PTY where isTTY=true, which is exactly how lazygit
9
+ // runs its pager (via github.com/creack/pty). This makes the test
10
+ // realistic: it catches the original bug where --stdin + TTY incorrectly
11
+ // entered interactive TUI mode instead of scrollback mode.
12
+
13
+ import { describe, test, expect, afterAll, beforeAll } from "bun:test"
14
+ import { launchTerminal } from "tuistory"
15
+ import fs from "fs"
16
+ import path from "path"
17
+
18
+ const TEMP_DIR = path.join(import.meta.dir, ".test-stdin-pager-tmp")
19
+
20
+ function tempFile(name: string, content: string): string {
21
+ const p = path.join(TEMP_DIR, name)
22
+ fs.writeFileSync(p, content)
23
+ return p
24
+ }
25
+
26
+ function launchCritique(diffPath: string, opts?: { cols?: number; rows?: number }) {
27
+ return launchTerminal({
28
+ command: "bash",
29
+ args: ["-c", `cat "${diffPath}" | bun run src/cli.tsx --stdin`],
30
+ cols: opts?.cols ?? 100,
31
+ rows: opts?.rows ?? 30,
32
+ cwd: process.cwd(),
33
+ env: {
34
+ PATH: process.env.PATH,
35
+ HOME: process.env.HOME,
36
+ TERM: "xterm-256color",
37
+ },
38
+ })
39
+ }
40
+
41
+ // -- Sample diffs --
42
+
43
+ const SINGLE_FILE_DIFF = [
44
+ "diff --git a/src/hello.ts b/src/hello.ts",
45
+ "--- a/src/hello.ts",
46
+ "+++ b/src/hello.ts",
47
+ "@@ -1,3 +1,3 @@",
48
+ " const greeting = 'hello'",
49
+ "-console.log(greeting)",
50
+ "+console.log(greeting + ' world')",
51
+ " export default greeting",
52
+ ].join("\n")
53
+
54
+ // Empty patch — lazygit sends this when there are no changes for a file
55
+ const EMPTY_DIFF = ""
56
+
57
+ // Diff with only context lines and no actual changes (can happen with -U999)
58
+ const CONTEXT_ONLY_DIFF = [
59
+ "diff --git a/readme.md b/readme.md",
60
+ "--- a/readme.md",
61
+ "+++ b/readme.md",
62
+ "@@ -1,3 +1,3 @@",
63
+ " # My Project",
64
+ " ",
65
+ " Some description",
66
+ ].join("\n")
67
+
68
+ // Multiple files in a single diff
69
+ const MULTI_FILE_DIFF = [
70
+ "diff --git a/src/index.ts b/src/index.ts",
71
+ "--- a/src/index.ts",
72
+ "+++ b/src/index.ts",
73
+ "@@ -1,4 +1,6 @@",
74
+ " import { App } from './app'",
75
+ "+import { Logger } from './logger'",
76
+ " ",
77
+ " const app = new App()",
78
+ "+const logger = new Logger()",
79
+ " app.start()",
80
+ "diff --git a/src/logger.ts b/src/logger.ts",
81
+ "new file mode 100644",
82
+ "--- /dev/null",
83
+ "+++ b/src/logger.ts",
84
+ "@@ -0,0 +1,5 @@",
85
+ "+export class Logger {",
86
+ "+ log(msg: string) {",
87
+ "+ console.log(`[LOG] ${msg}`)",
88
+ "+ }",
89
+ "+}",
90
+ ].join("\n")
91
+
92
+ // Deletion-only diff
93
+ const DELETE_ONLY_DIFF = [
94
+ "diff --git a/src/deprecated.ts b/src/deprecated.ts",
95
+ "deleted file mode 100644",
96
+ "--- a/src/deprecated.ts",
97
+ "+++ /dev/null",
98
+ "@@ -1,4 +0,0 @@",
99
+ "-// This module is no longer needed",
100
+ "-export function oldHelper() {",
101
+ "- return 'deprecated'",
102
+ "-}",
103
+ ].join("\n")
104
+
105
+ // Addition-only diff (new file)
106
+ const NEW_FILE_DIFF = [
107
+ "diff --git a/src/utils.ts b/src/utils.ts",
108
+ "new file mode 100644",
109
+ "--- /dev/null",
110
+ "+++ b/src/utils.ts",
111
+ "@@ -0,0 +1,7 @@",
112
+ "+export function clamp(value: number, min: number, max: number): number {",
113
+ "+ return Math.min(Math.max(value, min), max)",
114
+ "+}",
115
+ "+",
116
+ "+export function identity<T>(x: T): T {",
117
+ "+ return x",
118
+ "+}",
119
+ ].join("\n")
120
+
121
+ // Large hunk with many changes
122
+ const LARGE_HUNK_DIFF = [
123
+ "diff --git a/config.json b/config.json",
124
+ "--- a/config.json",
125
+ "+++ b/config.json",
126
+ "@@ -1,9 +1,11 @@",
127
+ " {",
128
+ '- "name": "my-app",',
129
+ '+ "name": "my-awesome-app",',
130
+ '- "version": "1.0.0",',
131
+ '+ "version": "2.0.0",',
132
+ ' "description": "A sample app",',
133
+ '- "main": "index.js",',
134
+ '+ "main": "dist/index.js",',
135
+ '+ "types": "dist/index.d.ts",',
136
+ ' "scripts": {',
137
+ '- "build": "tsc"',
138
+ '+ "build": "tsc --project tsconfig.build.json",',
139
+ '+ "test": "bun test"',
140
+ " }",
141
+ " }",
142
+ ].join("\n")
143
+
144
+ // Rename diff (common in lazygit)
145
+ const RENAME_DIFF = [
146
+ "diff --git a/src/old-name.ts b/src/new-name.ts",
147
+ "similarity index 90%",
148
+ "rename from src/old-name.ts",
149
+ "rename to src/new-name.ts",
150
+ "--- a/src/old-name.ts",
151
+ "+++ b/src/new-name.ts",
152
+ "@@ -1,3 +1,3 @@",
153
+ "-export const name = 'old'",
154
+ "+export const name = 'new'",
155
+ " export const version = 1",
156
+ " export default name",
157
+ ].join("\n")
158
+
159
+ // Binary file diff (lazygit shows these)
160
+ const BINARY_DIFF = [
161
+ "diff --git a/logo.png b/logo.png",
162
+ "new file mode 100644",
163
+ "Binary files /dev/null and b/logo.png differ",
164
+ ].join("\n")
165
+
166
+ describe("--stdin pager mode (lazygit issue #25)", () => {
167
+ beforeAll(() => {
168
+ fs.mkdirSync(TEMP_DIR, { recursive: true })
169
+ })
170
+
171
+ afterAll(() => {
172
+ try {
173
+ fs.rmSync(TEMP_DIR, { recursive: true })
174
+ } catch {}
175
+ })
176
+
177
+ test("single file change", async () => {
178
+ const diffPath = tempFile("single.diff", SINGLE_FILE_DIFF)
179
+ const session = await launchCritique(diffPath)
180
+ await session.waitForText("hello", { timeout: 15000 })
181
+ const trimmed = await session.text({ trimEnd: true })
182
+
183
+ expect(trimmed).toMatchInlineSnapshot(`
184
+ "
185
+ a/src/hello.ts → b/src/hello.ts +1-1
186
+
187
+ 1 const greeting = 'hello'
188
+ 2 - console.log(greeting)
189
+ 2 + console.log(greeting + ' world')
190
+ 3 export default greeting"
191
+ `)
192
+
193
+ const lines = trimmed.split("\n").filter((l) => l.trim().length > 0)
194
+ expect(lines.length).toBeGreaterThan(0)
195
+ expect(lines.length).toBeLessThan(25)
196
+ session.close()
197
+ }, 30000)
198
+
199
+ test("empty diff produces no crash", async () => {
200
+ const diffPath = tempFile("empty.diff", EMPTY_DIFF)
201
+ const session = await launchCritique(diffPath)
202
+
203
+ // Empty diff should cause critique to exit quickly.
204
+ // Wait a bit for it to process and exit.
205
+ await new Promise((r) => setTimeout(r, 5000))
206
+ const trimmed = await session.text({ trimEnd: true, immediate: true })
207
+
208
+ // Should either be empty or show an error message — not a TUI
209
+ expect(trimmed).toMatchInlineSnapshot(`
210
+ "
211
+ No changes to display"
212
+ `)
213
+
214
+ session.close()
215
+ }, 15000)
216
+
217
+ test("context-only diff (no actual changes)", async () => {
218
+ const diffPath = tempFile("context-only.diff", CONTEXT_ONLY_DIFF)
219
+ const session = await launchCritique(diffPath)
220
+
221
+ await new Promise((r) => setTimeout(r, 5000))
222
+ const trimmed = await session.text({ trimEnd: true, immediate: true })
223
+
224
+ expect(trimmed).toMatchInlineSnapshot(`
225
+ "
226
+ a/readme.md → b/readme.md +0-0
227
+
228
+ 1 # My Project
229
+ 2
230
+ 3 Some description"
231
+ `)
232
+
233
+ session.close()
234
+ }, 15000)
235
+
236
+ test("multiple files in one diff", async () => {
237
+ const diffPath = tempFile("multi.diff", MULTI_FILE_DIFF)
238
+ const session = await launchCritique(diffPath)
239
+ await session.waitForText("Logger", { timeout: 15000 })
240
+ const trimmed = await session.text({ trimEnd: true })
241
+
242
+ expect(trimmed).toMatchInlineSnapshot(`
243
+ "
244
+ b/src/logger.ts +5-0
245
+
246
+ 1 + export class Logger {
247
+ 2 + log(msg: string) {
248
+ 3 + console.log(\`[LOG] \${msg}\`)
249
+ 4 + }
250
+ 5 + }
251
+
252
+
253
+ a/src/index.ts → b/src/index.ts +2-0
254
+
255
+ 1 import { App } from './app'
256
+ 2 + import { Logger } from './logger'
257
+ 3
258
+ 4 const app = new App()
259
+ 5 + const logger = new Logger()
260
+ 6 app.start()"
261
+ `)
262
+
263
+ // Should contain both filenames
264
+ expect(trimmed).toContain("index.ts")
265
+ expect(trimmed).toContain("logger.ts")
266
+
267
+ // Should not show the privacy notice
268
+ expect(trimmed).not.toContain("URL is private")
269
+
270
+ const lines = trimmed.split("\n").filter((l) => l.trim().length > 0)
271
+ expect(lines.length).toBeLessThan(40)
272
+ session.close()
273
+ }, 30000)
274
+
275
+ test("deleted file", async () => {
276
+ const diffPath = tempFile("delete.diff", DELETE_ONLY_DIFF)
277
+ const session = await launchCritique(diffPath)
278
+ await session.waitForText("deprecated", { timeout: 15000 })
279
+ const trimmed = await session.text({ trimEnd: true })
280
+
281
+ expect(trimmed).toMatchInlineSnapshot(`
282
+ "
283
+ a/src/deprecated.ts +0-4
284
+
285
+ 1 - // This module is no longer needed
286
+ 2 - export function oldHelper() {
287
+ 3 - return 'deprecated'
288
+ 4 - }"
289
+ `)
290
+
291
+ expect(trimmed).toContain("deprecated")
292
+ expect(trimmed).not.toContain("URL is private")
293
+ session.close()
294
+ }, 30000)
295
+
296
+ test("new file", async () => {
297
+ const diffPath = tempFile("newfile.diff", NEW_FILE_DIFF)
298
+ const session = await launchCritique(diffPath)
299
+ await session.waitForText("clamp", { timeout: 15000 })
300
+ const trimmed = await session.text({ trimEnd: true })
301
+
302
+ expect(trimmed).toMatchInlineSnapshot(`
303
+ "
304
+ b/src/utils.ts +7-0
305
+
306
+ 1 + export function clamp(value: number, min: number, max: number): number {
307
+ 2 + return Math.min(Math.max(value, min), max)
308
+ 3 + }
309
+ 4 +
310
+ 5 + export function identity<T>(x: T): T {
311
+ 6 + return x
312
+ 7 + }"
313
+ `)
314
+
315
+ expect(trimmed).toContain("clamp")
316
+ expect(trimmed).toContain("identity")
317
+ expect(trimmed).not.toContain("URL is private")
318
+ session.close()
319
+ }, 30000)
320
+
321
+ test("large hunk with many changes", async () => {
322
+ const diffPath = tempFile("large.diff", LARGE_HUNK_DIFF)
323
+ const session = await launchCritique(diffPath)
324
+ await session.waitForText("config.json", { timeout: 15000 })
325
+ const trimmed = await session.text({ trimEnd: true })
326
+
327
+ expect(trimmed).toMatchInlineSnapshot(`
328
+ "
329
+ a/config.json → b/config.json +6-4
330
+
331
+ 1 {
332
+ 2 - "name": "my-app",
333
+ 3 - "version": "1.0.0",
334
+ 2 + "name": "my-awesome-app",
335
+ 3 + "version": "2.0.0",
336
+ 4 "description": "A sample app",
337
+ 5 - "main": "index.js",
338
+ 5 + "main": "dist/index.js",
339
+ 6 + "types": "dist/index.d.ts",
340
+ 7 "scripts": {
341
+ 7 - "build": "tsc"
342
+ 8 + "build": "tsc --project tsconfig.build.json",
343
+ 9 + "test": "bun test"
344
+ 10 }
345
+ 11 }"
346
+ `)
347
+
348
+ expect(trimmed).toContain("config.json")
349
+ expect(trimmed).toContain("my-awesome-app")
350
+ expect(trimmed).not.toContain("URL is private")
351
+ session.close()
352
+ }, 30000)
353
+
354
+ test("file rename with changes", async () => {
355
+ const diffPath = tempFile("rename.diff", RENAME_DIFF)
356
+ const session = await launchCritique(diffPath)
357
+ await session.waitForText("new-name", { timeout: 15000 })
358
+ const trimmed = await session.text({ trimEnd: true })
359
+
360
+ expect(trimmed).toMatchInlineSnapshot(`
361
+ "
362
+ src/old-name.ts → src/new-name.ts +1-1
363
+
364
+ 1 - export const name = 'old'
365
+ 1 + export const name = 'new'
366
+ 2 export const version = 1
367
+ 3 export default name"
368
+ `)
369
+
370
+ // Should show the rename
371
+ expect(trimmed).toContain("old-name")
372
+ expect(trimmed).toContain("new-name")
373
+ expect(trimmed).not.toContain("URL is private")
374
+ session.close()
375
+ }, 30000)
376
+
377
+ test("binary file diff", async () => {
378
+ const diffPath = tempFile("binary.diff", BINARY_DIFF)
379
+ const session = await launchCritique(diffPath)
380
+
381
+ // Binary diffs may not render content — wait for exit or timeout
382
+ await new Promise((r) => setTimeout(r, 5000))
383
+ const trimmed = await session.text({ trimEnd: true, immediate: true })
384
+
385
+ expect(trimmed).toMatchInlineSnapshot(`
386
+ "
387
+ unknown +0-0"
388
+ `)
389
+
390
+ expect(trimmed).not.toContain("URL is private")
391
+ session.close()
392
+ }, 15000)
393
+
394
+ test("narrow terminal (40 cols) forces unified view", async () => {
395
+ const diffPath = tempFile("narrow.diff", SINGLE_FILE_DIFF)
396
+ const session = await launchCritique(diffPath, { cols: 40 })
397
+ await session.waitForText("hello", { timeout: 15000 })
398
+ const trimmed = await session.text({ trimEnd: true })
399
+
400
+ expect(trimmed).toMatchInlineSnapshot(`
401
+ "
402
+ a/src/hello.ts → b/src/hello.ts +1-1
403
+
404
+ 1 const greeting = 'hello'
405
+ 2 - console.log(greeting)
406
+ 2 + console.log(greeting + ' world')
407
+ 3 export default greeting"
408
+ `)
409
+
410
+ expect(trimmed).toContain("hello")
411
+ expect(trimmed).not.toContain("URL is private")
412
+
413
+ // Every non-empty line should fit within 40 cols
414
+ const lines = trimmed.split("\n").filter((l) => l.trim().length > 0)
415
+ for (const line of lines) {
416
+ expect(line.length).toBeLessThanOrEqual(40)
417
+ }
418
+ session.close()
419
+ }, 30000)
420
+ })
package/src/themes.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  // Loads JSON theme files lazily on demand, resolves color references,
3
3
  // and provides both UI colors and Tree-sitter compatible syntax styles.
4
4
 
5
- import { parseColor, RGBA } from "@opentui/core";
5
+ import { parseColor, RGBA } from "@opentuah/core";
6
6
  import { fileURLToPath } from "url";
7
7
 
8
8
  // Only import the default theme statically for fast startup
@@ -1,8 +1,8 @@
1
1
  import { describe, test, expect, afterEach } from "bun:test"
2
- import { createTestRenderer } from "@opentui/core/testing"
3
- import { createRoot } from "@opentui/react"
2
+ import { createTestRenderer } from "@opentuah/core/testing"
3
+ import { createRoot } from "@opentuah/react"
4
4
  import React from "react"
5
- import { RGBA } from "@opentui/core"
5
+ import { RGBA } from "@opentuah/core"
6
6
 
7
7
  describe("getSpanLines rendering", () => {
8
8
  let renderer: Awaited<ReturnType<typeof createTestRenderer>>["renderer"] | null = null
package/src/web-utils.tsx CHANGED
@@ -8,7 +8,7 @@ import fs from "fs"
8
8
  import { tmpdir } from "os"
9
9
  import { join } from "path"
10
10
  import { getResolvedTheme, rgbaToHex } from "./themes.ts"
11
- import type { CapturedFrame, RootRenderable, CliRenderer } from "@opentui/core"
11
+ import type { CapturedFrame, RootRenderable, CliRenderer } from "@opentuah/core"
12
12
  import type { IndexedHunk, ReviewYaml } from "./review/types.ts"
13
13
  import { loadStoredLicenseKey, loadOrCreateOwnerSecret } from "./license.ts"
14
14
 
@@ -24,7 +24,7 @@ export interface CaptureOptions {
24
24
  title?: string
25
25
  /** Wrap mode for long lines (default: "word") */
26
26
  wrapMode?: "word" | "char" | "none"
27
- /** Show privacy/expiry notice block at top (default: true) */
27
+ /** Show privacy/expiry notice block at top (default: false, enabled for web uploads) */
28
28
  showNotice?: boolean
29
29
  }
30
30
 
@@ -118,9 +118,9 @@ export async function renderDiffToFrame(
118
118
  diffContent: string,
119
119
  options: CaptureOptions
120
120
  ): Promise<CapturedFrame> {
121
- const { createTestRenderer } = await import("@opentui/core/testing")
122
- const { createRoot } = await import("@opentui/react")
123
- const { getTreeSitterClient } = await import("@opentui/core")
121
+ const { createTestRenderer } = await import("@opentuah/core/testing")
122
+ const { createRoot } = await import("@opentuah/react")
123
+ const { getTreeSitterClient } = await import("@opentuah/core")
124
124
  const React = await import("react")
125
125
  const { parsePatch, formatPatch } = await import("diff")
126
126
 
@@ -160,7 +160,7 @@ export async function renderDiffToFrame(
160
160
  const webMuted = rgbaToHex(webTheme.textMuted)
161
161
 
162
162
  const showExpiryNotice = shouldShowExpiryNotice()
163
- const showNotice = options.showNotice !== false
163
+ const showNotice = options.showNotice === true
164
164
 
165
165
  // Create the diff view component
166
166
  // NOTE: No height: "100%" - let content determine its natural height
@@ -293,8 +293,8 @@ export async function captureToHtml(
293
293
  ): Promise<string> {
294
294
  const { frameToHtmlDocument } = await import("./ansi-html.ts")
295
295
 
296
- // Render diff to captured frame
297
- const frame = await renderDiffToFrame(diffContent, options)
296
+ // Render diff to captured frame (with notice for web uploads)
297
+ const frame = await renderDiffToFrame(diffContent, { ...options, showNotice: true })
298
298
 
299
299
  // Get theme colors for HTML output
300
300
  const theme = getResolvedTheme(options.themeName)
@@ -375,9 +375,9 @@ export interface ReviewRenderOptions extends CaptureOptions {
375
375
  export async function renderReviewToFrame(
376
376
  options: ReviewRenderOptions
377
377
  ): Promise<CapturedFrame> {
378
- const { createTestRenderer } = await import("@opentui/core/testing")
379
- const { createRoot } = await import("@opentui/react")
380
- const { getTreeSitterClient } = await import("@opentui/core")
378
+ const { createTestRenderer } = await import("@opentuah/core/testing")
379
+ const { createRoot } = await import("@opentuah/react")
380
+ const { getTreeSitterClient } = await import("@opentuah/core")
381
381
  const React = await import("react")
382
382
 
383
383
  // Pre-initialize TreeSitter client to ensure syntax highlighting works
@@ -396,7 +396,7 @@ export async function renderReviewToFrame(
396
396
  const webText = rgbaToHex(theme.text)
397
397
  const webMuted = rgbaToHex(theme.textMuted)
398
398
  const showExpiryNotice = shouldShowExpiryNotice()
399
- const showNotice = options.showNotice !== false
399
+ const showNotice = options.showNotice === true
400
400
 
401
401
  // Content-fitting: start small, double if clipped, shrink to fit
402
402
  let currentHeight = 100
@@ -488,8 +488,8 @@ export async function captureReviewToHtml(
488
488
  ): Promise<string> {
489
489
  const { frameToHtmlDocument } = await import("./ansi-html.ts")
490
490
 
491
- // Render review to captured frame
492
- const frame = await renderReviewToFrame(options)
491
+ // Render review to captured frame (with notice for web uploads)
492
+ const frame = await renderReviewToFrame({ ...options, showNotice: true })
493
493
 
494
494
  // Get theme colors for HTML output
495
495
  const theme = getResolvedTheme(options.themeName)
package/tsconfig.json CHANGED
@@ -7,7 +7,7 @@
7
7
  "module": "Preserve",
8
8
  "moduleDetection": "force",
9
9
  "jsx": "react-jsx",
10
- "jsxImportSource": "@opentui/react",
10
+ "jsxImportSource": "@opentuah/react",
11
11
  "allowJs": true,
12
12
 
13
13
  "noImplicitAny": false,