critique 0.1.33 → 0.1.34

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
@@ -8,7 +8,7 @@ ALWAYS!
8
8
 
9
9
  ### using unreleased opentui versions
10
10
 
11
- to use a pkg.pr.new preview URL for opentui, get the last commit hash (first 7 chars) from PR https://github.com/anomalyco/opentui/pull/536:
11
+ to use a pkg.pr.new preview URL for opentui, get the last commit hash (40 chars always) from PR https://github.com/anomalyco/opentui/pull/536:
12
12
 
13
13
  ```bash
14
14
  gh pr view 536 -R anomalyco/opentui --json commits --jq '.commits[-1].oid[:40]'
package/CHANGELOG.md CHANGED
@@ -1,3 +1,16 @@
1
+ # 0.1.34
2
+
3
+ - `review` command:
4
+ - Add diagram parser to extract and render diagrams from AI descriptions
5
+ - Show generating indicator below last hunk with animated spinner and dots
6
+ - Use "diagram" language in system prompt for code blocks
7
+ - Web preview:
8
+ - Fix text selection by switching from flex to block layout for lines
9
+ - UI:
10
+ - Fix layout shift in session multiselect by moving time ago to label
11
+ - Themes:
12
+ - Add "conceal" color support
13
+
1
14
  # 0.1.33
2
15
 
3
16
  - Dependencies:
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.33",
5
+ "version": "0.1.34",
6
6
  "private": false,
7
7
  "bin": "./src/cli.tsx",
8
8
  "scripts": {
@@ -121,7 +121,7 @@ This improves both security and developer experience when working with the API.
121
121
 
122
122
  The new request flow with error handling:
123
123
 
124
- \`\`\`
124
+ \`\`\`diagram
125
125
  ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐
126
126
  │ Client │────▶│ Router │────▶│ Handler │────▶│ Database │
127
127
  └─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘
@@ -182,7 +182,7 @@ Added test case for the new error handling behavior, ensuring that:
182
182
 
183
183
  ### Test Flow Diagram
184
184
 
185
- \`\`\`
185
+ \`\`\`diagram
186
186
  ┌──────────────────────────────────────────────────────────────────┐
187
187
  │ Test Suite │
188
188
  └──────────────────────────────────────────────────────────────────┘
package/src/ansi-html.ts CHANGED
@@ -32,7 +32,7 @@ function escapeHtml(text: string): string {
32
32
 
33
33
  /**
34
34
  * Convert a single span to HTML
35
- * Always wraps in a span element so flex layout works properly
35
+ * Always wraps in span for consistent inline-block sizing
36
36
  */
37
37
  function spanToHtml(span: TerminalSpan): string {
38
38
  const styles: string[] = []
@@ -63,7 +63,7 @@ function spanToHtml(span: TerminalSpan): string {
63
63
 
64
64
  const escapedText = escapeHtml(span.text)
65
65
 
66
- // Always wrap in span for consistent flex behavior
66
+ // Always wrap in span for consistent inline-block sizing
67
67
  if (styles.length === 0) {
68
68
  return `<span>${escapedText}</span>`
69
69
  }
@@ -168,10 +168,7 @@ html, body {
168
168
  color: ${textColor};
169
169
  font-family: ${fontFamily};
170
170
  font-size: ${fontSize};
171
- line-height: 1.5;
172
- -webkit-font-smoothing: antialiased;
173
- -moz-osx-font-smoothing: grayscale;
174
- text-rendering: optimizeLegibility;
171
+ line-height: 1.7;
175
172
  }
176
173
  body {
177
174
  overflow: auto;
@@ -183,15 +180,18 @@ body {
183
180
  }
184
181
  .line {
185
182
  white-space: pre;
186
- display: flex;
183
+ display: block;
187
184
  content-visibility: auto;
188
- contain-intrinsic-block-size: auto 1.5em;
185
+ contain-intrinsic-block-size: auto 1.7em;
189
186
  background-color: ${backgroundColor};
190
187
  transform: translateZ(0);
191
188
  backface-visibility: hidden;
192
189
  }
193
190
  .line span {
194
191
  white-space: pre;
192
+ display: inline-block;
193
+ line-height: 1.7;
194
+ vertical-align: top;
195
195
  }
196
196
  /* Disable content-visibility on iOS Safari where it can cause rendering issues */
197
197
  @supports (-webkit-touch-callout: none) {
@@ -229,8 +229,8 @@ ${content}
229
229
  function adjustFontSize() {
230
230
  const viewportWidth = window.innerWidth;
231
231
  const calculatedSize = (viewportWidth - padding) / (cols * charRatio);
232
- // Round to nearest even integer to prevent subpixel rendering issues
233
- // (with line-height: 1.5, even font-size always yields integer line-height)
232
+ // Round to nearest even integer to reduce subpixel rendering issues
233
+ // Note: with line-height 1.7, some subpixel values are unavoidable
234
234
  const clamped = Math.max(minFontSize, Math.min(maxFontSize, calculatedSize));
235
235
  const fontSize = Math.round(clamped / 2) * 2;
236
236
  document.body.style.fontSize = fontSize + 'px';
package/src/cli.tsx CHANGED
@@ -332,11 +332,13 @@ async function runReviewMode(
332
332
  const selected = filteredSessions.length > 0
333
333
  ? await clack.multiselect({
334
334
  message: "Select sessions to include as context (space to toggle, enter to confirm)",
335
- options: filteredSessions.map((s) => ({
336
- value: s.sessionId,
337
- label: s.title || `Session ${s.sessionId.slice(0, 8)}`,
338
- hint: s.updatedAt ? formatTimeAgo(s.updatedAt) : undefined,
339
- })),
335
+ options: filteredSessions.map((s) => {
336
+ const title = s.title || `Session ${s.sessionId.slice(0, 8)}`;
337
+ const timeAgo = s.updatedAt ? formatTimeAgo(s.updatedAt) : "";
338
+ // Include time in label to prevent layout shift (hints only show on focus)
339
+ const label = timeAgo ? `${title} ${pc.default.dim(`(${timeAgo})`)}` : title;
340
+ return { value: s.sessionId, label };
341
+ }),
340
342
  required: false,
341
343
  })
342
344
  : [];
@@ -449,7 +451,7 @@ async function runReviewMode(
449
451
  try {
450
452
  const { htmlDesktop, htmlMobile } = await captureResponsiveHtml(
451
453
  renderCommand,
452
- { desktopCols: 240, mobileCols: 100, baseRows, themeName }
454
+ { desktopCols: 230, mobileCols: 100, baseRows, themeName }
453
455
  );
454
456
 
455
457
  // Clean up temp files
@@ -581,7 +583,7 @@ async function runWebMode(
581
583
  positionalFilters: options['--'],
582
584
  });
583
585
 
584
- const desktopCols = options.cols || 240;
586
+ const desktopCols = options.cols || 230;
585
587
  const mobileCols = options.mobileCols || 100;
586
588
  const themeName = options.theme && themeNames.includes(options.theme)
587
589
  ? options.theme
@@ -494,9 +494,9 @@ HOW TO EXPLAIN - Diagrams First, Text Last
494
494
  ═══════════════════════════════════════════════════════════════════════════════
495
495
 
496
496
  PREFER ASCII DIAGRAMS - they explain better than words.
497
- ALWAYS wrap diagrams in \`\`\` code blocks - never render them as plain text:
497
+ ALWAYS wrap diagrams in \`\`\`diagram code blocks - never render them as plain text:
498
498
 
499
- \`\`\`
499
+ \`\`\`diagram
500
500
  ┌─────────────┐ ┌─────────────┐ ┌────────────┐
501
501
  │ Request │ ───> │ Router │ ───> │ Handler │
502
502
  └─────────────┘ └──────┬──────┘ └──────┬─────┘
@@ -507,7 +507,7 @@ ALWAYS wrap diagrams in \`\`\` code blocks - never render them as plain text:
507
507
  └─────────────┘ └─────────────┘
508
508
  \`\`\`
509
509
 
510
- \`\`\`
510
+ \`\`\`diagram
511
511
  ┌──────────────────┐
512
512
  │ Initial │
513
513
  └────────┬─────────┘
@@ -0,0 +1,327 @@
1
+ // Tests for diagram parser
2
+
3
+ import { describe, expect, it } from "bun:test"
4
+ import {
5
+ parseDiagram,
6
+ parseDiagramLine,
7
+ diagramToDebugString,
8
+ } from "./diagram-parser.ts"
9
+
10
+ describe("parseDiagramLine", () => {
11
+ it("should parse empty line", () => {
12
+ expect(parseDiagramLine("")).toMatchInlineSnapshot(`
13
+ {
14
+ "segments": [],
15
+ }
16
+ `)
17
+ })
18
+
19
+ it("should parse pure text as text segment", () => {
20
+ expect(parseDiagramLine("Hello World")).toMatchInlineSnapshot(`
21
+ {
22
+ "segments": [
23
+ {
24
+ "text": "Hello",
25
+ "type": "text",
26
+ },
27
+ {
28
+ "text": " ",
29
+ "type": "muted",
30
+ },
31
+ {
32
+ "text": "World",
33
+ "type": "text",
34
+ },
35
+ ],
36
+ }
37
+ `)
38
+ })
39
+
40
+ it("should parse box drawing characters as muted", () => {
41
+ expect(parseDiagramLine("┌─────┐")).toMatchInlineSnapshot(`
42
+ {
43
+ "segments": [
44
+ {
45
+ "text": "┌─────┐",
46
+ "type": "muted",
47
+ },
48
+ ],
49
+ }
50
+ `)
51
+ })
52
+
53
+ it("should parse mixed box and text", () => {
54
+ expect(parseDiagramLine("│ Client │")).toMatchInlineSnapshot(`
55
+ {
56
+ "segments": [
57
+ {
58
+ "text": "│ ",
59
+ "type": "muted",
60
+ },
61
+ {
62
+ "text": "Client",
63
+ "type": "text",
64
+ },
65
+ {
66
+ "text": " │",
67
+ "type": "muted",
68
+ },
69
+ ],
70
+ }
71
+ `)
72
+ })
73
+
74
+ it("should parse arrows as muted", () => {
75
+ expect(parseDiagramLine("A ──▶ B")).toMatchInlineSnapshot(`
76
+ {
77
+ "segments": [
78
+ {
79
+ "text": "A",
80
+ "type": "text",
81
+ },
82
+ {
83
+ "text": " ──▶ ",
84
+ "type": "muted",
85
+ },
86
+ {
87
+ "text": "B",
88
+ "type": "text",
89
+ },
90
+ ],
91
+ }
92
+ `)
93
+ })
94
+
95
+ it("should parse ASCII pipes and dashes as muted", () => {
96
+ expect(parseDiagramLine("+---+")).toMatchInlineSnapshot(`
97
+ {
98
+ "segments": [
99
+ {
100
+ "text": "+---+",
101
+ "type": "muted",
102
+ },
103
+ ],
104
+ }
105
+ `)
106
+ expect(parseDiagramLine("| X |")).toMatchInlineSnapshot(`
107
+ {
108
+ "segments": [
109
+ {
110
+ "text": "| ",
111
+ "type": "muted",
112
+ },
113
+ {
114
+ "text": "X",
115
+ "type": "text",
116
+ },
117
+ {
118
+ "text": " |",
119
+ "type": "muted",
120
+ },
121
+ ],
122
+ }
123
+ `)
124
+ })
125
+ })
126
+
127
+ describe("parseDiagram", () => {
128
+ it("should parse simple box diagram", () => {
129
+ const diagram = `┌─────────┐
130
+ │ Test │
131
+ └─────────┘`
132
+ expect(parseDiagram(diagram)).toMatchInlineSnapshot(`
133
+ [
134
+ {
135
+ "segments": [
136
+ {
137
+ "text": "┌─────────┐",
138
+ "type": "muted",
139
+ },
140
+ ],
141
+ },
142
+ {
143
+ "segments": [
144
+ {
145
+ "text": "│ ",
146
+ "type": "muted",
147
+ },
148
+ {
149
+ "text": "Test",
150
+ "type": "text",
151
+ },
152
+ {
153
+ "text": " │",
154
+ "type": "muted",
155
+ },
156
+ ],
157
+ },
158
+ {
159
+ "segments": [
160
+ {
161
+ "text": "└─────────┘",
162
+ "type": "muted",
163
+ },
164
+ ],
165
+ },
166
+ ]
167
+ `)
168
+ })
169
+
170
+ it("should parse flow diagram with arrows", () => {
171
+ const diagram = `┌─────────────┐ ┌─────────────┐ ┌─────────────┐
172
+ │ Client │────▶│ Server │────▶│ Database │
173
+ └─────────────┘ └─────────────┘ └─────────────┘`
174
+ expect(parseDiagram(diagram)).toMatchInlineSnapshot(`
175
+ [
176
+ {
177
+ "segments": [
178
+ {
179
+ "text": "┌─────────────┐ ┌─────────────┐ ┌─────────────┐",
180
+ "type": "muted",
181
+ },
182
+ ],
183
+ },
184
+ {
185
+ "segments": [
186
+ {
187
+ "text": "│ ",
188
+ "type": "muted",
189
+ },
190
+ {
191
+ "text": "Client",
192
+ "type": "text",
193
+ },
194
+ {
195
+ "text": " │────▶│ ",
196
+ "type": "muted",
197
+ },
198
+ {
199
+ "text": "Server",
200
+ "type": "text",
201
+ },
202
+ {
203
+ "text": " │────▶│ ",
204
+ "type": "muted",
205
+ },
206
+ {
207
+ "text": "Database",
208
+ "type": "text",
209
+ },
210
+ {
211
+ "text": " │",
212
+ "type": "muted",
213
+ },
214
+ ],
215
+ },
216
+ {
217
+ "segments": [
218
+ {
219
+ "text": "└─────────────┘ └─────────────┘ └─────────────┘",
220
+ "type": "muted",
221
+ },
222
+ ],
223
+ },
224
+ ]
225
+ `)
226
+ })
227
+ })
228
+
229
+ describe("diagramToDebugString", () => {
230
+ it("should replace muted segments with asterisks", () => {
231
+ const diagram = `┌─────────┐
232
+ │ Test │
233
+ └─────────┘`
234
+ const parsed = parseDiagram(diagram)
235
+ expect(diagramToDebugString(parsed)).toMatchInlineSnapshot(`
236
+ "***********
237
+ ***Test****
238
+ ***********"
239
+ `)
240
+ })
241
+
242
+ it("should show text normally and mute structural chars", () => {
243
+ const diagram = `┌─────────────┐ ┌─────────────┐
244
+ │ Client │────▶│ Server │
245
+ └─────────────┘ └─────────────┘`
246
+ const parsed = parseDiagram(diagram)
247
+ expect(diagramToDebugString(parsed)).toMatchInlineSnapshot(`
248
+ "***********************************
249
+ ****Client**************Server*****
250
+ ***********************************"
251
+ `)
252
+ })
253
+
254
+ it("should handle vertical flow diagram", () => {
255
+ const diagram = ` ┌─────────┐
256
+ │ Start │
257
+ └────┬────┘
258
+
259
+
260
+ ┌─────────┐
261
+ │ End │
262
+ └─────────┘`
263
+ const parsed = parseDiagram(diagram)
264
+ expect(diagramToDebugString(parsed)).toMatchInlineSnapshot(`
265
+ "****************
266
+ ********Start***
267
+ ****************
268
+ ***********
269
+ ***********
270
+ ****************
271
+ ********End*****
272
+ ****************"
273
+ `)
274
+ })
275
+
276
+ it("should handle complex architecture diagram", () => {
277
+ const diagram = `┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐
278
+ │ Client │────▶│ Router │────▶│ Handler │────▶│ Database │
279
+ └─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘
280
+ │ │ │
281
+ │ │ │
282
+ ▼ ▼ ▼
283
+ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐
284
+ │ Validate │ │ Check │ │ Query │
285
+ │ Route │ │ Auth │ │ Execute │
286
+ └─────────────┘ └─────────────┘ └─────────────┘`
287
+ const parsed = parseDiagram(diagram)
288
+ expect(diagramToDebugString(parsed)).toMatchInlineSnapshot(`
289
+ "***************************************************************************
290
+ ****Client**************Router*************Handler*************Database****
291
+ ***************************************************************************
292
+ ********************************************************************
293
+ ********************************************************************
294
+ ********************************************************************
295
+ ***************************************************************************
296
+ ***********************Validate*************Check***************Query******
297
+ ************************Route***************Auth****************Execute****
298
+ ***************************************************************************"
299
+ `)
300
+ })
301
+
302
+ it("should handle ASCII-style diagram", () => {
303
+ const diagram = `+-------+ +-------+
304
+ | Input | --> | Output|
305
+ +-------+ +-------+`
306
+ const parsed = parseDiagram(diagram)
307
+ expect(diagramToDebugString(parsed)).toMatchInlineSnapshot(`
308
+ "***********************
309
+ **Input*********Output*
310
+ ***********************"
311
+ `)
312
+ })
313
+
314
+ it("should handle diagram with downward arrows", () => {
315
+ const diagram = ` A
316
+
317
+
318
+ B`
319
+ const parsed = parseDiagram(diagram)
320
+ expect(diagramToDebugString(parsed)).toMatchInlineSnapshot(`
321
+ "**A
322
+ ***
323
+ ***
324
+ **B"
325
+ `)
326
+ })
327
+ })
@@ -0,0 +1,229 @@
1
+ // Diagram parser - parses ASCII/Unicode diagrams into colored segments
2
+ // Used to highlight structural characters (boxes, arrows, lines) differently from text
3
+
4
+ /**
5
+ * A segment of text with a specific color type
6
+ */
7
+ export interface DiagramSegment {
8
+ text: string
9
+ type: "text" | "muted"
10
+ }
11
+
12
+ /**
13
+ * A parsed line of diagram content
14
+ */
15
+ export interface ParsedDiagramLine {
16
+ segments: DiagramSegment[]
17
+ }
18
+
19
+ // Box drawing characters (Unicode)
20
+ const BOX_DRAWING_CHARS = new Set([
21
+ // Light box drawing
22
+ "┌",
23
+ "┐",
24
+ "└",
25
+ "┘",
26
+ "─",
27
+ "│",
28
+ "├",
29
+ "┤",
30
+ "┬",
31
+ "┴",
32
+ "┼",
33
+ // Double box drawing
34
+ "╔",
35
+ "╗",
36
+ "╚",
37
+ "╝",
38
+ "═",
39
+ "║",
40
+ "╠",
41
+ "╣",
42
+ "╦",
43
+ "╩",
44
+ "╬",
45
+ // Heavy box drawing
46
+ "┏",
47
+ "┓",
48
+ "┗",
49
+ "┛",
50
+ "━",
51
+ "┃",
52
+ "┣",
53
+ "┫",
54
+ "┳",
55
+ "┻",
56
+ "╋",
57
+ // Mixed light/heavy
58
+ "┍",
59
+ "┎",
60
+ "┑",
61
+ "┒",
62
+ "┕",
63
+ "┖",
64
+ "┙",
65
+ "┚",
66
+ "┝",
67
+ "┞",
68
+ "┟",
69
+ "┠",
70
+ "┡",
71
+ "┢",
72
+ "┥",
73
+ "┦",
74
+ "┧",
75
+ "┨",
76
+ "┩",
77
+ "┪",
78
+ "┭",
79
+ "┮",
80
+ "┯",
81
+ "┰",
82
+ "┱",
83
+ "┲",
84
+ "┵",
85
+ "┶",
86
+ "┷",
87
+ "┸",
88
+ "┹",
89
+ "┺",
90
+ "┽",
91
+ "┾",
92
+ "┿",
93
+ "╀",
94
+ "╁",
95
+ "╂",
96
+ "╃",
97
+ "╄",
98
+ "╅",
99
+ "╆",
100
+ "╇",
101
+ "╈",
102
+ "╉",
103
+ "╊",
104
+ // Rounded corners
105
+ "╭",
106
+ "╮",
107
+ "╯",
108
+ "╰",
109
+ ])
110
+
111
+ // Arrow characters
112
+ const ARROW_CHARS = new Set([
113
+ // Unicode arrows
114
+ "▶",
115
+ "◀",
116
+ "▼",
117
+ "▲",
118
+ "►",
119
+ "◄",
120
+ "▾",
121
+ "▴",
122
+ "→",
123
+ "←",
124
+ "↓",
125
+ "↑",
126
+ "↔",
127
+ "↕",
128
+ "↖",
129
+ "↗",
130
+ "↘",
131
+ "↙",
132
+ "⇒",
133
+ "⇐",
134
+ "⇓",
135
+ "⇑",
136
+ "⇔",
137
+ "⇕",
138
+ // Triangle arrows
139
+ "△",
140
+ "▽",
141
+ "◁",
142
+ "▷",
143
+ "⊳",
144
+ "⊲",
145
+ "⊴",
146
+ "⊵",
147
+ ])
148
+
149
+ // ASCII diagram characters (structural, not text)
150
+ // Note: "v" and "V" are NOT included because they appear in regular text
151
+ // like "Server", "Validate", etc.
152
+ const ASCII_STRUCTURAL_CHARS = new Set(["-", "|", "+", "/", "\\", "<", ">", "^"])
153
+
154
+ /**
155
+ * Check if a character is a diagram structural character (should be muted)
156
+ */
157
+ function isDiagramChar(char: string): boolean {
158
+ return (
159
+ BOX_DRAWING_CHARS.has(char) ||
160
+ ARROW_CHARS.has(char) ||
161
+ ASCII_STRUCTURAL_CHARS.has(char)
162
+ )
163
+ }
164
+
165
+ /**
166
+ * Parse a single line of diagram content into segments
167
+ */
168
+ export function parseDiagramLine(line: string): ParsedDiagramLine {
169
+ if (!line) {
170
+ return { segments: [] }
171
+ }
172
+
173
+ const segments: DiagramSegment[] = []
174
+ let currentText = ""
175
+ let currentType: "text" | "muted" | null = null
176
+
177
+ // Iterate through each character (handling Unicode properly)
178
+ for (const char of line) {
179
+ const isMuted = isDiagramChar(char) || char === " "
180
+ const type = isMuted ? "muted" : "text"
181
+
182
+ if (currentType === null) {
183
+ currentType = type
184
+ currentText = char
185
+ } else if (type === currentType) {
186
+ currentText += char
187
+ } else {
188
+ // Type changed, push current segment and start new one
189
+ segments.push({ text: currentText, type: currentType })
190
+ currentText = char
191
+ currentType = type
192
+ }
193
+ }
194
+
195
+ // Push final segment
196
+ if (currentText && currentType !== null) {
197
+ segments.push({ text: currentText, type: currentType })
198
+ }
199
+
200
+ return { segments }
201
+ }
202
+
203
+ /**
204
+ * Parse entire diagram content into lines of segments
205
+ */
206
+ export function parseDiagram(content: string): ParsedDiagramLine[] {
207
+ const lines = content.split("\n")
208
+ return lines.map(parseDiagramLine)
209
+ }
210
+
211
+ /**
212
+ * Convert parsed diagram to a debug string for testing
213
+ * Muted segments are replaced with '*' characters
214
+ */
215
+ export function diagramToDebugString(parsed: ParsedDiagramLine[]): string {
216
+ return parsed
217
+ .map((line) => {
218
+ return line.segments
219
+ .map((segment) => {
220
+ if (segment.type === "muted") {
221
+ // Replace each character with '*' to show what would be muted
222
+ return "*".repeat([...segment.text].length)
223
+ }
224
+ return segment.text
225
+ })
226
+ .join("")
227
+ })
228
+ .join("\n")
229
+ }
@@ -876,7 +876,7 @@ The diagram above should not wrap.`,
876
876
 
877
877
  await testSetup.renderOnce()
878
878
  const frame = testSetup.captureCharFrame()
879
-
879
+
880
880
  // With 80-char width, the 65-char diagram should fit without wrapping
881
881
  expect(frame).toContain("┌─────────────┐ ┌─────────────┐ ┌─────────────┐")
882
882
  expect(frame).toContain("│ Client │────▶│ Server │────▶│ Database │")
@@ -946,6 +946,8 @@ The diagram above should not wrap.`,
946
946
  `)
947
947
  })
948
948
 
949
+ // SKIPPED: This test has a pre-existing issue with yoga-layout binding errors
950
+ // when reusing renderers across testRender calls
949
951
  it("should TRUNCATE 4-box diagram at 70 cols WITH renderer", async () => {
950
952
  // 4-box diagram is 79 chars wide, at 70 cols it truncates (not wraps)
951
953
  // This proves wrapMode: "none" is working
@@ -1024,14 +1026,55 @@ The diagram above should not wrap.`,
1024
1026
  `)
1025
1027
  })
1026
1028
 
1029
+ it("should render diagram code blocks with colored segments", async () => {
1030
+ // Diagrams with lang="diagram" should have structural chars colored differently
1031
+ const diagramHunk = createHunk(1, "src/config.ts", 0, 1, 1, [
1032
+ "+export const x = 1",
1033
+ ])
1034
+
1035
+ const diagramReviewData: ReviewYaml = {
1036
+ hunks: [{
1037
+ hunkIds: [1],
1038
+ markdownDescription: `## Architecture Diagram
1039
+
1040
+ \`\`\`diagram
1041
+ ┌───────┐ ┌───────┐
1042
+ │ Input │────▶│Output │
1043
+ └───────┘ └───────┘
1044
+ \`\`\`
1045
+
1046
+ The diagram above shows the flow.`,
1047
+ }],
1048
+ }
1049
+
1050
+ testSetup = await testRender(
1051
+ <ReviewAppView
1052
+ hunks={[diagramHunk]}
1053
+ reviewData={diagramReviewData}
1054
+ isGenerating={false}
1055
+ themeName="github"
1056
+ width={60}
1057
+ />,
1058
+ { width: 60, height: 20 },
1059
+ )
1060
+
1061
+ await testSetup.renderOnce()
1062
+ const frame = testSetup.captureCharFrame()
1063
+ // The diagram should render with the structural characters
1064
+ // (box drawing and arrows) and text labels (Input, Output)
1065
+ expect(frame).toContain("Input")
1066
+ expect(frame).toContain("Output")
1067
+ expect(frame).toContain("Architecture Diagram")
1068
+ })
1069
+
1027
1070
  // ============================================================================
1028
1071
  // LONG LINE WRAPPING TESTS
1029
1072
  // ============================================================================
1030
- //
1073
+ //
1031
1074
  // THE ISSUE:
1032
1075
  // When diff lines are long enough to wrap in split view, and the left/right
1033
1076
  // sides wrap to different numbers of visual lines, the alignment breaks.
1034
- //
1077
+ //
1035
1078
  // Example of broken alignment (single render):
1036
1079
  // 1 - const response... 1 + const response...
1037
1080
  // example.com/users'); API_BASE_URL...
@@ -1188,4 +1231,59 @@ Added environment-based URL and auth header.`,
1188
1231
  "
1189
1232
  `)
1190
1233
  })
1234
+
1235
+ it("should show generating indicator below last hunk when isGenerating is true", async () => {
1236
+ // When generating, a centered spinner+text indicator should appear below the content
1237
+ testSetup = await testRender(
1238
+ <ReviewAppView
1239
+ hunks={exampleHunks}
1240
+ reviewData={{
1241
+ hunks: [{
1242
+ hunkIds: [3],
1243
+ markdownDescription: `## Import changes
1244
+
1245
+ Added logger import.`,
1246
+ }],
1247
+ }}
1248
+ isGenerating={true}
1249
+ themeName="github"
1250
+ width={80}
1251
+ />,
1252
+ {
1253
+ width: 80,
1254
+ height: 25,
1255
+ },
1256
+ )
1257
+
1258
+ await testSetup.renderOnce()
1259
+ const frame = testSetup.captureCharFrame()
1260
+ expect(frame).toMatchInlineSnapshot(`
1261
+ "
1262
+ Import changes
1263
+
1264
+ Added logger import.
1265
+
1266
+
1267
+ rc/index.ts +1-0
1268
+
1269
+ import { main } from './utils'
1270
+ + import { logger } from './logger'
1271
+
1272
+
1273
+
1274
+
1275
+ ⠋ generating
1276
+
1277
+
1278
+
1279
+
1280
+
1281
+
1282
+
1283
+
1284
+ (1 section) t theme run with --web to share & collaborate
1285
+
1286
+ "
1287
+ `)
1288
+ })
1191
1289
  })
@@ -2,13 +2,14 @@
2
2
 
3
3
  import * as React from "react"
4
4
  import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/react"
5
- import { MacOSScrollAccel, SyntaxStyle, BoxRenderable, CodeRenderable } from "@opentui/core"
5
+ import { MacOSScrollAccel, SyntaxStyle, BoxRenderable, CodeRenderable, TextRenderable } from "@opentui/core"
6
6
  import type { Token } from "marked"
7
7
  import { getResolvedTheme, getSyntaxTheme, defaultThemeName, themeNames, rgbaToHex } from "../themes.ts"
8
8
  import { detectFiletype, countChanges, getViewMode } from "../diff-utils.ts"
9
9
  import { DiffView } from "../components/diff-view.tsx"
10
10
  import { watchReviewYaml } from "./yaml-watcher.ts"
11
11
  import { createSubHunk } from "./hunk-parser.ts"
12
+ import { parseDiagram } from "./diagram-parser.ts"
12
13
  import { useAppStore } from "../store.ts"
13
14
  import Dropdown from "../dropdown.tsx"
14
15
  import type { IndexedHunk, ReviewYaml, ReviewGroup } from "./types.ts"
@@ -68,6 +69,12 @@ export function ReviewApp({
68
69
 
69
70
  // Keyboard navigation
70
71
  useKeyboard((key) => {
72
+ // Ctrl+D toggles debug console
73
+ if (key.ctrl && key.name === "d") {
74
+ renderer.console.toggle()
75
+ return
76
+ }
77
+
71
78
  if (showThemePicker) {
72
79
  if (key.name === "escape") {
73
80
  setShowThemePicker(false)
@@ -167,22 +174,43 @@ export function ReviewApp({
167
174
  }
168
175
 
169
176
  /**
170
- * Animated "generating..." indicator with cycling dots
177
+ * Inline generating indicator with spinner and animated dots
171
178
  */
172
- function GeneratingIndicator({ color }: { color: string }) {
173
- const [dotPhase, setDotPhase] = React.useState(0)
179
+ function GeneratingIndicatorInline({ color }: { color: string }) {
180
+ const [phase, setPhase] = React.useState(0)
174
181
 
175
182
  React.useEffect(() => {
176
183
  const interval = setInterval(() => {
177
- setDotPhase((p) => (p + 1) % 4)
178
- }, 300)
184
+ setPhase((p) => (p + 1) % 12)
185
+ }, 100)
179
186
  return () => clearInterval(interval)
180
187
  }, [])
181
188
 
182
- const dots = ".".repeat(dotPhase).padEnd(3, " ")
189
+ // Braille spinner pattern (cycles every 8 phases)
190
+ const spinnerChars = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧"]
191
+ const spinner = spinnerChars[phase % 8]
192
+ // Dots cycle every 4 phases (so they change ~3x slower than spinner)
193
+ const dotCount = Math.floor(phase / 3) % 4
194
+ const dots = ".".repeat(dotCount).padEnd(3, " ")
183
195
 
196
+ return <text fg={color}>{spinner} generating{dots}</text>
197
+ }
198
+
199
+ /**
200
+ * Centered generating indicator for loading state (when no content yet)
201
+ */
202
+ function GeneratingIndicator({ color, bgColor }: { color: string; bgColor: string }) {
184
203
  return (
185
- <text fg={color}>generating{dots} </text>
204
+ <box
205
+ style={{
206
+ flexGrow: 1,
207
+ alignItems: "center",
208
+ justifyContent: "center",
209
+ backgroundColor: bgColor,
210
+ }}
211
+ >
212
+ <GeneratingIndicatorInline color={color} />
213
+ </box>
186
214
  )
187
215
  }
188
216
 
@@ -251,9 +279,14 @@ export function ReviewAppView({
251
279
  backgroundColor: bgColor,
252
280
  }}
253
281
  >
254
- <text fg={rgbaToHex(resolvedTheme.text)}>
255
- {isGenerating ? "Generating review..." : "No review groups generated"}
256
- </text>
282
+ {isGenerating ? (
283
+ <GeneratingIndicator
284
+ color={rgbaToHex(resolvedTheme.textMuted)}
285
+ bgColor={bgColor}
286
+ />
287
+ ) : (
288
+ <text fg={rgbaToHex(resolvedTheme.text)}>No review groups generated</text>
289
+ )}
257
290
  </box>
258
291
  )
259
292
  }
@@ -338,6 +371,12 @@ export function ReviewAppView({
338
371
  </box>
339
372
  )
340
373
  })}
374
+ {/* Generating indicator - shown below last hunk */}
375
+ {isGenerating && (
376
+ <box style={{ alignItems: "center", justifyContent: "center", marginTop: gap, marginBottom: gap }}>
377
+ <GeneratingIndicatorInline color={rgbaToHex(resolvedTheme.textMuted)} />
378
+ </box>
379
+ )}
341
380
  </box>
342
381
  </scrollbox>
343
382
 
@@ -353,9 +392,6 @@ export function ReviewAppView({
353
392
  alignItems: "center",
354
393
  }}
355
394
  >
356
- {isGenerating && (
357
- <GeneratingIndicator color={rgbaToHex(resolvedTheme.textMuted)} />
358
- )}
359
395
  <text fg={rgbaToHex(resolvedTheme.textMuted)}>
360
396
  ({groups.length} section{groups.length !== 1 ? "s" : ""})
361
397
  </text>
@@ -433,6 +469,9 @@ function MarkdownBlock({ content, themeName, width, renderer }: MarkdownBlockPro
433
469
  () => SyntaxStyle.fromStyles(syntaxTheme),
434
470
  [syntaxTheme],
435
471
  )
472
+ const resolvedTheme = getResolvedTheme(themeName)
473
+ const textColor = rgbaToHex(resolvedTheme.text)
474
+ const concealColor = rgbaToHex(resolvedTheme.conceal)
436
475
 
437
476
  // Max width for prose (constrained), code blocks use full terminal width
438
477
  const maxProseWidth = Math.min(80, width)
@@ -463,11 +502,55 @@ function MarkdownBlock({ content, themeName, width, renderer }: MarkdownBlockPro
463
502
  // Code blocks: create custom CodeRenderable with wrapMode: "none" and overflow: "hidden"
464
503
  if (token.type === "code") {
465
504
  const codeToken = token as { text: string; lang?: string }
505
+
506
+
466
507
  const wrapper = new BoxRenderable(renderer, {
467
508
  id: `code-wrapper-${nodeCounter++}`,
468
509
  alignSelf: "center",
469
510
  overflow: "hidden",
470
511
  })
512
+
513
+ // Special handling for diagram language - color structural chars as muted
514
+ if (codeToken.lang === "diagram") {
515
+ console.log("[diagram-debug] MATCHED diagram lang, parsing...")
516
+ const diagramWrapper = new BoxRenderable(renderer, {
517
+ id: `diagram-${nodeCounter++}`,
518
+ flexDirection: "column",
519
+ })
520
+ const parsedLines = parseDiagram(codeToken.text)
521
+ for (let i = 0; i < parsedLines.length; i++) {
522
+ const line = parsedLines[i]
523
+ // Skip empty lines or add a single space to maintain line height
524
+ if (line.segments.length === 0) {
525
+ const emptyLine = new TextRenderable(renderer, {
526
+ id: `diagram-empty-${nodeCounter++}-${i}`,
527
+ content: " ",
528
+ fg: concealColor,
529
+ })
530
+ diagramWrapper.add(emptyLine)
531
+ continue
532
+ }
533
+ // Create a row box for each line
534
+ const lineBox = new BoxRenderable(renderer, {
535
+ id: `diagram-line-${nodeCounter++}-${i}`,
536
+ flexDirection: "row",
537
+ })
538
+ // Add each segment as a separate text renderable with appropriate color
539
+ for (let j = 0; j < line.segments.length; j++) {
540
+ const segment = line.segments[j]
541
+ const segmentRenderable = new TextRenderable(renderer, {
542
+ id: `diagram-seg-${nodeCounter++}-${i}-${j}`,
543
+ content: segment.text,
544
+ fg: segment.type === "muted" ? concealColor : textColor,
545
+ })
546
+ lineBox.add(segmentRenderable)
547
+ }
548
+ diagramWrapper.add(lineBox)
549
+ }
550
+ wrapper.add(diagramWrapper)
551
+ return wrapper
552
+ }
553
+
471
554
  const codeRenderable = new CodeRenderable(renderer, {
472
555
  id: `code-${nodeCounter++}`,
473
556
  content: codeToken.text,
@@ -495,7 +578,7 @@ function MarkdownBlock({ content, themeName, width, renderer }: MarkdownBlockPro
495
578
  // Other elements (hr, space, etc.) use default rendering
496
579
  return undefined
497
580
  }
498
- }, [renderer, maxProseWidth, syntaxStyle])
581
+ }, [renderer, maxProseWidth, syntaxStyle, textColor, concealColor])
499
582
 
500
583
  // Use very large width when renderer available so code blocks don't wrap
501
584
  // Prose is constrained via renderNode, code blocks can overflow
@@ -6,6 +6,7 @@
6
6
  "darkBgElement": "#161b22",
7
7
  "darkFg": "#e6edf3",
8
8
  "darkFgMuted": "#8b949e",
9
+ "darkConceal": "#484f58",
9
10
  "darkBorder": "#30363d",
10
11
  "darkBorderSubtle": "#21262d",
11
12
  "darkKeyword": "#ff7b72",
@@ -24,6 +25,7 @@
24
25
  "lightBgPanel": "#f0f3f6",
25
26
  "lightFg": "#24292f",
26
27
  "lightFgMuted": "#57606a",
28
+ "lightConceal": "#8c959f",
27
29
  "lightKeyword": "#cf222e",
28
30
  "lightString": "#0a3069",
29
31
  "lightConstant": "#0550ae",
@@ -236,6 +238,10 @@
236
238
  "syntaxPunctuation": {
237
239
  "dark": "darkFg",
238
240
  "light": "lightFg"
241
+ },
242
+ "conceal": {
243
+ "dark": "darkConceal",
244
+ "light": "lightConceal"
239
245
  }
240
246
  }
241
247
  }
package/src/themes.ts CHANGED
@@ -37,6 +37,7 @@ export interface ResolvedTheme {
37
37
  // Text colors
38
38
  text: RGBA;
39
39
  textMuted: RGBA;
40
+ conceal: RGBA;
40
41
  // Diff colors
41
42
  diffAddedBg: RGBA;
42
43
  diffRemovedBg: RGBA;
@@ -122,12 +123,12 @@ function loadTheme(name: string): ThemeJson {
122
123
  if (themeCache[name]) {
123
124
  return themeCache[name];
124
125
  }
125
-
126
+
126
127
  const fileName = THEME_FILES[name];
127
128
  if (!fileName) {
128
129
  return github; // Fallback to default
129
130
  }
130
-
131
+
131
132
  try {
132
133
  // Use dynamic import with synchronous pattern for JSON
133
134
  // This works because JSON imports are resolved at bundle time by Bun
@@ -174,7 +175,7 @@ function resolveTheme(
174
175
  const fallbackText: ColorValue = "#d4d4d4";
175
176
 
176
177
  const text = resolveColor(t.text ?? fallbackText);
177
-
178
+
178
179
  return {
179
180
  primary: resolveColor(t.primary ?? t.syntaxFunction ?? fallbackGray),
180
181
  syntaxComment: resolveColor(t.syntaxComment ?? fallbackGray),
@@ -188,6 +189,7 @@ function resolveTheme(
188
189
  syntaxPunctuation: resolveColor(t.syntaxPunctuation ?? fallbackGray),
189
190
  text,
190
191
  textMuted: resolveColor(t.textMuted ?? fallbackGray),
192
+ conceal: resolveColor(t.conceal ?? t.textMuted ?? fallbackGray),
191
193
  diffAddedBg: resolveColor(t.diffAddedBg ?? "#1e3a1e"),
192
194
  diffRemovedBg: resolveColor(t.diffRemovedBg ?? "#3a1e1e"),
193
195
  diffContextBg: resolveColor(t.diffContextBg ?? fallbackBg),
@@ -233,7 +235,7 @@ export function getSyntaxTheme(
233
235
  return {
234
236
  // Default text style
235
237
  default: { fg: resolved.text },
236
-
238
+
237
239
  // Code syntax styles
238
240
  keyword: { fg: resolved.syntaxKeyword, italic: true },
239
241
  "keyword.import": { fg: resolved.syntaxKeyword },
@@ -271,7 +273,7 @@ export function getSyntaxTheme(
271
273
  "punctuation.bracket": { fg: resolved.syntaxPunctuation },
272
274
  "punctuation.delimiter": { fg: resolved.syntaxOperator },
273
275
  "punctuation.special": { fg: resolved.syntaxOperator },
274
-
276
+
275
277
  // Markdown styles - these are the Tree-sitter scope names for markdown
276
278
  "markup.heading": { fg: resolved.markdownHeading, bold: true },
277
279
  "markup.heading.1": { fg: resolved.markdownHeading, bold: true },
@@ -294,7 +296,7 @@ export function getSyntaxTheme(
294
296
  label: { fg: resolved.markdownLinkText },
295
297
  spell: { fg: resolved.text },
296
298
  nospell: { fg: resolved.text },
297
- conceal: { fg: resolved.textMuted },
299
+ conceal: { fg: resolved.conceal || resolved.textMuted },
298
300
  "string.special": { fg: resolved.markdownLink, underline: true },
299
301
  "string.special.url": { fg: resolved.markdownLink, underline: true },
300
302
  };