revspec 0.3.0 → 0.5.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.5.0",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "revspec": "./bin/revspec.ts"
@@ -136,6 +136,11 @@ export class ReviewState {
136
136
  }
137
137
  }
138
138
 
139
+ deleteThread(threadId: string): void {
140
+ this.threads = this.threads.filter((t) => t.id !== threadId);
141
+ this._unreadThreadIds.delete(threadId);
142
+ }
143
+
139
144
  addOwnerReply(threadId: string, text: string, ts?: number): void {
140
145
  const thread = this.threads.find((t) => t.id === threadId);
141
146
  if (!thread) return;
package/src/tui/app.ts CHANGED
@@ -17,6 +17,7 @@ import { buildPagerNodes, createPager, countExtraVisualLines, type PagerComponen
17
17
  import {
18
18
  buildTopBar,
19
19
  buildBottomBar,
20
+ setBottomBarMessage,
20
21
  createTopBar,
21
22
  createBottomBar,
22
23
  type TopBarComponents,
@@ -28,6 +29,7 @@ import { createSearch } from "./search";
28
29
  import { createThreadList } from "./thread-list";
29
30
  import { createConfirm } from "./confirm";
30
31
  import { createHelp } from "./help";
32
+ import { createKeybindRegistry, type KeyBinding } from "./ui/keybinds";
31
33
 
32
34
  export async function runTui(
33
35
  specFile: string,
@@ -128,7 +130,8 @@ export async function runTui(
128
130
 
129
131
  buildPagerNodes(pager.lineNode, state, searchQuery, state.unreadThreadIds);
130
132
  buildTopBar(topBar, specFile, state, state.unreadCount(), specMtimeChanged);
131
- buildBottomBar(bottomBar, commandBuffer);
133
+ const hasThread = !!state.threadAtLine(state.cursorLine);
134
+ buildBottomBar(bottomBar, commandBuffer, hasThread);
132
135
  renderer.requestRender();
133
136
  }
134
137
 
@@ -138,16 +141,6 @@ export async function runTui(
138
141
  // Command mode state
139
142
  let commandBuffer: string | null = null;
140
143
 
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
144
  // Overlay state — when an overlay is active, normal keybindings are blocked.
152
145
  // The overlay's own key handlers manage its lifecycle.
153
146
  type ActiveOverlay = {
@@ -199,6 +192,7 @@ export async function runTui(
199
192
  // Signal to watch process that session has ended
200
193
  appendEvent(jsonlPath, { type: "session-end", author: "reviewer", ts: Date.now() });
201
194
  liveWatcher.stop();
195
+ keybinds.destroy();
202
196
  renderer.destroy();
203
197
  resolve();
204
198
  }
@@ -229,12 +223,12 @@ export async function runTui(
229
223
  if (cmd === "w") {
230
224
  // Merge JSONL -> JSON, stay open
231
225
  doMerge();
232
- bottomBar.text.content = " \u2714 Merged to review JSON";
226
+ setBottomBarMessage(bottomBar, " \u2714 Merged to review JSON");
233
227
  renderer.requestRender();
234
228
  setTimeout(() => { refreshPager(); }, 1200);
235
229
  return "stay";
236
230
  }
237
- if (cmd === "wq") {
231
+ if (cmd === "wq" || cmd === "qw") {
238
232
  // Merge and exit
239
233
  mergeAndExit(resolve);
240
234
  return "merged";
@@ -242,19 +236,21 @@ export async function runTui(
242
236
  if (cmd === "q") {
243
237
  // Exit only if merged (no pending changes)
244
238
  if (hasPendingChanges()) {
245
- bottomBar.text.content = " Unmerged changes. Use :w to save or :q! to discard";
239
+ setBottomBarMessage(bottomBar, " Unmerged changes. Use :w to save or :q! to discard");
246
240
  renderer.requestRender();
247
241
  setTimeout(() => { refreshPager(); }, 2000);
248
242
  return "stay";
249
243
  }
250
244
  appendEvent(jsonlPath, { type: "session-end", author: "reviewer", ts: Date.now() });
251
245
  liveWatcher.stop();
246
+ keybinds.destroy();
252
247
  return "exit";
253
248
  }
254
249
  if (cmd === "q!") {
255
250
  // Exit without merging
256
251
  appendEvent(jsonlPath, { type: "session-end", author: "reviewer", ts: Date.now() });
257
252
  liveWatcher.stop();
253
+ keybinds.destroy();
258
254
  return "exit";
259
255
  }
260
256
  return "stay"; // unknown command, ignore
@@ -376,6 +372,35 @@ export async function runTui(
376
372
  return null;
377
373
  }
378
374
 
375
+ // --- Keybind registry ---
376
+
377
+ const bindings: KeyBinding[] = [
378
+ { key: "j", action: "cursor-down" },
379
+ { key: "down", action: "cursor-down" },
380
+ { key: "k", action: "cursor-up" },
381
+ { key: "up", action: "cursor-up" },
382
+ { key: "C-d", action: "half-page-down" },
383
+ { key: "C-u", action: "half-page-up" },
384
+ { key: "G", action: "goto-bottom" },
385
+ { key: "gg", action: "goto-top" },
386
+ { key: "n", action: "search-next" },
387
+ { key: "N", action: "search-prev" },
388
+ { key: "c", action: "comment" },
389
+ { key: "l", action: "thread-list" },
390
+ { key: "r", action: "resolve" },
391
+ { key: "R", action: "resolve-all" },
392
+ { key: "dd", action: "delete-draft" },
393
+ { key: "a", action: "approve" },
394
+ { key: "]t", action: "next-thread" },
395
+ { key: "[t", action: "prev-thread" },
396
+ { key: "]r", action: "next-unread" },
397
+ { key: "[r", action: "prev-unread" },
398
+ { key: "?", action: "help" },
399
+ { key: "/", action: "search" },
400
+ { key: ":", action: "command-mode" },
401
+ ];
402
+ const keybinds = createKeybindRegistry(bindings);
403
+
379
404
  refreshPager();
380
405
  renderer.start();
381
406
 
@@ -451,177 +476,149 @@ export async function runTui(
451
476
  }
452
477
 
453
478
  // Normal mode keybindings
454
- switch (key.name) {
455
- case "j":
456
- case "down": {
479
+ const action = keybinds.match(key);
480
+
481
+ // Show pending sequence hint
482
+ if (!action) {
483
+ const p = keybinds.pending();
484
+ if (p) {
485
+ setBottomBarMessage(bottomBar, ` ${p}`);
486
+ renderer.requestRender();
487
+ }
488
+ return;
489
+ }
490
+
491
+ switch (action) {
492
+ case "cursor-down":
457
493
  if (state.cursorLine < state.lineCount) {
458
494
  state.cursorLine++;
459
495
  ensureCursorVisible();
460
496
  refreshPager();
461
497
  }
462
498
  break;
463
- }
464
- case "k":
465
- case "up": {
499
+ case "cursor-up":
466
500
  if (state.cursorLine > 1) {
467
501
  state.cursorLine--;
468
502
  ensureCursorVisible();
469
503
  refreshPager();
470
504
  }
471
505
  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
- }
506
+ case "half-page-down": {
507
+ const half = Math.max(1, Math.floor(pageSize() / 2));
508
+ state.cursorLine = Math.min(state.cursorLine + half, state.lineCount);
509
+ ensureCursorVisible();
510
+ refreshPager();
513
511
  break;
514
512
  }
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
- }
513
+ case "half-page-up": {
514
+ const half = Math.max(1, Math.floor(pageSize() / 2));
515
+ state.cursorLine = Math.max(state.cursorLine - half, 1);
516
+ ensureCursorVisible();
517
+ refreshPager();
523
518
  break;
524
519
  }
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);
520
+ case "goto-bottom":
521
+ state.cursorLine = state.lineCount;
522
+ ensureCursorVisible();
523
+ refreshPager();
524
+ break;
525
+ case "goto-top":
526
+ state.cursorLine = 1;
527
+ ensureCursorVisible();
528
+ refreshPager();
529
+ break;
530
+ case "search-next":
531
+ if (searchQuery) {
532
+ const match = findNextMatch(state.specLines, searchQuery, state.cursorLine, 1);
533
+ if (match !== null) {
534
+ state.cursorLine = match;
535
+ ensureCursorVisible();
537
536
  }
538
537
  } 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);
538
+ setBottomBarMessage(bottomBar, " No active search \u2014 use / to search");
539
+ renderer.requestRender();
540
+ setTimeout(() => { refreshPager(); }, 1500);
541
+ }
542
+ refreshPager();
543
+ break;
544
+ case "search-prev":
545
+ if (searchQuery) {
546
+ const match = findNextMatch(state.specLines, searchQuery, state.cursorLine, -1);
547
+ if (match !== null) {
548
+ state.cursorLine = match;
549
+ ensureCursorVisible();
550
550
  }
551
+ } else {
552
+ setBottomBarMessage(bottomBar, " No active search \u2014 use / to search");
553
+ renderer.requestRender();
554
+ setTimeout(() => { refreshPager(); }, 1500);
551
555
  }
552
556
  refreshPager();
553
557
  break;
554
- }
555
- case "c": {
558
+ case "comment":
556
559
  showCommentInput();
557
560
  break;
558
- }
559
- case "l": {
560
- // Thread list
561
+ case "thread-list":
561
562
  showThreadListOverlay();
562
563
  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
- }
564
+ case "resolve": {
565
+ const thread = state.threadAtLine(state.cursorLine);
566
+ if (thread) {
567
+ const wasResolved = thread.status === "resolved";
568
+ state.resolveThread(thread.id);
569
+ state.markRead(thread.id);
570
+ appendEvent(jsonlPath, { type: wasResolved ? "unresolve" : "resolve", threadId: thread.id, author: "reviewer", ts: Date.now() });
589
571
  refreshPager();
590
- bottomBar.text.content = ` \u2714 Resolved ${pending} pending thread(s)`;
572
+ const msg = wasResolved
573
+ ? ` \u21a9 Reopened thread #${thread.id}`
574
+ : ` \u2714 Resolved thread #${thread.id}`;
575
+ setBottomBarMessage(bottomBar, msg);
591
576
  renderer.requestRender();
592
577
  setTimeout(() => { refreshPager(); }, 1500);
593
578
  }
594
579
  break;
595
580
  }
596
- case "g": {
597
- if (key.shift) {
598
- // G (shift+g) go to last line
599
- state.cursorLine = state.lineCount;
600
- ensureCursorVisible();
601
- refreshPager();
602
- } 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
- }
581
+ case "resolve-all": {
582
+ const { pending } = state.activeThreadCount();
583
+ const pendingThreads = state.threads.filter(t => t.status === "pending");
584
+ state.resolveAllPending();
585
+ for (const t of pendingThreads) {
586
+ appendEvent(jsonlPath, { type: "resolve", threadId: t.id, author: "reviewer", ts: Date.now() });
616
587
  }
588
+ refreshPager();
589
+ setBottomBarMessage(bottomBar, ` \u2714 Resolved ${pending} pending thread(s)`);
590
+ renderer.requestRender();
591
+ setTimeout(() => { refreshPager(); }, 1500);
617
592
  break;
618
593
  }
619
- case "a": {
620
- // Approve
594
+ case "delete-draft": {
595
+ const thread = state.threadAtLine(state.cursorLine);
596
+ if (!thread) break;
597
+ const deleteOverlay = createConfirm({
598
+ renderer,
599
+ title: "Delete Thread",
600
+ message: `Delete thread #${thread.id} on line ${thread.line}?`,
601
+ onConfirm: () => {
602
+ dismissOverlay();
603
+ state.deleteThread(thread.id);
604
+ appendEvent(jsonlPath, { type: "delete", threadId: thread.id, author: "reviewer", ts: Date.now() });
605
+ refreshPager();
606
+ setBottomBarMessage(bottomBar, ` \u2714 Deleted thread #${thread.id}`);
607
+ renderer.requestRender();
608
+ setTimeout(() => { refreshPager(); }, 1500);
609
+ },
610
+ onCancel: () => {
611
+ dismissOverlay();
612
+ },
613
+ });
614
+ showOverlay(deleteOverlay);
615
+ break;
616
+ }
617
+ case "approve":
621
618
  if (state.canApprove()) {
622
619
  const confirmOverlay = createConfirm({
623
620
  renderer,
624
- message: "Approve spec and proceed to implementation? [y/n]",
621
+ message: "Approve spec and proceed to implementation?",
625
622
  onConfirm: () => {
626
623
  dismissOverlay();
627
624
  appendEvent(jsonlPath, { type: "approve", author: "reviewer", ts: Date.now() });
@@ -632,105 +629,63 @@ export async function runTui(
632
629
  },
633
630
  });
634
631
  showOverlay(confirmOverlay);
635
- return;
636
632
  } else {
637
- // Show why approval is blocked
638
633
  const { open, pending } = state.activeThreadCount();
639
634
  const total = open + pending;
640
- const msg =
641
- total === 0
642
- ? "No threads to approve"
643
- : `${total} thread${total !== 1 ? "s" : ""} still open/pending`;
644
- bottomBar.text.content = ` \u26a0 ${msg}`;
635
+ const msg = total === 0
636
+ ? "No threads to approve"
637
+ : `${total} thread${total !== 1 ? "s" : ""} still open/pending`;
638
+ setBottomBarMessage(bottomBar, ` \u26a0 ${msg}`);
645
639
  renderer.requestRender();
646
- setTimeout(() => {
647
- refreshPager();
648
- }, 2000);
640
+ setTimeout(() => { refreshPager(); }, 2000);
649
641
  }
650
642
  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;
643
+ case "next-thread": {
644
+ const next = state.nextActiveThread();
645
+ if (next !== null) {
646
+ state.cursorLine = next;
647
+ ensureCursorVisible();
716
648
  }
717
- // Check for "?" to show help overlay
718
- if (key.sequence === "?") {
719
- showHelpOverlay();
720
- break;
649
+ refreshPager();
650
+ break;
651
+ }
652
+ case "prev-thread": {
653
+ const prev = state.prevActiveThread();
654
+ if (prev !== null) {
655
+ state.cursorLine = prev;
656
+ ensureCursorVisible();
721
657
  }
722
- // Check for "/" to enter search mode
723
- if (key.sequence === "/") {
724
- showSearchOverlay();
725
- break;
658
+ refreshPager();
659
+ break;
660
+ }
661
+ case "next-unread": {
662
+ const nextLine = state.nextUnreadThread();
663
+ if (nextLine !== null) {
664
+ state.cursorLine = nextLine;
665
+ ensureCursorVisible();
726
666
  }
727
- // Check for ":" to enter command mode
728
- if (key.sequence === ":") {
729
- commandBuffer = "";
730
- refreshPager();
667
+ refreshPager();
668
+ break;
669
+ }
670
+ case "prev-unread": {
671
+ const prevLine = state.prevUnreadThread();
672
+ if (prevLine !== null) {
673
+ state.cursorLine = prevLine;
674
+ ensureCursorVisible();
731
675
  }
676
+ refreshPager();
732
677
  break;
733
678
  }
679
+ case "help":
680
+ showHelpOverlay();
681
+ break;
682
+ case "search":
683
+ showSearchOverlay();
684
+ break;
685
+ case "command-mode":
686
+ commandBuffer = "";
687
+ refreshPager();
688
+ break;
734
689
  }
735
690
  });
736
691
  });