revspec 0.3.0 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "revspec",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "revspec": "./bin/revspec.ts"
package/src/tui/app.ts CHANGED
@@ -28,6 +28,7 @@ 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,
@@ -138,16 +139,6 @@ export async function runTui(
138
139
  // Command mode state
139
140
  let commandBuffer: string | null = null;
140
141
 
141
- // Bracket-pending state for ]t / [t / ]r / [r navigation
142
- let bracketPending: "]" | "[" | null = null;
143
- let bracketPendingTimer: ReturnType<typeof setTimeout> | null = null;
144
-
145
- // Delete-pending state: first `d` sets timer, second `d` within 500ms executes
146
- let deletePendingTimer: ReturnType<typeof setTimeout> | null = null;
147
-
148
- // g-pending state: first `g` sets timer, second `g` within 500ms goes to top
149
- let gPendingTimer: ReturnType<typeof setTimeout> | null = null;
150
-
151
142
  // Overlay state — when an overlay is active, normal keybindings are blocked.
152
143
  // The overlay's own key handlers manage its lifecycle.
153
144
  type ActiveOverlay = {
@@ -199,6 +190,7 @@ export async function runTui(
199
190
  // Signal to watch process that session has ended
200
191
  appendEvent(jsonlPath, { type: "session-end", author: "reviewer", ts: Date.now() });
201
192
  liveWatcher.stop();
193
+ keybinds.destroy();
202
194
  renderer.destroy();
203
195
  resolve();
204
196
  }
@@ -249,12 +241,14 @@ export async function runTui(
249
241
  }
250
242
  appendEvent(jsonlPath, { type: "session-end", author: "reviewer", ts: Date.now() });
251
243
  liveWatcher.stop();
244
+ keybinds.destroy();
252
245
  return "exit";
253
246
  }
254
247
  if (cmd === "q!") {
255
248
  // Exit without merging
256
249
  appendEvent(jsonlPath, { type: "session-end", author: "reviewer", ts: Date.now() });
257
250
  liveWatcher.stop();
251
+ keybinds.destroy();
258
252
  return "exit";
259
253
  }
260
254
  return "stay"; // unknown command, ignore
@@ -376,6 +370,35 @@ export async function runTui(
376
370
  return null;
377
371
  }
378
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
+
379
402
  refreshPager();
380
403
  renderer.start();
381
404
 
@@ -451,173 +474,140 @@ export async function runTui(
451
474
  }
452
475
 
453
476
  // Normal mode keybindings
454
- switch (key.name) {
455
- case "j":
456
- case "down": {
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();
485
+ }
486
+ return;
487
+ }
488
+
489
+ switch (action) {
490
+ case "cursor-down":
457
491
  if (state.cursorLine < state.lineCount) {
458
492
  state.cursorLine++;
459
493
  ensureCursorVisible();
460
494
  refreshPager();
461
495
  }
462
496
  break;
463
- }
464
- case "k":
465
- case "up": {
497
+ case "cursor-up":
466
498
  if (state.cursorLine > 1) {
467
499
  state.cursorLine--;
468
500
  ensureCursorVisible();
469
501
  refreshPager();
470
502
  }
471
503
  break;
472
- }
473
- case "d": {
474
- // Ctrl+D half page down
475
- if (key.ctrl) {
476
- if (deletePendingTimer) { clearTimeout(deletePendingTimer); deletePendingTimer = null; }
477
- const half = Math.max(1, Math.floor(pageSize() / 2));
478
- state.cursorLine = Math.min(state.cursorLine + half, state.lineCount);
479
- ensureCursorVisible();
480
- refreshPager();
481
- } else {
482
- // d without ctrl — delete draft comment (dd = double-tap within 500ms)
483
- refreshPager();
484
- const thread = state.threadAtLine(state.cursorLine);
485
- if (!thread) break;
486
- if (deletePendingTimer) {
487
- // Second d within 500ms — execute delete
488
- clearTimeout(deletePendingTimer);
489
- deletePendingTimer = null;
490
- const hadReviewerMsg = thread.messages.some((m) => m.author === "reviewer");
491
- if (hadReviewerMsg) {
492
- state.deleteLastDraftMessage(thread.id);
493
- appendEvent(jsonlPath, { type: "delete", threadId: thread.id, author: "reviewer", ts: Date.now() });
494
- refreshPager();
495
- bottomBar.text.content = " \u2714 Deleted draft comment";
496
- renderer.requestRender();
497
- setTimeout(() => { refreshPager(); }, 1500);
498
- } else {
499
- bottomBar.text.content = " No reviewer message to delete";
500
- renderer.requestRender();
501
- setTimeout(() => { refreshPager(); }, 1500);
502
- }
503
- } else {
504
- // First d — show hint and start timer
505
- bottomBar.text.content = " Press d again to delete";
506
- renderer.requestRender();
507
- deletePendingTimer = setTimeout(() => {
508
- deletePendingTimer = null;
509
- refreshPager();
510
- }, 500);
511
- }
512
- }
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();
513
509
  break;
514
510
  }
515
- case "u": {
516
- // Ctrl+U half page up
517
- if (key.ctrl) {
518
- const half = Math.max(1, Math.floor(pageSize() / 2));
519
- state.cursorLine = Math.max(state.cursorLine - half, 1);
520
- ensureCursorVisible();
521
- refreshPager();
522
- }
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();
523
516
  break;
524
517
  }
525
- case "n": {
526
- if (!key.shift) {
527
- if (searchQuery) {
528
- const match = findNextMatch(state.specLines, searchQuery, state.cursorLine, 1);
529
- if (match !== null) {
530
- state.cursorLine = match;
531
- ensureCursorVisible();
532
- }
533
- } else {
534
- bottomBar.text.content = " No active search \u2014 use / to search";
535
- renderer.requestRender();
536
- 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();
537
534
  }
538
535
  } else {
539
- // Shift+N = prev search match
540
- if (searchQuery) {
541
- const match = findNextMatch(state.specLines, searchQuery, state.cursorLine, -1);
542
- if (match !== null) {
543
- state.cursorLine = match;
544
- ensureCursorVisible();
545
- }
546
- } else {
547
- bottomBar.text.content = " No active search \u2014 use / to search";
548
- renderer.requestRender();
549
- setTimeout(() => { refreshPager(); }, 1500);
536
+ bottomBar.text.content = " No active search \u2014 use / to search";
537
+ renderer.requestRender();
538
+ setTimeout(() => { refreshPager(); }, 1500);
539
+ }
540
+ refreshPager();
541
+ break;
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();
550
548
  }
549
+ } else {
550
+ bottomBar.text.content = " No active search \u2014 use / to search";
551
+ renderer.requestRender();
552
+ setTimeout(() => { refreshPager(); }, 1500);
551
553
  }
552
554
  refreshPager();
553
555
  break;
554
- }
555
- case "c": {
556
+ case "comment":
556
557
  showCommentInput();
557
558
  break;
558
- }
559
- case "l": {
560
- // Thread list
559
+ case "thread-list":
561
560
  showThreadListOverlay();
562
561
  break;
563
- }
564
- case "r": {
565
- if (!key.shift) {
566
- // Resolve thread at cursor
567
- const thread = state.threadAtLine(state.cursorLine);
568
- if (thread) {
569
- const wasResolved = thread.status === "resolved";
570
- state.resolveThread(thread.id);
571
- state.markRead(thread.id);
572
- appendEvent(jsonlPath, { type: wasResolved ? "unresolve" : "resolve", threadId: thread.id, author: "reviewer", ts: Date.now() });
573
- refreshPager();
574
- const msg = wasResolved
575
- ? ` \u21a9 Reopened thread #${thread.id}`
576
- : ` \u2714 Resolved thread #${thread.id}`;
577
- bottomBar.text.content = msg;
578
- renderer.requestRender();
579
- setTimeout(() => { refreshPager(); }, 1500);
580
- }
581
- } else {
582
- // Shift+R = resolve all pending
583
- const { pending } = state.activeThreadCount();
584
- const pendingThreads = state.threads.filter(t => t.status === "pending");
585
- state.resolveAllPending();
586
- for (const t of pendingThreads) {
587
- appendEvent(jsonlPath, { type: "resolve", threadId: t.id, author: "reviewer", ts: Date.now() });
588
- }
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() });
589
569
  refreshPager();
590
- bottomBar.text.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;
591
574
  renderer.requestRender();
592
575
  setTimeout(() => { refreshPager(); }, 1500);
593
576
  }
594
577
  break;
595
578
  }
596
- case "g": {
597
- if (key.shift) {
598
- // G (shift+g) go to last line
599
- state.cursorLine = state.lineCount;
600
- ensureCursorVisible();
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() });
601
599
  refreshPager();
600
+ bottomBar.text.content = " \u2714 Deleted draft comment";
601
+ renderer.requestRender();
602
+ setTimeout(() => { refreshPager(); }, 1500);
602
603
  } else {
603
- // g first of gg sequence
604
- if (gPendingTimer) {
605
- // Second g within 500ms — go to first line
606
- clearTimeout(gPendingTimer);
607
- gPendingTimer = null;
608
- state.cursorLine = 1;
609
- ensureCursorVisible();
610
- refreshPager();
611
- } else {
612
- gPendingTimer = setTimeout(() => {
613
- gPendingTimer = null;
614
- }, 500);
615
- }
604
+ bottomBar.text.content = " No reviewer message to delete";
605
+ renderer.requestRender();
606
+ setTimeout(() => { refreshPager(); }, 1500);
616
607
  }
617
608
  break;
618
609
  }
619
- case "a": {
620
- // Approve
610
+ case "approve":
621
611
  if (state.canApprove()) {
622
612
  const confirmOverlay = createConfirm({
623
613
  renderer,
@@ -632,105 +622,63 @@ export async function runTui(
632
622
  },
633
623
  });
634
624
  showOverlay(confirmOverlay);
635
- return;
636
625
  } else {
637
- // Show why approval is blocked
638
626
  const { open, pending } = state.activeThreadCount();
639
627
  const total = open + pending;
640
- const msg =
641
- total === 0
642
- ? "No threads to approve"
643
- : `${total} thread${total !== 1 ? "s" : ""} still open/pending`;
628
+ const msg = total === 0
629
+ ? "No threads to approve"
630
+ : `${total} thread${total !== 1 ? "s" : ""} still open/pending`;
644
631
  bottomBar.text.content = ` \u26a0 ${msg}`;
645
632
  renderer.requestRender();
646
- setTimeout(() => {
647
- refreshPager();
648
- }, 2000);
633
+ setTimeout(() => { refreshPager(); }, 2000);
649
634
  }
650
635
  break;
651
- }
652
- default: {
653
- // Handle bracket-pending sequences (]t / [t / ]r / [r)
654
- if (bracketPending !== null) {
655
- const pending = bracketPending;
656
- bracketPending = null;
657
- if (bracketPendingTimer) { clearTimeout(bracketPendingTimer); bracketPendingTimer = null; }
658
- if (key.name === "t" || key.sequence === "t") {
659
- if (pending === "]") {
660
- const next = state.nextActiveThread();
661
- if (next !== null) {
662
- state.cursorLine = next;
663
- ensureCursorVisible();
664
- refreshPager();
665
- }
666
- } else {
667
- const prev = state.prevActiveThread();
668
- if (prev !== null) {
669
- state.cursorLine = prev;
670
- ensureCursorVisible();
671
- refreshPager();
672
- }
673
- }
674
- } else if (key.name === "r" || key.sequence === "r") {
675
- if (pending === "]") {
676
- const nextLine = state.nextUnreadThread();
677
- if (nextLine !== null) {
678
- state.cursorLine = nextLine;
679
- ensureCursorVisible();
680
- refreshPager();
681
- }
682
- } else {
683
- const prevLine = state.prevUnreadThread();
684
- if (prevLine !== null) {
685
- state.cursorLine = prevLine;
686
- ensureCursorVisible();
687
- refreshPager();
688
- }
689
- }
690
- }
691
- refreshPager(); // clear the bracket hint
692
- break;
693
- }
694
- // Check for "]" or "[" to start bracket sequence
695
- if (key.sequence === "]") {
696
- bracketPending = "]";
697
- bottomBar.text.content = " ]...";
698
- renderer.requestRender();
699
- bracketPendingTimer = setTimeout(() => {
700
- bracketPending = null;
701
- bracketPendingTimer = null;
702
- refreshPager();
703
- }, 500);
704
- break;
705
- }
706
- if (key.sequence === "[") {
707
- bracketPending = "[";
708
- bottomBar.text.content = " [...";
709
- renderer.requestRender();
710
- bracketPendingTimer = setTimeout(() => {
711
- bracketPending = null;
712
- bracketPendingTimer = null;
713
- refreshPager();
714
- }, 500);
715
- break;
636
+ case "next-thread": {
637
+ const next = state.nextActiveThread();
638
+ if (next !== null) {
639
+ state.cursorLine = next;
640
+ ensureCursorVisible();
641
+ refreshPager();
716
642
  }
717
- // Check for "?" to show help overlay
718
- if (key.sequence === "?") {
719
- showHelpOverlay();
720
- 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();
721
651
  }
722
- // Check for "/" to enter search mode
723
- if (key.sequence === "/") {
724
- showSearchOverlay();
725
- 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();
726
660
  }
727
- // Check for ":" to enter command mode
728
- if (key.sequence === ":") {
729
- commandBuffer = "";
661
+ break;
662
+ }
663
+ case "prev-unread": {
664
+ const prevLine = state.prevUnreadThread();
665
+ if (prevLine !== null) {
666
+ state.cursorLine = prevLine;
667
+ ensureCursorVisible();
730
668
  refreshPager();
731
669
  }
732
670
  break;
733
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;
734
682
  }
735
683
  });
736
684
  });