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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "revspec",
3
- "version": "0.8.4",
3
+ "version": "0.8.5",
4
4
  "description": "Terminal-based spec review tool with real-time AI conversation",
5
5
  "type": "module",
6
6
  "license": "MIT",
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
 
@@ -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
- * Word-wrap a string at the given width, breaking at word boundaries.
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; // no space found — hard break
111
+ if (breakAt <= 0) breakAt = width;
66
112
  lines.push(remaining.slice(0, breakAt));
67
- remaining = remaining.slice(breakAt).replace(/^ /, ""); // trim leading space on continuation
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 and render inline markdown
255
+ // Parse inline markdown, then wrap if needed
215
256
  const segments = parseMarkdownLine(specText);
216
- addSegments(lineNode, segments, theme.text, isCursor ? theme.backgroundElement : undefined);
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") {