revspec 0.7.3 → 0.8.1

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
@@ -30,11 +30,11 @@ Install the `/revspec` skill for Claude Code:
30
30
  /plugin install revspec
31
31
  ```
32
32
 
33
- Or manually (symlink, stays updated with repo):
33
+ Or manually (clone + symlink, stays updated with `git pull`):
34
34
 
35
35
  ```bash
36
- mkdir -p ~/.claude/skills
37
- ln -sfn /path/to/revspec/skills/revspec ~/.claude/skills/revspec
36
+ git clone https://github.com/icyrainz/revspec.git ~/.local/share/revspec
37
+ ln -sfn ~/.local/share/revspec/skills/revspec ~/.claude/skills/revspec
38
38
  ```
39
39
 
40
40
  ## Usage
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "revspec",
3
- "version": "0.7.3",
3
+ "version": "0.8.1",
4
4
  "description": "Terminal-based spec review tool with real-time AI conversation",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -113,6 +113,36 @@ export class ReviewState {
113
113
  return this.threads.reduce((max, t) => (t.line > max.line ? t : max)).line;
114
114
  }
115
115
 
116
+ nextHeading(level: number): number | null {
117
+ const prefix = "#".repeat(level) + " ";
118
+ const guard = "#".repeat(level + 1);
119
+ for (let i = this.cursorLine; i < this.specLines.length; i++) {
120
+ const line = this.specLines[i];
121
+ if (line.startsWith(prefix) && !line.startsWith(guard)) return i + 1;
122
+ }
123
+ // Wrap: search from top
124
+ for (let i = 0; i < this.cursorLine - 1; i++) {
125
+ const line = this.specLines[i];
126
+ if (line.startsWith(prefix) && !line.startsWith(guard)) return i + 1;
127
+ }
128
+ return null;
129
+ }
130
+
131
+ prevHeading(level: number): number | null {
132
+ const prefix = "#".repeat(level) + " ";
133
+ const guard = "#".repeat(level + 1);
134
+ for (let i = this.cursorLine - 2; i >= 0; i--) {
135
+ const line = this.specLines[i];
136
+ if (line.startsWith(prefix) && !line.startsWith(guard)) return i + 1;
137
+ }
138
+ // Wrap: search from bottom
139
+ for (let i = this.specLines.length - 1; i >= this.cursorLine; i--) {
140
+ const line = this.specLines[i];
141
+ if (line.startsWith(prefix) && !line.startsWith(guard)) return i + 1;
142
+ }
143
+ return null;
144
+ }
145
+
116
146
  canApprove(): boolean {
117
147
  // No threads = clean approval (spec is good as-is)
118
148
  if (this.threads.length === 0) return true;
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,6 +124,40 @@ export async function runTui(
123
124
  // Command mode state
124
125
  let commandBuffer: string | null = null;
125
126
 
127
+ // Jump list — mirrors vim's :jumps behavior.
128
+ // pushJump() is called BEFORE each big jump to record the departure position.
129
+ // Ctrl+O traverses backward, Ctrl+I forward. Making a new jump while in the
130
+ // middle of the list discards forward history (same as vim).
131
+ const jumpList: number[] = [1];
132
+ let jumpIndex: number = 0;
133
+ const MAX_JUMP_LIST = 50;
134
+
135
+ function pushJump(): void {
136
+ const cur = state.cursorLine;
137
+ // Discard forward history when making a new jump from the middle
138
+ if (jumpIndex < jumpList.length - 1) {
139
+ jumpList.splice(jumpIndex + 1);
140
+ }
141
+ // Don't push duplicate of the list tail
142
+ if (jumpList.length > 0 && jumpList[jumpList.length - 1] === cur) return;
143
+ jumpList.push(cur);
144
+ if (jumpList.length > MAX_JUMP_LIST) jumpList.shift();
145
+ jumpIndex = jumpList.length - 1;
146
+ }
147
+
148
+ function savePrevPosition(): void {
149
+ pushJump();
150
+ }
151
+
152
+ // Map visual row back to spec line number (for H/M/L)
153
+ function visualRowToSpecLine(targetRow: number): number {
154
+ for (let i = 0; i < state.specLines.length; i++) {
155
+ const row = i + countExtraVisualLines(state.specLines, i);
156
+ if (row >= targetRow) return i + 1;
157
+ }
158
+ return state.lineCount;
159
+ }
160
+
126
161
  // Active spec poll interval (for submit spinner leak prevention)
127
162
  let activeSpecPoll: ReturnType<typeof setInterval> | null = null;
128
163
 
@@ -188,7 +223,7 @@ export async function runTui(
188
223
  const { open, pending } = state.activeThreadCount();
189
224
  const total = open + pending;
190
225
  if (total > 0) {
191
- setBottomBarMessage(bottomBar, ` \u26a0 ${total} unresolved thread(s). Use :q! to force quit`);
226
+ setBottomBarMessage(bottomBar, `${total} unresolved thread(s). Use :q! to force quit`, "warn");
192
227
  renderer.requestRender();
193
228
  setTimeout(() => { refreshPager(); }, 2000);
194
229
  return "stay";
@@ -203,12 +238,13 @@ export async function runTui(
203
238
  // :{N} — jump to line number
204
239
  const lineNum = parseInt(cmd, 10);
205
240
  if (!isNaN(lineNum) && lineNum > 0) {
241
+ savePrevPosition();
206
242
  state.cursorLine = Math.min(lineNum, state.lineCount);
207
243
  ensureCursorVisible();
208
244
  refreshPager();
209
245
  return "stay";
210
246
  }
211
- setBottomBarMessage(bottomBar, ` Unknown command: ${cmd}`);
247
+ setBottomBarMessage(bottomBar, `Unknown command: ${cmd}`, "warn");
212
248
  renderer.requestRender();
213
249
  setTimeout(() => { refreshPager(); }, 1500);
214
250
  return "stay";
@@ -257,6 +293,7 @@ export async function runTui(
257
293
  dismissOverlay();
258
294
  },
259
295
  onCancel: () => {
296
+ if (existingThread) state.markRead(existingThread.id);
260
297
  dismissOverlay();
261
298
  },
262
299
  });
@@ -271,6 +308,7 @@ export async function runTui(
271
308
  cursorLine: state.cursorLine,
272
309
  onResult: (lineNumber: number, query: string) => {
273
310
  searchQuery = query;
311
+ savePrevPosition();
274
312
  state.cursorLine = lineNumber;
275
313
  dismissOverlay();
276
314
  ensureCursorVisible();
@@ -289,6 +327,7 @@ export async function runTui(
289
327
  renderer,
290
328
  threads: state.threads,
291
329
  onSelect: (lineNumber: number) => {
330
+ savePrevPosition();
292
331
  state.cursorLine = lineNumber;
293
332
  dismissOverlay();
294
333
  ensureCursorVisible();
@@ -390,6 +429,16 @@ export async function runTui(
390
429
  { key: "[t", action: "prev-thread" },
391
430
  { key: "]r", action: "next-unread" },
392
431
  { key: "[r", action: "prev-unread" },
432
+ { key: "]1", action: "next-h1" },
433
+ { key: "[1", action: "prev-h1" },
434
+ { key: "]2", action: "next-h2" },
435
+ { key: "[2", action: "prev-h2" },
436
+ { key: "]3", action: "next-h3" },
437
+ { key: "[3", action: "prev-h3" },
438
+ { key: "''", action: "jump-back" },
439
+ { key: "H", action: "screen-top" },
440
+ { key: "M", action: "screen-middle" },
441
+ { key: "L", action: "screen-bottom" },
393
442
  { key: "zz", action: "center-cursor" },
394
443
  { key: "?", action: "help" },
395
444
  { key: "/", action: "search" },
@@ -398,6 +447,10 @@ export async function runTui(
398
447
  const keybinds = createKeybindRegistry(bindings, 300);
399
448
 
400
449
  refreshPager();
450
+ if (state.threads.length === 0) {
451
+ setBottomBarMessage(bottomBar, "Navigate to a line and press c to start reviewing", "info");
452
+ renderer.requestRender();
453
+ }
401
454
  renderer.start();
402
455
 
403
456
  // 8. Set up keybinding handler
@@ -460,6 +513,38 @@ export async function runTui(
460
513
  return;
461
514
  }
462
515
 
516
+ // Ctrl+O: jump back in jump list
517
+ if (key.ctrl && key.name === "o") {
518
+ // Starting backward traversal from head — save current position first
519
+ // (without splicing forward history, unlike pushJump)
520
+ if (jumpIndex === jumpList.length - 1) {
521
+ const cur = state.cursorLine;
522
+ if (jumpList[jumpIndex] !== cur) {
523
+ jumpList.push(cur);
524
+ if (jumpList.length > MAX_JUMP_LIST) jumpList.shift();
525
+ jumpIndex = jumpList.length - 1;
526
+ }
527
+ }
528
+ if (jumpIndex > 0) {
529
+ jumpIndex--;
530
+ state.cursorLine = Math.min(jumpList[jumpIndex], state.lineCount);
531
+ ensureCursorVisible();
532
+ refreshPager();
533
+ }
534
+ return;
535
+ }
536
+
537
+ // Ctrl+I / Tab: jump forward in jump list
538
+ if ((key.ctrl && key.name === "i") || key.name === "tab") {
539
+ if (jumpIndex < jumpList.length - 1) {
540
+ jumpIndex++;
541
+ state.cursorLine = Math.min(jumpList[jumpIndex], state.lineCount);
542
+ ensureCursorVisible();
543
+ refreshPager();
544
+ }
545
+ return;
546
+ }
547
+
463
548
  // Escape clears search highlights
464
549
  if (key.name === "escape") {
465
550
  if (searchQuery) {
@@ -512,11 +597,13 @@ export async function runTui(
512
597
  break;
513
598
  }
514
599
  case "goto-bottom":
600
+ savePrevPosition();
515
601
  state.cursorLine = state.lineCount;
516
602
  ensureCursorVisible();
517
603
  refreshPager();
518
604
  break;
519
605
  case "goto-top":
606
+ savePrevPosition();
520
607
  state.cursorLine = 1;
521
608
  ensureCursorVisible();
522
609
  refreshPager();
@@ -533,29 +620,47 @@ export async function runTui(
533
620
  if (searchQuery) {
534
621
  const match = findNextMatch(state.specLines, searchQuery, state.cursorLine, 1);
535
622
  if (match !== null) {
623
+ const wrapped = match <= state.cursorLine;
624
+ savePrevPosition();
536
625
  state.cursorLine = match;
537
626
  ensureCursorVisible();
627
+ refreshPager();
628
+ if (wrapped) {
629
+ setBottomBarMessage(bottomBar, "Search wrapped to top", "info");
630
+ renderer.requestRender();
631
+ setTimeout(() => { refreshPager(); }, 1200);
632
+ }
633
+ } else {
634
+ refreshPager();
538
635
  }
539
636
  } else {
540
- setBottomBarMessage(bottomBar, " No active search \u2014 use / to search");
637
+ setBottomBarMessage(bottomBar, "No active search \u2014 use / to search");
541
638
  renderer.requestRender();
542
639
  setTimeout(() => { refreshPager(); }, 1500);
543
640
  }
544
- refreshPager();
545
641
  break;
546
642
  case "search-prev":
547
643
  if (searchQuery) {
548
644
  const match = findNextMatch(state.specLines, searchQuery, state.cursorLine, -1);
549
645
  if (match !== null) {
646
+ const wrapped = match >= state.cursorLine;
647
+ savePrevPosition();
550
648
  state.cursorLine = match;
551
649
  ensureCursorVisible();
650
+ refreshPager();
651
+ if (wrapped) {
652
+ setBottomBarMessage(bottomBar, "Search wrapped to bottom", "info");
653
+ renderer.requestRender();
654
+ setTimeout(() => { refreshPager(); }, 1200);
655
+ }
656
+ } else {
657
+ refreshPager();
552
658
  }
553
659
  } else {
554
- setBottomBarMessage(bottomBar, " No active search \u2014 use / to search");
660
+ setBottomBarMessage(bottomBar, "No active search \u2014 use / to search");
555
661
  renderer.requestRender();
556
662
  setTimeout(() => { refreshPager(); }, 1500);
557
663
  }
558
- refreshPager();
559
664
  break;
560
665
  case "comment":
561
666
  showCommentInput();
@@ -571,14 +676,13 @@ export async function runTui(
571
676
  state.markRead(thread.id);
572
677
  appendEvent(jsonlPath, { type: wasResolved ? "unresolve" : "resolve", threadId: thread.id, author: "reviewer", ts: Date.now() });
573
678
  refreshPager();
574
- const msg = wasResolved
575
- ? ` \u21a9 Reopened thread #${thread.id}`
576
- : ` \u2714 Resolved thread #${thread.id}`;
577
- setBottomBarMessage(bottomBar, msg);
679
+ setBottomBarMessage(bottomBar,
680
+ wasResolved ? `Reopened thread #${thread.id}` : `Resolved thread #${thread.id}`,
681
+ "success");
578
682
  renderer.requestRender();
579
683
  setTimeout(() => { refreshPager(); }, 1500);
580
684
  } else {
581
- setBottomBarMessage(bottomBar, " No thread on this line");
685
+ setBottomBarMessage(bottomBar, "No thread on this line");
582
686
  renderer.requestRender();
583
687
  setTimeout(() => { refreshPager(); }, 1500);
584
688
  }
@@ -592,7 +696,7 @@ export async function runTui(
592
696
  appendEvent(jsonlPath, { type: "resolve", threadId: t.id, author: "reviewer", ts: Date.now() });
593
697
  }
594
698
  refreshPager();
595
- setBottomBarMessage(bottomBar, ` \u2714 Resolved ${pending} pending thread(s)`);
699
+ setBottomBarMessage(bottomBar, `Resolved ${pending} pending thread(s)`, "success");
596
700
  renderer.requestRender();
597
701
  setTimeout(() => { refreshPager(); }, 1500);
598
702
  break;
@@ -600,7 +704,7 @@ export async function runTui(
600
704
  case "delete-draft": {
601
705
  const thread = state.threadAtLine(state.cursorLine);
602
706
  if (!thread) {
603
- setBottomBarMessage(bottomBar, " No thread on this line");
707
+ setBottomBarMessage(bottomBar, "No thread on this line");
604
708
  renderer.requestRender();
605
709
  setTimeout(() => { refreshPager(); }, 1500);
606
710
  break;
@@ -614,7 +718,7 @@ export async function runTui(
614
718
  state.deleteThread(thread.id);
615
719
  appendEvent(jsonlPath, { type: "delete", threadId: thread.id, author: "reviewer", ts: Date.now() });
616
720
  refreshPager();
617
- setBottomBarMessage(bottomBar, ` \u2714 Deleted thread #${thread.id}`);
721
+ setBottomBarMessage(bottomBar, `Deleted thread #${thread.id}`, "success");
618
722
  renderer.requestRender();
619
723
  setTimeout(() => { refreshPager(); }, 1500);
620
724
  },
@@ -627,16 +731,17 @@ export async function runTui(
627
731
  }
628
732
  case "submit":
629
733
  if (state.threads.length === 0) {
630
- setBottomBarMessage(bottomBar, " No threads to submit.");
734
+ setBottomBarMessage(bottomBar, "No threads to submit.");
631
735
  renderer.requestRender();
632
736
  break;
633
737
  }
634
738
  unresolvedGate(() => {
635
739
  appendEvent(jsonlPath, { type: "submit", author: "reviewer", ts: Date.now() });
636
740
 
741
+ const count = state.threads.length;
637
742
  const spinnerOverlay = createSpinner({
638
743
  renderer,
639
- message: "Waiting for agent to update spec...",
744
+ message: `Submitting ${count} thread${count === 1 ? "" : "s"}...`,
640
745
  onCancel: () => {
641
746
  clearInterval(activeSpecPoll!);
642
747
  activeSpecPoll = null;
@@ -646,7 +751,7 @@ export async function runTui(
646
751
  clearInterval(activeSpecPoll!);
647
752
  activeSpecPoll = null;
648
753
  dismissOverlay();
649
- setBottomBarMessage(bottomBar, " \u26a0 Agent did not update spec. Press S to retry.");
754
+ setBottomBarMessage(bottomBar, "Agent did not update spec. Press S to retry.", "warn");
650
755
  renderer.requestRender();
651
756
  setTimeout(() => { refreshPager(); }, 3000);
652
757
  },
@@ -669,6 +774,9 @@ export async function runTui(
669
774
  searchQuery = null;
670
775
  ensureCursorVisible();
671
776
  refreshPager();
777
+ setBottomBarMessage(bottomBar, "Spec rewritten \u2014 review cleared", "success");
778
+ renderer.requestRender();
779
+ setTimeout(() => { refreshPager(); }, 2500);
672
780
  }
673
781
  } catch {}
674
782
  }, 500);
@@ -693,11 +801,12 @@ export async function runTui(
693
801
  case "next-thread": {
694
802
  const next = state.nextThread();
695
803
  if (next !== null) {
804
+ savePrevPosition();
696
805
  state.cursorLine = next;
697
806
  ensureCursorVisible();
698
807
  refreshPager();
699
808
  } else {
700
- setBottomBarMessage(bottomBar, " No threads");
809
+ setBottomBarMessage(bottomBar, "No threads");
701
810
  renderer.requestRender();
702
811
  setTimeout(() => { refreshPager(); }, 1500);
703
812
  }
@@ -706,11 +815,12 @@ export async function runTui(
706
815
  case "prev-thread": {
707
816
  const prev = state.prevThread();
708
817
  if (prev !== null) {
818
+ savePrevPosition();
709
819
  state.cursorLine = prev;
710
820
  ensureCursorVisible();
711
821
  refreshPager();
712
822
  } else {
713
- setBottomBarMessage(bottomBar, " No threads");
823
+ setBottomBarMessage(bottomBar, "No threads");
714
824
  renderer.requestRender();
715
825
  setTimeout(() => { refreshPager(); }, 1500);
716
826
  }
@@ -719,11 +829,12 @@ export async function runTui(
719
829
  case "next-unread": {
720
830
  const nextLine = state.nextUnreadThread();
721
831
  if (nextLine !== null) {
832
+ savePrevPosition();
722
833
  state.cursorLine = nextLine;
723
834
  ensureCursorVisible();
724
835
  refreshPager();
725
836
  } else {
726
- setBottomBarMessage(bottomBar, " No unread replies");
837
+ setBottomBarMessage(bottomBar, "No unread replies");
727
838
  renderer.requestRender();
728
839
  setTimeout(() => { refreshPager(); }, 1500);
729
840
  }
@@ -732,16 +843,87 @@ export async function runTui(
732
843
  case "prev-unread": {
733
844
  const prevLine = state.prevUnreadThread();
734
845
  if (prevLine !== null) {
846
+ savePrevPosition();
735
847
  state.cursorLine = prevLine;
736
848
  ensureCursorVisible();
737
849
  refreshPager();
738
850
  } else {
739
- setBottomBarMessage(bottomBar, " No unread replies");
851
+ setBottomBarMessage(bottomBar, "No unread replies");
852
+ renderer.requestRender();
853
+ setTimeout(() => { refreshPager(); }, 1500);
854
+ }
855
+ break;
856
+ }
857
+ case "next-h1":
858
+ case "next-h2":
859
+ case "next-h3": {
860
+ const level = parseInt(action.slice(-1));
861
+ const next = state.nextHeading(level);
862
+ if (next !== null) {
863
+ savePrevPosition();
864
+ state.cursorLine = next;
865
+ ensureCursorVisible();
866
+ refreshPager();
867
+ } else {
868
+ setBottomBarMessage(bottomBar, `No h${level} headings`);
740
869
  renderer.requestRender();
741
870
  setTimeout(() => { refreshPager(); }, 1500);
742
871
  }
743
872
  break;
744
873
  }
874
+ case "prev-h1":
875
+ case "prev-h2":
876
+ case "prev-h3": {
877
+ const level = parseInt(action.slice(-1));
878
+ const prev = state.prevHeading(level);
879
+ if (prev !== null) {
880
+ savePrevPosition();
881
+ state.cursorLine = prev;
882
+ ensureCursorVisible();
883
+ refreshPager();
884
+ } else {
885
+ setBottomBarMessage(bottomBar, `No h${level} headings`);
886
+ renderer.requestRender();
887
+ setTimeout(() => { refreshPager(); }, 1500);
888
+ }
889
+ break;
890
+ }
891
+ case "jump-back": {
892
+ // '' swaps between current position and last jump entry
893
+ if (jumpList.length > 1) {
894
+ const cur = state.cursorLine;
895
+ const prevIdx = jumpIndex > 0 ? jumpIndex - 1 : 0;
896
+ const target = jumpList[prevIdx];
897
+ // Record current position at our spot so '' can swap back
898
+ jumpList[jumpIndex] = cur;
899
+ jumpIndex = prevIdx;
900
+ state.cursorLine = Math.min(target, state.lineCount);
901
+ ensureCursorVisible();
902
+ refreshPager();
903
+ }
904
+ break;
905
+ }
906
+ case "screen-top": {
907
+ const topRow = pager.scrollBox.scrollTop;
908
+ savePrevPosition();
909
+ state.cursorLine = visualRowToSpecLine(topRow);
910
+ refreshPager();
911
+ break;
912
+ }
913
+ case "screen-middle": {
914
+ const midRow = pager.scrollBox.scrollTop + Math.floor(pageSize() / 2);
915
+ savePrevPosition();
916
+ state.cursorLine = visualRowToSpecLine(midRow);
917
+ refreshPager();
918
+ break;
919
+ }
920
+ case "screen-bottom": {
921
+ const botRow = pager.scrollBox.scrollTop + pageSize() - 1;
922
+ savePrevPosition();
923
+ state.cursorLine = visualRowToSpecLine(botRow);
924
+ refreshPager();
925
+ break;
926
+ }
745
927
  case "help":
746
928
  showHelpOverlay();
747
929
  break;
@@ -168,8 +168,10 @@ function createThreadView(
168
168
  const dialog = createDialog({
169
169
  renderer,
170
170
  title,
171
- width: "80%",
172
- height: "85%",
171
+ width: "70%",
172
+ height: "80%",
173
+ top: "8%",
174
+ left: "15%",
173
175
  borderColor: theme.blue,
174
176
  onDismiss: onCancel,
175
177
  hints: insertHints,
@@ -220,18 +222,29 @@ function createThreadView(
220
222
  scrollBox.add(renderMessage(msg));
221
223
  }
222
224
 
223
- // --- Separator ---
224
- const sep = new TextRenderable(renderer, {
225
- content: "\u2500".repeat(40),
225
+ // --- Layout: pack bottom elements into a single container ---
226
+ // Outside tmux, opentui's ScrollBox expands over intermediate siblings.
227
+ // Keeping exactly 2 direct children (ScrollBox + bottomPanel) avoids the issue,
228
+ // matching the pattern that works in help/thread-list dialogs.
229
+ dialog.container.remove(dialog.hintBox.id);
230
+ const bottomPanel = new BoxRenderable(renderer, {
231
+ width: "100%",
232
+ height: 6, // separator(1) + textarea(4) + hints(1)
233
+ flexShrink: 0,
234
+ flexGrow: 0,
235
+ flexDirection: "column",
236
+ });
237
+ const sep = new BoxRenderable(renderer, {
226
238
  width: "100%",
227
239
  height: 1,
228
- fg: theme.backgroundElement,
229
- wrapMode: "none",
240
+ flexShrink: 0,
241
+ border: ["top"],
242
+ borderColor: theme.border,
230
243
  });
231
- dialog.container.add(sep);
232
-
233
- // --- Textarea (visible in both modes, focused only in insert) ---
234
- dialog.container.add(textarea);
244
+ bottomPanel.add(sep);
245
+ bottomPanel.add(textarea);
246
+ bottomPanel.add(dialog.hintBox);
247
+ dialog.container.add(bottomPanel);
235
248
 
236
249
  // --- Mode helpers (need dialog.setHints available) ---
237
250
  function enterInsert(): void {
@@ -31,10 +31,10 @@ export function createConfirm(opts: ConfirmOptions): ConfirmOverlay {
31
31
  const dialog = createDialog({
32
32
  renderer,
33
33
  title,
34
- width: "50%",
34
+ width: "44%",
35
35
  height: 9,
36
- top: "35%",
37
- left: "25%",
36
+ top: "30%",
37
+ left: "28%",
38
38
  borderColor: theme.warning,
39
39
  onDismiss: onCancel,
40
40
  hints: CONFIRM_HINTS,
package/src/tui/help.ts CHANGED
@@ -50,10 +50,10 @@ export function createHelp(opts: {
50
50
  const dialog = createDialog({
51
51
  renderer,
52
52
  title: "Help",
53
- width: "60%",
54
- height: Math.min(34, renderer.height - 2),
53
+ width: "64%",
54
+ height: Math.min(32, renderer.height - 4),
55
55
  top: "10%",
56
- left: "20%",
56
+ left: "18%",
57
57
  borderColor: theme.info,
58
58
  onDismiss: onClose,
59
59
  hints: HELP_HINTS,
@@ -92,6 +92,12 @@ export function createHelp(opts: {
92
92
  " Esc Clear search",
93
93
  " ]t/[t Next/prev thread",
94
94
  " ]r/[r Next/prev unread",
95
+ " ]1/[1 Next/prev h1 heading",
96
+ " ]2/[2 Next/prev h2 heading",
97
+ " ]3/[3 Next/prev h3 heading",
98
+ " Ctrl+o/i Jump list back/forward",
99
+ " '' Jump to previous position",
100
+ " H/M/L Screen top/middle/bottom",
95
101
  ]);
96
102
 
97
103
  addHelpSection(dialog.content, renderer, "Review", [
@@ -99,7 +105,7 @@ export function createHelp(opts: {
99
105
  " r Resolve thread (toggle)",
100
106
  " R Resolve all pending",
101
107
  " dd Delete thread",
102
- " t List threads",
108
+ " t List threads (Ctrl+f to filter)",
103
109
  " S Submit for rewrite",
104
110
  " A Approve spec",
105
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
@@ -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");
@@ -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)`,
53
- width: "70%",
54
- height: "60%",
55
- top: "15%",
56
- left: "15%",
90
+ title: buildTitle(allThreads, currentFilter),
91
+ width: "56%",
92
+ height: "50%",
93
+ top: "20%",
94
+ left: "22%",
57
95
  borderColor: theme.mauve,
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" || key.name === "y") {
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
  }
@@ -23,6 +23,7 @@ export interface DialogOptions {
23
23
  export interface DialogComponents {
24
24
  container: BoxRenderable;
25
25
  content: ScrollBoxRenderable;
26
+ hintBox: BoxRenderable;
26
27
  hintText: TextRenderable;
27
28
  setHints: (hints: Hint[]) => void;
28
29
  cleanup: () => void;
@@ -102,5 +103,5 @@ export function createDialog(opts: DialogOptions): DialogComponents {
102
103
  renderer.keyInput.off("keypress", keyHandler);
103
104
  }
104
105
 
105
- return { container, content, hintText, setHints, cleanup };
106
+ return { container, content, hintBox, hintText, setHints, cleanup };
106
107
  }
@@ -37,6 +37,7 @@ export const THREAD_INSERT_HINTS: Hint[] = [
37
37
  export const THREAD_LIST_HINTS: Hint[] = [
38
38
  { key: "j/k", action: "navigate" },
39
39
  { key: "y/Enter", action: "jump" },
40
+ { key: "Ctrl+f", action: "filter" },
40
41
  { key: "q/Esc", action: "close" },
41
42
  ];
42
43
 
@@ -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 = {