revspec 0.8.0 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "revspec",
3
- "version": "0.8.0",
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",
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,29 @@ 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
+ // 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
+
128
148
  function savePrevPosition(): void {
129
- prevCursorLine = state.cursorLine;
149
+ pushJump();
130
150
  }
131
151
 
132
152
  // Map visual row back to spec line number (for H/M/L)
@@ -203,7 +223,7 @@ export async function runTui(
203
223
  const { open, pending } = state.activeThreadCount();
204
224
  const total = open + pending;
205
225
  if (total > 0) {
206
- 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");
207
227
  renderer.requestRender();
208
228
  setTimeout(() => { refreshPager(); }, 2000);
209
229
  return "stay";
@@ -224,7 +244,7 @@ export async function runTui(
224
244
  refreshPager();
225
245
  return "stay";
226
246
  }
227
- setBottomBarMessage(bottomBar, ` Unknown command: ${cmd}`);
247
+ setBottomBarMessage(bottomBar, `Unknown command: ${cmd}`, "warn");
228
248
  renderer.requestRender();
229
249
  setTimeout(() => { refreshPager(); }, 1500);
230
250
  return "stay";
@@ -273,6 +293,7 @@ export async function runTui(
273
293
  dismissOverlay();
274
294
  },
275
295
  onCancel: () => {
296
+ if (existingThread) state.markRead(existingThread.id);
276
297
  dismissOverlay();
277
298
  },
278
299
  });
@@ -426,6 +447,10 @@ export async function runTui(
426
447
  const keybinds = createKeybindRegistry(bindings, 300);
427
448
 
428
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
+ }
429
454
  renderer.start();
430
455
 
431
456
  // 8. Set up keybinding handler
@@ -488,6 +513,38 @@ export async function runTui(
488
513
  return;
489
514
  }
490
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
+
491
548
  // Escape clears search highlights
492
549
  if (key.name === "escape") {
493
550
  if (searchQuery) {
@@ -563,31 +620,47 @@ export async function runTui(
563
620
  if (searchQuery) {
564
621
  const match = findNextMatch(state.specLines, searchQuery, state.cursorLine, 1);
565
622
  if (match !== null) {
623
+ const wrapped = match <= state.cursorLine;
566
624
  savePrevPosition();
567
625
  state.cursorLine = match;
568
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();
569
635
  }
570
636
  } else {
571
- setBottomBarMessage(bottomBar, " No active search \u2014 use / to search");
637
+ setBottomBarMessage(bottomBar, "No active search \u2014 use / to search");
572
638
  renderer.requestRender();
573
639
  setTimeout(() => { refreshPager(); }, 1500);
574
640
  }
575
- refreshPager();
576
641
  break;
577
642
  case "search-prev":
578
643
  if (searchQuery) {
579
644
  const match = findNextMatch(state.specLines, searchQuery, state.cursorLine, -1);
580
645
  if (match !== null) {
646
+ const wrapped = match >= state.cursorLine;
581
647
  savePrevPosition();
582
648
  state.cursorLine = match;
583
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();
584
658
  }
585
659
  } else {
586
- setBottomBarMessage(bottomBar, " No active search \u2014 use / to search");
660
+ setBottomBarMessage(bottomBar, "No active search \u2014 use / to search");
587
661
  renderer.requestRender();
588
662
  setTimeout(() => { refreshPager(); }, 1500);
589
663
  }
590
- refreshPager();
591
664
  break;
592
665
  case "comment":
593
666
  showCommentInput();
@@ -603,14 +676,13 @@ export async function runTui(
603
676
  state.markRead(thread.id);
604
677
  appendEvent(jsonlPath, { type: wasResolved ? "unresolve" : "resolve", threadId: thread.id, author: "reviewer", ts: Date.now() });
605
678
  refreshPager();
606
- const msg = wasResolved
607
- ? ` \u21a9 Reopened thread #${thread.id}`
608
- : ` \u2714 Resolved thread #${thread.id}`;
609
- setBottomBarMessage(bottomBar, msg);
679
+ setBottomBarMessage(bottomBar,
680
+ wasResolved ? `Reopened thread #${thread.id}` : `Resolved thread #${thread.id}`,
681
+ "success");
610
682
  renderer.requestRender();
611
683
  setTimeout(() => { refreshPager(); }, 1500);
612
684
  } else {
613
- setBottomBarMessage(bottomBar, " No thread on this line");
685
+ setBottomBarMessage(bottomBar, "No thread on this line");
614
686
  renderer.requestRender();
615
687
  setTimeout(() => { refreshPager(); }, 1500);
616
688
  }
@@ -624,7 +696,7 @@ export async function runTui(
624
696
  appendEvent(jsonlPath, { type: "resolve", threadId: t.id, author: "reviewer", ts: Date.now() });
625
697
  }
626
698
  refreshPager();
627
- setBottomBarMessage(bottomBar, ` \u2714 Resolved ${pending} pending thread(s)`);
699
+ setBottomBarMessage(bottomBar, `Resolved ${pending} pending thread(s)`, "success");
628
700
  renderer.requestRender();
629
701
  setTimeout(() => { refreshPager(); }, 1500);
630
702
  break;
@@ -632,7 +704,7 @@ export async function runTui(
632
704
  case "delete-draft": {
633
705
  const thread = state.threadAtLine(state.cursorLine);
634
706
  if (!thread) {
635
- setBottomBarMessage(bottomBar, " No thread on this line");
707
+ setBottomBarMessage(bottomBar, "No thread on this line");
636
708
  renderer.requestRender();
637
709
  setTimeout(() => { refreshPager(); }, 1500);
638
710
  break;
@@ -646,7 +718,7 @@ export async function runTui(
646
718
  state.deleteThread(thread.id);
647
719
  appendEvent(jsonlPath, { type: "delete", threadId: thread.id, author: "reviewer", ts: Date.now() });
648
720
  refreshPager();
649
- setBottomBarMessage(bottomBar, ` \u2714 Deleted thread #${thread.id}`);
721
+ setBottomBarMessage(bottomBar, `Deleted thread #${thread.id}`, "success");
650
722
  renderer.requestRender();
651
723
  setTimeout(() => { refreshPager(); }, 1500);
652
724
  },
@@ -659,16 +731,17 @@ export async function runTui(
659
731
  }
660
732
  case "submit":
661
733
  if (state.threads.length === 0) {
662
- setBottomBarMessage(bottomBar, " No threads to submit.");
734
+ setBottomBarMessage(bottomBar, "No threads to submit.");
663
735
  renderer.requestRender();
664
736
  break;
665
737
  }
666
738
  unresolvedGate(() => {
667
739
  appendEvent(jsonlPath, { type: "submit", author: "reviewer", ts: Date.now() });
668
740
 
741
+ const count = state.threads.length;
669
742
  const spinnerOverlay = createSpinner({
670
743
  renderer,
671
- message: "Waiting for agent to update spec...",
744
+ message: `Submitting ${count} thread${count === 1 ? "" : "s"}...`,
672
745
  onCancel: () => {
673
746
  clearInterval(activeSpecPoll!);
674
747
  activeSpecPoll = null;
@@ -678,7 +751,7 @@ export async function runTui(
678
751
  clearInterval(activeSpecPoll!);
679
752
  activeSpecPoll = null;
680
753
  dismissOverlay();
681
- 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");
682
755
  renderer.requestRender();
683
756
  setTimeout(() => { refreshPager(); }, 3000);
684
757
  },
@@ -701,6 +774,9 @@ export async function runTui(
701
774
  searchQuery = null;
702
775
  ensureCursorVisible();
703
776
  refreshPager();
777
+ setBottomBarMessage(bottomBar, "Spec rewritten \u2014 review cleared", "success");
778
+ renderer.requestRender();
779
+ setTimeout(() => { refreshPager(); }, 2500);
704
780
  }
705
781
  } catch {}
706
782
  }, 500);
@@ -730,7 +806,7 @@ export async function runTui(
730
806
  ensureCursorVisible();
731
807
  refreshPager();
732
808
  } else {
733
- setBottomBarMessage(bottomBar, " No threads");
809
+ setBottomBarMessage(bottomBar, "No threads");
734
810
  renderer.requestRender();
735
811
  setTimeout(() => { refreshPager(); }, 1500);
736
812
  }
@@ -744,7 +820,7 @@ export async function runTui(
744
820
  ensureCursorVisible();
745
821
  refreshPager();
746
822
  } else {
747
- setBottomBarMessage(bottomBar, " No threads");
823
+ setBottomBarMessage(bottomBar, "No threads");
748
824
  renderer.requestRender();
749
825
  setTimeout(() => { refreshPager(); }, 1500);
750
826
  }
@@ -758,7 +834,7 @@ export async function runTui(
758
834
  ensureCursorVisible();
759
835
  refreshPager();
760
836
  } else {
761
- setBottomBarMessage(bottomBar, " No unread replies");
837
+ setBottomBarMessage(bottomBar, "No unread replies");
762
838
  renderer.requestRender();
763
839
  setTimeout(() => { refreshPager(); }, 1500);
764
840
  }
@@ -772,7 +848,7 @@ export async function runTui(
772
848
  ensureCursorVisible();
773
849
  refreshPager();
774
850
  } else {
775
- setBottomBarMessage(bottomBar, " No unread replies");
851
+ setBottomBarMessage(bottomBar, "No unread replies");
776
852
  renderer.requestRender();
777
853
  setTimeout(() => { refreshPager(); }, 1500);
778
854
  }
@@ -789,7 +865,7 @@ export async function runTui(
789
865
  ensureCursorVisible();
790
866
  refreshPager();
791
867
  } else {
792
- setBottomBarMessage(bottomBar, ` No h${level} headings`);
868
+ setBottomBarMessage(bottomBar, `No h${level} headings`);
793
869
  renderer.requestRender();
794
870
  setTimeout(() => { refreshPager(); }, 1500);
795
871
  }
@@ -806,18 +882,25 @@ export async function runTui(
806
882
  ensureCursorVisible();
807
883
  refreshPager();
808
884
  } else {
809
- setBottomBarMessage(bottomBar, ` No h${level} headings`);
885
+ setBottomBarMessage(bottomBar, `No h${level} headings`);
810
886
  renderer.requestRender();
811
887
  setTimeout(() => { refreshPager(); }, 1500);
812
888
  }
813
889
  break;
814
890
  }
815
891
  case "jump-back": {
816
- const tmp = state.cursorLine;
817
- state.cursorLine = prevCursorLine;
818
- prevCursorLine = tmp;
819
- ensureCursorVisible();
820
- refreshPager();
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
+ }
821
904
  break;
822
905
  }
823
906
  case "screen-top": {
package/src/tui/help.ts CHANGED
@@ -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
@@ -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,24 +35,59 @@ 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%",
@@ -59,101 +97,117 @@ export function createThreadList(opts: ThreadListOptions): ThreadListOverlay {
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
  }
@@ -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 = {