revspec 0.8.4 → 0.8.6
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/bin/revspec.ts +1 -2
- package/package.json +1 -1
- package/src/cli/watch.ts +12 -1
- package/src/tui/app.ts +15 -3
- package/src/tui/pager.ts +83 -34
- package/src/tui/search.ts +10 -0
- package/src/tui/ui/theme.ts +5 -5
package/bin/revspec.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { existsSync } from "fs";
|
|
3
3
|
import { resolve, basename, extname, dirname, join } from "path";
|
|
4
4
|
import { runTui } from "../src/tui/app";
|
|
5
|
+
import pkg from "../package.json";
|
|
5
6
|
|
|
6
7
|
const args = process.argv.slice(2);
|
|
7
8
|
const subcommand = args[0];
|
|
@@ -36,7 +37,6 @@ if (args.length === 0 || args.includes("--help") || args.includes("-h")) {
|
|
|
36
37
|
}
|
|
37
38
|
|
|
38
39
|
if (args.includes("--version") || args.includes("-v")) {
|
|
39
|
-
const pkg = await Bun.file(new URL("../package.json", import.meta.url)).json();
|
|
40
40
|
console.log(`revspec ${pkg.version}`);
|
|
41
41
|
process.exit(0);
|
|
42
42
|
}
|
|
@@ -55,7 +55,6 @@ if (!existsSync(specPath)) {
|
|
|
55
55
|
}
|
|
56
56
|
|
|
57
57
|
// 2. Launch TUI
|
|
58
|
-
const pkg = await Bun.file(new URL("../package.json", import.meta.url)).json();
|
|
59
58
|
await runTui(specPath, pkg.version);
|
|
60
59
|
|
|
61
60
|
process.exit(0);
|
package/package.json
CHANGED
package/src/cli/watch.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { existsSync, readFileSync, writeFileSync, unlinkSync, renameSync } from "fs";
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync, unlinkSync, renameSync, statSync } from "fs";
|
|
2
2
|
import { watch as fsWatch } from "fs";
|
|
3
3
|
import { resolve, dirname, basename, join } from "path";
|
|
4
4
|
import {
|
|
@@ -69,6 +69,17 @@ export async function runWatch(specFile: string): Promise<void> {
|
|
|
69
69
|
}
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
+
// Reset offset if JSONL was deleted/recreated/truncated (file smaller than offset)
|
|
73
|
+
if (existsSync(jsonlPath)) {
|
|
74
|
+
if (offset > statSync(jsonlPath).size) {
|
|
75
|
+
offset = 0;
|
|
76
|
+
lastSubmitTs = 0;
|
|
77
|
+
}
|
|
78
|
+
} else {
|
|
79
|
+
offset = 0;
|
|
80
|
+
lastSubmitTs = 0;
|
|
81
|
+
}
|
|
82
|
+
|
|
72
83
|
// Read spec lines for context
|
|
73
84
|
const specLines = readFileSync(specPath, "utf8").split("\n");
|
|
74
85
|
|
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
|
|
|
@@ -30,14 +30,7 @@ export function buildPagerContent(state: ReviewState, searchQuery?: string | nul
|
|
|
30
30
|
}
|
|
31
31
|
let indicator = " ";
|
|
32
32
|
if (thread) {
|
|
33
|
-
|
|
34
|
-
if (isUnread) {
|
|
35
|
-
indicator = "\u2588";
|
|
36
|
-
} else if (thread.status === "resolved") {
|
|
37
|
-
indicator = "=";
|
|
38
|
-
} else {
|
|
39
|
-
indicator = "\u258c";
|
|
40
|
-
}
|
|
33
|
+
indicator = "\u2588"; // █ full block for all statuses
|
|
41
34
|
}
|
|
42
35
|
const numStr = String(lineNum);
|
|
43
36
|
const padded = " ".repeat(numWidth - numStr.length) + numStr;
|
|
@@ -54,7 +47,53 @@ export function buildPagerContent(state: ReviewState, searchQuery?: string | nul
|
|
|
54
47
|
* Line numbers and thread hints are dimmed.
|
|
55
48
|
*/
|
|
56
49
|
/**
|
|
57
|
-
*
|
|
50
|
+
* Wrap pre-parsed markdown segments at the given width.
|
|
51
|
+
* Breaks at segment boundaries when possible, word boundaries within segments otherwise.
|
|
52
|
+
* Returns an array of segment arrays (one per visual line).
|
|
53
|
+
*/
|
|
54
|
+
function wrapSegments(segments: StyledSegment[], width: number): StyledSegment[][] {
|
|
55
|
+
if (width <= 0) return [segments];
|
|
56
|
+
// Check total length
|
|
57
|
+
let totalLen = 0;
|
|
58
|
+
for (const s of segments) totalLen += s.text.length;
|
|
59
|
+
if (totalLen <= width) return [segments];
|
|
60
|
+
|
|
61
|
+
const lines: StyledSegment[][] = [];
|
|
62
|
+
let curLine: StyledSegment[] = [];
|
|
63
|
+
let curWidth = 0;
|
|
64
|
+
|
|
65
|
+
for (const seg of segments) {
|
|
66
|
+
if (curWidth + seg.text.length <= width) {
|
|
67
|
+
curLine.push(seg);
|
|
68
|
+
curWidth += seg.text.length;
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
// Segment doesn't fit — break it at word boundaries
|
|
72
|
+
let remaining = seg.text;
|
|
73
|
+
while (remaining.length > 0) {
|
|
74
|
+
const avail = width - curWidth;
|
|
75
|
+
if (remaining.length <= avail) {
|
|
76
|
+
curLine.push({ ...seg, text: remaining });
|
|
77
|
+
curWidth += remaining.length;
|
|
78
|
+
break;
|
|
79
|
+
}
|
|
80
|
+
if (avail > 0) {
|
|
81
|
+
let breakAt = remaining.lastIndexOf(" ", avail);
|
|
82
|
+
if (breakAt <= 0) breakAt = avail; // hard break
|
|
83
|
+
curLine.push({ ...seg, text: remaining.slice(0, breakAt) });
|
|
84
|
+
remaining = remaining.slice(breakAt).replace(/^ /, "");
|
|
85
|
+
}
|
|
86
|
+
lines.push(curLine);
|
|
87
|
+
curLine = [];
|
|
88
|
+
curWidth = 0;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
if (curLine.length > 0) lines.push(curLine);
|
|
92
|
+
return lines;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Word-wrap a raw string (for countExtraVisualLines estimation).
|
|
58
97
|
*/
|
|
59
98
|
function wordWrap(text: string, width: number): string[] {
|
|
60
99
|
if (width <= 0 || text.length <= width) return [text];
|
|
@@ -62,9 +101,9 @@ function wordWrap(text: string, width: number): string[] {
|
|
|
62
101
|
let remaining = text;
|
|
63
102
|
while (remaining.length > width) {
|
|
64
103
|
let breakAt = remaining.lastIndexOf(" ", width);
|
|
65
|
-
if (breakAt <= 0) breakAt = width;
|
|
104
|
+
if (breakAt <= 0) breakAt = width;
|
|
66
105
|
lines.push(remaining.slice(0, breakAt));
|
|
67
|
-
remaining = remaining.slice(breakAt).replace(/^ /, "");
|
|
106
|
+
remaining = remaining.slice(breakAt).replace(/^ /, "");
|
|
68
107
|
}
|
|
69
108
|
if (remaining.length > 0) lines.push(remaining);
|
|
70
109
|
return lines;
|
|
@@ -82,11 +121,17 @@ export function buildPagerNodes(lineNode: TextRenderable, state: ReviewState, se
|
|
|
82
121
|
const contentWidth = wrapWidth && wrapWidth > gutterWidth ? wrapWidth - gutterWidth : 0;
|
|
83
122
|
|
|
84
123
|
// Pre-scan for table blocks so we can calculate column widths
|
|
124
|
+
// Skip lines inside fenced code blocks — pipes in code are not tables
|
|
85
125
|
const tableBlocks = new Map<number, TableBlock>();
|
|
126
|
+
let preScanCodeBlock = false;
|
|
86
127
|
for (let i = 0; i < state.specLines.length; i++) {
|
|
128
|
+
if (state.specLines[i].trimStart().startsWith("```")) {
|
|
129
|
+
preScanCodeBlock = !preScanCodeBlock;
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
if (preScanCodeBlock) continue;
|
|
87
133
|
if (state.specLines[i].trimStart().startsWith("|") && !tableBlocks.has(i)) {
|
|
88
134
|
const block = collectTable(state.specLines, i);
|
|
89
|
-
// Mark all lines in this block
|
|
90
135
|
for (let j = 0; j < block.lines.length; j++) {
|
|
91
136
|
tableBlocks.set(i + j, block);
|
|
92
137
|
}
|
|
@@ -104,20 +149,18 @@ export function buildPagerNodes(lineNode: TextRenderable, state: ReviewState, se
|
|
|
104
149
|
const prefix = isCursor ? ">" : " ";
|
|
105
150
|
const specText = state.specLines[i];
|
|
106
151
|
|
|
107
|
-
// Thread indicator — gutter bar on the left
|
|
152
|
+
// Thread indicator — gutter bar on the left (all █, color-coded)
|
|
108
153
|
let indicator = " ";
|
|
109
154
|
let indicatorColor: string = theme.textDim;
|
|
110
155
|
if (thread) {
|
|
156
|
+
indicator = "\u2588"; // █ full block
|
|
111
157
|
const isUnread = unreadThreadIds && unreadThreadIds.has(thread.id);
|
|
112
158
|
if (isUnread) {
|
|
113
|
-
indicator = "\u2588"; // █ full block — unread reply
|
|
114
159
|
indicatorColor = theme.yellow;
|
|
115
160
|
} else if (thread.status === "resolved") {
|
|
116
|
-
indicator = "="; // resolved
|
|
117
161
|
indicatorColor = theme.green;
|
|
118
162
|
} else {
|
|
119
|
-
|
|
120
|
-
indicatorColor = theme.blue;
|
|
163
|
+
indicatorColor = theme.text; // open — white
|
|
121
164
|
}
|
|
122
165
|
}
|
|
123
166
|
|
|
@@ -199,21 +242,21 @@ export function buildPagerNodes(lineNode: TextRenderable, state: ReviewState, se
|
|
|
199
242
|
lineNode.add(TextNodeRenderable.fromString(part, { fg: theme.text, bg: isCursor ? theme.backgroundElement : undefined }));
|
|
200
243
|
}
|
|
201
244
|
}
|
|
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
245
|
} else {
|
|
214
|
-
// Parse
|
|
246
|
+
// Parse inline markdown, then wrap if needed
|
|
215
247
|
const segments = parseMarkdownLine(specText);
|
|
216
|
-
|
|
248
|
+
const bg = isCursor ? theme.backgroundElement : undefined;
|
|
249
|
+
if (contentWidth > 0 && specText.length > contentWidth) {
|
|
250
|
+
const wrapped = wrapSegments(segments, contentWidth);
|
|
251
|
+
addSegments(lineNode, wrapped[0], theme.text, bg);
|
|
252
|
+
for (let c = 1; c < wrapped.length; c++) {
|
|
253
|
+
lineNode.add(TextNodeRenderable.fromString("\n", {}));
|
|
254
|
+
lineNode.add(TextNodeRenderable.fromString(gutterBlank, { fg: theme.textDim }));
|
|
255
|
+
addSegments(lineNode, wrapped[c], theme.text, bg);
|
|
256
|
+
}
|
|
257
|
+
} else {
|
|
258
|
+
addSegments(lineNode, segments, theme.text, bg);
|
|
259
|
+
}
|
|
217
260
|
}
|
|
218
261
|
|
|
219
262
|
// Newline between lines (except last)
|
|
@@ -236,8 +279,14 @@ export function countExtraVisualLines(specLines: string[], cursorIndex: number,
|
|
|
236
279
|
|
|
237
280
|
let extra = 0;
|
|
238
281
|
let i = 0;
|
|
282
|
+
let inCode = false;
|
|
239
283
|
while (i < specLines.length) {
|
|
240
|
-
if (specLines[i].trimStart().startsWith("
|
|
284
|
+
if (specLines[i].trimStart().startsWith("```")) {
|
|
285
|
+
inCode = !inCode;
|
|
286
|
+
i++;
|
|
287
|
+
continue;
|
|
288
|
+
}
|
|
289
|
+
if (!inCode && specLines[i].trimStart().startsWith("|")) {
|
|
241
290
|
const tableStart = i;
|
|
242
291
|
while (i < specLines.length && specLines[i].trimStart().startsWith("|")) {
|
|
243
292
|
i++;
|
|
@@ -250,8 +299,8 @@ export function countExtraVisualLines(specLines: string[], cursorIndex: number,
|
|
|
250
299
|
if (cursorIndex >= tableEnd) extra++;
|
|
251
300
|
continue;
|
|
252
301
|
}
|
|
253
|
-
// Word wrap: count extra continuation lines
|
|
254
|
-
if (contentWidth > 0 && i < cursorIndex && specLines[i].length > contentWidth) {
|
|
302
|
+
// Word wrap: count extra continuation lines (not in code blocks — those render unwrapped)
|
|
303
|
+
if (!inCode && contentWidth > 0 && i < cursorIndex && specLines[i].length > contentWidth) {
|
|
255
304
|
extra += wordWrap(specLines[i], contentWidth).length - 1;
|
|
256
305
|
}
|
|
257
306
|
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") {
|
package/src/tui/ui/theme.ts
CHANGED
|
@@ -2,7 +2,7 @@ export const theme = {
|
|
|
2
2
|
// Surfaces
|
|
3
3
|
base: undefined,
|
|
4
4
|
backgroundPanel: "#313244",
|
|
5
|
-
backgroundElement:
|
|
5
|
+
backgroundElement: "#313244",
|
|
6
6
|
|
|
7
7
|
// Text hierarchy
|
|
8
8
|
text: "#cdd6f4",
|
|
@@ -28,10 +28,10 @@ export const theme = {
|
|
|
28
28
|
} as const;
|
|
29
29
|
|
|
30
30
|
export const STATUS_ICONS: Record<string, string> = {
|
|
31
|
-
open: "\
|
|
32
|
-
pending: "\u2588", // █ full block
|
|
33
|
-
resolved: "
|
|
34
|
-
outdated: "
|
|
31
|
+
open: "\u2588", // █ full block — white
|
|
32
|
+
pending: "\u2588", // █ full block — yellow (unread)
|
|
33
|
+
resolved: "\u2588", // █ full block — green
|
|
34
|
+
outdated: "\u2588", // █ full block — dim
|
|
35
35
|
};
|
|
36
36
|
|
|
37
37
|
export const SPLIT_BORDER = {
|