revspec 0.2.1 → 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/CLAUDE.md +10 -2
- package/README.md +86 -29
- package/bin/revspec.ts +2 -1
- package/package.json +1 -1
- package/scripts/install-skill.sh +20 -0
- package/scripts/release.sh +5 -6
- package/skills/revspec/SKILL.md +137 -0
- package/src/cli/reply.ts +4 -1
- package/src/cli/watch.ts +6 -0
- package/src/protocol/live-events.ts +5 -3
- package/src/tui/app.ts +64 -114
- package/src/tui/comment-input.ts +5 -82
- package/src/tui/help.ts +5 -5
- package/src/tui/pager.ts +394 -81
- package/src/tui/status-bar.ts +85 -33
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
|
-
|
|
7
|
-
|
|
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
|
-
*
|
|
31
|
-
*
|
|
37
|
+
* Parse inline markdown (bold, italic, code) into styled segments.
|
|
38
|
+
* Strips syntax markers and returns display text with style info.
|
|
32
39
|
*/
|
|
33
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
65
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
|
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
|
|
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
|
}
|