critique 0.1.78 → 0.1.103

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
@@ -131,3 +131,14 @@ npx opensrc <owner>/<repo> # GitHub repo (e.g., npx opensrc vercel/ai)
131
131
  ```
132
132
 
133
133
  <!-- opensrc:end -->
134
+
135
+ ## opentui fork
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",
141
+
142
+ To find my opentui folder with that fork see kimaki projects via kimaki cli, the one named opentui.
143
+
144
+ To apply fixes there you must create a new branch and then merge it in the branch called opentuah. then publish and update the versions here. to publish there is a script specifically for opentuah.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,174 @@
1
+ # 0.1.103
2
+
3
+ - Syntax highlighting:
4
+ - JSON: render punctuation (quotes/brackets/separators) using comment color instead of operator red / full-bright
5
+
6
+ # 0.1.102
7
+
8
+ - `critique --web` / `critique web`:
9
+ - Render the upload notice block in a single muted color (no mixed emphasis)
10
+
11
+ # 0.1.101
12
+
13
+ - `critique` / `critique --web` / OG images:
14
+ - Pass `addedBg`/`removedBg` alongside content backgrounds so opentui's word-level highlights don't inherit dark defaults on light themes
15
+ - Keep a small `github-light`-only `addedWordBg` override to make added word highlights visible in images
16
+
17
+ # 0.1.100
18
+
19
+ - Dependencies:
20
+ - Update `@opentui/core` / `@opentui/react` npm aliases to `@opentuah/*@0.1.88`
21
+ - Remove `@opentuah/core-darwin-arm64` optional dependency (core pulls the correct platform binary via its own optional deps)
22
+
23
+ # 0.1.99
24
+
25
+ - `critique` / `critique review` / `critique web` / `critique hunks list`:
26
+ - Define `--filter <pattern>` as an array schema in goke so repeated flags are parsed explicitly as `string[]`
27
+ - `critique review`:
28
+ - Define `--session <id>` as an array schema so repeated session flags are handled natively
29
+
30
+ # 0.1.98
31
+
32
+ - Dependencies:
33
+ - Replace `@xmorse/cac` with `goke` for CLI argument parsing
34
+
35
+ # 0.1.97
36
+
37
+ - `README`:
38
+ - Make the `CodeRabbit` sponsor text clickable while keeping the compact logo link
39
+
40
+ # 0.1.96
41
+
42
+ - `README`:
43
+ - Add a bottom `Sponsors` section with a compact CodeRabbit logo link to `https://coderabbit.link/remorses`
44
+
45
+ # 0.1.95
46
+
47
+ - Syntax highlighting:
48
+ - `json`:
49
+ - Color JSON quote characters (`"`) separately as `punctuation.delimiter` while keeping string text highlighted as `property` (keys) and `string` (values)
50
+
51
+ # 0.1.94
52
+
53
+ - Syntax highlighting:
54
+ - Fix JSON syntax highlighting by using local query file with captures compatible with themes.ts (nvim-treesitter uses incompatible `#set!` and `#eq?` predicates)
55
+ - Fix syntax highlighting in web preview by pre-initializing TreeSitter client before rendering
56
+
57
+ # 0.1.93
58
+
59
+ - Dependencies:
60
+ - Update `@opentui/core` and `@opentui/react` npm aliases to `@opentuah/*@^0.1.81`
61
+
62
+ # 0.1.92
63
+
64
+ - `critique`:
65
+ - Keep dropdown search on `<textarea>` and fix filtering by syncing `plainText` from `onContentChange` in a microtask, plus extract shared filtering logic for deterministic matching across theme/file pickers
66
+ - Make watch-mode loading/empty backgrounds reactive to the selected theme by subscribing to `useAppStore` instead of reading `getState()` once
67
+ - `critique pick`:
68
+ - Use the active global theme for picker UI colors instead of always forcing the default theme
69
+ - `diff rendering`:
70
+ - Remount the `DiffView` wrapper on theme changes so internal diff backgrounds refresh more reliably when switching themes
71
+ - Tests:
72
+ - Add `src/components/diff-view.test.tsx` to verify theme-switch background updates in `DiffView`
73
+ - Add dropdown filtering coverage via exported `filterDropdownOptions` tests in `src/dropdown.test.tsx`
74
+
75
+ # 0.1.91
76
+
77
+ - `critique`:
78
+ - Improve main diff layout scrolling behavior by allowing the scrollbox to shrink within the column layout (`flexShrink: 1`)
79
+ - Tests:
80
+ - Add `src/cli-scroll.test.tsx` using opentui test renderer to verify mouse-wheel scrolling changes visible content in the main diff view
81
+ - Guard CLI entrypoint parsing with `import.meta.main` and export `App`/`AppProps` to support renderer-driven CLI view tests
82
+
83
+ # 0.1.90
84
+
85
+ - `critique`:
86
+ - Fix dropdown search input in file/theme pickers by switching to `<input onInput>` so typed text filters options immediately
87
+ - Ensure diff internals fully refresh on theme change by remounting `<diff>` when `themeName` changes
88
+ - Make loading/empty-state backgrounds reactive to theme changes in watch mode
89
+ - `critique pick`:
90
+ - Use the currently selected global theme instead of always forcing the default theme
91
+ - Tests:
92
+ - Add a dropdown regression test that reproduces and verifies theme search filtering
93
+ - Add a DiffView regression test that verifies diff background colors actually change after a runtime theme switch
94
+
95
+ # 0.1.89
96
+
97
+ - `critique`:
98
+ - Keep the main diff scrollbox focused so mouse-wheel scrolling works reliably in the default diff view
99
+
100
+ # 0.1.88
101
+
102
+ - Dependencies:
103
+ - Keep `@opentui/*` imports/package names and install Jake's fork via npm alias mapping
104
+ - Map `@opentui/core` -> `npm:@opentuah/core@latest` and `@opentui/react` -> `npm:@opentuah/react@latest`
105
+
106
+ # 0.1.87
107
+
108
+ - `hunks`:
109
+ - `critique hunks add <id>` now appends dirty submodule diffs before hunk lookup
110
+ - Fixes mismatch where IDs listed by `critique hunks list` for submodule changes could fail with "Hunk not found"
111
+
112
+ # 0.1.86
113
+
114
+ - New `hunks` commands for non-interactive selective staging:
115
+ - `critique hunks list` - list all hunks with stable IDs
116
+ - `critique hunks add <id>` - stage specific hunks by ID
117
+ - `--staged` flag to list staged hunks
118
+ - `--filter` flag to filter by file pattern
119
+ - Hunk IDs use format `file:@-old,len+new,len` (stable across runs)
120
+ - Uses `diff` npm package for reliable parsing instead of shell/awk
121
+ - Switch to `@xmorse/cac` fork for space-separated subcommand support
122
+
123
+ # 0.1.85
124
+
125
+ - Docs: remove undocumented `--local` option from README (fixes #24)
126
+
127
+ # 0.1.84
128
+
129
+ - New `--scrollback` option: output diff to terminal scrollback instead of interactive TUI
130
+ - Renders using opentui test renderer and converts to ANSI escape sequences
131
+ - Auto-detects terminal color support (truecolor → 256 → 16 → plain text)
132
+ - Respects `FORCE_COLOR` and `NO_COLOR` environment variables
133
+ - Outputs plain text when piped (non-TTY)
134
+ - Add `supports-color` dependency for terminal capability detection
135
+
136
+ # 0.1.83
137
+
138
+ - Internal:
139
+ - Update opentui to 0.1.77
140
+ - Simplify span capture: use opentui's built-in `getSpanLines()` instead of custom implementation
141
+ - Remove ~60 lines of duplicated span capture code in `web-utils.tsx`
142
+
143
+ # 0.1.82
144
+
145
+ - `--web`:
146
+ - Content-fitting for HTML rendering: starts with small buffer (100 rows), doubles until content fits, then shrinks to exact size
147
+ - Reduces memory usage for small diffs while still supporting large ones
148
+ - Rename `CaptureOptions.rows` to `maxRows` to clarify it's the upper bound for buffer growth
149
+
150
+ # 0.1.81
151
+
152
+ - `review`:
153
+ - Fix diagram overflow expanding container width - long diagrams now truncate from right instead of shifting all content left
154
+ - Update AI prompt: diagrams should be under 70 chars wide, prose must be outside code blocks
155
+
156
+ # 0.1.80
157
+
158
+ - OG images:
159
+ - Increased default font size from 16px to 20px for better readability
160
+ - Fixed text clipping by adding flexShrink 0 to all elements
161
+ - Always use github-light theme (no dark mode support in OG protocol)
162
+ - `review --web` now generates OG images from the first few hunks of the diff
163
+ - Fixed `buildPatch` to include `diff --git` header for proper diff parsing
164
+
165
+ # 0.1.79
166
+
167
+ - TUI:
168
+ - `critique`: Esc closes file/theme dropdowns before exiting
169
+ - `critique`: Add dropdown escape handling test coverage
170
+ - `critique review`: Esc closes the theme picker dropdown before exiting
171
+
1
172
  # 0.1.78
2
173
 
3
174
  - **Breaking change:** Single positional argument now uses `git diff` instead of `git show`
package/README.md CHANGED
@@ -155,6 +155,58 @@ critique pick feature-branch
155
155
 
156
156
  Use the interactive UI to select files. Selected files are immediately applied as patches, deselected files are restored.
157
157
 
158
+ ### Selective Hunk Staging
159
+
160
+ Non-interactive hunk staging for scripts and AI agents. Similar to `git add -p` but scriptable.
161
+
162
+ ```bash
163
+ # List all unstaged hunks with stable IDs
164
+ critique hunks list
165
+
166
+ # List staged hunks
167
+ critique hunks list --staged
168
+
169
+ # Filter by file pattern
170
+ critique hunks list --filter "src/**/*.ts"
171
+
172
+ # Stage specific hunks by ID
173
+ critique hunks add 'src/main.ts:@-10,6+10,7'
174
+
175
+ # Stage multiple hunks
176
+ critique hunks add 'src/main.ts:@-10,6+10,7' 'src/utils.ts:@-5,3+5,4'
177
+ ```
178
+
179
+ **Hunk ID format:** `file:@-oldStart,oldLines+newStart,newLines`
180
+
181
+ The ID is derived from the `@@` header in unified diff format, making it stable across runs (unlike incremental IDs).
182
+
183
+ **Example workflow:**
184
+
185
+ ```bash
186
+ # 1. List available hunks
187
+ $ critique hunks list
188
+ src/main.ts:@-10,6+10,7
189
+ @@ -10,6 +10,7 @@
190
+ +import { newFeature } from './feature'
191
+ ---
192
+ src/main.ts:@-50,3+51,5
193
+ @@ -50,3 +51,5 @@
194
+ + newFeature()
195
+ + return result
196
+ ---
197
+
198
+ # 2. Stage just the import hunk
199
+ $ critique hunks add 'src/main.ts:@-10,6+10,7'
200
+ Staged: src/main.ts:@-10,6+10,7
201
+
202
+ # 3. Commit it separately
203
+ $ git commit -m "Add newFeature import"
204
+
205
+ # 4. Stage and commit the usage
206
+ $ critique hunks add 'src/main.ts:@-50,3+51,5'
207
+ $ git commit -m "Use newFeature in main"
208
+ ```
209
+
158
210
  ### Web Preview
159
211
 
160
212
  Generate a shareable web preview of your diff that you can send to anyone - no installation required.
@@ -187,9 +239,6 @@ critique web -- src/api.ts src/utils.ts
187
239
 
188
240
  # Custom title for the HTML page
189
241
  critique web --title "Fix authentication bug"
190
-
191
- # Generate local HTML file instead of uploading
192
- critique web --local
193
242
  ```
194
243
 
195
244
  **Features:**
@@ -208,7 +257,6 @@ critique web --local
208
257
  | `--commit <ref>` | Show changes from a specific commit | - |
209
258
  | `--cols <n>` | Terminal width for rendering | `240` |
210
259
  | `--mobile-cols <n>` | Terminal width for mobile version | `100` |
211
- | `--local` | Save HTML locally instead of uploading | - |
212
260
  | `--filter <pattern>` | Filter files by glob (can be used multiple times) | - |
213
261
  | `--title <text>` | Custom HTML document title | `Critique Diff` |
214
262
  | `--theme <name>` | Theme for rendering (disables auto dark/light mode) | - |
@@ -270,6 +318,14 @@ Files with more than 6000 lines of diff are also hidden for performance.
270
318
  - [diff](https://github.com/kpdecker/jsdiff) - Diff algorithm
271
319
  - [Hono](https://hono.dev/) - Web framework for the preview worker
272
320
 
321
+ ## Sponsors
322
+
323
+ <a href="https://coderabbit.link/remorses" target="_blank" rel="noopener noreferrer">
324
+ <img src="https://github.com/coderabbitai.png" alt="CodeRabbit" height="24" />
325
+ </a>
326
+
327
+ Sponsored by [CodeRabbit](https://coderabbit.link/remorses).
328
+
273
329
  ## License
274
330
 
275
331
  MIT
package/global.d.ts ADDED
@@ -0,0 +1,5 @@
1
+ // Type declarations for importing .scm files with Bun
2
+ declare module "*.scm" {
3
+ const value: string
4
+ export default value
5
+ }
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.78",
5
+ "version": "0.1.103",
6
6
  "license": "MIT",
7
7
  "private": false,
8
8
  "bin": "./src/cli.tsx",
@@ -28,16 +28,17 @@
28
28
  "dependencies": {
29
29
  "@agentclientprotocol/sdk": "^0.13.1",
30
30
  "@clack/prompts": "1.0.0-alpha.9",
31
- "@opentui/core": "^0.1.75",
32
- "@opentui/react": "^0.1.75",
31
+ "@opentui/core": "npm:@opentuah/core@0.1.88",
32
+ "@opentui/react": "npm:@opentuah/react@0.1.88",
33
33
  "@parcel/watcher": "^2.5.6",
34
- "cac": "^6.7.14",
34
+ "goke": "^6.1.3",
35
35
  "diff": "^8.0.2",
36
36
  "js-yaml": "^4.1.1",
37
37
  "marked": "^17.0.1",
38
38
  "picocolors": "^1.1.1",
39
39
  "react": "^19.2.0",
40
40
  "resend": "^6.8.0",
41
+ "supports-color": "^10.2.2",
41
42
  "zustand": "^5.0.8"
42
43
  },
43
44
  "optionalDependencies": {
package/parsers-config.ts CHANGED
@@ -3,6 +3,14 @@
3
3
  // Warn: when taking queries from the nvim-treesitter repo, make sure to include the query dependencies as well
4
4
  // marked with for example `; inherits: ecma` at the top of the file. Just put the dependencies before the actual query.
5
5
  // ALSO: Some queries use breaking changes in the nvim-treesitter repo, that are not compatible with the (web-)tree-sitter parser.
6
+ import { resolve, dirname } from "path"
7
+ import { fileURLToPath } from "url"
8
+
9
+ // Local query files for languages where remote queries have incompatible predicates
10
+ import jsonHighlights from "./queries/json/highlights.scm" with { type: "file" }
11
+
12
+ const __dirname = dirname(fileURLToPath(import.meta.url))
13
+
6
14
  export default {
7
15
  parsers: [
8
16
  {
@@ -164,7 +172,9 @@ export default {
164
172
  wasm: "https://github.com/tree-sitter/tree-sitter-json/releases/download/v0.24.8/tree-sitter-json.wasm",
165
173
  queries: {
166
174
  highlights: [
167
- "https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/json/highlights.scm",
175
+ // Local query file - nvim-treesitter and tree-sitter-json queries use predicates/captures
176
+ // incompatible with web-tree-sitter or themes.ts
177
+ resolve(__dirname, jsonHighlights),
168
178
  ],
169
179
  },
170
180
  },
@@ -0,0 +1,42 @@
1
+ ; JSON highlights query for critique
2
+ ; Uses captures compatible with themes.ts mappings
3
+ ; No predicates (like #set! or #eq?) that are unsupported by web-tree-sitter
4
+
5
+ (pair
6
+ key: (string
7
+ (string_content) @property))
8
+
9
+ (pair
10
+ value: (string
11
+ (string_content) @string))
12
+
13
+ (array
14
+ (string
15
+ (string_content) @string))
16
+
17
+ (number) @number
18
+
19
+ [
20
+ (true)
21
+ (false)
22
+ ] @boolean
23
+
24
+ (null) @constant
25
+
26
+ (escape_sequence) @string
27
+
28
+ ; Keep JSON punctuation muted (not operator-red / not full-bright)
29
+ ("\"") @comment
30
+
31
+ ; JSON separators should render muted, not like operators
32
+ [
33
+ ","
34
+ ":"
35
+ ] @comment
36
+
37
+ [
38
+ "["
39
+ "]"
40
+ "{"
41
+ "}"
42
+ ] @comment
@@ -0,0 +1,13 @@
1
+ {
2
+ "_comment": "Example JSON for critique syntax highlighting preview.",
3
+ "name": "critique",
4
+ "count": 12,
5
+ "enabled": true,
6
+ "ratio": 0.125,
7
+ "tags": ["diff", "tui", "web"],
8
+ "nested": {
9
+ "a": 1,
10
+ "b": null,
11
+ "url": "https://critique.work/v/example"
12
+ }
13
+ }
@@ -264,7 +264,7 @@ async function main() {
264
264
  if (webMode) {
265
265
  console.log("Capturing preview...")
266
266
 
267
- const { htmlDesktop, htmlMobile } = await captureReviewResponsiveHtml({
267
+ const { htmlDesktop, htmlMobile, ogImage } = await captureReviewResponsiveHtml({
268
268
  hunks: exampleHunks,
269
269
  reviewData: exampleReviewData,
270
270
  desktopCols: 200,
@@ -275,7 +275,7 @@ async function main() {
275
275
  })
276
276
 
277
277
  console.log("Uploading...")
278
- const result = await uploadHtml(htmlDesktop, htmlMobile)
278
+ const result = await uploadHtml(htmlDesktop, htmlMobile, ogImage)
279
279
  console.log(`\nPreview URL: ${result.url}`)
280
280
  console.log("(expires in 7 days)")
281
281
  return
@@ -0,0 +1,205 @@
1
+ import { describe, test, expect } from "bun:test"
2
+ import { spanToAnsi, frameToAnsi } from "./ansi-output.ts"
3
+ import { RGBA } from "@opentui/core"
4
+ import type { CapturedFrame, CapturedSpan, CapturedLine } from "@opentui/core"
5
+
6
+ const themeBg = RGBA.fromValues(0, 0, 0, 1) // Black background
7
+
8
+ describe("spanToAnsi", () => {
9
+ test("plain text with no colors (level 0)", () => {
10
+ const span: CapturedSpan = {
11
+ text: "hello",
12
+ fg: RGBA.fromValues(1, 1, 1, 1),
13
+ bg: RGBA.fromValues(0, 0, 0, 0),
14
+ attributes: 0,
15
+ width: 5,
16
+ }
17
+ expect(spanToAnsi(span, 0, themeBg)).toMatchInlineSnapshot(`"hello"`)
18
+ })
19
+
20
+ test("truecolor foreground (level 3)", () => {
21
+ const span: CapturedSpan = {
22
+ text: "red",
23
+ fg: RGBA.fromValues(1, 0, 0, 1), // Pure red
24
+ bg: RGBA.fromValues(0, 0, 0, 0),
25
+ attributes: 0,
26
+ width: 3,
27
+ }
28
+ expect(spanToAnsi(span, 3, themeBg)).toMatchInlineSnapshot(`"\x1B[38;2;255;0;0mred\x1B[0m"`)
29
+ })
30
+
31
+ test("truecolor foreground and background (level 3)", () => {
32
+ const span: CapturedSpan = {
33
+ text: "styled",
34
+ fg: RGBA.fromValues(1, 1, 1, 1), // White
35
+ bg: RGBA.fromValues(0, 0, 1, 1), // Blue
36
+ attributes: 0,
37
+ width: 6,
38
+ }
39
+ expect(spanToAnsi(span, 3, themeBg)).toMatchInlineSnapshot(`"\x1B[38;2;255;255;255;48;2;0;0;255mstyled\x1B[0m"`)
40
+ })
41
+
42
+ test("256 colors (level 2)", () => {
43
+ const span: CapturedSpan = {
44
+ text: "256",
45
+ fg: RGBA.fromValues(1, 0, 0, 1), // Red
46
+ bg: RGBA.fromValues(0, 0, 0, 0),
47
+ attributes: 0,
48
+ width: 3,
49
+ }
50
+ expect(spanToAnsi(span, 2, themeBg)).toMatchInlineSnapshot(`"\x1B[38;5;196m256\x1B[0m"`)
51
+ })
52
+
53
+ test("16 colors (level 1)", () => {
54
+ const span: CapturedSpan = {
55
+ text: "basic",
56
+ fg: RGBA.fromValues(1, 0, 0, 1), // Red -> bright red
57
+ bg: RGBA.fromValues(0, 0, 0, 0),
58
+ attributes: 0,
59
+ width: 5,
60
+ }
61
+ expect(spanToAnsi(span, 1, themeBg)).toMatchInlineSnapshot(`"\x1B[31mbasic\x1B[0m"`)
62
+ })
63
+
64
+ test("bold attribute", () => {
65
+ const span: CapturedSpan = {
66
+ text: "bold",
67
+ fg: RGBA.fromValues(1, 1, 1, 1),
68
+ bg: RGBA.fromValues(0, 0, 0, 0),
69
+ attributes: 1, // BOLD
70
+ width: 4,
71
+ }
72
+ expect(spanToAnsi(span, 3, themeBg)).toMatchInlineSnapshot(`"\x1B[38;2;255;255;255;1mbold\x1B[0m"`)
73
+ })
74
+
75
+ test("multiple attributes (bold + italic + underline)", () => {
76
+ const span: CapturedSpan = {
77
+ text: "fancy",
78
+ fg: RGBA.fromValues(1, 1, 1, 1),
79
+ bg: RGBA.fromValues(0, 0, 0, 0),
80
+ attributes: 1 | 4 | 8, // BOLD | ITALIC | UNDERLINE
81
+ width: 5,
82
+ }
83
+ expect(spanToAnsi(span, 3, themeBg)).toMatchInlineSnapshot(`"\x1B[38;2;255;255;255;1;3;4mfancy\x1B[0m"`)
84
+ })
85
+
86
+ test("transparent foreground returns plain text", () => {
87
+ const span: CapturedSpan = {
88
+ text: "transparent",
89
+ fg: RGBA.fromValues(1, 1, 1, 0), // Fully transparent
90
+ bg: RGBA.fromValues(0, 0, 0, 0),
91
+ attributes: 0,
92
+ width: 11,
93
+ }
94
+ expect(spanToAnsi(span, 3, themeBg)).toMatchInlineSnapshot(`"transparent"`)
95
+ })
96
+
97
+ test("alpha blending with background", () => {
98
+ const span: CapturedSpan = {
99
+ text: "blend",
100
+ fg: RGBA.fromValues(1, 1, 1, 0.5), // 50% white on black bg = gray
101
+ bg: RGBA.fromValues(0, 0, 0, 0),
102
+ attributes: 0,
103
+ width: 5,
104
+ }
105
+ expect(spanToAnsi(span, 3, themeBg)).toMatchInlineSnapshot(`"\x1B[38;2;128;128;128mblend\x1B[0m"`)
106
+ })
107
+ })
108
+
109
+ describe("frameToAnsi", () => {
110
+ test("single line frame", () => {
111
+ const frame: CapturedFrame = {
112
+ cols: 10,
113
+ rows: 1,
114
+ cursor: [0, 0],
115
+ lines: [
116
+ {
117
+ spans: [
118
+ { text: "hello", fg: RGBA.fromValues(1, 0, 0, 1), bg: RGBA.fromValues(0, 0, 0, 0), attributes: 0, width: 5 },
119
+ ],
120
+ },
121
+ ],
122
+ }
123
+ expect(frameToAnsi(frame, themeBg)).toMatchInlineSnapshot(`"hello"`)
124
+ })
125
+
126
+ test("multi-line frame", () => {
127
+ const frame: CapturedFrame = {
128
+ cols: 10,
129
+ rows: 2,
130
+ cursor: [0, 0],
131
+ lines: [
132
+ {
133
+ spans: [
134
+ { text: "line1", fg: RGBA.fromValues(1, 0, 0, 1), bg: RGBA.fromValues(0, 0, 0, 0), attributes: 0, width: 5 },
135
+ ],
136
+ },
137
+ {
138
+ spans: [
139
+ { text: "line2", fg: RGBA.fromValues(0, 1, 0, 1), bg: RGBA.fromValues(0, 0, 0, 0), attributes: 0, width: 5 },
140
+ ],
141
+ },
142
+ ],
143
+ }
144
+ expect(frameToAnsi(frame, themeBg)).toMatchInlineSnapshot(`
145
+ "line1
146
+ line2"
147
+ `)
148
+ })
149
+
150
+ test("trims empty lines from end", () => {
151
+ const frame: CapturedFrame = {
152
+ cols: 10,
153
+ rows: 3,
154
+ cursor: [0, 0],
155
+ lines: [
156
+ {
157
+ spans: [
158
+ { text: "content", fg: RGBA.fromValues(1, 1, 1, 1), bg: RGBA.fromValues(0, 0, 0, 0), attributes: 0, width: 7 },
159
+ ],
160
+ },
161
+ { spans: [] }, // Empty line
162
+ { spans: [{ text: " ", fg: RGBA.fromValues(1, 1, 1, 1), bg: RGBA.fromValues(0, 0, 0, 0), attributes: 0, width: 3 }] }, // Whitespace only
163
+ ],
164
+ }
165
+ expect(frameToAnsi(frame, themeBg)).toMatchInlineSnapshot(`"content"`)
166
+ })
167
+
168
+ test("preserves empty lines when trimEmptyLines is false", () => {
169
+ const frame: CapturedFrame = {
170
+ cols: 10,
171
+ rows: 2,
172
+ cursor: [0, 0],
173
+ lines: [
174
+ {
175
+ spans: [
176
+ { text: "content", fg: RGBA.fromValues(1, 1, 1, 1), bg: RGBA.fromValues(0, 0, 0, 0), attributes: 0, width: 7 },
177
+ ],
178
+ },
179
+ { spans: [] },
180
+ ],
181
+ }
182
+ expect(frameToAnsi(frame, themeBg, { trimEmptyLines: false })).toMatchInlineSnapshot(`
183
+ "content
184
+ "
185
+ `)
186
+ })
187
+
188
+ test("multiple spans per line", () => {
189
+ const frame: CapturedFrame = {
190
+ cols: 20,
191
+ rows: 1,
192
+ cursor: [0, 0],
193
+ lines: [
194
+ {
195
+ spans: [
196
+ { text: "red", fg: RGBA.fromValues(1, 0, 0, 1), bg: RGBA.fromValues(0, 0, 0, 0), attributes: 0, width: 3 },
197
+ { text: " ", fg: RGBA.fromValues(1, 1, 1, 1), bg: RGBA.fromValues(0, 0, 0, 0), attributes: 0, width: 1 },
198
+ { text: "green", fg: RGBA.fromValues(0, 1, 0, 1), bg: RGBA.fromValues(0, 0, 0, 0), attributes: 0, width: 5 },
199
+ ],
200
+ },
201
+ ],
202
+ }
203
+ expect(frameToAnsi(frame, themeBg)).toMatchInlineSnapshot(`"red green"`)
204
+ })
205
+ })