critique 0.1.121 → 0.1.122

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/dist/cli.js CHANGED
@@ -937,7 +937,7 @@ async function runWebMode(diffContent, options) {
937
937
  const { renderDiffToOgImage } = await import("./image.js");
938
938
  const ogImg = await renderDiffToOgImage(diffContent, {
939
939
  themeName: "github-light",
940
- stabilizeMs: 100,
940
+ stabilizeMs: 2000,
941
941
  });
942
942
  if (ogImg) {
943
943
  await uploadOgImage(result.id, ogImg);
@@ -791,11 +791,11 @@ The diagram above should not wrap.`,
791
791
  Architecture
792
792
 
793
793
  ┌─────────────┐ ┌─────────────┐ ┌─────────────┐
794
- ┌─────────────┐
794
+ ┌─────────────┐
795
795
  │ Client │────▶│ Router │────▶│ Handler │────▶│
796
- Database │
796
+ Database │
797
797
  └─────────────┘ └─────────────┘ └─────────────┘
798
- └─────────────┘
798
+ └─────────────┘
799
799
 
800
800
 
801
801
  src/config.ts +1-0
@@ -1 +1 @@
1
- {"version":3,"file":"web-utils.d.ts","sourceRoot":"","sources":["../src/web-utils.tsx"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"web-utils.d.ts","sourceRoot":"","sources":["../src/web-utils.tsx"],"names":[],"mappings":"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"}
package/dist/web-utils.js CHANGED
@@ -8,6 +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.js";
11
+ import { DiffRenderable } from "@opentuah/core";
11
12
  import { loadStoredLicenseKey, loadOrCreateOwnerSecret } from "./license.js";
12
13
  const execAsync = promisify(exec);
13
14
  // Worker URL for uploading HTML previews
@@ -41,39 +42,70 @@ function getContentHeight(root) {
41
42
  return Math.ceil(maxBottom);
42
43
  }
43
44
  /**
44
- * Wait for async rendering (tree-sitter highlighting etc.) to stabilize.
45
+ * Find all DiffRenderable instances in the renderer tree.
46
+ * Walks the tree recursively checking instanceof DiffRenderable.
47
+ */
48
+ function findDiffRenderables(root) {
49
+ const results = [];
50
+ function walk(node) {
51
+ if (!node.getChildren)
52
+ return;
53
+ for (const child of node.getChildren()) {
54
+ if (child instanceof DiffRenderable) {
55
+ results.push(child);
56
+ }
57
+ walk(child);
58
+ }
59
+ }
60
+ walk(root);
61
+ return results;
62
+ }
63
+ /**
64
+ * Wait for tree-sitter syntax highlighting to complete on all diff elements,
65
+ * then wait for rendering to stabilize (no more requestRender calls).
45
66
  *
46
- * Uses an idle+max model:
47
- * - Exits when no render requests for `idleMs` (tree-sitter is done)
48
- * - Hard cap at `maxMs` to prevent unbounded waits on huge diffs
49
- * - Polls at `pollMs` granularity (finer than the idle threshold)
67
+ * Two-phase approach:
68
+ * 1. Wait for DiffRenderable.isHighlighting to become false on all diffs
69
+ * (deterministic, exits as soon as tree-sitter is done)
70
+ * 2. Wait for render idle no new requestRender calls for idleMs
71
+ * (catches deferred rebuilds like DiffRenderable.requestRebuild which
72
+ * uses queueMicrotask to schedule buildView + requestRender after
73
+ * highlighting completes)
50
74
  *
51
- * The old approach used a single `stabilizeMs` with 100ms polling, which
52
- * meant stabilizeMs=100 gave only one polling window and could miss late
53
- * highlights, while stabilizeMs=500 was wastefully slow for small diffs.
75
+ * Phase 2 is critical because DiffRenderable's split view rebuild happens
76
+ * asynchronously via microtask AFTER isHighlighting goes false. Without it,
77
+ * the captured frame may have concealed (unhighlighted) content on one side.
54
78
  */
55
- async function waitForRenderStabilization(renderer, renderOnce, stabilizeMs = 500) {
56
- // Derive idle/max from the legacy parameter:
57
- // - Interactive TUI (500ms): idleMs=200, maxMs=800
58
- // - Web/batch (100ms): idleMs=80, maxMs=400
59
- const idleMs = Math.max(Math.round(stabilizeMs * 0.4), 60);
60
- const maxMs = Math.max(stabilizeMs, Math.round(stabilizeMs * 4));
79
+ async function waitForHighlightAndRenderStabilization(renderer, renderOnce, maxMs = 2000) {
80
+ const startTime = Date.now();
61
81
  const pollMs = 20;
82
+ const idleMs = 80;
83
+ // Track render requests to detect when rendering has quiesced
62
84
  let lastRenderTime = Date.now();
63
- const startTime = lastRenderTime;
64
85
  const originalRequestRender = renderer.root.requestRender.bind(renderer.root);
65
86
  renderer.root.requestRender = function () {
66
87
  lastRenderTime = Date.now();
67
88
  originalRequestRender();
68
89
  };
69
- while (true) {
90
+ // Do one render cycle to kick off highlighting
91
+ await renderOnce();
92
+ // Phase 1: wait for isHighlighting to become false on all diffs
93
+ while (Date.now() - startTime < maxMs) {
94
+ const diffs = findDiffRenderables(renderer.root);
95
+ if (diffs.length === 0 || diffs.every(d => !d.isHighlighting)) {
96
+ break;
97
+ }
98
+ await new Promise(resolve => setTimeout(resolve, pollMs));
99
+ await renderOnce();
100
+ }
101
+ // Phase 2: wait for render to stabilize (catches deferred rebuilds)
102
+ // Reset the render timestamp so we wait for any post-highlight renders
103
+ lastRenderTime = Date.now();
104
+ await renderOnce();
105
+ while (Date.now() - startTime < maxMs) {
70
106
  const now = Date.now();
71
- // Exit if idle long enough (no render requests for idleMs)
72
107
  if (now - lastRenderTime >= idleMs)
73
108
  break;
74
- // Exit if hard cap reached
75
- if (now - startTime >= maxMs)
76
- break;
77
109
  await new Promise(resolve => setTimeout(resolve, pollMs));
78
110
  await renderOnce();
79
111
  }
@@ -203,8 +235,8 @@ async function renderDiffToFrameWithSectionPositions(diffContent, options) {
203
235
  resize(options.cols, finalHeight);
204
236
  await renderOnce();
205
237
  }
206
- // Wait for async highlighting to complete (only once at the end)
207
- await waitForRenderStabilization(renderer, renderOnce, options.stabilizeMs ?? 500);
238
+ // Wait for tree-sitter highlighting + render stabilization
239
+ await waitForHighlightAndRenderStabilization(renderer, renderOnce, options.stabilizeMs ?? 2000);
208
240
  const sectionPositions = [];
209
241
  for (let idx = 0; idx < fileNames.length; idx++) {
210
242
  const section = fileSectionRefs.get(idx);
@@ -419,7 +451,9 @@ export async function captureResponsiveHtml(diffContent, options) {
419
451
  // These act as upper bounds to prevent runaway memory usage
420
452
  const desktopRows = Math.max(options.baseRows * 3, 5000);
421
453
  const mobileRows = Math.max(Math.ceil(desktopRows * (options.desktopCols / options.mobileCols)), 10000);
422
- const stabilizeMs = options.stabilizeMs ?? 100;
454
+ // With deterministic isHighlighting detection, stabilizeMs is just a safety cap.
455
+ // The function exits instantly when highlighting completes, so 2000ms is fine.
456
+ const stabilizeMs = options.stabilizeMs ?? 2000;
423
457
  // Run all renders in parallel: desktop HTML, mobile HTML, and OG image
424
458
  const ogImagePromise = options.skipOgImage
425
459
  ? Promise.resolve(null)
@@ -532,8 +566,8 @@ export async function renderReviewToFrame(options) {
532
566
  resize(options.cols, finalHeight);
533
567
  await renderOnce();
534
568
  }
535
- // Wait for async highlighting to complete (only once at the end)
536
- await waitForRenderStabilization(renderer, renderOnce, options.stabilizeMs ?? 500);
569
+ // Wait for tree-sitter highlighting + render stabilization
570
+ await waitForHighlightAndRenderStabilization(renderer, renderOnce, options.stabilizeMs ?? 2000);
537
571
  // Capture the final frame
538
572
  const buffer = renderer.currentRenderBuffer;
539
573
  const cursorState = renderer.getCursorState();
@@ -575,7 +609,7 @@ export async function captureReviewResponsiveHtml(options) {
575
609
  // These act as upper bounds to prevent runaway memory usage
576
610
  const desktopRows = Math.max(options.baseRows * 3, 5000);
577
611
  const mobileRows = Math.max(Math.ceil(desktopRows * (options.desktopCols / options.mobileCols)), 10000);
578
- const stabilizeMs = options.stabilizeMs ?? 100;
612
+ const stabilizeMs = options.stabilizeMs ?? 2000;
579
613
  // Generate OG image from first few hunks' raw diff (in parallel with HTML renders)
580
614
  const ogImagePromise = options.skipOgImage
581
615
  ? Promise.resolve(null)
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.121",
5
+ "version": "0.1.122",
6
6
  "license": "MIT",
7
7
  "private": false,
8
8
  "bin": "./dist/cli.js",
@@ -44,8 +44,8 @@
44
44
  "dependencies": {
45
45
  "@agentclientprotocol/sdk": "^0.13.1",
46
46
  "@clack/prompts": "1.0.0-alpha.9",
47
- "@opentuah/core": "0.1.96",
48
- "@opentuah/react": "0.1.96",
47
+ "@opentuah/core": "0.1.97",
48
+ "@opentuah/react": "0.1.97",
49
49
  "@parcel/watcher": "^2.5.6",
50
50
  "diff": "^8.0.2",
51
51
  "goke": "^6.1.3",
package/src/cli.tsx CHANGED
@@ -1187,7 +1187,7 @@ async function runWebMode(
1187
1187
  const { renderDiffToOgImage } = await import("./image.js");
1188
1188
  const ogImg = await renderDiffToOgImage(diffContent, {
1189
1189
  themeName: "github-light",
1190
- stabilizeMs: 100,
1190
+ stabilizeMs: 2000,
1191
1191
  });
1192
1192
  if (ogImg) {
1193
1193
  await uploadOgImage(result.id, ogImg);
@@ -928,11 +928,11 @@ The diagram above should not wrap.`,
928
928
  Architecture
929
929
 
930
930
  ┌─────────────┐ ┌─────────────┐ ┌─────────────┐
931
- ┌─────────────┐
931
+ ┌─────────────┐
932
932
  │ Client │────▶│ Router │────▶│ Handler │────▶│
933
- Database │
933
+ Database │
934
934
  └─────────────┘ └─────────────┘ └─────────────┘
935
- └─────────────┘
935
+ └─────────────┘
936
936
 
937
937
 
938
938
  src/config.ts +1-0
package/src/web-utils.tsx CHANGED
@@ -9,6 +9,7 @@ import { tmpdir } from "os"
9
9
  import { join } from "path"
10
10
  import { getResolvedTheme, rgbaToHex } from "./themes.js"
11
11
  import type { BoxRenderable, CapturedFrame, CapturedLine, RootRenderable, CliRenderer } from "@opentuah/core"
12
+ import { DiffRenderable } from "@opentuah/core"
12
13
  import type { IndexedHunk, ReviewYaml } from "./review/types.js"
13
14
  import { loadStoredLicenseKey, loadOrCreateOwnerSecret } from "./license.js"
14
15
 
@@ -93,43 +94,80 @@ function getContentHeight(root: RootRenderable): number {
93
94
  }
94
95
 
95
96
  /**
96
- * Wait for async rendering (tree-sitter highlighting etc.) to stabilize.
97
+ * Find all DiffRenderable instances in the renderer tree.
98
+ * Walks the tree recursively checking instanceof DiffRenderable.
99
+ */
100
+ function findDiffRenderables(root: RootRenderable): InstanceType<typeof DiffRenderable>[] {
101
+ const results: InstanceType<typeof DiffRenderable>[] = []
102
+
103
+ function walk(node: { getChildren?: () => any[] }) {
104
+ if (!node.getChildren) return
105
+ for (const child of node.getChildren()) {
106
+ if (child instanceof DiffRenderable) {
107
+ results.push(child)
108
+ }
109
+ walk(child)
110
+ }
111
+ }
112
+
113
+ walk(root)
114
+ return results
115
+ }
116
+
117
+ /**
118
+ * Wait for tree-sitter syntax highlighting to complete on all diff elements,
119
+ * then wait for rendering to stabilize (no more requestRender calls).
97
120
  *
98
- * Uses an idle+max model:
99
- * - Exits when no render requests for `idleMs` (tree-sitter is done)
100
- * - Hard cap at `maxMs` to prevent unbounded waits on huge diffs
101
- * - Polls at `pollMs` granularity (finer than the idle threshold)
121
+ * Two-phase approach:
122
+ * 1. Wait for DiffRenderable.isHighlighting to become false on all diffs
123
+ * (deterministic, exits as soon as tree-sitter is done)
124
+ * 2. Wait for render idle no new requestRender calls for idleMs
125
+ * (catches deferred rebuilds like DiffRenderable.requestRebuild which
126
+ * uses queueMicrotask to schedule buildView + requestRender after
127
+ * highlighting completes)
102
128
  *
103
- * The old approach used a single `stabilizeMs` with 100ms polling, which
104
- * meant stabilizeMs=100 gave only one polling window and could miss late
105
- * highlights, while stabilizeMs=500 was wastefully slow for small diffs.
129
+ * Phase 2 is critical because DiffRenderable's split view rebuild happens
130
+ * asynchronously via microtask AFTER isHighlighting goes false. Without it,
131
+ * the captured frame may have concealed (unhighlighted) content on one side.
106
132
  */
107
- async function waitForRenderStabilization(
133
+ async function waitForHighlightAndRenderStabilization(
108
134
  renderer: CliRenderer,
109
135
  renderOnce: () => Promise<void>,
110
- stabilizeMs: number = 500
136
+ maxMs: number = 2000
111
137
  ): Promise<void> {
112
- // Derive idle/max from the legacy parameter:
113
- // - Interactive TUI (500ms): idleMs=200, maxMs=800
114
- // - Web/batch (100ms): idleMs=80, maxMs=400
115
- const idleMs = Math.max(Math.round(stabilizeMs * 0.4), 60)
116
- const maxMs = Math.max(stabilizeMs, Math.round(stabilizeMs * 4))
138
+ const startTime = Date.now()
117
139
  const pollMs = 20
140
+ const idleMs = 80
118
141
 
142
+ // Track render requests to detect when rendering has quiesced
119
143
  let lastRenderTime = Date.now()
120
- const startTime = lastRenderTime
121
144
  const originalRequestRender = renderer.root.requestRender.bind(renderer.root)
122
145
  renderer.root.requestRender = function() {
123
146
  lastRenderTime = Date.now()
124
147
  originalRequestRender()
125
148
  }
126
149
 
127
- while (true) {
150
+ // Do one render cycle to kick off highlighting
151
+ await renderOnce()
152
+
153
+ // Phase 1: wait for isHighlighting to become false on all diffs
154
+ while (Date.now() - startTime < maxMs) {
155
+ const diffs = findDiffRenderables(renderer.root)
156
+ if (diffs.length === 0 || diffs.every(d => !d.isHighlighting)) {
157
+ break
158
+ }
159
+ await new Promise(resolve => setTimeout(resolve, pollMs))
160
+ await renderOnce()
161
+ }
162
+
163
+ // Phase 2: wait for render to stabilize (catches deferred rebuilds)
164
+ // Reset the render timestamp so we wait for any post-highlight renders
165
+ lastRenderTime = Date.now()
166
+ await renderOnce()
167
+
168
+ while (Date.now() - startTime < maxMs) {
128
169
  const now = Date.now()
129
- // Exit if idle long enough (no render requests for idleMs)
130
170
  if (now - lastRenderTime >= idleMs) break
131
- // Exit if hard cap reached
132
- if (now - startTime >= maxMs) break
133
171
  await new Promise(resolve => setTimeout(resolve, pollMs))
134
172
  await renderOnce()
135
173
  }
@@ -315,8 +353,8 @@ async function renderDiffToFrameWithSectionPositions(
315
353
  await renderOnce()
316
354
  }
317
355
 
318
- // Wait for async highlighting to complete (only once at the end)
319
- await waitForRenderStabilization(renderer, renderOnce, options.stabilizeMs ?? 500)
356
+ // Wait for tree-sitter highlighting + render stabilization
357
+ await waitForHighlightAndRenderStabilization(renderer, renderOnce, options.stabilizeMs ?? 2000)
320
358
 
321
359
  const sectionPositions: FileSectionPosition[] = []
322
360
  for (let idx = 0; idx < fileNames.length; idx++) {
@@ -585,7 +623,9 @@ export async function captureResponsiveHtml(
585
623
  // These act as upper bounds to prevent runaway memory usage
586
624
  const desktopRows = Math.max(options.baseRows * 3, 5000)
587
625
  const mobileRows = Math.max(Math.ceil(desktopRows * (options.desktopCols / options.mobileCols)), 10000)
588
- const stabilizeMs = options.stabilizeMs ?? 100
626
+ // With deterministic isHighlighting detection, stabilizeMs is just a safety cap.
627
+ // The function exits instantly when highlighting completes, so 2000ms is fine.
628
+ const stabilizeMs = options.stabilizeMs ?? 2000
589
629
 
590
630
  // Run all renders in parallel: desktop HTML, mobile HTML, and OG image
591
631
  const ogImagePromise = options.skipOgImage
@@ -725,8 +765,8 @@ export async function renderReviewToFrame(
725
765
  await renderOnce()
726
766
  }
727
767
 
728
- // Wait for async highlighting to complete (only once at the end)
729
- await waitForRenderStabilization(renderer, renderOnce, options.stabilizeMs ?? 500)
768
+ // Wait for tree-sitter highlighting + render stabilization
769
+ await waitForHighlightAndRenderStabilization(renderer, renderOnce, options.stabilizeMs ?? 2000)
730
770
 
731
771
  // Capture the final frame
732
772
  const buffer = renderer.currentRenderBuffer
@@ -792,7 +832,7 @@ export async function captureReviewResponsiveHtml(
792
832
  // These act as upper bounds to prevent runaway memory usage
793
833
  const desktopRows = Math.max(options.baseRows * 3, 5000)
794
834
  const mobileRows = Math.max(Math.ceil(desktopRows * (options.desktopCols / options.mobileCols)), 10000)
795
- const stabilizeMs = options.stabilizeMs ?? 100
835
+ const stabilizeMs = options.stabilizeMs ?? 2000
796
836
 
797
837
  // Generate OG image from first few hunks' raw diff (in parallel with HTML renders)
798
838
  const ogImagePromise = options.skipOgImage
@@ -1,3 +0,0 @@
1
- diff --git a/logo.png b/logo.png
2
- new file mode 100644
3
- Binary files /dev/null and b/logo.png differ
@@ -1,8 +0,0 @@
1
- diff --git a/src/hello.ts b/src/hello.ts
2
- --- a/src/hello.ts
3
- +++ b/src/hello.ts
4
- @@ -1,3 +1,3 @@
5
- const greeting = 'hello'
6
- -console.log(greeting)
7
- +console.log(greeting + ' world')
8
- export default greeting
@@ -1,7 +0,0 @@
1
- diff --git a/readme.md b/readme.md
2
- --- a/readme.md
3
- +++ b/readme.md
4
- @@ -1,3 +1,3 @@
5
- # My Project
6
-
7
- Some description
@@ -1,9 +0,0 @@
1
- diff --git a/src/deprecated.ts b/src/deprecated.ts
2
- deleted file mode 100644
3
- --- a/src/deprecated.ts
4
- +++ /dev/null
5
- @@ -1,4 +0,0 @@
6
- -// This module is no longer needed
7
- -export function oldHelper() {
8
- - return 'deprecated'
9
- -}
File without changes
@@ -1,19 +0,0 @@
1
- diff --git a/config.json b/config.json
2
- --- a/config.json
3
- +++ b/config.json
4
- @@ -1,9 +1,11 @@
5
- {
6
- - "name": "my-app",
7
- + "name": "my-awesome-app",
8
- - "version": "1.0.0",
9
- + "version": "2.0.0",
10
- "description": "A sample app",
11
- - "main": "index.js",
12
- + "main": "dist/index.js",
13
- + "types": "dist/index.d.ts",
14
- "scripts": {
15
- - "build": "tsc"
16
- + "build": "tsc --project tsconfig.build.json",
17
- + "test": "bun test"
18
- }
19
- }
@@ -1,20 +0,0 @@
1
- diff --git a/src/index.ts b/src/index.ts
2
- --- a/src/index.ts
3
- +++ b/src/index.ts
4
- @@ -1,4 +1,6 @@
5
- import { App } from './app'
6
- +import { Logger } from './logger'
7
-
8
- const app = new App()
9
- +const logger = new Logger()
10
- app.start()
11
- diff --git a/src/logger.ts b/src/logger.ts
12
- new file mode 100644
13
- --- /dev/null
14
- +++ b/src/logger.ts
15
- @@ -0,0 +1,5 @@
16
- +export class Logger {
17
- + log(msg: string) {
18
- + console.log(`[LOG] ${msg}`)
19
- + }
20
- +}
@@ -1,12 +0,0 @@
1
- diff --git a/src/utils.ts b/src/utils.ts
2
- new file mode 100644
3
- --- /dev/null
4
- +++ b/src/utils.ts
5
- @@ -0,0 +1,7 @@
6
- +export function clamp(value: number, min: number, max: number): number {
7
- + return Math.min(Math.max(value, min), max)
8
- +}
9
- +
10
- +export function identity<T>(x: T): T {
11
- + return x
12
- +}
@@ -1,11 +0,0 @@
1
- diff --git a/src/old-name.ts b/src/new-name.ts
2
- similarity index 90%
3
- rename from src/old-name.ts
4
- rename to src/new-name.ts
5
- --- a/src/old-name.ts
6
- +++ b/src/new-name.ts
7
- @@ -1,3 +1,3 @@
8
- -export const name = 'old'
9
- +export const name = 'new'
10
- export const version = 1
11
- export default name
@@ -1,8 +0,0 @@
1
- diff --git a/src/hello.ts b/src/hello.ts
2
- --- a/src/hello.ts
3
- +++ b/src/hello.ts
4
- @@ -1,3 +1,3 @@
5
- const greeting = 'hello'
6
- -console.log(greeting)
7
- +console.log(greeting + ' world')
8
- export default greeting