revspec 0.8.2 → 0.8.3

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/README.md CHANGED
@@ -94,7 +94,7 @@ Opens a TUI with vim-style navigation. Press `c` on any line to open a thread an
94
94
  The thread popup has two vim-style modes, indicated by border color and label:
95
95
 
96
96
  - **Insert mode** (green border) — type your comment, `Tab` sends, `Esc` switches to normal mode
97
- - **Normal mode** (blue border) — `j/k` and `Ctrl+D/U` scroll the conversation, `gg/G` top/bottom, `c` to reply, `r` to resolve, `q/Esc` to close
97
+ - **Normal mode** (blue border) — `j/k` and `Ctrl+D/U` scroll the conversation, `gg/G` top/bottom, `i/c` to reply, `r` to resolve, `q/Esc` to close
98
98
 
99
99
  ### Markdown rendering
100
100
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "revspec",
3
- "version": "0.8.2",
3
+ "version": "0.8.3",
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
@@ -61,9 +61,12 @@ export async function runTui(
61
61
 
62
62
  // Create and start the live watcher
63
63
  const liveWatcher: LiveWatcher = createLiveWatcher(jsonlPath, (ownerEvents) => {
64
+ let lastReplyThread: { id: string; line: number } | null = null;
64
65
  for (const event of ownerEvents) {
65
66
  if (event.type === "reply" && event.threadId && event.text) {
66
67
  state.addOwnerReply(event.threadId, event.text, event.ts);
68
+ const thread = state.threads.find((t) => t.id === event.threadId);
69
+ if (thread) lastReplyThread = { id: thread.id, line: thread.line };
67
70
  // If the thread popup is open for this thread, push the message in
68
71
  if (activeOverlay?.addMessage && activeOverlay?.threadId === event.threadId) {
69
72
  activeOverlay.addMessage({ author: "owner", text: event.text, ts: event.ts });
@@ -71,6 +74,10 @@ export async function runTui(
71
74
  }
72
75
  }
73
76
  refreshPager();
77
+ // Flash notification for AI replies when not viewing that thread
78
+ if (lastReplyThread && activeOverlay?.threadId !== lastReplyThread.id) {
79
+ showTransient(`AI replied on line ${lastReplyThread.line}`, "info");
80
+ }
74
81
  });
75
82
  liveWatcher.start();
76
83
 
@@ -113,8 +120,11 @@ export async function runTui(
113
120
 
114
121
  buildPagerNodes(pager.lineNode, state, searchQuery, state.unreadThreadIds);
115
122
  buildTopBar(topBar, specFile, state, state.unreadCount(), specMtimeChanged);
116
- const hasThread = !!state.threadAtLine(state.cursorLine);
117
- buildBottomBar(bottomBar, commandBuffer, hasThread);
123
+ // Don't overwrite transient messages (welcome hint, warnings) during navigation
124
+ if (!messageTimer) {
125
+ const hasThread = !!state.threadAtLine(state.cursorLine);
126
+ buildBottomBar(bottomBar, commandBuffer, hasThread);
127
+ }
118
128
  renderer.requestRender();
119
129
  }
120
130
 
@@ -291,13 +301,24 @@ export async function runTui(
291
301
  }
292
302
  },
293
303
  onResolve: () => {
304
+ let didResolve = false;
294
305
  if (existingThread) {
295
306
  const wasResolved = existingThread.status === "resolved";
307
+ didResolve = !wasResolved;
296
308
  state.resolveThread(existingThread.id);
297
309
  state.markRead(existingThread.id);
298
310
  appendEvent(jsonlPath, { type: wasResolved ? "unresolve" : "resolve", threadId: existingThread.id, author: "reviewer", ts: Date.now() });
299
311
  }
300
312
  dismissOverlay();
313
+ // Auto-advance to next thread only when resolving (not reopening)
314
+ if (didResolve) {
315
+ const nextLine = state.nextThread();
316
+ if (nextLine !== null) {
317
+ state.cursorLine = nextLine;
318
+ ensureCursorVisible();
319
+ }
320
+ }
321
+ refreshPager();
301
322
  },
302
323
  onCancel: () => {
303
324
  if (existingThread) state.markRead(existingThread.id);
@@ -455,8 +476,7 @@ export async function runTui(
455
476
 
456
477
  refreshPager();
457
478
  if (state.threads.length === 0) {
458
- setBottomBarMessage(bottomBar, "Navigate to a line and press c to comment | ? for help", "info");
459
- renderer.requestRender();
479
+ showTransient("Navigate to a line and press c to comment | ? for help", "info", 8000);
460
480
  }
461
481
  renderer.start();
462
482
 
@@ -763,6 +783,9 @@ export async function runTui(
763
783
  liveWatcher.start();
764
784
  dismissOverlay();
765
785
  searchQuery = null;
786
+ jumpList.length = 0;
787
+ jumpList.push(1);
788
+ jumpIndex = 0;
766
789
  ensureCursorVisible();
767
790
  refreshPager();
768
791
  showTransient("Spec rewritten \u2014 review cleared", "success", 2500);
@@ -773,18 +796,7 @@ export async function runTui(
773
796
  break;
774
797
  case "approve":
775
798
  unresolvedGate(() => {
776
- const confirmOverlay = createConfirm({
777
- renderer,
778
- message: "Approve spec and proceed to implementation?",
779
- onConfirm: () => {
780
- dismissOverlay();
781
- exitTui(resolve, "approve");
782
- },
783
- onCancel: () => {
784
- dismissOverlay();
785
- },
786
- });
787
- showOverlay(confirmOverlay);
799
+ exitTui(resolve, "approve");
788
800
  });
789
801
  break;
790
802
  case "next-thread": {
package/src/tui/help.ts CHANGED
@@ -78,7 +78,7 @@ export function createHelp(opts: {
78
78
 
79
79
  addHelpSection(dialog.content, renderer, "Thread Popup", [
80
80
  " New thread: INSERT mode (green border) — type and Tab to send.",
81
- " Existing thread: NORMAL mode (blue border) — read conversation,",
81
+ " Existing thread: NORMAL mode (blue border) — scroll conversation,",
82
82
  " c to reply, r to resolve, q/Esc to close.",
83
83
  ]);
84
84
 
@@ -15,14 +15,16 @@ export function createLiveWatcher(
15
15
  let pollTimer: ReturnType<typeof setInterval> | null = null
16
16
 
17
17
  function check() {
18
- const { events, newOffset } = readEventsFromOffset(jsonlPath, offset)
19
- if (events.length > 0) {
20
- offset = newOffset
21
- const ownerEvents = events.filter((e) => e.author === "owner")
22
- if (ownerEvents.length > 0) {
23
- onOwnerEvents(ownerEvents)
18
+ try {
19
+ const { events, newOffset } = readEventsFromOffset(jsonlPath, offset)
20
+ if (events.length > 0) {
21
+ offset = newOffset
22
+ const ownerEvents = events.filter((e) => e.author === "owner")
23
+ if (ownerEvents.length > 0) {
24
+ onOwnerEvents(ownerEvents)
25
+ }
24
26
  }
25
- }
27
+ } catch {}
26
28
  }
27
29
 
28
30
  return {
package/src/tui/pager.ts CHANGED
@@ -1,5 +1,4 @@
1
1
  import type { ReviewState } from "../state/review-state";
2
- import type { Thread } from "../protocol/types";
3
2
  import {
4
3
  ScrollBoxRenderable,
5
4
  TextRenderable,
@@ -10,16 +9,6 @@ import {
10
9
  import { theme } from "./ui/theme";
11
10
  import { parseMarkdownLine, addSegments, collectTable, renderTableBorder, renderTableSeparator, renderTableRow, parseTableCells, type TableBlock } from "./ui/markdown";
12
11
 
13
- const MAX_HINT_LENGTH = 40;
14
-
15
- function threadHint(thread: Thread): string {
16
- if (thread.messages.length === 0) return "";
17
- const last = thread.messages[thread.messages.length - 1];
18
- const text = last.text.replace(/\n/g, " ");
19
- if (text.length <= MAX_HINT_LENGTH) return text;
20
- return text.slice(0, MAX_HINT_LENGTH - 1) + "\u2026";
21
- }
22
-
23
12
  // --- Plain text builder (for tests) ---
24
13
 
25
14
  /**
@@ -127,7 +116,7 @@ export function buildPagerNodes(lineNode: TextRenderable, state: ReviewState, se
127
116
  // Gutter: cursor + indicator + line number (dimmed)
128
117
  lineNode.add(TextNodeRenderable.fromString(
129
118
  `${prefix}`,
130
- { fg: isCursor ? theme.yellow : theme.textDim, bg: isCursor ? theme.backgroundElement : undefined }
119
+ { fg: isCursor ? theme.mauve : theme.textDim, bg: isCursor ? theme.backgroundElement : undefined }
131
120
  ));
132
121
  lineNode.add(TextNodeRenderable.fromString(
133
122
  indicator,
@@ -171,7 +160,10 @@ export function buildPagerNodes(lineNode: TextRenderable, state: ReviewState, se
171
160
  lineNode.add(TextNodeRenderable.fromString(gutterBlank, { fg: theme.textDim }));
172
161
  renderTableBorder(lineNode, tableBlock.colWidths, "bottom");
173
162
  }
174
- } else if (searchQuery && specText.toLowerCase().includes(searchQuery.toLowerCase())) {
163
+ } else if (searchQuery && (() => {
164
+ const cs = searchQuery !== searchQuery.toLowerCase();
165
+ return cs ? specText.includes(searchQuery) : specText.toLowerCase().includes(searchQuery.toLowerCase());
166
+ })()) {
175
167
  // Line contains search match — show colored match segments (no markdown styling)
176
168
  const escaped = searchQuery.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
177
169
  const caseSensitive = searchQuery !== searchQuery.toLowerCase();
@@ -193,15 +185,6 @@ export function buildPagerNodes(lineNode: TextRenderable, state: ReviewState, se
193
185
  addSegments(lineNode, segments, theme.text, isCursor ? theme.backgroundElement : undefined);
194
186
  }
195
187
 
196
- // Thread hint (dimmed, inline)
197
- if (thread && thread.messages.length > 0) {
198
- const hint = threadHint(thread);
199
- lineNode.add(TextNodeRenderable.fromString(
200
- ` \u00ab ${hint}`,
201
- { fg: theme.textDim, attributes: TextAttributes.DIM, bg: isCursor ? theme.backgroundElement : undefined }
202
- ));
203
- }
204
-
205
188
  // Newline between lines (except last)
206
189
  if (i < state.specLines.length - 1) {
207
190
  lineNode.add(TextNodeRenderable.fromString("\n", {}));
@@ -6,7 +6,7 @@ import {
6
6
  } from "@opentui/core";
7
7
  import { theme } from "./ui/theme";
8
8
 
9
- const SPINNER_FRAMES = ["", "", "", "", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
9
+ const SPINNER_FRAMES = ["|", "/", "-", "\\"];
10
10
 
11
11
  export interface SpinnerOverlay {
12
12
  container: BoxRenderable;
@@ -33,16 +33,16 @@ export function buildTopBar(
33
33
  // Filename — bold
34
34
  t.add(TextNodeRenderable.fromString(` ${name}`, { fg: theme.text, attributes: TextAttributes.BOLD }));
35
35
 
36
- t.add(TextNodeRenderable.fromString(" \u00b7 ", { fg: theme.textDim }));
37
-
38
- // Thread summary
39
- if (open > 0 || pending > 0) {
40
- const parts: string[] = [];
41
- if (open > 0) parts.push(`${open} open`);
42
- if (pending > 0) parts.push(`${pending} pending`);
43
- t.add(TextNodeRenderable.fromString(parts.join(", "), { fg: theme.yellow }));
44
- } else {
45
- t.add(TextNodeRenderable.fromString("No active threads", { fg: theme.textMuted }));
36
+ // Thread progress shown when threads exist
37
+ if (state.threads.length > 0) {
38
+ const resolved = state.threads.filter((t) => t.status === "resolved").length;
39
+ const total = state.threads.length;
40
+ t.add(TextNodeRenderable.fromString(" \u00b7 ", { fg: theme.textDim }));
41
+ if (resolved === total) {
42
+ t.add(TextNodeRenderable.fromString(`${resolved}/${total} resolved`, { fg: theme.green }));
43
+ } else {
44
+ t.add(TextNodeRenderable.fromString(`${resolved}/${total} resolved`, { fg: theme.yellow }));
45
+ }
46
46
  }
47
47
 
48
48
  // Unread replies
@@ -50,7 +50,7 @@ export function buildTopBar(
50
50
  t.add(TextNodeRenderable.fromString(" \u00b7 ", { fg: theme.textDim }));
51
51
  t.add(TextNodeRenderable.fromString(
52
52
  `${unreadCount} new repl${unreadCount === 1 ? "y" : "ies"}`,
53
- { fg: theme.green, attributes: TextAttributes.BOLD }
53
+ { fg: theme.yellow, attributes: TextAttributes.BOLD }
54
54
  ));
55
55
  }
56
56
 
@@ -66,6 +66,16 @@ export function buildTopBar(
66
66
  : `${Math.round(((state.cursorLine - 1) / (state.lineCount - 1)) * 100)}%`;
67
67
  t.add(TextNodeRenderable.fromString(" \u00b7 ", { fg: theme.textDim }));
68
68
  t.add(TextNodeRenderable.fromString(`L${state.cursorLine}/${state.lineCount} ${posLabel}`, { fg: theme.textMuted }));
69
+
70
+ // Current section breadcrumb — nearest heading above cursor
71
+ for (let i = state.cursorLine - 1; i >= 0; i--) {
72
+ const match = state.specLines[i].match(/^(#{1,3})\s+(.+)/);
73
+ if (match) {
74
+ t.add(TextNodeRenderable.fromString(" \u00b7 ", { fg: theme.textDim }));
75
+ t.add(TextNodeRenderable.fromString(match[2].trim(), { fg: theme.textDim, attributes: TextAttributes.ITALIC }));
76
+ break;
77
+ }
78
+ }
69
79
  }
70
80
 
71
81
  export type MessageIcon = "warn" | "success" | "info";
@@ -57,7 +57,11 @@ function buildTitle(threads: Thread[], mode: FilterMode): string {
57
57
  }
58
58
 
59
59
  function threadsToOptions(threads: Thread[]) {
60
- return threads.map((t) => {
60
+ const STATUS_ORDER: Record<string, number> = { open: 0, pending: 1, resolved: 2 };
61
+ const sorted = [...threads].sort((a, b) =>
62
+ (STATUS_ORDER[a.status] ?? 3) - (STATUS_ORDER[b.status] ?? 3) || a.line - b.line
63
+ );
64
+ return sorted.map((t) => {
61
65
  const icon = STATUS_ICONS[t.status];
62
66
  return {
63
67
  name: `${icon} #${t.id} line ${t.line}: ${previewText(t)}`,
@@ -29,7 +29,7 @@ export function parseInlineMarkdown(text: string): StyledSegment[] {
29
29
  // 7: ~~strikethrough~~
30
30
  // 8: [link text](url) — display text only
31
31
  // 9: `code`
32
- const regex = /(\*\*\*(.+?)\*\*\*|\*\*(.+?)\*\*|\*(.+?)\*|__(.+?)__|_(.+?)_|~~(.+?)~~|\[([^\]]+)\]\([^)]+\)|`([^`]+)`)/g;
32
+ const regex = /(\*\*\*(.+?)\*\*\*|\*\*(.+?)\*\*|\*(.+?)\*|(?<!\w)__(.+?)__(?!\w)|(?<!\w)_(.+?)_(?!\w)|~~(.+?)~~|\[([^\]]+)\]\([^)]+\)|`([^`]+)`)/g;
33
33
  let pos = 0;
34
34
  let match;
35
35
  while ((match = regex.exec(text)) !== null) {