revspec 0.8.1 → 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
@@ -54,12 +54,18 @@ Opens a TUI with vim-style navigation. Press `c` on any line to open a thread an
54
54
  | `j/k` | Move cursor down/up |
55
55
  | `gg` / `G` | Go to top / bottom |
56
56
  | `Ctrl+D/U` | Half page down/up |
57
+ | `H/M/L` | Jump to screen top / middle / bottom |
57
58
  | `zz` | Center cursor line in viewport |
58
59
  | `/` | Search (smartcase) |
59
60
  | `n/N` | Next/prev search match |
60
61
  | `Esc` | Clear search highlights |
61
62
  | `]t/[t` | Next/prev thread |
62
63
  | `]r/[r` | Next/prev unread AI reply |
64
+ | `]1/[1` | Next/prev h1 heading |
65
+ | `]2/[2` | Next/prev h2 heading |
66
+ | `]3/[3` | Next/prev h3 heading |
67
+ | `Ctrl+O/I` | Jump list back/forward |
68
+ | `''` | Jump to previous position |
63
69
 
64
70
  **Review**
65
71
 
@@ -69,7 +75,7 @@ Opens a TUI with vim-style navigation. Press `c` on any line to open a thread an
69
75
  | `r` | Resolve thread (toggle) |
70
76
  | `R` | Resolve all pending |
71
77
  | `dd` | Delete thread (with confirm) |
72
- | `t` | List threads |
78
+ | `t` | List threads (`Ctrl+F` to filter all/active/resolved) |
73
79
  | `S` | Submit for rewrite (AI updates spec, TUI reloads) |
74
80
  | `A` | Approve spec (finalize and exit) |
75
81
 
@@ -78,24 +84,17 @@ Opens a TUI with vim-style navigation. Press `c` on any line to open a thread an
78
84
  | Key | Action |
79
85
  |-----|--------|
80
86
  | `:q` | Quit (warns if unresolved threads) |
81
- | `:q!` | Force quit |
87
+ | `:q!` | Force quit (also `:wq!`, `:qa!`, etc.) |
82
88
  | `:{N}` | Jump to line N |
83
89
  | `Ctrl+C` | Force quit |
84
90
  | `?` | Help |
85
91
 
86
- **Popups**
87
-
88
- | Key | Action |
89
- |-----|--------|
90
- | `y/Enter` | Confirm / select |
91
- | `q/Esc` | Cancel / close |
92
-
93
92
  ### Thread popup
94
93
 
95
- The thread popup has two modes:
94
+ The thread popup has two vim-style modes, indicated by border color and label:
96
95
 
97
- - **Insert mode** — type your comment, `Tab` sends, `Esc` switches to normal mode
98
- - **Normal mode** — `j/k` and `Ctrl+D/U` scroll the conversation, `gg/G` top/bottom, `c` to reply, `r` to resolve, `q/Esc` to close
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, `i/c` to reply, `r` to resolve, `q/Esc` to close
99
98
 
100
99
  ### Markdown rendering
101
100
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "revspec",
3
- "version": "0.8.1",
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
 
@@ -124,6 +134,15 @@ export async function runTui(
124
134
  // Command mode state
125
135
  let commandBuffer: string | null = null;
126
136
 
137
+ // Transient message timer — prevents stale timeouts from clobbering each other
138
+ let messageTimer: ReturnType<typeof setTimeout> | null = null;
139
+ function showTransient(message: string, icon?: import("./status-bar").MessageIcon, ms = 1500): void {
140
+ if (messageTimer) clearTimeout(messageTimer);
141
+ setBottomBarMessage(bottomBar, message, icon);
142
+ renderer.requestRender();
143
+ messageTimer = setTimeout(() => { messageTimer = null; refreshPager(); }, ms);
144
+ }
145
+
127
146
  // Jump list — mirrors vim's :jumps behavior.
128
147
  // pushJump() is called BEFORE each big jump to record the departure position.
129
148
  // Ctrl+O traverses backward, Ctrl+I forward. Making a new jump while in the
@@ -219,22 +238,22 @@ export async function runTui(
219
238
 
220
239
  // Process command buffer input
221
240
  function processCommand(cmd: string, resolve: () => void): "exit" | "stay" {
222
- if (cmd === "q" || cmd === "wq" || cmd === "qw") {
241
+ const forceQuit = ["q!", "qa!", "wq!", "wqa!", "qw!", "qwa!"];
242
+ const safeQuit = ["q", "qa", "wq", "wqa", "qw", "qwa"];
243
+ if (forceQuit.includes(cmd)) {
244
+ exitTui(resolve, "session-end");
245
+ return "exit";
246
+ }
247
+ if (safeQuit.includes(cmd)) {
223
248
  const { open, pending } = state.activeThreadCount();
224
249
  const total = open + pending;
225
250
  if (total > 0) {
226
- setBottomBarMessage(bottomBar, `${total} unresolved thread(s). Use :q! to force quit`, "warn");
227
- renderer.requestRender();
228
- setTimeout(() => { refreshPager(); }, 2000);
251
+ showTransient(`${total} unresolved thread(s). Use :q! to force quit`, "warn", 2000);
229
252
  return "stay";
230
253
  }
231
254
  exitTui(resolve, "session-end");
232
255
  return "exit";
233
256
  }
234
- if (cmd === "q!") {
235
- exitTui(resolve, "session-end");
236
- return "exit";
237
- }
238
257
  // :{N} — jump to line number
239
258
  const lineNum = parseInt(cmd, 10);
240
259
  if (!isNaN(lineNum) && lineNum > 0) {
@@ -244,9 +263,7 @@ export async function runTui(
244
263
  refreshPager();
245
264
  return "stay";
246
265
  }
247
- setBottomBarMessage(bottomBar, `Unknown command: ${cmd}`, "warn");
248
- renderer.requestRender();
249
- setTimeout(() => { refreshPager(); }, 1500);
266
+ showTransient(`Unknown command: ${cmd}`, "warn");
250
267
  return "stay";
251
268
  }
252
269
 
@@ -284,13 +301,24 @@ export async function runTui(
284
301
  }
285
302
  },
286
303
  onResolve: () => {
304
+ let didResolve = false;
287
305
  if (existingThread) {
288
306
  const wasResolved = existingThread.status === "resolved";
307
+ didResolve = !wasResolved;
289
308
  state.resolveThread(existingThread.id);
290
309
  state.markRead(existingThread.id);
291
310
  appendEvent(jsonlPath, { type: wasResolved ? "unresolve" : "resolve", threadId: existingThread.id, author: "reviewer", ts: Date.now() });
292
311
  }
293
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();
294
322
  },
295
323
  onCancel: () => {
296
324
  if (existingThread) state.markRead(existingThread.id);
@@ -448,8 +476,7 @@ export async function runTui(
448
476
 
449
477
  refreshPager();
450
478
  if (state.threads.length === 0) {
451
- setBottomBarMessage(bottomBar, "Navigate to a line and press c to start reviewing", "info");
452
- renderer.requestRender();
479
+ showTransient("Navigate to a line and press c to comment | ? for help", "info", 8000);
453
480
  }
454
481
  renderer.start();
455
482
 
@@ -626,17 +653,13 @@ export async function runTui(
626
653
  ensureCursorVisible();
627
654
  refreshPager();
628
655
  if (wrapped) {
629
- setBottomBarMessage(bottomBar, "Search wrapped to top", "info");
630
- renderer.requestRender();
631
- setTimeout(() => { refreshPager(); }, 1200);
656
+ showTransient("Search wrapped to top", "info", 1200);
632
657
  }
633
658
  } else {
634
659
  refreshPager();
635
660
  }
636
661
  } else {
637
- setBottomBarMessage(bottomBar, "No active search \u2014 use / to search");
638
- renderer.requestRender();
639
- setTimeout(() => { refreshPager(); }, 1500);
662
+ showTransient("No active search \u2014 use / to search");
640
663
  }
641
664
  break;
642
665
  case "search-prev":
@@ -649,17 +672,13 @@ export async function runTui(
649
672
  ensureCursorVisible();
650
673
  refreshPager();
651
674
  if (wrapped) {
652
- setBottomBarMessage(bottomBar, "Search wrapped to bottom", "info");
653
- renderer.requestRender();
654
- setTimeout(() => { refreshPager(); }, 1200);
675
+ showTransient("Search wrapped to bottom", "info", 1200);
655
676
  }
656
677
  } else {
657
678
  refreshPager();
658
679
  }
659
680
  } else {
660
- setBottomBarMessage(bottomBar, "No active search \u2014 use / to search");
661
- renderer.requestRender();
662
- setTimeout(() => { refreshPager(); }, 1500);
681
+ showTransient("No active search \u2014 use / to search");
663
682
  }
664
683
  break;
665
684
  case "comment":
@@ -676,37 +695,33 @@ export async function runTui(
676
695
  state.markRead(thread.id);
677
696
  appendEvent(jsonlPath, { type: wasResolved ? "unresolve" : "resolve", threadId: thread.id, author: "reviewer", ts: Date.now() });
678
697
  refreshPager();
679
- setBottomBarMessage(bottomBar,
698
+ showTransient(
680
699
  wasResolved ? `Reopened thread #${thread.id}` : `Resolved thread #${thread.id}`,
681
700
  "success");
682
- renderer.requestRender();
683
- setTimeout(() => { refreshPager(); }, 1500);
684
701
  } else {
685
- setBottomBarMessage(bottomBar, "No thread on this line");
686
- renderer.requestRender();
687
- setTimeout(() => { refreshPager(); }, 1500);
702
+ showTransient("No thread on this line");
688
703
  }
689
704
  break;
690
705
  }
691
706
  case "resolve-all": {
692
707
  const { pending } = state.activeThreadCount();
708
+ if (pending === 0) {
709
+ showTransient("No pending threads");
710
+ break;
711
+ }
693
712
  const pendingThreads = state.threads.filter(t => t.status === "pending");
694
713
  state.resolveAllPending();
695
714
  for (const t of pendingThreads) {
696
715
  appendEvent(jsonlPath, { type: "resolve", threadId: t.id, author: "reviewer", ts: Date.now() });
697
716
  }
698
717
  refreshPager();
699
- setBottomBarMessage(bottomBar, `Resolved ${pending} pending thread(s)`, "success");
700
- renderer.requestRender();
701
- setTimeout(() => { refreshPager(); }, 1500);
718
+ showTransient(`Resolved ${pending} pending thread(s)`, "success");
702
719
  break;
703
720
  }
704
721
  case "delete-draft": {
705
722
  const thread = state.threadAtLine(state.cursorLine);
706
723
  if (!thread) {
707
- setBottomBarMessage(bottomBar, "No thread on this line");
708
- renderer.requestRender();
709
- setTimeout(() => { refreshPager(); }, 1500);
724
+ showTransient("No thread on this line");
710
725
  break;
711
726
  }
712
727
  const deleteOverlay = createConfirm({
@@ -718,9 +733,7 @@ export async function runTui(
718
733
  state.deleteThread(thread.id);
719
734
  appendEvent(jsonlPath, { type: "delete", threadId: thread.id, author: "reviewer", ts: Date.now() });
720
735
  refreshPager();
721
- setBottomBarMessage(bottomBar, `Deleted thread #${thread.id}`, "success");
722
- renderer.requestRender();
723
- setTimeout(() => { refreshPager(); }, 1500);
736
+ showTransient(`Deleted thread #${thread.id}`, "success");
724
737
  },
725
738
  onCancel: () => {
726
739
  dismissOverlay();
@@ -751,9 +764,7 @@ export async function runTui(
751
764
  clearInterval(activeSpecPoll!);
752
765
  activeSpecPoll = null;
753
766
  dismissOverlay();
754
- setBottomBarMessage(bottomBar, "Agent did not update spec. Press S to retry.", "warn");
755
- renderer.requestRender();
756
- setTimeout(() => { refreshPager(); }, 3000);
767
+ showTransient("AI did not update the spec. Press S to resubmit.", "warn", 3000);
757
768
  },
758
769
  });
759
770
  showOverlay(spinnerOverlay);
@@ -772,11 +783,12 @@ export async function runTui(
772
783
  liveWatcher.start();
773
784
  dismissOverlay();
774
785
  searchQuery = null;
786
+ jumpList.length = 0;
787
+ jumpList.push(1);
788
+ jumpIndex = 0;
775
789
  ensureCursorVisible();
776
790
  refreshPager();
777
- setBottomBarMessage(bottomBar, "Spec rewritten \u2014 review cleared", "success");
778
- renderer.requestRender();
779
- setTimeout(() => { refreshPager(); }, 2500);
791
+ showTransient("Spec rewritten \u2014 review cleared", "success", 2500);
780
792
  }
781
793
  } catch {}
782
794
  }, 500);
@@ -784,45 +796,34 @@ export async function runTui(
784
796
  break;
785
797
  case "approve":
786
798
  unresolvedGate(() => {
787
- const confirmOverlay = createConfirm({
788
- renderer,
789
- message: "Approve spec and proceed to implementation?",
790
- onConfirm: () => {
791
- dismissOverlay();
792
- exitTui(resolve, "approve");
793
- },
794
- onCancel: () => {
795
- dismissOverlay();
796
- },
797
- });
798
- showOverlay(confirmOverlay);
799
+ exitTui(resolve, "approve");
799
800
  });
800
801
  break;
801
802
  case "next-thread": {
802
- const next = state.nextThread();
803
- if (next !== null) {
803
+ const nextT = state.nextThread();
804
+ if (nextT !== null) {
805
+ const wrapped = nextT <= state.cursorLine;
804
806
  savePrevPosition();
805
- state.cursorLine = next;
807
+ state.cursorLine = nextT;
806
808
  ensureCursorVisible();
807
809
  refreshPager();
810
+ if (wrapped) showTransient("Wrapped to first thread", "info", 1200);
808
811
  } else {
809
- setBottomBarMessage(bottomBar, "No threads");
810
- renderer.requestRender();
811
- setTimeout(() => { refreshPager(); }, 1500);
812
+ showTransient("No threads");
812
813
  }
813
814
  break;
814
815
  }
815
816
  case "prev-thread": {
816
- const prev = state.prevThread();
817
- if (prev !== null) {
817
+ const prevT = state.prevThread();
818
+ if (prevT !== null) {
819
+ const wrapped = prevT >= state.cursorLine;
818
820
  savePrevPosition();
819
- state.cursorLine = prev;
821
+ state.cursorLine = prevT;
820
822
  ensureCursorVisible();
821
823
  refreshPager();
824
+ if (wrapped) showTransient("Wrapped to last thread", "info", 1200);
822
825
  } else {
823
- setBottomBarMessage(bottomBar, "No threads");
824
- renderer.requestRender();
825
- setTimeout(() => { refreshPager(); }, 1500);
826
+ showTransient("No threads");
826
827
  }
827
828
  break;
828
829
  }
@@ -834,9 +835,7 @@ export async function runTui(
834
835
  ensureCursorVisible();
835
836
  refreshPager();
836
837
  } else {
837
- setBottomBarMessage(bottomBar, "No unread replies");
838
- renderer.requestRender();
839
- setTimeout(() => { refreshPager(); }, 1500);
838
+ showTransient("No unread replies");
840
839
  }
841
840
  break;
842
841
  }
@@ -848,9 +847,7 @@ export async function runTui(
848
847
  ensureCursorVisible();
849
848
  refreshPager();
850
849
  } else {
851
- setBottomBarMessage(bottomBar, "No unread replies");
852
- renderer.requestRender();
853
- setTimeout(() => { refreshPager(); }, 1500);
850
+ showTransient("No unread replies");
854
851
  }
855
852
  break;
856
853
  }
@@ -865,9 +862,7 @@ export async function runTui(
865
862
  ensureCursorVisible();
866
863
  refreshPager();
867
864
  } else {
868
- setBottomBarMessage(bottomBar, `No h${level} headings`);
869
- renderer.requestRender();
870
- setTimeout(() => { refreshPager(); }, 1500);
865
+ showTransient(`No h${level} headings`);
871
866
  }
872
867
  break;
873
868
  }
@@ -882,9 +877,7 @@ export async function runTui(
882
877
  ensureCursorVisible();
883
878
  refreshPager();
884
879
  } else {
885
- setBottomBarMessage(bottomBar, `No h${level} headings`);
886
- renderer.requestRender();
887
- setTimeout(() => { refreshPager(); }, 1500);
880
+ showTransient(`No h${level} headings`);
888
881
  }
889
882
  break;
890
883
  }
@@ -931,6 +924,7 @@ export async function runTui(
931
924
  showSearchOverlay();
932
925
  break;
933
926
  case "command-mode":
927
+ if (messageTimer) { clearTimeout(messageTimer); messageTimer = null; }
934
928
  commandBuffer = "";
935
929
  refreshPager();
936
930
  break;
@@ -65,7 +65,7 @@ function createThreadView(
65
65
  focusedBackgroundColor: theme.backgroundPanel,
66
66
  focusedTextColor: theme.text,
67
67
  wrapMode: "word",
68
- placeholder: "Press c to reply...",
68
+ placeholder: "Type your comment...",
69
69
  placeholderColor: theme.textDim,
70
70
  initialValue: "",
71
71
  });
@@ -107,6 +107,7 @@ function createThreadView(
107
107
  onCancel();
108
108
  return;
109
109
 
110
+ case "i":
110
111
  case "c":
111
112
  enterInsert();
112
113
  return;
@@ -251,6 +252,7 @@ function createThreadView(
251
252
  mode = "insert";
252
253
  textarea.focus();
253
254
  dialog.setHints(insertHints);
255
+ dialog.container.borderColor = theme.green;
254
256
  renderer.requestRender();
255
257
  }
256
258
 
@@ -258,6 +260,7 @@ function createThreadView(
258
260
  mode = "normal";
259
261
  textarea.blur();
260
262
  dialog.setHints(normalHints);
263
+ dialog.container.borderColor = theme.blue;
261
264
  renderer.requestRender();
262
265
  }
263
266
 
@@ -35,7 +35,7 @@ export function createConfirm(opts: ConfirmOptions): ConfirmOverlay {
35
35
  height: 9,
36
36
  top: "30%",
37
37
  left: "28%",
38
- borderColor: theme.warning,
38
+ borderColor: theme.mauve,
39
39
  onDismiss: onCancel,
40
40
  hints: CONFIRM_HINTS,
41
41
  });
package/src/tui/help.ts CHANGED
@@ -54,7 +54,7 @@ export function createHelp(opts: {
54
54
  height: Math.min(32, renderer.height - 4),
55
55
  top: "10%",
56
56
  left: "18%",
57
- borderColor: theme.info,
57
+ borderColor: theme.blue,
58
58
  onDismiss: onClose,
59
59
  hints: HELP_HINTS,
60
60
  });
@@ -77,8 +77,8 @@ export function createHelp(opts: {
77
77
  ]);
78
78
 
79
79
  addHelpSection(dialog.content, renderer, "Thread Popup", [
80
- " New thread: INSERT mode — type and Tab to send.",
81
- " Existing thread: NORMAL mode — read conversation,",
80
+ " New thread: INSERT mode (green border) — type and Tab to send.",
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.text : 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;
@@ -32,7 +32,7 @@ export function createSpinner(opts: {
32
32
  backgroundColor: theme.backgroundPanel,
33
33
  border: true,
34
34
  borderStyle: "single",
35
- borderColor: theme.blue,
35
+ borderColor: theme.mauve,
36
36
  title: " Submitting ",
37
37
  flexDirection: "column",
38
38
  paddingLeft: 2,
@@ -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)}`,
@@ -92,7 +96,7 @@ export function createThreadList(opts: ThreadListOptions): ThreadListOverlay {
92
96
  height: "50%",
93
97
  top: "20%",
94
98
  left: "22%",
95
- borderColor: theme.mauve,
99
+ borderColor: theme.blue,
96
100
  onDismiss: onCancel,
97
101
  hints: THREAD_LIST_HINTS,
98
102
  });
@@ -177,7 +181,7 @@ export function createThreadList(opts: ThreadListOptions): ThreadListOverlay {
177
181
  return;
178
182
  }
179
183
  if (filtered.length === 0) return;
180
- if (key.name === "return" || key.name === "y") {
184
+ if (key.name === "return") {
181
185
  key.preventDefault();
182
186
  key.stopPropagation();
183
187
  const selected = select.getSelectedOption();
@@ -1,4 +1,4 @@
1
- import { TextRenderable, TextNodeRenderable } from "@opentui/core";
1
+ import { TextRenderable, TextNodeRenderable, TextAttributes } from "@opentui/core";
2
2
  import { theme } from "./theme";
3
3
 
4
4
  export interface Hint {
@@ -11,8 +11,14 @@ export function buildHints(text: TextRenderable, hints: Hint[]): void {
11
11
  text.add(TextNodeRenderable.fromString(" ", {}));
12
12
  for (let i = 0; i < hints.length; i++) {
13
13
  const h = hints[i];
14
- text.add(TextNodeRenderable.fromString(`[${h.key}]`, { fg: theme.blue }));
15
- text.add(TextNodeRenderable.fromString(` ${h.action}`, { fg: theme.textMuted }));
14
+ // Mode labels (empty action) get distinct styling so they stand out
15
+ const isMode = h.action === "";
16
+ const keyFg = isMode ? (h.key === "INSERT" ? theme.green : theme.blue) : theme.blue;
17
+ const keyAttrs = isMode ? TextAttributes.BOLD : undefined;
18
+ text.add(TextNodeRenderable.fromString(`[${h.key}]`, { fg: keyFg, attributes: keyAttrs }));
19
+ if (!isMode) {
20
+ text.add(TextNodeRenderable.fromString(` ${h.action}`, { fg: theme.textMuted }));
21
+ }
16
22
  if (i < hints.length - 1) {
17
23
  text.add(TextNodeRenderable.fromString(" ", {}));
18
24
  }
@@ -21,7 +21,7 @@ export const PAGER_HINTS = {
21
21
 
22
22
  export const THREAD_NORMAL_HINTS: Hint[] = [
23
23
  { key: "NORMAL", action: "" },
24
- { key: "c", action: "reply" },
24
+ { key: "i/c", action: "reply" },
25
25
  { key: "r", action: "resolve" },
26
26
  { key: "q/Esc", action: "close" },
27
27
  ];
@@ -36,7 +36,7 @@ export const THREAD_INSERT_HINTS: Hint[] = [
36
36
 
37
37
  export const THREAD_LIST_HINTS: Hint[] = [
38
38
  { key: "j/k", action: "navigate" },
39
- { key: "y/Enter", action: "jump" },
39
+ { key: "Enter", action: "jump" },
40
40
  { key: "Ctrl+f", action: "filter" },
41
41
  { key: "q/Esc", action: "close" },
42
42
  ];
@@ -52,5 +52,5 @@ export const HELP_HINTS: Hint[] = [
52
52
 
53
53
  export const CONFIRM_HINTS: Hint[] = [
54
54
  { key: "y/Enter", action: "yes" },
55
- { key: "q/Esc", action: "no" },
55
+ { key: "q/Esc", action: "cancel" },
56
56
  ];
@@ -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) {