revspec 0.8.0 → 0.8.2

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, `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.0",
3
+ "version": "0.8.2",
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
@@ -27,6 +27,7 @@ import { createConfirm } from "./confirm";
27
27
  import { createHelp } from "./help";
28
28
  import { createSpinner } from "./spinner";
29
29
  import { createKeybindRegistry, type KeyBinding } from "./ui/keybinds";
30
+ import { theme } from "./ui/theme";
30
31
 
31
32
  export async function runTui(
32
33
  specFile: string,
@@ -123,10 +124,38 @@ export async function runTui(
123
124
  // Command mode state
124
125
  let commandBuffer: string | null = null;
125
126
 
126
- // Previous position for '' jump-back
127
- let prevCursorLine: number = 1;
127
+ // Transient message timer prevents stale timeouts from clobbering each other
128
+ let messageTimer: ReturnType<typeof setTimeout> | null = null;
129
+ function showTransient(message: string, icon?: import("./status-bar").MessageIcon, ms = 1500): void {
130
+ if (messageTimer) clearTimeout(messageTimer);
131
+ setBottomBarMessage(bottomBar, message, icon);
132
+ renderer.requestRender();
133
+ messageTimer = setTimeout(() => { messageTimer = null; refreshPager(); }, ms);
134
+ }
135
+
136
+ // Jump list — mirrors vim's :jumps behavior.
137
+ // pushJump() is called BEFORE each big jump to record the departure position.
138
+ // Ctrl+O traverses backward, Ctrl+I forward. Making a new jump while in the
139
+ // middle of the list discards forward history (same as vim).
140
+ const jumpList: number[] = [1];
141
+ let jumpIndex: number = 0;
142
+ const MAX_JUMP_LIST = 50;
143
+
144
+ function pushJump(): void {
145
+ const cur = state.cursorLine;
146
+ // Discard forward history when making a new jump from the middle
147
+ if (jumpIndex < jumpList.length - 1) {
148
+ jumpList.splice(jumpIndex + 1);
149
+ }
150
+ // Don't push duplicate of the list tail
151
+ if (jumpList.length > 0 && jumpList[jumpList.length - 1] === cur) return;
152
+ jumpList.push(cur);
153
+ if (jumpList.length > MAX_JUMP_LIST) jumpList.shift();
154
+ jumpIndex = jumpList.length - 1;
155
+ }
156
+
128
157
  function savePrevPosition(): void {
129
- prevCursorLine = state.cursorLine;
158
+ pushJump();
130
159
  }
131
160
 
132
161
  // Map visual row back to spec line number (for H/M/L)
@@ -199,22 +228,22 @@ export async function runTui(
199
228
 
200
229
  // Process command buffer input
201
230
  function processCommand(cmd: string, resolve: () => void): "exit" | "stay" {
202
- if (cmd === "q" || cmd === "wq" || cmd === "qw") {
231
+ const forceQuit = ["q!", "qa!", "wq!", "wqa!", "qw!", "qwa!"];
232
+ const safeQuit = ["q", "qa", "wq", "wqa", "qw", "qwa"];
233
+ if (forceQuit.includes(cmd)) {
234
+ exitTui(resolve, "session-end");
235
+ return "exit";
236
+ }
237
+ if (safeQuit.includes(cmd)) {
203
238
  const { open, pending } = state.activeThreadCount();
204
239
  const total = open + pending;
205
240
  if (total > 0) {
206
- setBottomBarMessage(bottomBar, ` \u26a0 ${total} unresolved thread(s). Use :q! to force quit`);
207
- renderer.requestRender();
208
- setTimeout(() => { refreshPager(); }, 2000);
241
+ showTransient(`${total} unresolved thread(s). Use :q! to force quit`, "warn", 2000);
209
242
  return "stay";
210
243
  }
211
244
  exitTui(resolve, "session-end");
212
245
  return "exit";
213
246
  }
214
- if (cmd === "q!") {
215
- exitTui(resolve, "session-end");
216
- return "exit";
217
- }
218
247
  // :{N} — jump to line number
219
248
  const lineNum = parseInt(cmd, 10);
220
249
  if (!isNaN(lineNum) && lineNum > 0) {
@@ -224,9 +253,7 @@ export async function runTui(
224
253
  refreshPager();
225
254
  return "stay";
226
255
  }
227
- setBottomBarMessage(bottomBar, ` Unknown command: ${cmd}`);
228
- renderer.requestRender();
229
- setTimeout(() => { refreshPager(); }, 1500);
256
+ showTransient(`Unknown command: ${cmd}`, "warn");
230
257
  return "stay";
231
258
  }
232
259
 
@@ -273,6 +300,7 @@ export async function runTui(
273
300
  dismissOverlay();
274
301
  },
275
302
  onCancel: () => {
303
+ if (existingThread) state.markRead(existingThread.id);
276
304
  dismissOverlay();
277
305
  },
278
306
  });
@@ -426,6 +454,10 @@ export async function runTui(
426
454
  const keybinds = createKeybindRegistry(bindings, 300);
427
455
 
428
456
  refreshPager();
457
+ if (state.threads.length === 0) {
458
+ setBottomBarMessage(bottomBar, "Navigate to a line and press c to comment | ? for help", "info");
459
+ renderer.requestRender();
460
+ }
429
461
  renderer.start();
430
462
 
431
463
  // 8. Set up keybinding handler
@@ -488,6 +520,38 @@ export async function runTui(
488
520
  return;
489
521
  }
490
522
 
523
+ // Ctrl+O: jump back in jump list
524
+ if (key.ctrl && key.name === "o") {
525
+ // Starting backward traversal from head — save current position first
526
+ // (without splicing forward history, unlike pushJump)
527
+ if (jumpIndex === jumpList.length - 1) {
528
+ const cur = state.cursorLine;
529
+ if (jumpList[jumpIndex] !== cur) {
530
+ jumpList.push(cur);
531
+ if (jumpList.length > MAX_JUMP_LIST) jumpList.shift();
532
+ jumpIndex = jumpList.length - 1;
533
+ }
534
+ }
535
+ if (jumpIndex > 0) {
536
+ jumpIndex--;
537
+ state.cursorLine = Math.min(jumpList[jumpIndex], state.lineCount);
538
+ ensureCursorVisible();
539
+ refreshPager();
540
+ }
541
+ return;
542
+ }
543
+
544
+ // Ctrl+I / Tab: jump forward in jump list
545
+ if ((key.ctrl && key.name === "i") || key.name === "tab") {
546
+ if (jumpIndex < jumpList.length - 1) {
547
+ jumpIndex++;
548
+ state.cursorLine = Math.min(jumpList[jumpIndex], state.lineCount);
549
+ ensureCursorVisible();
550
+ refreshPager();
551
+ }
552
+ return;
553
+ }
554
+
491
555
  // Escape clears search highlights
492
556
  if (key.name === "escape") {
493
557
  if (searchQuery) {
@@ -563,31 +627,39 @@ export async function runTui(
563
627
  if (searchQuery) {
564
628
  const match = findNextMatch(state.specLines, searchQuery, state.cursorLine, 1);
565
629
  if (match !== null) {
630
+ const wrapped = match <= state.cursorLine;
566
631
  savePrevPosition();
567
632
  state.cursorLine = match;
568
633
  ensureCursorVisible();
634
+ refreshPager();
635
+ if (wrapped) {
636
+ showTransient("Search wrapped to top", "info", 1200);
637
+ }
638
+ } else {
639
+ refreshPager();
569
640
  }
570
641
  } else {
571
- setBottomBarMessage(bottomBar, " No active search \u2014 use / to search");
572
- renderer.requestRender();
573
- setTimeout(() => { refreshPager(); }, 1500);
642
+ showTransient("No active search \u2014 use / to search");
574
643
  }
575
- refreshPager();
576
644
  break;
577
645
  case "search-prev":
578
646
  if (searchQuery) {
579
647
  const match = findNextMatch(state.specLines, searchQuery, state.cursorLine, -1);
580
648
  if (match !== null) {
649
+ const wrapped = match >= state.cursorLine;
581
650
  savePrevPosition();
582
651
  state.cursorLine = match;
583
652
  ensureCursorVisible();
653
+ refreshPager();
654
+ if (wrapped) {
655
+ showTransient("Search wrapped to bottom", "info", 1200);
656
+ }
657
+ } else {
658
+ refreshPager();
584
659
  }
585
660
  } else {
586
- setBottomBarMessage(bottomBar, " No active search \u2014 use / to search");
587
- renderer.requestRender();
588
- setTimeout(() => { refreshPager(); }, 1500);
661
+ showTransient("No active search \u2014 use / to search");
589
662
  }
590
- refreshPager();
591
663
  break;
592
664
  case "comment":
593
665
  showCommentInput();
@@ -603,38 +675,33 @@ export async function runTui(
603
675
  state.markRead(thread.id);
604
676
  appendEvent(jsonlPath, { type: wasResolved ? "unresolve" : "resolve", threadId: thread.id, author: "reviewer", ts: Date.now() });
605
677
  refreshPager();
606
- const msg = wasResolved
607
- ? ` \u21a9 Reopened thread #${thread.id}`
608
- : ` \u2714 Resolved thread #${thread.id}`;
609
- setBottomBarMessage(bottomBar, msg);
610
- renderer.requestRender();
611
- setTimeout(() => { refreshPager(); }, 1500);
678
+ showTransient(
679
+ wasResolved ? `Reopened thread #${thread.id}` : `Resolved thread #${thread.id}`,
680
+ "success");
612
681
  } else {
613
- setBottomBarMessage(bottomBar, " No thread on this line");
614
- renderer.requestRender();
615
- setTimeout(() => { refreshPager(); }, 1500);
682
+ showTransient("No thread on this line");
616
683
  }
617
684
  break;
618
685
  }
619
686
  case "resolve-all": {
620
687
  const { pending } = state.activeThreadCount();
688
+ if (pending === 0) {
689
+ showTransient("No pending threads");
690
+ break;
691
+ }
621
692
  const pendingThreads = state.threads.filter(t => t.status === "pending");
622
693
  state.resolveAllPending();
623
694
  for (const t of pendingThreads) {
624
695
  appendEvent(jsonlPath, { type: "resolve", threadId: t.id, author: "reviewer", ts: Date.now() });
625
696
  }
626
697
  refreshPager();
627
- setBottomBarMessage(bottomBar, ` \u2714 Resolved ${pending} pending thread(s)`);
628
- renderer.requestRender();
629
- setTimeout(() => { refreshPager(); }, 1500);
698
+ showTransient(`Resolved ${pending} pending thread(s)`, "success");
630
699
  break;
631
700
  }
632
701
  case "delete-draft": {
633
702
  const thread = state.threadAtLine(state.cursorLine);
634
703
  if (!thread) {
635
- setBottomBarMessage(bottomBar, " No thread on this line");
636
- renderer.requestRender();
637
- setTimeout(() => { refreshPager(); }, 1500);
704
+ showTransient("No thread on this line");
638
705
  break;
639
706
  }
640
707
  const deleteOverlay = createConfirm({
@@ -646,9 +713,7 @@ export async function runTui(
646
713
  state.deleteThread(thread.id);
647
714
  appendEvent(jsonlPath, { type: "delete", threadId: thread.id, author: "reviewer", ts: Date.now() });
648
715
  refreshPager();
649
- setBottomBarMessage(bottomBar, ` \u2714 Deleted thread #${thread.id}`);
650
- renderer.requestRender();
651
- setTimeout(() => { refreshPager(); }, 1500);
716
+ showTransient(`Deleted thread #${thread.id}`, "success");
652
717
  },
653
718
  onCancel: () => {
654
719
  dismissOverlay();
@@ -659,16 +724,17 @@ export async function runTui(
659
724
  }
660
725
  case "submit":
661
726
  if (state.threads.length === 0) {
662
- setBottomBarMessage(bottomBar, " No threads to submit.");
727
+ setBottomBarMessage(bottomBar, "No threads to submit.");
663
728
  renderer.requestRender();
664
729
  break;
665
730
  }
666
731
  unresolvedGate(() => {
667
732
  appendEvent(jsonlPath, { type: "submit", author: "reviewer", ts: Date.now() });
668
733
 
734
+ const count = state.threads.length;
669
735
  const spinnerOverlay = createSpinner({
670
736
  renderer,
671
- message: "Waiting for agent to update spec...",
737
+ message: `Submitting ${count} thread${count === 1 ? "" : "s"}...`,
672
738
  onCancel: () => {
673
739
  clearInterval(activeSpecPoll!);
674
740
  activeSpecPoll = null;
@@ -678,9 +744,7 @@ export async function runTui(
678
744
  clearInterval(activeSpecPoll!);
679
745
  activeSpecPoll = null;
680
746
  dismissOverlay();
681
- setBottomBarMessage(bottomBar, " \u26a0 Agent did not update spec. Press S to retry.");
682
- renderer.requestRender();
683
- setTimeout(() => { refreshPager(); }, 3000);
747
+ showTransient("AI did not update the spec. Press S to resubmit.", "warn", 3000);
684
748
  },
685
749
  });
686
750
  showOverlay(spinnerOverlay);
@@ -701,6 +765,7 @@ export async function runTui(
701
765
  searchQuery = null;
702
766
  ensureCursorVisible();
703
767
  refreshPager();
768
+ showTransient("Spec rewritten \u2014 review cleared", "success", 2500);
704
769
  }
705
770
  } catch {}
706
771
  }, 500);
@@ -723,30 +788,30 @@ export async function runTui(
723
788
  });
724
789
  break;
725
790
  case "next-thread": {
726
- const next = state.nextThread();
727
- if (next !== null) {
791
+ const nextT = state.nextThread();
792
+ if (nextT !== null) {
793
+ const wrapped = nextT <= state.cursorLine;
728
794
  savePrevPosition();
729
- state.cursorLine = next;
795
+ state.cursorLine = nextT;
730
796
  ensureCursorVisible();
731
797
  refreshPager();
798
+ if (wrapped) showTransient("Wrapped to first thread", "info", 1200);
732
799
  } else {
733
- setBottomBarMessage(bottomBar, " No threads");
734
- renderer.requestRender();
735
- setTimeout(() => { refreshPager(); }, 1500);
800
+ showTransient("No threads");
736
801
  }
737
802
  break;
738
803
  }
739
804
  case "prev-thread": {
740
- const prev = state.prevThread();
741
- if (prev !== null) {
805
+ const prevT = state.prevThread();
806
+ if (prevT !== null) {
807
+ const wrapped = prevT >= state.cursorLine;
742
808
  savePrevPosition();
743
- state.cursorLine = prev;
809
+ state.cursorLine = prevT;
744
810
  ensureCursorVisible();
745
811
  refreshPager();
812
+ if (wrapped) showTransient("Wrapped to last thread", "info", 1200);
746
813
  } else {
747
- setBottomBarMessage(bottomBar, " No threads");
748
- renderer.requestRender();
749
- setTimeout(() => { refreshPager(); }, 1500);
814
+ showTransient("No threads");
750
815
  }
751
816
  break;
752
817
  }
@@ -758,9 +823,7 @@ export async function runTui(
758
823
  ensureCursorVisible();
759
824
  refreshPager();
760
825
  } else {
761
- setBottomBarMessage(bottomBar, " No unread replies");
762
- renderer.requestRender();
763
- setTimeout(() => { refreshPager(); }, 1500);
826
+ showTransient("No unread replies");
764
827
  }
765
828
  break;
766
829
  }
@@ -772,9 +835,7 @@ export async function runTui(
772
835
  ensureCursorVisible();
773
836
  refreshPager();
774
837
  } else {
775
- setBottomBarMessage(bottomBar, " No unread replies");
776
- renderer.requestRender();
777
- setTimeout(() => { refreshPager(); }, 1500);
838
+ showTransient("No unread replies");
778
839
  }
779
840
  break;
780
841
  }
@@ -789,9 +850,7 @@ export async function runTui(
789
850
  ensureCursorVisible();
790
851
  refreshPager();
791
852
  } else {
792
- setBottomBarMessage(bottomBar, ` No h${level} headings`);
793
- renderer.requestRender();
794
- setTimeout(() => { refreshPager(); }, 1500);
853
+ showTransient(`No h${level} headings`);
795
854
  }
796
855
  break;
797
856
  }
@@ -806,18 +865,23 @@ export async function runTui(
806
865
  ensureCursorVisible();
807
866
  refreshPager();
808
867
  } else {
809
- setBottomBarMessage(bottomBar, ` No h${level} headings`);
810
- renderer.requestRender();
811
- setTimeout(() => { refreshPager(); }, 1500);
868
+ showTransient(`No h${level} headings`);
812
869
  }
813
870
  break;
814
871
  }
815
872
  case "jump-back": {
816
- const tmp = state.cursorLine;
817
- state.cursorLine = prevCursorLine;
818
- prevCursorLine = tmp;
819
- ensureCursorVisible();
820
- refreshPager();
873
+ // '' swaps between current position and last jump entry
874
+ if (jumpList.length > 1) {
875
+ const cur = state.cursorLine;
876
+ const prevIdx = jumpIndex > 0 ? jumpIndex - 1 : 0;
877
+ const target = jumpList[prevIdx];
878
+ // Record current position at our spot so '' can swap back
879
+ jumpList[jumpIndex] = cur;
880
+ jumpIndex = prevIdx;
881
+ state.cursorLine = Math.min(target, state.lineCount);
882
+ ensureCursorVisible();
883
+ refreshPager();
884
+ }
821
885
  break;
822
886
  }
823
887
  case "screen-top": {
@@ -848,6 +912,7 @@ export async function runTui(
848
912
  showSearchOverlay();
849
913
  break;
850
914
  case "command-mode":
915
+ if (messageTimer) { clearTimeout(messageTimer); messageTimer = null; }
851
916
  commandBuffer = "";
852
917
  refreshPager();
853
918
  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) — read conversation,",
82
82
  " c to reply, r to resolve, q/Esc to close.",
83
83
  ]);
84
84
 
@@ -95,6 +95,7 @@ export function createHelp(opts: {
95
95
  " ]1/[1 Next/prev h1 heading",
96
96
  " ]2/[2 Next/prev h2 heading",
97
97
  " ]3/[3 Next/prev h3 heading",
98
+ " Ctrl+o/i Jump list back/forward",
98
99
  " '' Jump to previous position",
99
100
  " H/M/L Screen top/middle/bottom",
100
101
  ]);
@@ -104,7 +105,7 @@ export function createHelp(opts: {
104
105
  " r Resolve thread (toggle)",
105
106
  " R Resolve all pending",
106
107
  " dd Delete thread",
107
- " t List threads",
108
+ " t List threads (Ctrl+f to filter)",
108
109
  " S Submit for rewrite",
109
110
  " A Approve spec",
110
111
  ]);
package/src/tui/pager.ts CHANGED
@@ -45,7 +45,7 @@ export function buildPagerContent(state: ReviewState, searchQuery?: string | nul
45
45
  if (isUnread) {
46
46
  indicator = "\u2588";
47
47
  } else if (thread.status === "resolved") {
48
- indicator = "\u2713";
48
+ indicator = "=";
49
49
  } else {
50
50
  indicator = "\u258c";
51
51
  }
@@ -104,7 +104,7 @@ export function buildPagerNodes(lineNode: TextRenderable, state: ReviewState, se
104
104
  indicator = "\u2588"; // █ full block — unread reply
105
105
  indicatorColor = theme.yellow;
106
106
  } else if (thread.status === "resolved") {
107
- indicator = "\u2713"; // resolved
107
+ indicator = "="; // resolved
108
108
  indicatorColor = theme.green;
109
109
  } else {
110
110
  indicator = "\u258c"; // ▌ half block — has thread
@@ -127,7 +127,7 @@ export function buildPagerNodes(lineNode: TextRenderable, state: ReviewState, se
127
127
  // Gutter: cursor + indicator + line number (dimmed)
128
128
  lineNode.add(TextNodeRenderable.fromString(
129
129
  `${prefix}`,
130
- { fg: isCursor ? theme.text : theme.textDim, bg: isCursor ? theme.backgroundElement : undefined }
130
+ { fg: isCursor ? theme.yellow : theme.textDim, bg: isCursor ? theme.backgroundElement : undefined }
131
131
  ));
132
132
  lineNode.add(TextNodeRenderable.fromString(
133
133
  indicator,
@@ -171,8 +171,8 @@ export function buildPagerNodes(lineNode: TextRenderable, state: ReviewState, se
171
171
  lineNode.add(TextNodeRenderable.fromString(gutterBlank, { fg: theme.textDim }));
172
172
  renderTableBorder(lineNode, tableBlock.colWidths, "bottom");
173
173
  }
174
- } else if (searchQuery) {
175
- // When searching, show colored match segments (no markdown styling)
174
+ } else if (searchQuery && specText.toLowerCase().includes(searchQuery.toLowerCase())) {
175
+ // Line contains search match — show colored match segments (no markdown styling)
176
176
  const escaped = searchQuery.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
177
177
  const caseSensitive = searchQuery !== searchQuery.toLowerCase();
178
178
  const searchRegex = new RegExp(`(${escaped})`, caseSensitive ? "g" : "gi");
@@ -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,
@@ -51,9 +51,11 @@ export function createSpinner(opts: {
51
51
  container.add(text);
52
52
 
53
53
  let frame = 0;
54
+ const startTime = Date.now();
54
55
  const spinInterval = setInterval(() => {
55
56
  frame = (frame + 1) % SPINNER_FRAMES.length;
56
- text.content = `${SPINNER_FRAMES[frame]} ${message}`;
57
+ const elapsed = Math.floor((Date.now() - startTime) / 1000);
58
+ text.content = `${SPINNER_FRAMES[frame]} ${message} (${elapsed}s)`;
57
59
  renderer.requestRender();
58
60
  }, 80);
59
61
 
@@ -60,18 +60,45 @@ export function buildTopBar(
60
60
  t.add(TextNodeRenderable.fromString("!! Spec changed externally", { fg: theme.red, attributes: TextAttributes.BOLD }));
61
61
  }
62
62
 
63
- // Cursor position
63
+ // Cursor position + scroll percentage
64
+ const posLabel = state.cursorLine <= 1 ? "Top"
65
+ : state.cursorLine >= state.lineCount ? "Bot"
66
+ : `${Math.round(((state.cursorLine - 1) / (state.lineCount - 1)) * 100)}%`;
64
67
  t.add(TextNodeRenderable.fromString(" \u00b7 ", { fg: theme.textDim }));
65
- t.add(TextNodeRenderable.fromString(`L${state.cursorLine}/${state.lineCount}`, { fg: theme.textMuted }));
68
+ t.add(TextNodeRenderable.fromString(`L${state.cursorLine}/${state.lineCount} ${posLabel}`, { fg: theme.textMuted }));
66
69
  }
67
70
 
71
+ export type MessageIcon = "warn" | "success" | "info";
72
+
73
+ const ICON_MAP: Record<MessageIcon, { symbol: string; fg: string }> = {
74
+ warn: { symbol: "!", fg: theme.yellow! },
75
+ success: { symbol: "*", fg: theme.green! },
76
+ info: { symbol: "-", fg: theme.blue! },
77
+ };
78
+
68
79
  /**
69
- * Set a transient message on the bottom bar (using TextNodes, not .content).
80
+ * Set a transient message on the bottom bar.
81
+ * With icon: renders as " ⚠ │ message text"
82
+ * Without icon: renders as " message text"
70
83
  */
71
- export function setBottomBarMessage(bar: BottomBarComponents, message: string, fg?: string): void {
84
+ export function setBottomBarMessage(
85
+ bar: BottomBarComponents,
86
+ message: string,
87
+ iconOrFg?: MessageIcon | string,
88
+ ): void {
72
89
  const t = bar.text;
73
90
  t.clear();
74
- t.add(TextNodeRenderable.fromString(message, { fg: fg ?? theme.text }));
91
+
92
+ // Detect if it's an icon type or a raw fg color
93
+ const icon = iconOrFg && iconOrFg in ICON_MAP ? ICON_MAP[iconOrFg as MessageIcon] : null;
94
+ const fg = icon ? icon.fg : (iconOrFg as string | undefined) ?? theme.text;
95
+
96
+ if (icon) {
97
+ t.add(TextNodeRenderable.fromString(` ${icon.symbol} `, { fg: icon.fg }));
98
+ t.add(TextNodeRenderable.fromString(message, { fg: fg! }));
99
+ } else {
100
+ t.add(TextNodeRenderable.fromString(` ${message}`, { fg: fg! }));
101
+ }
75
102
  }
76
103
 
77
104
  /**
@@ -24,6 +24,9 @@ export interface ThreadListOverlay {
24
24
 
25
25
  const MAX_PREVIEW_LENGTH = 50;
26
26
 
27
+ type FilterMode = "all" | "active" | "resolved";
28
+ const FILTER_CYCLE: FilterMode[] = ["all", "active", "resolved"];
29
+
27
30
  function previewText(thread: Thread): string {
28
31
  if (thread.messages.length === 0) return "(empty)";
29
32
  const first = thread.messages[0];
@@ -32,128 +35,179 @@ function previewText(thread: Thread): string {
32
35
  return text.slice(0, MAX_PREVIEW_LENGTH - 1) + "\u2026";
33
36
  }
34
37
 
38
+ function filterThreads(threads: Thread[], mode: FilterMode): Thread[] {
39
+ switch (mode) {
40
+ case "active":
41
+ return threads.filter((t) => t.status === "open" || t.status === "pending");
42
+ case "resolved":
43
+ return threads.filter((t) => t.status === "resolved");
44
+ case "all":
45
+ default:
46
+ return threads;
47
+ }
48
+ }
49
+
50
+ function buildTitle(threads: Thread[], mode: FilterMode): string {
51
+ const activeCount = threads.filter(
52
+ (t) => t.status === "open" || t.status === "pending"
53
+ ).length;
54
+ const total = threads.length;
55
+ const label = mode === "all" ? "all" : mode;
56
+ return `Threads (${activeCount} active, ${total} total) [${label}]`;
57
+ }
58
+
59
+ function threadsToOptions(threads: Thread[]) {
60
+ return threads.map((t) => {
61
+ const icon = STATUS_ICONS[t.status];
62
+ return {
63
+ name: `${icon} #${t.id} line ${t.line}: ${previewText(t)}`,
64
+ description: `${t.status} - ${t.messages.length} message(s)`,
65
+ value: t.line,
66
+ };
67
+ });
68
+ }
69
+
35
70
  /**
36
71
  * Create a thread list overlay showing all threads.
37
72
  * Select + Enter: jump to that thread's line.
73
+ * Ctrl+F: cycle filter (all → active → resolved).
38
74
  * Escape: cancel.
39
75
  */
40
76
  export function createThreadList(opts: ThreadListOptions): ThreadListOverlay {
41
77
  const { renderer, threads, onSelect, onCancel } = opts;
42
78
 
79
+ // Exclude outdated threads from the pool
43
80
  const allThreads = threads.filter(
44
81
  (t) => t.status === "open" || t.status === "pending" || t.status === "resolved"
45
82
  );
46
- const activeCount = threads.filter(
47
- (t) => t.status === "open" || t.status === "pending"
48
- ).length;
83
+
84
+ let filterIndex = 0;
85
+ let currentFilter: FilterMode = FILTER_CYCLE[0];
86
+ let filtered = filterThreads(allThreads, currentFilter);
49
87
 
50
88
  const dialog = createDialog({
51
89
  renderer,
52
- title: `Threads (${activeCount} active, ${allThreads.length} total)`,
90
+ title: buildTitle(allThreads, currentFilter),
53
91
  width: "56%",
54
92
  height: "50%",
55
93
  top: "20%",
56
94
  left: "22%",
57
- borderColor: theme.mauve,
95
+ borderColor: theme.blue,
58
96
  onDismiss: onCancel,
59
97
  hints: THREAD_LIST_HINTS,
60
98
  });
61
99
 
62
- let keyHandler: ((key: KeyEvent) => void) | null = null;
63
-
64
- if (allThreads.length === 0) {
65
- const emptyMsg = new TextRenderable(renderer, {
66
- content: "No threads. Press [Esc] to close.",
67
- width: "100%",
68
- height: 1,
69
- fg: theme.textDim,
70
- wrapMode: "none",
71
- });
72
- dialog.content.add(emptyMsg);
73
- } else {
74
- const selectOptions = allThreads.map((t) => {
75
- const icon = STATUS_ICONS[t.status];
76
- return {
77
- name: `${icon} #${t.id} line ${t.line}: ${previewText(t)}`,
78
- description: `${t.status} - ${t.messages.length} message(s)`,
79
- value: t.line,
80
- };
81
- });
82
-
83
- const select = new SelectRenderable(renderer, {
84
- width: "100%",
85
- flexGrow: 1,
86
- options: selectOptions,
87
- selectedIndex: 0,
88
- backgroundColor: theme.backgroundPanel,
89
- textColor: theme.text,
90
- focusedBackgroundColor: theme.backgroundPanel,
91
- focusedTextColor: theme.text,
92
- selectedBackgroundColor: theme.backgroundElement,
93
- selectedTextColor: "#f5c2e7",
94
- descriptionColor: theme.textDim,
95
- selectedDescriptionColor: theme.textMuted,
96
- showDescription: true,
97
- wrapSelection: true,
98
- });
99
-
100
- dialog.content.add(select);
100
+ const emptyMsg = new TextRenderable(renderer, {
101
+ content: "No threads. Press [Esc] to close.",
102
+ width: "100%",
103
+ height: 1,
104
+ fg: theme.textDim,
105
+ wrapMode: "none",
106
+ visible: filtered.length === 0,
107
+ });
108
+ dialog.content.add(emptyMsg);
109
+
110
+ const select = new SelectRenderable(renderer, {
111
+ width: "100%",
112
+ flexGrow: 1,
113
+ options: threadsToOptions(filtered),
114
+ selectedIndex: 0,
115
+ backgroundColor: theme.backgroundPanel,
116
+ textColor: theme.text,
117
+ focusedBackgroundColor: theme.backgroundPanel,
118
+ focusedTextColor: theme.text,
119
+ selectedBackgroundColor: theme.backgroundElement,
120
+ selectedTextColor: "#f5c2e7",
121
+ descriptionColor: theme.textDim,
122
+ selectedDescriptionColor: theme.textMuted,
123
+ showDescription: true,
124
+ wrapSelection: true,
125
+ visible: filtered.length > 0,
126
+ });
127
+ dialog.content.add(select);
101
128
 
129
+ if (filtered.length > 0) {
102
130
  setTimeout(() => {
103
131
  renderer.focusRenderable(select);
104
132
  renderer.requestRender();
105
133
  }, 0);
134
+ }
135
+
136
+ function applyFilter(): void {
137
+ filtered = filterThreads(allThreads, currentFilter);
138
+ dialog.container.title = ` ${buildTitle(allThreads, currentFilter)} `;
139
+ select.options = threadsToOptions(filtered);
140
+ select.visible = filtered.length > 0;
141
+ emptyMsg.visible = filtered.length === 0;
142
+ if (filtered.length === 0) {
143
+ emptyMsg.content = `No ${currentFilter === "all" ? "" : currentFilter + " "}threads. Press [Ctrl+f] to change filter.`;
144
+ }
145
+ if (filtered.length > 0) {
146
+ setTimeout(() => {
147
+ renderer.focusRenderable(select);
148
+ renderer.requestRender();
149
+ }, 0);
150
+ }
151
+ renderer.requestRender();
152
+ }
106
153
 
107
- // SelectRenderable ITEM_SELECTED event
108
- select.on(SelectRenderableEvents.ITEM_SELECTED, () => {
154
+ // SelectRenderable ITEM_SELECTED event
155
+ select.on(SelectRenderableEvents.ITEM_SELECTED, () => {
156
+ const selected = select.getSelectedOption();
157
+ if (selected && selected.value != null) {
158
+ onSelect(selected.value as number);
159
+ }
160
+ });
161
+
162
+ // Manual key handler — SelectRenderable focus is unreliable
163
+ const keyHandler = (key: KeyEvent) => {
164
+ if (key.name === "q") {
165
+ key.preventDefault();
166
+ key.stopPropagation();
167
+ onCancel();
168
+ return;
169
+ }
170
+ // Ctrl+F: cycle filter
171
+ if (key.ctrl && key.name === "f") {
172
+ key.preventDefault();
173
+ key.stopPropagation();
174
+ filterIndex = (filterIndex + 1) % FILTER_CYCLE.length;
175
+ currentFilter = FILTER_CYCLE[filterIndex];
176
+ applyFilter();
177
+ return;
178
+ }
179
+ if (filtered.length === 0) return;
180
+ if (key.name === "return") {
181
+ key.preventDefault();
182
+ key.stopPropagation();
109
183
  const selected = select.getSelectedOption();
110
184
  if (selected && selected.value != null) {
111
185
  onSelect(selected.value as number);
112
186
  }
113
- });
114
-
115
- // Manual key handler SelectRenderable focus is unreliable
116
- keyHandler = (key: KeyEvent) => {
117
- if (key.name === "q") {
118
- key.preventDefault();
119
- key.stopPropagation();
120
- onCancel();
121
- return;
122
- }
123
- if (key.name === "return" || key.name === "y") {
124
- key.preventDefault();
125
- key.stopPropagation();
126
- const selected = select.getSelectedOption();
127
- if (selected && selected.value != null) {
128
- onSelect(selected.value as number);
129
- }
130
- return;
131
- }
132
- if (key.name === "j" || key.name === "down") {
133
- key.preventDefault();
134
- key.stopPropagation();
135
- select.moveDown();
136
- renderer.requestRender();
137
- return;
138
- }
139
- if (key.name === "k" || key.name === "up") {
140
- key.preventDefault();
141
- key.stopPropagation();
142
- select.moveUp();
143
- renderer.requestRender();
144
- return;
145
- }
146
- };
147
- renderer.keyInput.on("keypress", keyHandler);
148
- }
187
+ return;
188
+ }
189
+ if (key.name === "j" || key.name === "down") {
190
+ key.preventDefault();
191
+ key.stopPropagation();
192
+ select.moveDown();
193
+ renderer.requestRender();
194
+ return;
195
+ }
196
+ if (key.name === "k" || key.name === "up") {
197
+ key.preventDefault();
198
+ key.stopPropagation();
199
+ select.moveUp();
200
+ renderer.requestRender();
201
+ return;
202
+ }
203
+ };
204
+ renderer.keyInput.on("keypress", keyHandler);
149
205
 
150
206
  return {
151
207
  container: dialog.container,
152
208
  cleanup() {
153
209
  dialog.cleanup();
154
- if (keyHandler) {
155
- renderer.keyInput.off("keypress", keyHandler);
156
- }
210
+ renderer.keyInput.off("keypress", keyHandler);
157
211
  },
158
212
  };
159
213
  }
@@ -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,8 @@ 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
+ { key: "Ctrl+f", action: "filter" },
40
41
  { key: "q/Esc", action: "close" },
41
42
  ];
42
43
 
@@ -51,5 +52,5 @@ export const HELP_HINTS: Hint[] = [
51
52
 
52
53
  export const CONFIRM_HINTS: Hint[] = [
53
54
  { key: "y/Enter", action: "yes" },
54
- { key: "q/Esc", action: "no" },
55
+ { key: "q/Esc", action: "cancel" },
55
56
  ];
@@ -29,9 +29,9 @@ export const theme = {
29
29
 
30
30
  export const STATUS_ICONS: Record<string, string> = {
31
31
  open: "\u258c", // ▌ half block
32
- pending: "\u25cb", // circle
33
- resolved: "\u2713", // ✓ checkmark
34
- outdated: "\u223c", // ∼ tilde
32
+ pending: "\u2588", // full block
33
+ resolved: "=",
34
+ outdated: "-",
35
35
  };
36
36
 
37
37
  export const SPLIT_BORDER = {