revspec 0.8.4 → 0.8.5
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/package.json +1 -1
- package/src/tui/app.ts +15 -3
- package/src/tui/pager.ts +79 -21
- package/src/tui/search.ts +10 -0
package/package.json
CHANGED
package/src/tui/app.ts
CHANGED
|
@@ -128,8 +128,17 @@ export async function runTui(
|
|
|
128
128
|
buildTopBar(topBar, specFile, state, state.unreadCount(), specMtimeChanged);
|
|
129
129
|
// Don't overwrite transient messages (welcome hint, warnings) during navigation
|
|
130
130
|
if (!messageTimer) {
|
|
131
|
-
const
|
|
132
|
-
|
|
131
|
+
const curThread = state.threadAtLine(state.cursorLine);
|
|
132
|
+
if (curThread && curThread.messages.length > 0 && commandBuffer === null) {
|
|
133
|
+
// Show thread preview in bottom bar
|
|
134
|
+
const first = curThread.messages[0].text.replace(/\n/g, " ");
|
|
135
|
+
const replies = curThread.messages.length - 1;
|
|
136
|
+
const preview = first.length > 60 ? first.slice(0, 59) + "\u2026" : first;
|
|
137
|
+
const replyStr = replies > 0 ? ` (${replies} repl${replies === 1 ? "y" : "ies"})` : "";
|
|
138
|
+
setBottomBarMessage(bottomBar, `${preview}${replyStr} [${curThread.status}]`);
|
|
139
|
+
} else {
|
|
140
|
+
buildBottomBar(bottomBar, commandBuffer, !!curThread);
|
|
141
|
+
}
|
|
133
142
|
}
|
|
134
143
|
renderer.requestRender();
|
|
135
144
|
}
|
|
@@ -351,8 +360,11 @@ export async function runTui(
|
|
|
351
360
|
searchQuery = query;
|
|
352
361
|
savePrevPosition();
|
|
353
362
|
state.cursorLine = lineNumber;
|
|
354
|
-
dismissOverlay();
|
|
355
363
|
ensureCursorVisible();
|
|
364
|
+
dismissOverlay(); // calls refreshPager with cursor + scroll already set
|
|
365
|
+
},
|
|
366
|
+
onPreview: (query: string | null) => {
|
|
367
|
+
searchQuery = query;
|
|
356
368
|
refreshPager();
|
|
357
369
|
},
|
|
358
370
|
onCancel: () => {
|
package/src/tui/pager.ts
CHANGED
|
@@ -7,7 +7,7 @@ import {
|
|
|
7
7
|
type CliRenderer,
|
|
8
8
|
} from "@opentui/core";
|
|
9
9
|
import { theme } from "./ui/theme";
|
|
10
|
-
import { parseMarkdownLine, addSegments, collectTable, renderTableBorder, renderTableSeparator, renderTableRow, parseTableCells, type TableBlock } from "./ui/markdown";
|
|
10
|
+
import { parseMarkdownLine, addSegments, collectTable, renderTableBorder, renderTableSeparator, renderTableRow, parseTableCells, type TableBlock, type StyledSegment } from "./ui/markdown";
|
|
11
11
|
|
|
12
12
|
// --- Plain text builder (for tests) ---
|
|
13
13
|
|
|
@@ -54,7 +54,53 @@ export function buildPagerContent(state: ReviewState, searchQuery?: string | nul
|
|
|
54
54
|
* Line numbers and thread hints are dimmed.
|
|
55
55
|
*/
|
|
56
56
|
/**
|
|
57
|
-
*
|
|
57
|
+
* Wrap pre-parsed markdown segments at the given width.
|
|
58
|
+
* Breaks at segment boundaries when possible, word boundaries within segments otherwise.
|
|
59
|
+
* Returns an array of segment arrays (one per visual line).
|
|
60
|
+
*/
|
|
61
|
+
function wrapSegments(segments: StyledSegment[], width: number): StyledSegment[][] {
|
|
62
|
+
if (width <= 0) return [segments];
|
|
63
|
+
// Check total length
|
|
64
|
+
let totalLen = 0;
|
|
65
|
+
for (const s of segments) totalLen += s.text.length;
|
|
66
|
+
if (totalLen <= width) return [segments];
|
|
67
|
+
|
|
68
|
+
const lines: StyledSegment[][] = [];
|
|
69
|
+
let curLine: StyledSegment[] = [];
|
|
70
|
+
let curWidth = 0;
|
|
71
|
+
|
|
72
|
+
for (const seg of segments) {
|
|
73
|
+
if (curWidth + seg.text.length <= width) {
|
|
74
|
+
curLine.push(seg);
|
|
75
|
+
curWidth += seg.text.length;
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
// Segment doesn't fit — break it at word boundaries
|
|
79
|
+
let remaining = seg.text;
|
|
80
|
+
while (remaining.length > 0) {
|
|
81
|
+
const avail = width - curWidth;
|
|
82
|
+
if (remaining.length <= avail) {
|
|
83
|
+
curLine.push({ ...seg, text: remaining });
|
|
84
|
+
curWidth += remaining.length;
|
|
85
|
+
break;
|
|
86
|
+
}
|
|
87
|
+
if (avail > 0) {
|
|
88
|
+
let breakAt = remaining.lastIndexOf(" ", avail);
|
|
89
|
+
if (breakAt <= 0) breakAt = avail; // hard break
|
|
90
|
+
curLine.push({ ...seg, text: remaining.slice(0, breakAt) });
|
|
91
|
+
remaining = remaining.slice(breakAt).replace(/^ /, "");
|
|
92
|
+
}
|
|
93
|
+
lines.push(curLine);
|
|
94
|
+
curLine = [];
|
|
95
|
+
curWidth = 0;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
if (curLine.length > 0) lines.push(curLine);
|
|
99
|
+
return lines;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Word-wrap a raw string (for countExtraVisualLines estimation).
|
|
58
104
|
*/
|
|
59
105
|
function wordWrap(text: string, width: number): string[] {
|
|
60
106
|
if (width <= 0 || text.length <= width) return [text];
|
|
@@ -62,9 +108,9 @@ function wordWrap(text: string, width: number): string[] {
|
|
|
62
108
|
let remaining = text;
|
|
63
109
|
while (remaining.length > width) {
|
|
64
110
|
let breakAt = remaining.lastIndexOf(" ", width);
|
|
65
|
-
if (breakAt <= 0) breakAt = width;
|
|
111
|
+
if (breakAt <= 0) breakAt = width;
|
|
66
112
|
lines.push(remaining.slice(0, breakAt));
|
|
67
|
-
remaining = remaining.slice(breakAt).replace(/^ /, "");
|
|
113
|
+
remaining = remaining.slice(breakAt).replace(/^ /, "");
|
|
68
114
|
}
|
|
69
115
|
if (remaining.length > 0) lines.push(remaining);
|
|
70
116
|
return lines;
|
|
@@ -82,11 +128,17 @@ export function buildPagerNodes(lineNode: TextRenderable, state: ReviewState, se
|
|
|
82
128
|
const contentWidth = wrapWidth && wrapWidth > gutterWidth ? wrapWidth - gutterWidth : 0;
|
|
83
129
|
|
|
84
130
|
// Pre-scan for table blocks so we can calculate column widths
|
|
131
|
+
// Skip lines inside fenced code blocks — pipes in code are not tables
|
|
85
132
|
const tableBlocks = new Map<number, TableBlock>();
|
|
133
|
+
let preScanCodeBlock = false;
|
|
86
134
|
for (let i = 0; i < state.specLines.length; i++) {
|
|
135
|
+
if (state.specLines[i].trimStart().startsWith("```")) {
|
|
136
|
+
preScanCodeBlock = !preScanCodeBlock;
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
if (preScanCodeBlock) continue;
|
|
87
140
|
if (state.specLines[i].trimStart().startsWith("|") && !tableBlocks.has(i)) {
|
|
88
141
|
const block = collectTable(state.specLines, i);
|
|
89
|
-
// Mark all lines in this block
|
|
90
142
|
for (let j = 0; j < block.lines.length; j++) {
|
|
91
143
|
tableBlocks.set(i + j, block);
|
|
92
144
|
}
|
|
@@ -199,21 +251,21 @@ export function buildPagerNodes(lineNode: TextRenderable, state: ReviewState, se
|
|
|
199
251
|
lineNode.add(TextNodeRenderable.fromString(part, { fg: theme.text, bg: isCursor ? theme.backgroundElement : undefined }));
|
|
200
252
|
}
|
|
201
253
|
}
|
|
202
|
-
} else if (contentWidth > 0 && specText.length > contentWidth) {
|
|
203
|
-
// Wrap long lines — first chunk gets markdown, continuations get blank gutter + markdown
|
|
204
|
-
const chunks = wordWrap(specText, contentWidth);
|
|
205
|
-
const segments = parseMarkdownLine(chunks[0]);
|
|
206
|
-
addSegments(lineNode, segments, theme.text, isCursor ? theme.backgroundElement : undefined);
|
|
207
|
-
for (let c = 1; c < chunks.length; c++) {
|
|
208
|
-
lineNode.add(TextNodeRenderable.fromString("\n", {}));
|
|
209
|
-
lineNode.add(TextNodeRenderable.fromString(gutterBlank, { fg: theme.textDim }));
|
|
210
|
-
const contSegments = parseMarkdownLine(chunks[c]);
|
|
211
|
-
addSegments(lineNode, contSegments, theme.text, isCursor ? theme.backgroundElement : undefined);
|
|
212
|
-
}
|
|
213
254
|
} else {
|
|
214
|
-
// Parse
|
|
255
|
+
// Parse inline markdown, then wrap if needed
|
|
215
256
|
const segments = parseMarkdownLine(specText);
|
|
216
|
-
|
|
257
|
+
const bg = isCursor ? theme.backgroundElement : undefined;
|
|
258
|
+
if (contentWidth > 0 && specText.length > contentWidth) {
|
|
259
|
+
const wrapped = wrapSegments(segments, contentWidth);
|
|
260
|
+
addSegments(lineNode, wrapped[0], theme.text, bg);
|
|
261
|
+
for (let c = 1; c < wrapped.length; c++) {
|
|
262
|
+
lineNode.add(TextNodeRenderable.fromString("\n", {}));
|
|
263
|
+
lineNode.add(TextNodeRenderable.fromString(gutterBlank, { fg: theme.textDim }));
|
|
264
|
+
addSegments(lineNode, wrapped[c], theme.text, bg);
|
|
265
|
+
}
|
|
266
|
+
} else {
|
|
267
|
+
addSegments(lineNode, segments, theme.text, bg);
|
|
268
|
+
}
|
|
217
269
|
}
|
|
218
270
|
|
|
219
271
|
// Newline between lines (except last)
|
|
@@ -236,8 +288,14 @@ export function countExtraVisualLines(specLines: string[], cursorIndex: number,
|
|
|
236
288
|
|
|
237
289
|
let extra = 0;
|
|
238
290
|
let i = 0;
|
|
291
|
+
let inCode = false;
|
|
239
292
|
while (i < specLines.length) {
|
|
240
|
-
if (specLines[i].trimStart().startsWith("
|
|
293
|
+
if (specLines[i].trimStart().startsWith("```")) {
|
|
294
|
+
inCode = !inCode;
|
|
295
|
+
i++;
|
|
296
|
+
continue;
|
|
297
|
+
}
|
|
298
|
+
if (!inCode && specLines[i].trimStart().startsWith("|")) {
|
|
241
299
|
const tableStart = i;
|
|
242
300
|
while (i < specLines.length && specLines[i].trimStart().startsWith("|")) {
|
|
243
301
|
i++;
|
|
@@ -250,8 +308,8 @@ export function countExtraVisualLines(specLines: string[], cursorIndex: number,
|
|
|
250
308
|
if (cursorIndex >= tableEnd) extra++;
|
|
251
309
|
continue;
|
|
252
310
|
}
|
|
253
|
-
// Word wrap: count extra continuation lines
|
|
254
|
-
if (contentWidth > 0 && i < cursorIndex && specLines[i].length > contentWidth) {
|
|
311
|
+
// Word wrap: count extra continuation lines (not in code blocks — those render unwrapped)
|
|
312
|
+
if (!inCode && contentWidth > 0 && i < cursorIndex && specLines[i].length > contentWidth) {
|
|
255
313
|
extra += wordWrap(specLines[i], contentWidth).length - 1;
|
|
256
314
|
}
|
|
257
315
|
i++;
|
package/src/tui/search.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import {
|
|
2
2
|
BoxRenderable,
|
|
3
3
|
InputRenderable,
|
|
4
|
+
InputRenderableEvents,
|
|
4
5
|
TextRenderable,
|
|
5
6
|
type CliRenderer,
|
|
6
7
|
type KeyEvent,
|
|
@@ -12,6 +13,7 @@ export interface SearchOptions {
|
|
|
12
13
|
specLines: string[];
|
|
13
14
|
cursorLine: number;
|
|
14
15
|
onResult: (lineNumber: number, query: string) => void;
|
|
16
|
+
onPreview?: (query: string | null) => void;
|
|
15
17
|
onCancel: () => void;
|
|
16
18
|
}
|
|
17
19
|
|
|
@@ -72,6 +74,14 @@ export function createSearch(opts: SearchOptions): SearchOverlay {
|
|
|
72
74
|
renderer.requestRender();
|
|
73
75
|
}, 0);
|
|
74
76
|
|
|
77
|
+
// Incremental search — preview highlights after 3+ characters
|
|
78
|
+
if (opts.onPreview) {
|
|
79
|
+
input.on(InputRenderableEvents.INPUT, () => {
|
|
80
|
+
const raw = input.value.trim();
|
|
81
|
+
opts.onPreview!(raw.length >= 3 ? raw : null);
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
75
85
|
// Key handler
|
|
76
86
|
const keyHandler = (key: KeyEvent) => {
|
|
77
87
|
if (key.name === "escape") {
|