revspec 0.2.2 → 0.3.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,
@@ -32,7 +32,8 @@ import { createHelp } from "./help";
32
32
  export async function runTui(
33
33
  specFile: string,
34
34
  reviewPath: string,
35
- draftPath: string
35
+ draftPath: string,
36
+ version?: string
36
37
  ): Promise<void> {
37
38
  // 1. Read spec file into lines
38
39
  const specContent = readFileSync(specFile, "utf8");
@@ -99,21 +100,20 @@ export async function runTui(
99
100
  useMouse: false,
100
101
  });
101
102
 
102
- // 6. Build layout: top bar, pager, bottom bar in a column
103
+ // 6. Build layout (opencode pattern): flex column, scrollbox fills middle
103
104
  const rootBox = new BoxRenderable(renderer, {
104
- width: "100%",
105
- height: "100%",
105
+ flexGrow: 1,
106
106
  flexDirection: "column",
107
+ width: "100%",
107
108
  });
108
109
 
109
110
  const topBar: TopBarComponents = createTopBar(renderer);
110
111
  const pager: PagerComponents = createPager(renderer);
111
112
  const bottomBar: BottomBarComponents = createBottomBar(renderer);
112
113
 
113
- rootBox.add(topBar.bar);
114
+ rootBox.add(topBar.box);
114
115
  rootBox.add(pager.scrollBox);
115
- rootBox.add(bottomBar.bar);
116
-
116
+ rootBox.add(bottomBar.box);
117
117
  renderer.root.add(rootBox);
118
118
 
119
119
  // 7. Initial render
@@ -126,13 +126,9 @@ export async function runTui(
126
126
  }
127
127
  } catch {}
128
128
 
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);
129
+ buildPagerNodes(pager.lineNode, state, searchQuery, state.unreadThreadIds);
130
+ buildTopBar(topBar, specFile, state, state.unreadCount(), specMtimeChanged);
131
+ buildBottomBar(bottomBar, commandBuffer);
136
132
  renderer.requestRender();
137
133
  }
138
134
 
@@ -209,9 +205,9 @@ export async function runTui(
209
205
 
210
206
  // Helper: scroll pager to ensure cursor line is visible
211
207
  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;
208
+ // Map spec line to visual row, accounting for table border extra lines
209
+ const extra = countExtraVisualLines(state.specLines, state.cursorLine - 1);
210
+ const cursorRow = state.cursorLine - 1 + extra;
215
211
  const viewportHeight = Math.max(1, renderer.height - 2); // minus top + bottom bar
216
212
 
217
213
  const currentScroll = pager.scrollBox.scrollTop;
@@ -233,7 +229,7 @@ export async function runTui(
233
229
  if (cmd === "w") {
234
230
  // Merge JSONL -> JSON, stay open
235
231
  doMerge();
236
- bottomBar.bar.content = " \u2714 Merged to review JSON";
232
+ bottomBar.text.content = " \u2714 Merged to review JSON";
237
233
  renderer.requestRender();
238
234
  setTimeout(() => { refreshPager(); }, 1200);
239
235
  return "stay";
@@ -246,7 +242,7 @@ export async function runTui(
246
242
  if (cmd === "q") {
247
243
  // Exit only if merged (no pending changes)
248
244
  if (hasPendingChanges()) {
249
- bottomBar.bar.content = " Unmerged changes. Use :w to save or :q! to discard";
245
+ bottomBar.text.content = " Unmerged changes. Use :w to save or :q! to discard";
250
246
  renderer.requestRender();
251
247
  setTimeout(() => { refreshPager(); }, 2000);
252
248
  return "stay";
@@ -354,6 +350,7 @@ export async function runTui(
354
350
  function showHelpOverlay(): void {
355
351
  const overlay = createHelp({
356
352
  renderer,
353
+ version: version ?? "?",
357
354
  onClose: () => {
358
355
  dismissOverlay();
359
356
  },
@@ -457,29 +454,19 @@ export async function runTui(
457
454
  switch (key.name) {
458
455
  case "j":
459
456
  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
- }
457
+ if (state.cursorLine < state.lineCount) {
458
+ state.cursorLine++;
459
+ ensureCursorVisible();
460
+ refreshPager();
469
461
  }
470
462
  break;
471
463
  }
472
464
  case "k":
473
465
  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
- }
466
+ if (state.cursorLine > 1) {
467
+ state.cursorLine--;
468
+ ensureCursorVisible();
469
+ refreshPager();
483
470
  }
484
471
  break;
485
472
  }
@@ -488,17 +475,11 @@ export async function runTui(
488
475
  if (key.ctrl) {
489
476
  if (deletePendingTimer) { clearTimeout(deletePendingTimer); deletePendingTimer = null; }
490
477
  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
- }
478
+ state.cursorLine = Math.min(state.cursorLine + half, state.lineCount);
479
+ ensureCursorVisible();
480
+ refreshPager();
499
481
  } else {
500
482
  // d without ctrl — delete draft comment (dd = double-tap within 500ms)
501
- ensureLineMode(pager);
502
483
  refreshPager();
503
484
  const thread = state.threadAtLine(state.cursorLine);
504
485
  if (!thread) break;
@@ -511,17 +492,17 @@ export async function runTui(
511
492
  state.deleteLastDraftMessage(thread.id);
512
493
  appendEvent(jsonlPath, { type: "delete", threadId: thread.id, author: "reviewer", ts: Date.now() });
513
494
  refreshPager();
514
- bottomBar.bar.content = " \u2714 Deleted draft comment";
495
+ bottomBar.text.content = " \u2714 Deleted draft comment";
515
496
  renderer.requestRender();
516
497
  setTimeout(() => { refreshPager(); }, 1500);
517
498
  } else {
518
- bottomBar.bar.content = " No reviewer message to delete";
499
+ bottomBar.text.content = " No reviewer message to delete";
519
500
  renderer.requestRender();
520
501
  setTimeout(() => { refreshPager(); }, 1500);
521
502
  }
522
503
  } else {
523
504
  // First d — show hint and start timer
524
- bottomBar.bar.content = " Press d again to delete";
505
+ bottomBar.text.content = " Press d again to delete";
525
506
  renderer.requestRender();
526
507
  deletePendingTimer = setTimeout(() => {
527
508
  deletePendingTimer = null;
@@ -535,14 +516,9 @@ export async function runTui(
535
516
  // Ctrl+U — half page up
536
517
  if (key.ctrl) {
537
518
  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
- }
519
+ state.cursorLine = Math.max(state.cursorLine - half, 1);
520
+ ensureCursorVisible();
521
+ refreshPager();
546
522
  }
547
523
  break;
548
524
  }
@@ -555,7 +531,7 @@ export async function runTui(
555
531
  ensureCursorVisible();
556
532
  }
557
533
  } else {
558
- bottomBar.bar.content = " No active search \u2014 use / to search";
534
+ bottomBar.text.content = " No active search \u2014 use / to search";
559
535
  renderer.requestRender();
560
536
  setTimeout(() => { refreshPager(); }, 1500);
561
537
  }
@@ -568,7 +544,7 @@ export async function runTui(
568
544
  ensureCursorVisible();
569
545
  }
570
546
  } else {
571
- bottomBar.bar.content = " No active search \u2014 use / to search";
547
+ bottomBar.text.content = " No active search \u2014 use / to search";
572
548
  renderer.requestRender();
573
549
  setTimeout(() => { refreshPager(); }, 1500);
574
550
  }
@@ -576,40 +552,16 @@ export async function runTui(
576
552
  refreshPager();
577
553
  break;
578
554
  }
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();
588
- } else {
589
- // Line -> Markdown: approximate scroll to cursor position
590
- refreshPager();
591
- pager.scrollBox.scrollTo(state.cursorLine - 1);
592
- renderer.requestRender();
593
- }
594
- break;
595
- }
596
555
  case "c": {
597
- // Comment: new or reply — auto-switch to line mode
598
- ensureLineMode(pager);
599
- refreshPager();
600
556
  showCommentInput();
601
557
  break;
602
558
  }
603
559
  case "l": {
604
560
  // Thread list
605
- ensureLineMode(pager);
606
- refreshPager();
607
561
  showThreadListOverlay();
608
562
  break;
609
563
  }
610
564
  case "r": {
611
- ensureLineMode(pager);
612
- refreshPager();
613
565
  if (!key.shift) {
614
566
  // Resolve thread at cursor
615
567
  const thread = state.threadAtLine(state.cursorLine);
@@ -622,7 +574,7 @@ export async function runTui(
622
574
  const msg = wasResolved
623
575
  ? ` \u21a9 Reopened thread #${thread.id}`
624
576
  : ` \u2714 Resolved thread #${thread.id}`;
625
- bottomBar.bar.content = msg;
577
+ bottomBar.text.content = msg;
626
578
  renderer.requestRender();
627
579
  setTimeout(() => { refreshPager(); }, 1500);
628
580
  }
@@ -635,7 +587,7 @@ export async function runTui(
635
587
  appendEvent(jsonlPath, { type: "resolve", threadId: t.id, author: "reviewer", ts: Date.now() });
636
588
  }
637
589
  refreshPager();
638
- bottomBar.bar.content = ` \u2714 Resolved ${pending} pending thread(s)`;
590
+ bottomBar.text.content = ` \u2714 Resolved ${pending} pending thread(s)`;
639
591
  renderer.requestRender();
640
592
  setTimeout(() => { refreshPager(); }, 1500);
641
593
  }
@@ -643,29 +595,19 @@ export async function runTui(
643
595
  }
644
596
  case "g": {
645
597
  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
- }
598
+ // G (shift+g) — go to last line
599
+ state.cursorLine = state.lineCount;
600
+ ensureCursorVisible();
601
+ refreshPager();
655
602
  } else {
656
603
  // g — first of gg sequence
657
604
  if (gPendingTimer) {
658
- // Second g within 500ms — go to first line / scroll to top
605
+ // Second g within 500ms — go to first line
659
606
  clearTimeout(gPendingTimer);
660
607
  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
- }
608
+ state.cursorLine = 1;
609
+ ensureCursorVisible();
610
+ refreshPager();
669
611
  } else {
670
612
  gPendingTimer = setTimeout(() => {
671
613
  gPendingTimer = null;
@@ -676,8 +618,6 @@ export async function runTui(
676
618
  }
677
619
  case "a": {
678
620
  // Approve
679
- ensureLineMode(pager);
680
- refreshPager();
681
621
  if (state.canApprove()) {
682
622
  const confirmOverlay = createConfirm({
683
623
  renderer,
@@ -701,7 +641,7 @@ export async function runTui(
701
641
  total === 0
702
642
  ? "No threads to approve"
703
643
  : `${total} thread${total !== 1 ? "s" : ""} still open/pending`;
704
- bottomBar.bar.content = ` \u26a0 ${msg}`;
644
+ bottomBar.text.content = ` \u26a0 ${msg}`;
705
645
  renderer.requestRender();
706
646
  setTimeout(() => {
707
647
  refreshPager();
@@ -754,7 +694,7 @@ export async function runTui(
754
694
  // Check for "]" or "[" to start bracket sequence
755
695
  if (key.sequence === "]") {
756
696
  bracketPending = "]";
757
- bottomBar.bar.content = " ]...";
697
+ bottomBar.text.content = " ]...";
758
698
  renderer.requestRender();
759
699
  bracketPendingTimer = setTimeout(() => {
760
700
  bracketPending = null;
@@ -765,7 +705,7 @@ export async function runTui(
765
705
  }
766
706
  if (key.sequence === "[") {
767
707
  bracketPending = "[";
768
- bottomBar.bar.content = " [...";
708
+ bottomBar.text.content = " [...";
769
709
  renderer.requestRender();
770
710
  bracketPendingTimer = setTimeout(() => {
771
711
  bracketPending = null;
package/src/tui/help.ts CHANGED
@@ -18,11 +18,14 @@ export interface HelpOverlay {
18
18
  */
19
19
  export function createHelp(opts: {
20
20
  renderer: CliRenderer;
21
+ version: string;
21
22
  onClose: () => void;
22
23
  }): HelpOverlay {
23
- const { renderer, onClose } = opts;
24
+ const { renderer, version, onClose } = opts;
24
25
 
25
26
  const helpText = [
27
+ "",
28
+ ` revspec v${version}`,
26
29
  "",
27
30
  " Navigation",
28
31
  " j/k Down/up",
@@ -35,10 +38,7 @@ export function createHelp(opts: {
35
38
  " ]t/[t Next/prev thread",
36
39
  " ]r/[r Next/prev unread thread",
37
40
  "",
38
- " View",
39
- " m Toggle markdown / line mode",
40
- "",
41
- " Review (switches to line mode)",
41
+ " Review",
42
42
  " c Comment / view thread / reply",
43
43
  " r Resolve thread",
44
44
  " R Resolve all pending",