revspec 0.8.3 → 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 +32 -7
- package/src/tui/help.ts +1 -0
- package/src/tui/pager.ts +108 -11
- package/src/tui/search.ts +10 -0
- package/src/tui/ui/markdown.ts +1 -1
package/package.json
CHANGED
package/src/tui/app.ts
CHANGED
|
@@ -108,6 +108,12 @@ export async function runTui(
|
|
|
108
108
|
rootBox.add(bottomBar.box);
|
|
109
109
|
renderer.root.add(rootBox);
|
|
110
110
|
|
|
111
|
+
// Wrap mode state
|
|
112
|
+
let wrapEnabled = false;
|
|
113
|
+
function currentWrapWidth(): number {
|
|
114
|
+
return wrapEnabled ? renderer.width : 0;
|
|
115
|
+
}
|
|
116
|
+
|
|
111
117
|
// 7. Initial render
|
|
112
118
|
function refreshPager(): void {
|
|
113
119
|
// Spec mutation guard
|
|
@@ -118,12 +124,21 @@ export async function runTui(
|
|
|
118
124
|
}
|
|
119
125
|
} catch {}
|
|
120
126
|
|
|
121
|
-
buildPagerNodes(pager.lineNode, state, searchQuery, state.unreadThreadIds);
|
|
127
|
+
buildPagerNodes(pager.lineNode, state, searchQuery, state.unreadThreadIds, currentWrapWidth());
|
|
122
128
|
buildTopBar(topBar, specFile, state, state.unreadCount(), specMtimeChanged);
|
|
123
129
|
// Don't overwrite transient messages (welcome hint, warnings) during navigation
|
|
124
130
|
if (!messageTimer) {
|
|
125
|
-
const
|
|
126
|
-
|
|
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
|
+
}
|
|
127
142
|
}
|
|
128
143
|
renderer.requestRender();
|
|
129
144
|
}
|
|
@@ -171,7 +186,7 @@ export async function runTui(
|
|
|
171
186
|
// Map visual row back to spec line number (for H/M/L)
|
|
172
187
|
function visualRowToSpecLine(targetRow: number): number {
|
|
173
188
|
for (let i = 0; i < state.specLines.length; i++) {
|
|
174
|
-
const row = i + countExtraVisualLines(state.specLines, i);
|
|
189
|
+
const row = i + countExtraVisualLines(state.specLines, i, currentWrapWidth());
|
|
175
190
|
if (row >= targetRow) return i + 1;
|
|
176
191
|
}
|
|
177
192
|
return state.lineCount;
|
|
@@ -219,7 +234,7 @@ export async function runTui(
|
|
|
219
234
|
// Helper: scroll pager to ensure cursor line is visible
|
|
220
235
|
function ensureCursorVisible(): void {
|
|
221
236
|
// Map spec line to visual row, accounting for table border extra lines
|
|
222
|
-
const extra = countExtraVisualLines(state.specLines, state.cursorLine - 1);
|
|
237
|
+
const extra = countExtraVisualLines(state.specLines, state.cursorLine - 1, currentWrapWidth());
|
|
223
238
|
const cursorRow = state.cursorLine - 1 + extra;
|
|
224
239
|
const viewportHeight = Math.max(1, renderer.height - 2); // minus top + bottom bar
|
|
225
240
|
|
|
@@ -254,6 +269,13 @@ export async function runTui(
|
|
|
254
269
|
exitTui(resolve, "session-end");
|
|
255
270
|
return "exit";
|
|
256
271
|
}
|
|
272
|
+
// :wrap — toggle line wrapping
|
|
273
|
+
if (cmd === "wrap") {
|
|
274
|
+
wrapEnabled = !wrapEnabled;
|
|
275
|
+
refreshPager();
|
|
276
|
+
showTransient(wrapEnabled ? "Line wrap on" : "Line wrap off", "info");
|
|
277
|
+
return "stay";
|
|
278
|
+
}
|
|
257
279
|
// :{N} — jump to line number
|
|
258
280
|
const lineNum = parseInt(cmd, 10);
|
|
259
281
|
if (!isNaN(lineNum) && lineNum > 0) {
|
|
@@ -338,8 +360,11 @@ export async function runTui(
|
|
|
338
360
|
searchQuery = query;
|
|
339
361
|
savePrevPosition();
|
|
340
362
|
state.cursorLine = lineNumber;
|
|
341
|
-
dismissOverlay();
|
|
342
363
|
ensureCursorVisible();
|
|
364
|
+
dismissOverlay(); // calls refreshPager with cursor + scroll already set
|
|
365
|
+
},
|
|
366
|
+
onPreview: (query: string | null) => {
|
|
367
|
+
searchQuery = query;
|
|
343
368
|
refreshPager();
|
|
344
369
|
},
|
|
345
370
|
onCancel: () => {
|
|
@@ -636,7 +661,7 @@ export async function runTui(
|
|
|
636
661
|
refreshPager();
|
|
637
662
|
break;
|
|
638
663
|
case "center-cursor": {
|
|
639
|
-
const extra = countExtraVisualLines(state.specLines, state.cursorLine - 1);
|
|
664
|
+
const extra = countExtraVisualLines(state.specLines, state.cursorLine - 1, currentWrapWidth());
|
|
640
665
|
const cursorRow = state.cursorLine - 1 + extra;
|
|
641
666
|
const halfView = Math.floor(pageSize() / 2);
|
|
642
667
|
pager.scrollBox.scrollTo(Math.max(0, cursorRow - halfView));
|
package/src/tui/help.ts
CHANGED
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
|
|
|
@@ -53,20 +53,92 @@ export function buildPagerContent(state: ReviewState, searchQuery?: string | nul
|
|
|
53
53
|
* Inline markdown is parsed and styled per line.
|
|
54
54
|
* Line numbers and thread hints are dimmed.
|
|
55
55
|
*/
|
|
56
|
-
|
|
56
|
+
/**
|
|
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).
|
|
104
|
+
*/
|
|
105
|
+
function wordWrap(text: string, width: number): string[] {
|
|
106
|
+
if (width <= 0 || text.length <= width) return [text];
|
|
107
|
+
const lines: string[] = [];
|
|
108
|
+
let remaining = text;
|
|
109
|
+
while (remaining.length > width) {
|
|
110
|
+
let breakAt = remaining.lastIndexOf(" ", width);
|
|
111
|
+
if (breakAt <= 0) breakAt = width;
|
|
112
|
+
lines.push(remaining.slice(0, breakAt));
|
|
113
|
+
remaining = remaining.slice(breakAt).replace(/^ /, "");
|
|
114
|
+
}
|
|
115
|
+
if (remaining.length > 0) lines.push(remaining);
|
|
116
|
+
return lines;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function buildPagerNodes(lineNode: TextRenderable, state: ReviewState, searchQuery?: string | null, unreadThreadIds?: ReadonlySet<string>, wrapWidth?: number): void {
|
|
57
120
|
lineNode.clear();
|
|
58
121
|
|
|
59
122
|
// Calculate dynamic gutter width based on total line count
|
|
60
123
|
const numWidth = Math.max(String(state.lineCount).length, 3);
|
|
61
|
-
|
|
62
|
-
|
|
124
|
+
const gutterWidth = 2 + numWidth + 2; // prefix(1) + indicator(1) + numWidth + spaces(2)
|
|
125
|
+
// Blank gutter for table borders and wrap continuations
|
|
126
|
+
const gutterBlank = " ".repeat(gutterWidth);
|
|
127
|
+
// Available content width for wrapping
|
|
128
|
+
const contentWidth = wrapWidth && wrapWidth > gutterWidth ? wrapWidth - gutterWidth : 0;
|
|
63
129
|
|
|
64
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
|
|
65
132
|
const tableBlocks = new Map<number, TableBlock>();
|
|
133
|
+
let preScanCodeBlock = false;
|
|
66
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;
|
|
67
140
|
if (state.specLines[i].trimStart().startsWith("|") && !tableBlocks.has(i)) {
|
|
68
141
|
const block = collectTable(state.specLines, i);
|
|
69
|
-
// Mark all lines in this block
|
|
70
142
|
for (let j = 0; j < block.lines.length; j++) {
|
|
71
143
|
tableBlocks.set(i + j, block);
|
|
72
144
|
}
|
|
@@ -180,9 +252,20 @@ export function buildPagerNodes(lineNode: TextRenderable, state: ReviewState, se
|
|
|
180
252
|
}
|
|
181
253
|
}
|
|
182
254
|
} else {
|
|
183
|
-
// Parse
|
|
255
|
+
// Parse inline markdown, then wrap if needed
|
|
184
256
|
const segments = parseMarkdownLine(specText);
|
|
185
|
-
|
|
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
|
+
}
|
|
186
269
|
}
|
|
187
270
|
|
|
188
271
|
// Newline between lines (except last)
|
|
@@ -195,14 +278,24 @@ export function buildPagerNodes(lineNode: TextRenderable, state: ReviewState, se
|
|
|
195
278
|
// --- Visual row offset calculation ---
|
|
196
279
|
|
|
197
280
|
/**
|
|
198
|
-
* Count extra visual lines (table borders) before a given spec line index.
|
|
281
|
+
* Count extra visual lines (table borders + word wrap) before a given spec line index.
|
|
199
282
|
* Used to map spec line numbers to actual visual rows in the rendered content.
|
|
200
283
|
*/
|
|
201
|
-
export function countExtraVisualLines(specLines: string[], cursorIndex: number): number {
|
|
284
|
+
export function countExtraVisualLines(specLines: string[], cursorIndex: number, wrapWidth?: number): number {
|
|
285
|
+
const numWidth = Math.max(String(specLines.length).length, 3);
|
|
286
|
+
const gutterWidth = 2 + numWidth + 2;
|
|
287
|
+
const contentWidth = wrapWidth && wrapWidth > gutterWidth ? wrapWidth - gutterWidth : 0;
|
|
288
|
+
|
|
202
289
|
let extra = 0;
|
|
203
290
|
let i = 0;
|
|
291
|
+
let inCode = false;
|
|
204
292
|
while (i < specLines.length) {
|
|
205
|
-
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("|")) {
|
|
206
299
|
const tableStart = i;
|
|
207
300
|
while (i < specLines.length && specLines[i].trimStart().startsWith("|")) {
|
|
208
301
|
i++;
|
|
@@ -215,6 +308,10 @@ export function countExtraVisualLines(specLines: string[], cursorIndex: number):
|
|
|
215
308
|
if (cursorIndex >= tableEnd) extra++;
|
|
216
309
|
continue;
|
|
217
310
|
}
|
|
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) {
|
|
313
|
+
extra += wordWrap(specLines[i], contentWidth).length - 1;
|
|
314
|
+
}
|
|
218
315
|
i++;
|
|
219
316
|
}
|
|
220
317
|
return extra;
|
|
@@ -243,7 +340,7 @@ export function createPager(renderer: CliRenderer): PagerComponents {
|
|
|
243
340
|
width: "100%",
|
|
244
341
|
flexGrow: 1,
|
|
245
342
|
scrollY: true,
|
|
246
|
-
scrollX:
|
|
343
|
+
scrollX: false,
|
|
247
344
|
backgroundColor: theme.base,
|
|
248
345
|
});
|
|
249
346
|
|
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") {
|
package/src/tui/ui/markdown.ts
CHANGED
|
@@ -82,7 +82,7 @@ export function parseMarkdownLine(line: string): StyledSegment[] {
|
|
|
82
82
|
const headingMatch = line.match(/^(#{1,6})\s+(.*)/);
|
|
83
83
|
if (headingMatch) {
|
|
84
84
|
const level = headingMatch[1].length;
|
|
85
|
-
const color = level <= 2 ? theme.blue : theme.mauve;
|
|
85
|
+
const color = level <= 2 ? theme.blue : level === 3 ? theme.mauve : theme.textMuted;
|
|
86
86
|
// Parse inline markdown within heading text
|
|
87
87
|
const inner = parseInlineMarkdown(headingMatch[2]);
|
|
88
88
|
return inner.map((s) => ({
|