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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "revspec",
3
- "version": "0.8.4",
3
+ "version": "0.8.6",
4
4
  "description": "Terminal-based spec review tool with real-time AI conversation",
5
5
  "type": "module",
6
6
  "license": "MIT",
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 hasThread = !!state.threadAtLine(state.cursorLine);
132
- buildBottomBar(bottomBar, commandBuffer, hasThread);
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
- const isUnread = unreadThreadIds && unreadThreadIds.has(thread.id);
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
- * Word-wrap a string at the given width, breaking at word boundaries.
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; // no space found — hard break
104
+ if (breakAt <= 0) breakAt = width;
66
105
  lines.push(remaining.slice(0, breakAt));
67
- remaining = remaining.slice(breakAt).replace(/^ /, ""); // trim leading space on continuation
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
- indicator = "\u258c"; // half block has thread
120
- indicatorColor = theme.blue;
163
+ indicatorColor = theme.text; // openwhite
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 and render inline markdown
246
+ // Parse inline markdown, then wrap if needed
215
247
  const segments = parseMarkdownLine(specText);
216
- addSegments(lineNode, segments, theme.text, isCursor ? theme.backgroundElement : undefined);
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") {
@@ -2,7 +2,7 @@ export const theme = {
2
2
  // Surfaces
3
3
  base: undefined,
4
4
  backgroundPanel: "#313244",
5
- backgroundElement: undefined,
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: "\u258c", // half block
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 = {