revspec 0.2.2 → 0.3.0

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/src/tui/pager.ts CHANGED
@@ -3,9 +3,8 @@ import type { Thread } from "../protocol/types";
3
3
  import {
4
4
  ScrollBoxRenderable,
5
5
  TextRenderable,
6
- MarkdownRenderable,
7
- SyntaxStyle,
8
- parseColor,
6
+ TextNodeRenderable,
7
+ TextAttributes,
9
8
  type CliRenderer,
10
9
  } from "@opentui/core";
11
10
  import { theme } from "./theme";
@@ -26,90 +25,440 @@ function threadHint(thread: Thread): string {
26
25
  return text.slice(0, MAX_HINT_LENGTH - 1) + "\u2026";
27
26
  }
28
27
 
28
+ // --- Inline markdown parser ---
29
+
30
+ interface StyledSegment {
31
+ text: string;
32
+ fg?: string;
33
+ attributes?: number;
34
+ }
35
+
29
36
  /**
30
- * Build plain text line-mode content (for commenting).
31
- * Each line: cursor marker + lineNum + content + thread indicator + hint.
37
+ * Parse inline markdown (bold, italic, code) into styled segments.
38
+ * Strips syntax markers and returns display text with style info.
32
39
  */
33
- export function buildPagerContent(state: ReviewState, searchQuery?: string | null, unreadThreadIds?: ReadonlySet<string>): string {
40
+ function parseInlineMarkdown(text: string): StyledSegment[] {
41
+ const segments: StyledSegment[] = [];
42
+ // Order matters: **bold** before *italic*
43
+ const regex = /(\*\*(.+?)\*\*|\*(.+?)\*|`([^`]+)`)/g;
44
+ let pos = 0;
45
+ let match;
46
+ while ((match = regex.exec(text)) !== null) {
47
+ if (match.index > pos) {
48
+ segments.push({ text: text.slice(pos, match.index) });
49
+ }
50
+ if (match[2] !== undefined) {
51
+ // **bold**
52
+ segments.push({ text: match[2], attributes: TextAttributes.BOLD });
53
+ } else if (match[3] !== undefined) {
54
+ // *italic*
55
+ segments.push({ text: match[3], attributes: TextAttributes.ITALIC });
56
+ } else if (match[4] !== undefined) {
57
+ // `code`
58
+ segments.push({ text: match[4], fg: theme.green });
59
+ }
60
+ pos = match.index + match[0].length;
61
+ }
62
+ if (pos < text.length) {
63
+ segments.push({ text: text.slice(pos) });
64
+ }
65
+ if (segments.length === 0) {
66
+ segments.push({ text });
67
+ }
68
+ return segments;
69
+ }
70
+
71
+ /**
72
+ * Parse a full line of markdown into styled segments.
73
+ * Handles block-level syntax (headings, lists, blockquotes, hr)
74
+ * and delegates inline content to parseInlineMarkdown.
75
+ */
76
+ function parseMarkdownLine(line: string): StyledSegment[] {
77
+ // Heading: # ... ###### (strip markers, bold + colored)
78
+ const headingMatch = line.match(/^(#{1,6})\s+(.*)/);
79
+ if (headingMatch) {
80
+ const level = headingMatch[1].length;
81
+ const color = level <= 2 ? theme.blue : theme.mauve;
82
+ // Parse inline markdown within heading text
83
+ const inner = parseInlineMarkdown(headingMatch[2]);
84
+ return inner.map((s) => ({
85
+ ...s,
86
+ fg: s.fg ?? color,
87
+ attributes: (s.attributes ?? 0) | TextAttributes.BOLD,
88
+ }));
89
+ }
90
+
91
+ // Horizontal rule: --- or *** or ___
92
+ if (/^(\s*[-*_]\s*){3,}$/.test(line)) {
93
+ return [{ text: "\u2500".repeat(40), fg: theme.overlay, attributes: TextAttributes.DIM }];
94
+ }
95
+
96
+ // Blockquote: > text
97
+ if (line.startsWith("> ")) {
98
+ const inner = parseInlineMarkdown(line.slice(2));
99
+ return [
100
+ { text: "\u2502 ", fg: theme.overlay },
101
+ ...inner.map((s) => ({
102
+ ...s,
103
+ fg: s.fg ?? theme.overlay,
104
+ attributes: (s.attributes ?? 0) | TextAttributes.ITALIC,
105
+ })),
106
+ ];
107
+ }
108
+
109
+ // Unordered list: - item, * item, + item
110
+ const ulMatch = line.match(/^(\s*)([-*+])\s+(.*)/);
111
+ if (ulMatch) {
112
+ return [
113
+ { text: ulMatch[1] + "\u2022 ", fg: theme.yellow },
114
+ ...parseInlineMarkdown(ulMatch[3]),
115
+ ];
116
+ }
117
+
118
+ // Ordered list: 1. item
119
+ const olMatch = line.match(/^(\s*)(\d+)\.\s+(.*)/);
120
+ if (olMatch) {
121
+ return [
122
+ { text: `${olMatch[1]}${olMatch[2]}. `, fg: theme.yellow },
123
+ ...parseInlineMarkdown(olMatch[3]),
124
+ ];
125
+ }
126
+
127
+ // Regular line — parse inline markdown only
128
+ return parseInlineMarkdown(line);
129
+ }
130
+
131
+ /**
132
+ * Add styled segments as TextNodeRenderable children to a parent.
133
+ */
134
+ function addSegments(parent: TextRenderable, segments: StyledSegment[], defaultFg: string): void {
135
+ for (const seg of segments) {
136
+ const node = TextNodeRenderable.fromString(seg.text, {
137
+ fg: seg.fg ?? defaultFg,
138
+ attributes: seg.attributes,
139
+ });
140
+ parent.add(node);
141
+ }
142
+ }
143
+
144
+ // --- Table rendering ---
145
+
146
+ const SEPARATOR_RE = /^\|[\s:]*-+[\s:]*(\|[\s:]*-+[\s:]*)*\|?\s*$/;
147
+
148
+ /** Split a table row into trimmed cell values (strips outer pipes). */
149
+ function parseTableCells(line: string): string[] {
150
+ const trimmed = line.trim();
151
+ // Remove leading/trailing pipes and split
152
+ const inner = trimmed.startsWith("|") ? trimmed.slice(1) : trimmed;
153
+ const withoutTrailing = inner.endsWith("|") ? inner.slice(0, -1) : inner;
154
+ return withoutTrailing.split("|").map((c) => c.trim());
155
+ }
156
+
157
+ /** Compute the display width of a string (strips inline markdown markers). */
158
+ function displayWidth(text: string): number {
159
+ // Remove **bold**, *italic*, `code` markers to get display length
160
+ return text
161
+ .replace(/\*\*(.+?)\*\*/g, "$1")
162
+ .replace(/\*(.+?)\*/g, "$1")
163
+ .replace(/`([^`]+)`/g, "$1")
164
+ .length;
165
+ }
166
+
167
+ interface TableBlock {
168
+ startIndex: number;
169
+ lines: string[];
170
+ separatorIndex: number; // relative to startIndex, -1 if none
171
+ colWidths: number[];
172
+ }
173
+
174
+ /** Scan ahead from a starting `|` line and collect the full table block. */
175
+ function collectTable(specLines: string[], start: number): TableBlock {
34
176
  const lines: string[] = [];
177
+ let i = start;
178
+ while (i < specLines.length && specLines[i].trimStart().startsWith("|")) {
179
+ lines.push(specLines[i]);
180
+ i++;
181
+ }
182
+
183
+ // Find separator row
184
+ let separatorIndex = -1;
185
+ for (let j = 0; j < lines.length; j++) {
186
+ if (SEPARATOR_RE.test(lines[j])) {
187
+ separatorIndex = j;
188
+ break;
189
+ }
190
+ }
191
+
192
+ // Calculate column widths from all non-separator rows
193
+ const allCells = lines
194
+ .filter((_, j) => j !== separatorIndex)
195
+ .map(parseTableCells);
196
+ const maxCols = Math.max(...allCells.map((r) => r.length), 0);
197
+ const colWidths: number[] = new Array(maxCols).fill(0);
198
+ for (const row of allCells) {
199
+ for (let c = 0; c < row.length; c++) {
200
+ colWidths[c] = Math.max(colWidths[c], displayWidth(row[c]));
201
+ }
202
+ }
203
+ // Minimum width of 3 per column
204
+ for (let c = 0; c < colWidths.length; c++) {
205
+ colWidths[c] = Math.max(colWidths[c], 3);
206
+ }
207
+
208
+ return { startIndex: start, lines, separatorIndex, colWidths };
209
+ }
210
+
211
+ /** Render a table separator row with box-drawing characters. */
212
+ function renderTableSeparator(parent: TextRenderable, colWidths: number[]): void {
213
+ const parts = colWidths.map((w) => "\u2500".repeat(w + 2));
214
+ const line = "\u251c" + parts.join("\u253c") + "\u2524";
215
+ parent.add(TextNodeRenderable.fromString(line, { fg: theme.overlay }));
216
+ }
35
217
 
218
+ /** Render a table data/header row with padded, styled cells. */
219
+ function renderTableRow(
220
+ parent: TextRenderable,
221
+ cells: string[],
222
+ colWidths: number[],
223
+ isHeader: boolean,
224
+ ): void {
225
+ for (let c = 0; c < colWidths.length; c++) {
226
+ const cellText = c < cells.length ? cells[c] : "";
227
+ const dw = displayWidth(cellText);
228
+ const padding = Math.max(0, colWidths[c] - dw);
229
+
230
+ // Left border
231
+ parent.add(TextNodeRenderable.fromString(
232
+ c === 0 ? "\u2502 " : " \u2502 ",
233
+ { fg: theme.overlay }
234
+ ));
235
+
236
+ // Cell content — parse inline markdown, apply header bold
237
+ const segments = parseInlineMarkdown(cellText);
238
+ for (const seg of segments) {
239
+ const attrs = isHeader
240
+ ? (seg.attributes ?? 0) | TextAttributes.BOLD
241
+ : seg.attributes;
242
+ parent.add(TextNodeRenderable.fromString(seg.text, {
243
+ fg: seg.fg ?? theme.text,
244
+ attributes: attrs,
245
+ }));
246
+ }
247
+
248
+ // Padding
249
+ if (padding > 0) {
250
+ parent.add(TextNodeRenderable.fromString(" ".repeat(padding), {}));
251
+ }
252
+ }
253
+ // Right border
254
+ parent.add(TextNodeRenderable.fromString(
255
+ " \u2502",
256
+ { fg: theme.overlay }
257
+ ));
258
+ }
259
+
260
+ /** Render a top or bottom border for the table. */
261
+ function renderTableBorder(parent: TextRenderable, colWidths: number[], position: "top" | "bottom"): void {
262
+ const [left, mid, right] = position === "top"
263
+ ? ["\u250c", "\u252c", "\u2510"]
264
+ : ["\u2514", "\u2534", "\u2518"];
265
+ const parts = colWidths.map((w) => "\u2500".repeat(w + 2));
266
+ parent.add(TextNodeRenderable.fromString(
267
+ left + parts.join(mid) + right,
268
+ { fg: theme.overlay }
269
+ ));
270
+ }
271
+
272
+ // --- Plain text builder (for tests) ---
273
+
274
+ /**
275
+ * Build plain text line-mode content (for testing / plain fallback).
276
+ */
277
+ export function buildPagerContent(state: ReviewState, searchQuery?: string | null, unreadThreadIds?: ReadonlySet<string>): string {
278
+ const lines: string[] = [];
36
279
  for (let i = 0; i < state.specLines.length; i++) {
37
280
  const lineNum = i + 1;
38
281
  const thread = state.threadAtLine(lineNum);
39
282
  const isCursor = lineNum === state.cursorLine;
40
-
41
283
  const prefix = isCursor ? ">" : " ";
42
284
  let specText = state.specLines[i];
43
-
44
285
  if (searchQuery) {
45
286
  const regex = new RegExp(searchQuery.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "gi");
46
287
  specText = specText.replace(regex, (match) => `>>${match}<<`);
47
288
  }
289
+ let indicator = " ";
290
+ if (thread) {
291
+ const isUnread = unreadThreadIds && unreadThreadIds.has(thread.id);
292
+ if (isUnread) {
293
+ indicator = "\u2588";
294
+ } else if (thread.status === "resolved") {
295
+ indicator = "\u2713";
296
+ } else {
297
+ indicator = "\u258c";
298
+ }
299
+ }
300
+ lines.push(`${prefix}${indicator}${padLineNum(lineNum)} ${specText}`);
301
+ }
302
+ return lines.join("\n");
303
+ }
304
+
305
+ // --- Styled node builder ---
306
+
307
+ /**
308
+ * Build styled line-mode content using TextNodeRenderable.
309
+ * Inline markdown is parsed and styled per line.
310
+ * Line numbers and thread hints are dimmed.
311
+ */
312
+ export function buildPagerNodes(lineNode: TextRenderable, state: ReviewState, searchQuery?: string | null, unreadThreadIds?: ReadonlySet<string>): void {
313
+ lineNode.clear();
314
+
315
+ // Pre-scan for table blocks so we can calculate column widths
316
+ const tableBlocks = new Map<number, TableBlock>();
317
+ for (let i = 0; i < state.specLines.length; i++) {
318
+ if (state.specLines[i].trimStart().startsWith("|") && !tableBlocks.has(i)) {
319
+ const block = collectTable(state.specLines, i);
320
+ // Mark all lines in this block
321
+ for (let j = 0; j < block.lines.length; j++) {
322
+ tableBlocks.set(i + j, block);
323
+ }
324
+ }
325
+ }
326
+
327
+ for (let i = 0; i < state.specLines.length; i++) {
328
+ const lineNum = i + 1;
329
+ const thread = state.threadAtLine(lineNum);
330
+ const isCursor = lineNum === state.cursorLine;
331
+
332
+ const prefix = isCursor ? ">" : " ";
333
+ const specText = state.specLines[i];
48
334
 
49
335
  // Thread indicator — gutter bar on the left
50
336
  let indicator = " ";
337
+ let indicatorColor = theme.overlay;
51
338
  if (thread) {
52
339
  const isUnread = unreadThreadIds && unreadThreadIds.has(thread.id);
53
340
  if (isUnread) {
54
341
  indicator = "\u2588"; // █ full block — unread reply
342
+ indicatorColor = theme.yellow;
55
343
  } else if (thread.status === "resolved") {
56
344
  indicator = "\u2713"; // ✓ resolved
345
+ indicatorColor = theme.green;
57
346
  } else {
58
347
  indicator = "\u258c"; // ▌ half block — has thread
348
+ indicatorColor = theme.blue;
59
349
  }
60
350
  }
61
351
 
62
- let line = `${prefix}${indicator}${padLineNum(lineNum)} ${specText}`;
352
+ // Check for table context before rendering gutter
353
+ const tableBlock = tableBlocks.get(i);
354
+ const isTable = tableBlock && !searchQuery;
355
+ const relIdx = isTable ? i - tableBlock.startIndex : -1;
63
356
 
64
- // No inline preview the gutter indicator (▌/█/✓) is enough.
65
- // Press c to open the thread and see the full conversation.
357
+ // Top border before first table row (on its own visual line with blank gutter)
358
+ if (isTable && relIdx === 0) {
359
+ lineNode.add(TextNodeRenderable.fromString(" ", { fg: theme.overlay }));
360
+ renderTableBorder(lineNode, tableBlock.colWidths, "top");
361
+ lineNode.add(TextNodeRenderable.fromString("\n", {}));
362
+ }
66
363
 
67
- lines.push(line);
68
- }
364
+ // Gutter: cursor + indicator + line number (dimmed)
365
+ lineNode.add(TextNodeRenderable.fromString(
366
+ `${prefix}`,
367
+ { fg: isCursor ? theme.text : theme.overlay }
368
+ ));
369
+ lineNode.add(TextNodeRenderable.fromString(
370
+ indicator,
371
+ { fg: indicatorColor }
372
+ ));
373
+ lineNode.add(TextNodeRenderable.fromString(
374
+ `${padLineNum(lineNum)} `,
375
+ { fg: theme.overlay, attributes: TextAttributes.DIM }
376
+ ));
69
377
 
70
- return lines.join("\n");
378
+ // Spec text — table or regular markdown
379
+ if (isTable) {
380
+ if (relIdx === tableBlock.separatorIndex) {
381
+ // Separator row → box-drawing line
382
+ renderTableSeparator(lineNode, tableBlock.colWidths);
383
+ } else {
384
+ // Header (before separator) or data (after separator)
385
+ const isHeader = tableBlock.separatorIndex >= 0 && relIdx < tableBlock.separatorIndex;
386
+ const cells = parseTableCells(specText);
387
+ renderTableRow(lineNode, cells, tableBlock.colWidths, isHeader);
388
+ }
389
+
390
+ // Bottom border after last row (on its own visual line with blank gutter)
391
+ if (relIdx === tableBlock.lines.length - 1) {
392
+ lineNode.add(TextNodeRenderable.fromString("\n", {}));
393
+ lineNode.add(TextNodeRenderable.fromString(" ", { fg: theme.overlay }));
394
+ renderTableBorder(lineNode, tableBlock.colWidths, "bottom");
395
+ }
396
+ } else if (searchQuery) {
397
+ // When searching, show raw text with search markers (no markdown styling)
398
+ const regex = new RegExp(searchQuery.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "gi");
399
+ const highlighted = specText.replace(regex, (match) => `>>${match}<<`);
400
+ lineNode.add(TextNodeRenderable.fromString(highlighted, { fg: theme.text }));
401
+ } else {
402
+ // Parse and render inline markdown
403
+ const segments = parseMarkdownLine(specText);
404
+ addSegments(lineNode, segments, theme.text);
405
+ }
406
+
407
+ // Thread hint (dimmed, inline)
408
+ if (thread && thread.messages.length > 0) {
409
+ const hint = threadHint(thread);
410
+ lineNode.add(TextNodeRenderable.fromString(
411
+ ` \u00ab ${hint}`,
412
+ { fg: theme.overlay, attributes: TextAttributes.DIM }
413
+ ));
414
+ }
415
+
416
+ // Newline between lines (except last)
417
+ if (i < state.specLines.length - 1) {
418
+ lineNode.add(TextNodeRenderable.fromString("\n", {}));
419
+ }
420
+ }
71
421
  }
72
422
 
73
- function createMarkdownStyle(): SyntaxStyle {
74
- return SyntaxStyle.fromStyles({
75
- default: { fg: parseColor(theme.text) },
76
- "markup.heading": { fg: parseColor(theme.blue), bold: true },
77
- "markup.heading.1": { fg: parseColor(theme.blue), bold: true },
78
- "markup.heading.2": { fg: parseColor(theme.blue), bold: true },
79
- "markup.heading.3": { fg: parseColor(theme.mauve), bold: true },
80
- "markup.heading.4": { fg: parseColor(theme.mauve) },
81
- "markup.heading.5": { fg: parseColor(theme.mauve) },
82
- "markup.heading.6": { fg: parseColor(theme.mauve) },
83
- "markup.bold": { fg: parseColor(theme.text), bold: true },
84
- "markup.strong": { fg: parseColor(theme.text), bold: true },
85
- "markup.italic": { fg: parseColor(theme.text), italic: true },
86
- "markup.link": { fg: parseColor(theme.blue) },
87
- "markup.link.url": { fg: parseColor(theme.blue) },
88
- "markup.list": { fg: parseColor(theme.yellow) },
89
- "markup.raw": { fg: parseColor(theme.green) },
90
- "markup.raw.inline": { fg: parseColor(theme.green) },
91
- "string": { fg: parseColor(theme.green) },
92
- "comment": { fg: parseColor(theme.overlay) },
93
- "punctuation.special": { fg: parseColor(theme.overlay) },
94
- });
423
+ // --- Visual row offset calculation ---
424
+
425
+ /**
426
+ * Count extra visual lines (table borders) before a given spec line index.
427
+ * Used to map spec line numbers to actual visual rows in the rendered content.
428
+ */
429
+ export function countExtraVisualLines(specLines: string[], cursorIndex: number): number {
430
+ let extra = 0;
431
+ let i = 0;
432
+ while (i < specLines.length) {
433
+ if (specLines[i].trimStart().startsWith("|")) {
434
+ const tableStart = i;
435
+ while (i < specLines.length && specLines[i].trimStart().startsWith("|")) {
436
+ i++;
437
+ }
438
+ const tableEnd = i; // first line AFTER the table
439
+
440
+ // Top border: rendered before first table row
441
+ if (cursorIndex >= tableStart) extra++;
442
+ // Bottom border: rendered after last table row
443
+ if (cursorIndex >= tableEnd) extra++;
444
+ continue;
445
+ }
446
+ i++;
447
+ }
448
+ return extra;
95
449
  }
96
450
 
451
+ // --- Pager components ---
452
+
97
453
  export interface PagerComponents {
98
454
  scrollBox: ScrollBoxRenderable;
99
- /** Plain text node for line mode */
100
455
  lineNode: TextRenderable;
101
- /** Rendered markdown node for reading mode */
102
- markdownNode: MarkdownRenderable;
103
- /** Current mode */
104
- mode: "markdown" | "line";
105
456
  }
106
457
 
107
458
  /**
108
- * Create the pager with both a markdown view and a line-mode view.
109
- * Only one is visible at a time. Toggle with `m`.
459
+ * Create the pager single styled line-mode view with inline markdown.
110
460
  */
111
461
  export function createPager(renderer: CliRenderer): PagerComponents {
112
- // Line mode (default) — plain text with line numbers, cursor, thread indicators
113
462
  const lineNode = new TextRenderable(renderer, {
114
463
  content: "",
115
464
  width: "100%",
@@ -118,51 +467,15 @@ export function createPager(renderer: CliRenderer): PagerComponents {
118
467
  bg: theme.base,
119
468
  });
120
469
 
121
- // Markdown mode — rendered markdown, full-width, beautiful reading
122
- const markdownNode = new MarkdownRenderable(renderer, {
123
- content: "",
124
- width: "100%",
125
- syntaxStyle: createMarkdownStyle(),
126
- conceal: true,
127
- visible: false, // hidden by default — line mode is default
128
- });
129
-
130
- // Scrollable container
131
470
  const scrollBox = new ScrollBoxRenderable(renderer, {
132
471
  width: "100%",
133
472
  flexGrow: 1,
134
- flexShrink: 1,
135
473
  scrollY: true,
136
474
  scrollX: true,
137
475
  backgroundColor: theme.base,
138
476
  });
139
477
 
140
- scrollBox.add(markdownNode);
141
478
  scrollBox.add(lineNode);
142
479
 
143
- return { scrollBox, lineNode, markdownNode, mode: "line" };
144
- }
145
-
146
- /**
147
- * Toggle between markdown and line mode.
148
- */
149
- export function togglePagerMode(pager: PagerComponents): void {
150
- if (pager.mode === "markdown") {
151
- pager.mode = "line";
152
- pager.markdownNode.visible = false;
153
- pager.lineNode.visible = true;
154
- } else {
155
- pager.mode = "markdown";
156
- pager.lineNode.visible = false;
157
- pager.markdownNode.visible = true;
158
- }
159
- }
160
-
161
- /**
162
- * Switch to line mode (for commenting).
163
- */
164
- export function ensureLineMode(pager: PagerComponents): void {
165
- if (pager.mode !== "line") {
166
- togglePagerMode(pager);
167
- }
480
+ return { scrollBox, lineNode };
168
481
  }