revspec 0.2.2 → 0.4.0

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/src/tui/app.ts CHANGED
@@ -13,10 +13,10 @@ import { mergeJsonlIntoReview } from "../protocol/live-merge";
13
13
  import type { Thread } from "../protocol/types";
14
14
  import { ReviewState } from "../state/review-state";
15
15
  import { createLiveWatcher, type LiveWatcher } from "./live-watcher";
16
- import { buildPagerContent, createPager, togglePagerMode, ensureLineMode, type PagerComponents } from "./pager";
16
+ import { buildPagerNodes, createPager, countExtraVisualLines, type PagerComponents } from "./pager";
17
17
  import {
18
- buildTopBarText,
19
- buildBottomBarText,
18
+ buildTopBar,
19
+ buildBottomBar,
20
20
  createTopBar,
21
21
  createBottomBar,
22
22
  type TopBarComponents,
@@ -28,11 +28,13 @@ import { createSearch } from "./search";
28
28
  import { createThreadList } from "./thread-list";
29
29
  import { createConfirm } from "./confirm";
30
30
  import { createHelp } from "./help";
31
+ import { createKeybindRegistry, type KeyBinding } from "./ui/keybinds";
31
32
 
32
33
  export async function runTui(
33
34
  specFile: string,
34
35
  reviewPath: string,
35
- draftPath: string
36
+ draftPath: string,
37
+ version?: string
36
38
  ): Promise<void> {
37
39
  // 1. Read spec file into lines
38
40
  const specContent = readFileSync(specFile, "utf8");
@@ -99,21 +101,20 @@ export async function runTui(
99
101
  useMouse: false,
100
102
  });
101
103
 
102
- // 6. Build layout: top bar, pager, bottom bar in a column
104
+ // 6. Build layout (opencode pattern): flex column, scrollbox fills middle
103
105
  const rootBox = new BoxRenderable(renderer, {
104
- width: "100%",
105
- height: "100%",
106
+ flexGrow: 1,
106
107
  flexDirection: "column",
108
+ width: "100%",
107
109
  });
108
110
 
109
111
  const topBar: TopBarComponents = createTopBar(renderer);
110
112
  const pager: PagerComponents = createPager(renderer);
111
113
  const bottomBar: BottomBarComponents = createBottomBar(renderer);
112
114
 
113
- rootBox.add(topBar.bar);
115
+ rootBox.add(topBar.box);
114
116
  rootBox.add(pager.scrollBox);
115
- rootBox.add(bottomBar.bar);
116
-
117
+ rootBox.add(bottomBar.box);
117
118
  renderer.root.add(rootBox);
118
119
 
119
120
  // 7. Initial render
@@ -126,13 +127,9 @@ export async function runTui(
126
127
  }
127
128
  } catch {}
128
129
 
129
- if (pager.mode === "line") {
130
- pager.lineNode.content = buildPagerContent(state, searchQuery, state.unreadThreadIds);
131
- } else {
132
- pager.markdownNode.content = state.specLines.join("\n");
133
- }
134
- topBar.bar.content = buildTopBarText(specFile, state, state.unreadCount(), specMtimeChanged, pager.mode);
135
- bottomBar.bar.content = buildBottomBarText(commandBuffer);
130
+ buildPagerNodes(pager.lineNode, state, searchQuery, state.unreadThreadIds);
131
+ buildTopBar(topBar, specFile, state, state.unreadCount(), specMtimeChanged);
132
+ buildBottomBar(bottomBar, commandBuffer);
136
133
  renderer.requestRender();
137
134
  }
138
135
 
@@ -142,16 +139,6 @@ export async function runTui(
142
139
  // Command mode state
143
140
  let commandBuffer: string | null = null;
144
141
 
145
- // Bracket-pending state for ]t / [t / ]r / [r navigation
146
- let bracketPending: "]" | "[" | null = null;
147
- let bracketPendingTimer: ReturnType<typeof setTimeout> | null = null;
148
-
149
- // Delete-pending state: first `d` sets timer, second `d` within 500ms executes
150
- let deletePendingTimer: ReturnType<typeof setTimeout> | null = null;
151
-
152
- // g-pending state: first `g` sets timer, second `g` within 500ms goes to top
153
- let gPendingTimer: ReturnType<typeof setTimeout> | null = null;
154
-
155
142
  // Overlay state — when an overlay is active, normal keybindings are blocked.
156
143
  // The overlay's own key handlers manage its lifecycle.
157
144
  type ActiveOverlay = {
@@ -203,15 +190,16 @@ export async function runTui(
203
190
  // Signal to watch process that session has ended
204
191
  appendEvent(jsonlPath, { type: "session-end", author: "reviewer", ts: Date.now() });
205
192
  liveWatcher.stop();
193
+ keybinds.destroy();
206
194
  renderer.destroy();
207
195
  resolve();
208
196
  }
209
197
 
210
198
  // Helper: scroll pager to ensure cursor line is visible
211
199
  function ensureCursorVisible(): void {
212
- // Each line in the pager is 1 row of text.
213
- // The cursor line index (0-based) in the pager is (state.cursorLine - 1).
214
- const cursorRow = state.cursorLine - 1;
200
+ // Map spec line to visual row, accounting for table border extra lines
201
+ const extra = countExtraVisualLines(state.specLines, state.cursorLine - 1);
202
+ const cursorRow = state.cursorLine - 1 + extra;
215
203
  const viewportHeight = Math.max(1, renderer.height - 2); // minus top + bottom bar
216
204
 
217
205
  const currentScroll = pager.scrollBox.scrollTop;
@@ -233,7 +221,7 @@ export async function runTui(
233
221
  if (cmd === "w") {
234
222
  // Merge JSONL -> JSON, stay open
235
223
  doMerge();
236
- bottomBar.bar.content = " \u2714 Merged to review JSON";
224
+ bottomBar.text.content = " \u2714 Merged to review JSON";
237
225
  renderer.requestRender();
238
226
  setTimeout(() => { refreshPager(); }, 1200);
239
227
  return "stay";
@@ -246,19 +234,21 @@ export async function runTui(
246
234
  if (cmd === "q") {
247
235
  // Exit only if merged (no pending changes)
248
236
  if (hasPendingChanges()) {
249
- bottomBar.bar.content = " Unmerged changes. Use :w to save or :q! to discard";
237
+ bottomBar.text.content = " Unmerged changes. Use :w to save or :q! to discard";
250
238
  renderer.requestRender();
251
239
  setTimeout(() => { refreshPager(); }, 2000);
252
240
  return "stay";
253
241
  }
254
242
  appendEvent(jsonlPath, { type: "session-end", author: "reviewer", ts: Date.now() });
255
243
  liveWatcher.stop();
244
+ keybinds.destroy();
256
245
  return "exit";
257
246
  }
258
247
  if (cmd === "q!") {
259
248
  // Exit without merging
260
249
  appendEvent(jsonlPath, { type: "session-end", author: "reviewer", ts: Date.now() });
261
250
  liveWatcher.stop();
251
+ keybinds.destroy();
262
252
  return "exit";
263
253
  }
264
254
  return "stay"; // unknown command, ignore
@@ -354,6 +344,7 @@ export async function runTui(
354
344
  function showHelpOverlay(): void {
355
345
  const overlay = createHelp({
356
346
  renderer,
347
+ version: version ?? "?",
357
348
  onClose: () => {
358
349
  dismissOverlay();
359
350
  },
@@ -379,6 +370,35 @@ export async function runTui(
379
370
  return null;
380
371
  }
381
372
 
373
+ // --- Keybind registry ---
374
+
375
+ const bindings: KeyBinding[] = [
376
+ { key: "j", action: "cursor-down" },
377
+ { key: "down", action: "cursor-down" },
378
+ { key: "k", action: "cursor-up" },
379
+ { key: "up", action: "cursor-up" },
380
+ { key: "C-d", action: "half-page-down" },
381
+ { key: "C-u", action: "half-page-up" },
382
+ { key: "G", action: "goto-bottom" },
383
+ { key: "gg", action: "goto-top" },
384
+ { key: "n", action: "search-next" },
385
+ { key: "N", action: "search-prev" },
386
+ { key: "c", action: "comment" },
387
+ { key: "l", action: "thread-list" },
388
+ { key: "r", action: "resolve" },
389
+ { key: "R", action: "resolve-all" },
390
+ { key: "dd", action: "delete-draft" },
391
+ { key: "a", action: "approve" },
392
+ { key: "]t", action: "next-thread" },
393
+ { key: "[t", action: "prev-thread" },
394
+ { key: "]r", action: "next-unread" },
395
+ { key: "[r", action: "prev-unread" },
396
+ { key: "?", action: "help" },
397
+ { key: "/", action: "search" },
398
+ { key: ":", action: "command-mode" },
399
+ ];
400
+ const keybinds = createKeybindRegistry(bindings);
401
+
382
402
  refreshPager();
383
403
  renderer.start();
384
404
 
@@ -454,230 +474,140 @@ export async function runTui(
454
474
  }
455
475
 
456
476
  // Normal mode keybindings
457
- switch (key.name) {
458
- case "j":
459
- case "down": {
460
- if (pager.mode === "markdown") {
461
- pager.scrollBox.scrollBy(1);
462
- renderer.requestRender();
463
- } else {
464
- if (state.cursorLine < state.lineCount) {
465
- state.cursorLine++;
466
- ensureCursorVisible();
467
- refreshPager();
468
- }
469
- }
470
- break;
477
+ const action = keybinds.match(key);
478
+
479
+ // Show pending sequence hint
480
+ if (!action) {
481
+ const p = keybinds.pending();
482
+ if (p) {
483
+ bottomBar.text.content = ` ${p}`;
484
+ renderer.requestRender();
471
485
  }
472
- case "k":
473
- case "up": {
474
- if (pager.mode === "markdown") {
475
- pager.scrollBox.scrollBy(-1);
476
- renderer.requestRender();
477
- } else {
478
- if (state.cursorLine > 1) {
479
- state.cursorLine--;
480
- ensureCursorVisible();
481
- refreshPager();
482
- }
486
+ return;
487
+ }
488
+
489
+ switch (action) {
490
+ case "cursor-down":
491
+ if (state.cursorLine < state.lineCount) {
492
+ state.cursorLine++;
493
+ ensureCursorVisible();
494
+ refreshPager();
483
495
  }
484
496
  break;
485
- }
486
- case "d": {
487
- // Ctrl+D — half page down
488
- if (key.ctrl) {
489
- if (deletePendingTimer) { clearTimeout(deletePendingTimer); deletePendingTimer = null; }
490
- const half = Math.max(1, Math.floor(pageSize() / 2));
491
- if (pager.mode === "markdown") {
492
- pager.scrollBox.scrollBy(half);
493
- renderer.requestRender();
494
- } else {
495
- state.cursorLine = Math.min(state.cursorLine + half, state.lineCount);
496
- ensureCursorVisible();
497
- refreshPager();
498
- }
499
- } else {
500
- // d without ctrl — delete draft comment (dd = double-tap within 500ms)
501
- ensureLineMode(pager);
497
+ case "cursor-up":
498
+ if (state.cursorLine > 1) {
499
+ state.cursorLine--;
500
+ ensureCursorVisible();
502
501
  refreshPager();
503
- const thread = state.threadAtLine(state.cursorLine);
504
- if (!thread) break;
505
- if (deletePendingTimer) {
506
- // Second d within 500ms — execute delete
507
- clearTimeout(deletePendingTimer);
508
- deletePendingTimer = null;
509
- const hadReviewerMsg = thread.messages.some((m) => m.author === "reviewer");
510
- if (hadReviewerMsg) {
511
- state.deleteLastDraftMessage(thread.id);
512
- appendEvent(jsonlPath, { type: "delete", threadId: thread.id, author: "reviewer", ts: Date.now() });
513
- refreshPager();
514
- bottomBar.bar.content = " \u2714 Deleted draft comment";
515
- renderer.requestRender();
516
- setTimeout(() => { refreshPager(); }, 1500);
517
- } else {
518
- bottomBar.bar.content = " No reviewer message to delete";
519
- renderer.requestRender();
520
- setTimeout(() => { refreshPager(); }, 1500);
521
- }
522
- } else {
523
- // First d — show hint and start timer
524
- bottomBar.bar.content = " Press d again to delete";
525
- renderer.requestRender();
526
- deletePendingTimer = setTimeout(() => {
527
- deletePendingTimer = null;
528
- refreshPager();
529
- }, 500);
530
- }
531
502
  }
532
503
  break;
504
+ case "half-page-down": {
505
+ const half = Math.max(1, Math.floor(pageSize() / 2));
506
+ state.cursorLine = Math.min(state.cursorLine + half, state.lineCount);
507
+ ensureCursorVisible();
508
+ refreshPager();
509
+ break;
533
510
  }
534
- case "u": {
535
- // Ctrl+U half page up
536
- if (key.ctrl) {
537
- const half = Math.max(1, Math.floor(pageSize() / 2));
538
- if (pager.mode === "markdown") {
539
- pager.scrollBox.scrollBy(-half);
540
- renderer.requestRender();
541
- } else {
542
- state.cursorLine = Math.max(state.cursorLine - half, 1);
543
- ensureCursorVisible();
544
- refreshPager();
545
- }
546
- }
511
+ case "half-page-up": {
512
+ const half = Math.max(1, Math.floor(pageSize() / 2));
513
+ state.cursorLine = Math.max(state.cursorLine - half, 1);
514
+ ensureCursorVisible();
515
+ refreshPager();
547
516
  break;
548
517
  }
549
- case "n": {
550
- if (!key.shift) {
551
- if (searchQuery) {
552
- const match = findNextMatch(state.specLines, searchQuery, state.cursorLine, 1);
553
- if (match !== null) {
554
- state.cursorLine = match;
555
- ensureCursorVisible();
556
- }
557
- } else {
558
- bottomBar.bar.content = " No active search \u2014 use / to search";
559
- renderer.requestRender();
560
- setTimeout(() => { refreshPager(); }, 1500);
518
+ case "goto-bottom":
519
+ state.cursorLine = state.lineCount;
520
+ ensureCursorVisible();
521
+ refreshPager();
522
+ break;
523
+ case "goto-top":
524
+ state.cursorLine = 1;
525
+ ensureCursorVisible();
526
+ refreshPager();
527
+ break;
528
+ case "search-next":
529
+ if (searchQuery) {
530
+ const match = findNextMatch(state.specLines, searchQuery, state.cursorLine, 1);
531
+ if (match !== null) {
532
+ state.cursorLine = match;
533
+ ensureCursorVisible();
561
534
  }
562
535
  } else {
563
- // Shift+N = prev search match
564
- if (searchQuery) {
565
- const match = findNextMatch(state.specLines, searchQuery, state.cursorLine, -1);
566
- if (match !== null) {
567
- state.cursorLine = match;
568
- ensureCursorVisible();
569
- }
570
- } else {
571
- bottomBar.bar.content = " No active search \u2014 use / to search";
572
- renderer.requestRender();
573
- setTimeout(() => { refreshPager(); }, 1500);
574
- }
536
+ bottomBar.text.content = " No active search \u2014 use / to search";
537
+ renderer.requestRender();
538
+ setTimeout(() => { refreshPager(); }, 1500);
575
539
  }
576
540
  refreshPager();
577
541
  break;
578
- }
579
- case "m": {
580
- // Toggle markdown / line mode with scroll position sync
581
- const wasMarkdown = pager.mode === "markdown";
582
- togglePagerMode(pager);
583
- if (wasMarkdown) {
584
- // Markdown -> Line: sync scroll position to cursor
585
- state.cursorLine = Math.max(1, pager.scrollBox.scrollTop + 1);
586
- refreshPager();
587
- ensureCursorVisible();
542
+ case "search-prev":
543
+ if (searchQuery) {
544
+ const match = findNextMatch(state.specLines, searchQuery, state.cursorLine, -1);
545
+ if (match !== null) {
546
+ state.cursorLine = match;
547
+ ensureCursorVisible();
548
+ }
588
549
  } else {
589
- // Line -> Markdown: approximate scroll to cursor position
590
- refreshPager();
591
- pager.scrollBox.scrollTo(state.cursorLine - 1);
550
+ bottomBar.text.content = " No active search \u2014 use / to search";
592
551
  renderer.requestRender();
552
+ setTimeout(() => { refreshPager(); }, 1500);
593
553
  }
594
- break;
595
- }
596
- case "c": {
597
- // Comment: new or reply — auto-switch to line mode
598
- ensureLineMode(pager);
599
554
  refreshPager();
555
+ break;
556
+ case "comment":
600
557
  showCommentInput();
601
558
  break;
602
- }
603
- case "l": {
604
- // Thread list
605
- ensureLineMode(pager);
606
- refreshPager();
559
+ case "thread-list":
607
560
  showThreadListOverlay();
608
561
  break;
609
- }
610
- case "r": {
611
- ensureLineMode(pager);
612
- refreshPager();
613
- if (!key.shift) {
614
- // Resolve thread at cursor
615
- const thread = state.threadAtLine(state.cursorLine);
616
- if (thread) {
617
- const wasResolved = thread.status === "resolved";
618
- state.resolveThread(thread.id);
619
- state.markRead(thread.id);
620
- appendEvent(jsonlPath, { type: wasResolved ? "unresolve" : "resolve", threadId: thread.id, author: "reviewer", ts: Date.now() });
621
- refreshPager();
622
- const msg = wasResolved
623
- ? ` \u21a9 Reopened thread #${thread.id}`
624
- : ` \u2714 Resolved thread #${thread.id}`;
625
- bottomBar.bar.content = msg;
626
- renderer.requestRender();
627
- setTimeout(() => { refreshPager(); }, 1500);
628
- }
629
- } else {
630
- // Shift+R = resolve all pending
631
- const { pending } = state.activeThreadCount();
632
- const pendingThreads = state.threads.filter(t => t.status === "pending");
633
- state.resolveAllPending();
634
- for (const t of pendingThreads) {
635
- appendEvent(jsonlPath, { type: "resolve", threadId: t.id, author: "reviewer", ts: Date.now() });
636
- }
562
+ case "resolve": {
563
+ const thread = state.threadAtLine(state.cursorLine);
564
+ if (thread) {
565
+ const wasResolved = thread.status === "resolved";
566
+ state.resolveThread(thread.id);
567
+ state.markRead(thread.id);
568
+ appendEvent(jsonlPath, { type: wasResolved ? "unresolve" : "resolve", threadId: thread.id, author: "reviewer", ts: Date.now() });
637
569
  refreshPager();
638
- bottomBar.bar.content = ` \u2714 Resolved ${pending} pending thread(s)`;
570
+ const msg = wasResolved
571
+ ? ` \u21a9 Reopened thread #${thread.id}`
572
+ : ` \u2714 Resolved thread #${thread.id}`;
573
+ bottomBar.text.content = msg;
639
574
  renderer.requestRender();
640
575
  setTimeout(() => { refreshPager(); }, 1500);
641
576
  }
642
577
  break;
643
578
  }
644
- case "g": {
645
- if (key.shift) {
646
- // G (shift+g) go to last line / scroll to bottom
647
- if (pager.mode === "markdown") {
648
- pager.scrollBox.scrollTo(pager.scrollBox.scrollHeight);
649
- renderer.requestRender();
650
- } else {
651
- state.cursorLine = state.lineCount;
652
- ensureCursorVisible();
653
- refreshPager();
654
- }
579
+ case "resolve-all": {
580
+ const { pending } = state.activeThreadCount();
581
+ const pendingThreads = state.threads.filter(t => t.status === "pending");
582
+ state.resolveAllPending();
583
+ for (const t of pendingThreads) {
584
+ appendEvent(jsonlPath, { type: "resolve", threadId: t.id, author: "reviewer", ts: Date.now() });
585
+ }
586
+ refreshPager();
587
+ bottomBar.text.content = ` \u2714 Resolved ${pending} pending thread(s)`;
588
+ renderer.requestRender();
589
+ setTimeout(() => { refreshPager(); }, 1500);
590
+ break;
591
+ }
592
+ case "delete-draft": {
593
+ const thread = state.threadAtLine(state.cursorLine);
594
+ if (!thread) break;
595
+ const hadReviewerMsg = thread.messages.some((m) => m.author === "reviewer");
596
+ if (hadReviewerMsg) {
597
+ state.deleteLastDraftMessage(thread.id);
598
+ appendEvent(jsonlPath, { type: "delete", threadId: thread.id, author: "reviewer", ts: Date.now() });
599
+ refreshPager();
600
+ bottomBar.text.content = " \u2714 Deleted draft comment";
601
+ renderer.requestRender();
602
+ setTimeout(() => { refreshPager(); }, 1500);
655
603
  } else {
656
- // g first of gg sequence
657
- if (gPendingTimer) {
658
- // Second g within 500ms — go to first line / scroll to top
659
- clearTimeout(gPendingTimer);
660
- gPendingTimer = null;
661
- if (pager.mode === "markdown") {
662
- pager.scrollBox.scrollTo(0);
663
- renderer.requestRender();
664
- } else {
665
- state.cursorLine = 1;
666
- ensureCursorVisible();
667
- refreshPager();
668
- }
669
- } else {
670
- gPendingTimer = setTimeout(() => {
671
- gPendingTimer = null;
672
- }, 500);
673
- }
604
+ bottomBar.text.content = " No reviewer message to delete";
605
+ renderer.requestRender();
606
+ setTimeout(() => { refreshPager(); }, 1500);
674
607
  }
675
608
  break;
676
609
  }
677
- case "a": {
678
- // Approve
679
- ensureLineMode(pager);
680
- refreshPager();
610
+ case "approve":
681
611
  if (state.canApprove()) {
682
612
  const confirmOverlay = createConfirm({
683
613
  renderer,
@@ -692,105 +622,63 @@ export async function runTui(
692
622
  },
693
623
  });
694
624
  showOverlay(confirmOverlay);
695
- return;
696
625
  } else {
697
- // Show why approval is blocked
698
626
  const { open, pending } = state.activeThreadCount();
699
627
  const total = open + pending;
700
- const msg =
701
- total === 0
702
- ? "No threads to approve"
703
- : `${total} thread${total !== 1 ? "s" : ""} still open/pending`;
704
- bottomBar.bar.content = ` \u26a0 ${msg}`;
628
+ const msg = total === 0
629
+ ? "No threads to approve"
630
+ : `${total} thread${total !== 1 ? "s" : ""} still open/pending`;
631
+ bottomBar.text.content = ` \u26a0 ${msg}`;
705
632
  renderer.requestRender();
706
- setTimeout(() => {
707
- refreshPager();
708
- }, 2000);
633
+ setTimeout(() => { refreshPager(); }, 2000);
709
634
  }
710
635
  break;
711
- }
712
- default: {
713
- // Handle bracket-pending sequences (]t / [t / ]r / [r)
714
- if (bracketPending !== null) {
715
- const pending = bracketPending;
716
- bracketPending = null;
717
- if (bracketPendingTimer) { clearTimeout(bracketPendingTimer); bracketPendingTimer = null; }
718
- if (key.name === "t" || key.sequence === "t") {
719
- if (pending === "]") {
720
- const next = state.nextActiveThread();
721
- if (next !== null) {
722
- state.cursorLine = next;
723
- ensureCursorVisible();
724
- refreshPager();
725
- }
726
- } else {
727
- const prev = state.prevActiveThread();
728
- if (prev !== null) {
729
- state.cursorLine = prev;
730
- ensureCursorVisible();
731
- refreshPager();
732
- }
733
- }
734
- } else if (key.name === "r" || key.sequence === "r") {
735
- if (pending === "]") {
736
- const nextLine = state.nextUnreadThread();
737
- if (nextLine !== null) {
738
- state.cursorLine = nextLine;
739
- ensureCursorVisible();
740
- refreshPager();
741
- }
742
- } else {
743
- const prevLine = state.prevUnreadThread();
744
- if (prevLine !== null) {
745
- state.cursorLine = prevLine;
746
- ensureCursorVisible();
747
- refreshPager();
748
- }
749
- }
750
- }
751
- refreshPager(); // clear the bracket hint
752
- break;
753
- }
754
- // Check for "]" or "[" to start bracket sequence
755
- if (key.sequence === "]") {
756
- bracketPending = "]";
757
- bottomBar.bar.content = " ]...";
758
- renderer.requestRender();
759
- bracketPendingTimer = setTimeout(() => {
760
- bracketPending = null;
761
- bracketPendingTimer = null;
762
- refreshPager();
763
- }, 500);
764
- break;
765
- }
766
- if (key.sequence === "[") {
767
- bracketPending = "[";
768
- bottomBar.bar.content = " [...";
769
- renderer.requestRender();
770
- bracketPendingTimer = setTimeout(() => {
771
- bracketPending = null;
772
- bracketPendingTimer = null;
773
- refreshPager();
774
- }, 500);
775
- break;
636
+ case "next-thread": {
637
+ const next = state.nextActiveThread();
638
+ if (next !== null) {
639
+ state.cursorLine = next;
640
+ ensureCursorVisible();
641
+ refreshPager();
776
642
  }
777
- // Check for "?" to show help overlay
778
- if (key.sequence === "?") {
779
- showHelpOverlay();
780
- break;
643
+ break;
644
+ }
645
+ case "prev-thread": {
646
+ const prev = state.prevActiveThread();
647
+ if (prev !== null) {
648
+ state.cursorLine = prev;
649
+ ensureCursorVisible();
650
+ refreshPager();
781
651
  }
782
- // Check for "/" to enter search mode
783
- if (key.sequence === "/") {
784
- showSearchOverlay();
785
- break;
652
+ break;
653
+ }
654
+ case "next-unread": {
655
+ const nextLine = state.nextUnreadThread();
656
+ if (nextLine !== null) {
657
+ state.cursorLine = nextLine;
658
+ ensureCursorVisible();
659
+ refreshPager();
786
660
  }
787
- // Check for ":" to enter command mode
788
- if (key.sequence === ":") {
789
- commandBuffer = "";
661
+ break;
662
+ }
663
+ case "prev-unread": {
664
+ const prevLine = state.prevUnreadThread();
665
+ if (prevLine !== null) {
666
+ state.cursorLine = prevLine;
667
+ ensureCursorVisible();
790
668
  refreshPager();
791
669
  }
792
670
  break;
793
671
  }
672
+ case "help":
673
+ showHelpOverlay();
674
+ break;
675
+ case "search":
676
+ showSearchOverlay();
677
+ break;
678
+ case "command-mode":
679
+ commandBuffer = "";
680
+ refreshPager();
681
+ break;
794
682
  }
795
683
  });
796
684
  });