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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "revspec",
3
- "version": "0.8.3",
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
@@ -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 hasThread = !!state.threadAtLine(state.cursorLine);
126
- 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
+ }
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
@@ -114,6 +114,7 @@ export function createHelp(opts: {
114
114
  " :q/:wq Quit (warns if unresolved)",
115
115
  " :q! Force quit",
116
116
  " :{N} Jump to line N",
117
+ " :wrap Toggle line wrapping",
117
118
  " Ctrl+C Force quit",
118
119
  ]);
119
120
 
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
- export function buildPagerNodes(lineNode: TextRenderable, state: ReviewState, searchQuery?: string | null, unreadThreadIds?: ReadonlySet<string>): void {
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
- // Blank gutter for table borders: prefix(1) + indicator(1) + numWidth + spaces(2)
62
- const gutterBlank = " ".repeat(2 + numWidth + 2);
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 and render inline markdown
255
+ // Parse inline markdown, then wrap if needed
184
256
  const segments = parseMarkdownLine(specText);
185
- 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
+ }
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: true,
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") {
@@ -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) => ({