revspec 0.2.1 → 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
 
@@ -200,6 +196,8 @@ export async function runTui(
200
196
 
201
197
  function mergeAndExit(resolve: () => void): void {
202
198
  doMerge();
199
+ // Signal to watch process that session has ended
200
+ appendEvent(jsonlPath, { type: "session-end", author: "reviewer", ts: Date.now() });
203
201
  liveWatcher.stop();
204
202
  renderer.destroy();
205
203
  resolve();
@@ -207,9 +205,9 @@ export async function runTui(
207
205
 
208
206
  // Helper: scroll pager to ensure cursor line is visible
209
207
  function ensureCursorVisible(): void {
210
- // Each line in the pager is 1 row of text.
211
- // The cursor line index (0-based) in the pager is (state.cursorLine - 1).
212
- 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;
213
211
  const viewportHeight = Math.max(1, renderer.height - 2); // minus top + bottom bar
214
212
 
215
213
  const currentScroll = pager.scrollBox.scrollTop;
@@ -231,7 +229,7 @@ export async function runTui(
231
229
  if (cmd === "w") {
232
230
  // Merge JSONL -> JSON, stay open
233
231
  doMerge();
234
- bottomBar.bar.content = " \u2714 Merged to review JSON";
232
+ bottomBar.text.content = " \u2714 Merged to review JSON";
235
233
  renderer.requestRender();
236
234
  setTimeout(() => { refreshPager(); }, 1200);
237
235
  return "stay";
@@ -244,16 +242,18 @@ export async function runTui(
244
242
  if (cmd === "q") {
245
243
  // Exit only if merged (no pending changes)
246
244
  if (hasPendingChanges()) {
247
- 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";
248
246
  renderer.requestRender();
249
247
  setTimeout(() => { refreshPager(); }, 2000);
250
248
  return "stay";
251
249
  }
250
+ appendEvent(jsonlPath, { type: "session-end", author: "reviewer", ts: Date.now() });
252
251
  liveWatcher.stop();
253
252
  return "exit";
254
253
  }
255
254
  if (cmd === "q!") {
256
255
  // Exit without merging
256
+ appendEvent(jsonlPath, { type: "session-end", author: "reviewer", ts: Date.now() });
257
257
  liveWatcher.stop();
258
258
  return "exit";
259
259
  }
@@ -263,7 +263,7 @@ export async function runTui(
263
263
  // --- Overlay launchers ---
264
264
 
265
265
  function showCommentInput(): void {
266
- const existingThread = state.threadAtLine(state.cursorLine);
266
+ let existingThread = state.threadAtLine(state.cursorLine);
267
267
 
268
268
  const overlay = createCommentInput({
269
269
  renderer,
@@ -278,13 +278,19 @@ export async function runTui(
278
278
  refreshPager();
279
279
  // Don't dismiss — overlay stays open, message appended by comment-input
280
280
  } else {
281
- // New comment — close overlay
281
+ // New comment — create thread, stay open
282
282
  state.addComment(state.cursorLine, text);
283
283
  const newThread = state.threadAtLine(state.cursorLine);
284
284
  if (newThread) {
285
285
  appendEvent(jsonlPath, { type: "comment", threadId: newThread.id, line: state.cursorLine, author: "reviewer", text, ts: Date.now() });
286
+ // Update overlay to reference the new thread
287
+ if (activeOverlay) {
288
+ activeOverlay.threadId = newThread.id;
289
+ activeOverlay.container.title = ` Thread #${newThread.id} (line ${state.cursorLine}) `;
290
+ }
291
+ existingThread = newThread;
286
292
  }
287
- dismissOverlay();
293
+ refreshPager();
288
294
  }
289
295
  },
290
296
  onResolve: () => {
@@ -344,6 +350,7 @@ export async function runTui(
344
350
  function showHelpOverlay(): void {
345
351
  const overlay = createHelp({
346
352
  renderer,
353
+ version: version ?? "?",
347
354
  onClose: () => {
348
355
  dismissOverlay();
349
356
  },
@@ -447,29 +454,19 @@ export async function runTui(
447
454
  switch (key.name) {
448
455
  case "j":
449
456
  case "down": {
450
- if (pager.mode === "markdown") {
451
- pager.scrollBox.scrollBy(1);
452
- renderer.requestRender();
453
- } else {
454
- if (state.cursorLine < state.lineCount) {
455
- state.cursorLine++;
456
- ensureCursorVisible();
457
- refreshPager();
458
- }
457
+ if (state.cursorLine < state.lineCount) {
458
+ state.cursorLine++;
459
+ ensureCursorVisible();
460
+ refreshPager();
459
461
  }
460
462
  break;
461
463
  }
462
464
  case "k":
463
465
  case "up": {
464
- if (pager.mode === "markdown") {
465
- pager.scrollBox.scrollBy(-1);
466
- renderer.requestRender();
467
- } else {
468
- if (state.cursorLine > 1) {
469
- state.cursorLine--;
470
- ensureCursorVisible();
471
- refreshPager();
472
- }
466
+ if (state.cursorLine > 1) {
467
+ state.cursorLine--;
468
+ ensureCursorVisible();
469
+ refreshPager();
473
470
  }
474
471
  break;
475
472
  }
@@ -478,17 +475,11 @@ export async function runTui(
478
475
  if (key.ctrl) {
479
476
  if (deletePendingTimer) { clearTimeout(deletePendingTimer); deletePendingTimer = null; }
480
477
  const half = Math.max(1, Math.floor(pageSize() / 2));
481
- if (pager.mode === "markdown") {
482
- pager.scrollBox.scrollBy(half);
483
- renderer.requestRender();
484
- } else {
485
- state.cursorLine = Math.min(state.cursorLine + half, state.lineCount);
486
- ensureCursorVisible();
487
- refreshPager();
488
- }
478
+ state.cursorLine = Math.min(state.cursorLine + half, state.lineCount);
479
+ ensureCursorVisible();
480
+ refreshPager();
489
481
  } else {
490
482
  // d without ctrl — delete draft comment (dd = double-tap within 500ms)
491
- ensureLineMode(pager);
492
483
  refreshPager();
493
484
  const thread = state.threadAtLine(state.cursorLine);
494
485
  if (!thread) break;
@@ -501,17 +492,17 @@ export async function runTui(
501
492
  state.deleteLastDraftMessage(thread.id);
502
493
  appendEvent(jsonlPath, { type: "delete", threadId: thread.id, author: "reviewer", ts: Date.now() });
503
494
  refreshPager();
504
- bottomBar.bar.content = " \u2714 Deleted draft comment";
495
+ bottomBar.text.content = " \u2714 Deleted draft comment";
505
496
  renderer.requestRender();
506
497
  setTimeout(() => { refreshPager(); }, 1500);
507
498
  } else {
508
- bottomBar.bar.content = " No reviewer message to delete";
499
+ bottomBar.text.content = " No reviewer message to delete";
509
500
  renderer.requestRender();
510
501
  setTimeout(() => { refreshPager(); }, 1500);
511
502
  }
512
503
  } else {
513
504
  // First d — show hint and start timer
514
- bottomBar.bar.content = " Press d again to delete";
505
+ bottomBar.text.content = " Press d again to delete";
515
506
  renderer.requestRender();
516
507
  deletePendingTimer = setTimeout(() => {
517
508
  deletePendingTimer = null;
@@ -525,14 +516,9 @@ export async function runTui(
525
516
  // Ctrl+U — half page up
526
517
  if (key.ctrl) {
527
518
  const half = Math.max(1, Math.floor(pageSize() / 2));
528
- if (pager.mode === "markdown") {
529
- pager.scrollBox.scrollBy(-half);
530
- renderer.requestRender();
531
- } else {
532
- state.cursorLine = Math.max(state.cursorLine - half, 1);
533
- ensureCursorVisible();
534
- refreshPager();
535
- }
519
+ state.cursorLine = Math.max(state.cursorLine - half, 1);
520
+ ensureCursorVisible();
521
+ refreshPager();
536
522
  }
537
523
  break;
538
524
  }
@@ -545,7 +531,7 @@ export async function runTui(
545
531
  ensureCursorVisible();
546
532
  }
547
533
  } else {
548
- bottomBar.bar.content = " No active search \u2014 use / to search";
534
+ bottomBar.text.content = " No active search \u2014 use / to search";
549
535
  renderer.requestRender();
550
536
  setTimeout(() => { refreshPager(); }, 1500);
551
537
  }
@@ -558,7 +544,7 @@ export async function runTui(
558
544
  ensureCursorVisible();
559
545
  }
560
546
  } else {
561
- bottomBar.bar.content = " No active search \u2014 use / to search";
547
+ bottomBar.text.content = " No active search \u2014 use / to search";
562
548
  renderer.requestRender();
563
549
  setTimeout(() => { refreshPager(); }, 1500);
564
550
  }
@@ -566,40 +552,16 @@ export async function runTui(
566
552
  refreshPager();
567
553
  break;
568
554
  }
569
- case "m": {
570
- // Toggle markdown / line mode with scroll position sync
571
- const wasMarkdown = pager.mode === "markdown";
572
- togglePagerMode(pager);
573
- if (wasMarkdown) {
574
- // Markdown -> Line: sync scroll position to cursor
575
- state.cursorLine = Math.max(1, pager.scrollBox.scrollTop + 1);
576
- refreshPager();
577
- ensureCursorVisible();
578
- } else {
579
- // Line -> Markdown: approximate scroll to cursor position
580
- refreshPager();
581
- pager.scrollBox.scrollTo(state.cursorLine - 1);
582
- renderer.requestRender();
583
- }
584
- break;
585
- }
586
555
  case "c": {
587
- // Comment: new or reply — auto-switch to line mode
588
- ensureLineMode(pager);
589
- refreshPager();
590
556
  showCommentInput();
591
557
  break;
592
558
  }
593
559
  case "l": {
594
560
  // Thread list
595
- ensureLineMode(pager);
596
- refreshPager();
597
561
  showThreadListOverlay();
598
562
  break;
599
563
  }
600
564
  case "r": {
601
- ensureLineMode(pager);
602
- refreshPager();
603
565
  if (!key.shift) {
604
566
  // Resolve thread at cursor
605
567
  const thread = state.threadAtLine(state.cursorLine);
@@ -612,7 +574,7 @@ export async function runTui(
612
574
  const msg = wasResolved
613
575
  ? ` \u21a9 Reopened thread #${thread.id}`
614
576
  : ` \u2714 Resolved thread #${thread.id}`;
615
- bottomBar.bar.content = msg;
577
+ bottomBar.text.content = msg;
616
578
  renderer.requestRender();
617
579
  setTimeout(() => { refreshPager(); }, 1500);
618
580
  }
@@ -625,7 +587,7 @@ export async function runTui(
625
587
  appendEvent(jsonlPath, { type: "resolve", threadId: t.id, author: "reviewer", ts: Date.now() });
626
588
  }
627
589
  refreshPager();
628
- bottomBar.bar.content = ` \u2714 Resolved ${pending} pending thread(s)`;
590
+ bottomBar.text.content = ` \u2714 Resolved ${pending} pending thread(s)`;
629
591
  renderer.requestRender();
630
592
  setTimeout(() => { refreshPager(); }, 1500);
631
593
  }
@@ -633,29 +595,19 @@ export async function runTui(
633
595
  }
634
596
  case "g": {
635
597
  if (key.shift) {
636
- // G (shift+g) — go to last line / scroll to bottom
637
- if (pager.mode === "markdown") {
638
- pager.scrollBox.scrollTo(pager.scrollBox.scrollHeight);
639
- renderer.requestRender();
640
- } else {
641
- state.cursorLine = state.lineCount;
642
- ensureCursorVisible();
643
- refreshPager();
644
- }
598
+ // G (shift+g) — go to last line
599
+ state.cursorLine = state.lineCount;
600
+ ensureCursorVisible();
601
+ refreshPager();
645
602
  } else {
646
603
  // g — first of gg sequence
647
604
  if (gPendingTimer) {
648
- // Second g within 500ms — go to first line / scroll to top
605
+ // Second g within 500ms — go to first line
649
606
  clearTimeout(gPendingTimer);
650
607
  gPendingTimer = null;
651
- if (pager.mode === "markdown") {
652
- pager.scrollBox.scrollTo(0);
653
- renderer.requestRender();
654
- } else {
655
- state.cursorLine = 1;
656
- ensureCursorVisible();
657
- refreshPager();
658
- }
608
+ state.cursorLine = 1;
609
+ ensureCursorVisible();
610
+ refreshPager();
659
611
  } else {
660
612
  gPendingTimer = setTimeout(() => {
661
613
  gPendingTimer = null;
@@ -666,8 +618,6 @@ export async function runTui(
666
618
  }
667
619
  case "a": {
668
620
  // Approve
669
- ensureLineMode(pager);
670
- refreshPager();
671
621
  if (state.canApprove()) {
672
622
  const confirmOverlay = createConfirm({
673
623
  renderer,
@@ -691,7 +641,7 @@ export async function runTui(
691
641
  total === 0
692
642
  ? "No threads to approve"
693
643
  : `${total} thread${total !== 1 ? "s" : ""} still open/pending`;
694
- bottomBar.bar.content = ` \u26a0 ${msg}`;
644
+ bottomBar.text.content = ` \u26a0 ${msg}`;
695
645
  renderer.requestRender();
696
646
  setTimeout(() => {
697
647
  refreshPager();
@@ -744,7 +694,7 @@ export async function runTui(
744
694
  // Check for "]" or "[" to start bracket sequence
745
695
  if (key.sequence === "]") {
746
696
  bracketPending = "]";
747
- bottomBar.bar.content = " ]...";
697
+ bottomBar.text.content = " ]...";
748
698
  renderer.requestRender();
749
699
  bracketPendingTimer = setTimeout(() => {
750
700
  bracketPending = null;
@@ -755,7 +705,7 @@ export async function runTui(
755
705
  }
756
706
  if (key.sequence === "[") {
757
707
  bracketPending = "[";
758
- bottomBar.bar.content = " [...";
708
+ bottomBar.text.content = " [...";
759
709
  renderer.requestRender();
760
710
  bracketPendingTimer = setTimeout(() => {
761
711
  bracketPending = null;
@@ -28,89 +28,12 @@ export interface CommentInputOverlay {
28
28
 
29
29
  export function createCommentInput(opts: CommentInputOptions): CommentInputOverlay {
30
30
  const { renderer, line, existingThread, onSubmit, onResolve, onCancel } = opts;
31
- const hasThread = existingThread && existingThread.messages.length > 0;
32
-
33
- if (!hasThread) {
34
- return createNewComment(renderer, line, onSubmit, onCancel);
35
- }
36
- return createThreadView(renderer, line, existingThread!, onSubmit, onResolve, onCancel);
37
- }
38
-
39
- // --- New comment: insert-only buffer, Tab submits and closes ---
40
- function createNewComment(
41
- renderer: CliRenderer,
42
- line: number,
43
- onSubmit: (text: string) => void,
44
- onCancel: () => void,
45
- ): CommentInputOverlay {
46
- const container = new BoxRenderable(renderer, {
47
- position: "absolute",
48
- top: "30%",
49
- left: "10%",
50
- width: "80%",
51
- height: 10,
52
- zIndex: 100,
53
- backgroundColor: theme.base,
54
- border: true,
55
- borderStyle: "single",
56
- borderColor: theme.borderComment,
57
- title: ` New comment on line ${line} `,
58
- flexDirection: "column",
59
- padding: 1,
60
- });
61
-
62
- const textarea = new TextareaRenderable(renderer, {
63
- width: "100%",
64
- flexGrow: 1,
65
- backgroundColor: theme.surface0,
66
- textColor: theme.text,
67
- focusedBackgroundColor: theme.surface0,
68
- focusedTextColor: theme.text,
69
- wrapMode: "word",
70
- placeholder: "Type your comment...",
71
- placeholderColor: theme.overlay,
72
- initialValue: "",
73
- });
74
-
75
- const hint = new TextRenderable(renderer, {
76
- content: " [Tab] submit [Esc] cancel",
77
- width: "100%",
78
- height: 1,
79
- fg: theme.hintFg,
80
- bg: theme.hintBg,
81
- wrapMode: "none",
82
- truncate: true,
83
- });
84
-
85
- container.add(textarea);
86
- container.add(hint);
87
- setTimeout(() => { textarea.focus(); renderer.requestRender(); }, 0);
88
-
89
- let submitted = false;
90
- const keyHandler = (key: KeyEvent) => {
91
- if (key.name === "escape") {
92
- key.preventDefault(); key.stopPropagation(); onCancel(); return;
93
- }
94
- if (key.name === "tab") {
95
- key.preventDefault(); key.stopPropagation();
96
- if (submitted) return;
97
- submitted = true;
98
- const text = textarea.plainText.trim();
99
- if (text.length > 0) onSubmit(text); else onCancel();
100
- return;
101
- }
102
- };
103
- renderer.keyInput.on("keypress", keyHandler);
104
-
105
- return {
106
- container,
107
- cleanup() { renderer.keyInput.off("keypress", keyHandler); textarea.destroy(); },
108
- addMessage() {},
109
- threadId: null,
110
- };
31
+ // Always use thread view even for new comments (empty history, just the input)
32
+ const thread = existingThread ?? { id: "", line, status: "open" as const, messages: [] };
33
+ return createThreadView(renderer, line, thread, onSubmit, onResolve, onCancel);
111
34
  }
112
35
 
113
- // --- Thread view: two modes (normal/insert), unified buffer ---
36
+ // --- Unified thread view: works for both new comments and existing threads ---
114
37
  function createThreadView(
115
38
  renderer: CliRenderer,
116
39
  line: number,
@@ -130,7 +53,7 @@ function createThreadView(
130
53
  border: true,
131
54
  borderStyle: "single",
132
55
  borderColor: theme.borderComment,
133
- title: ` Thread #${thread.id} (line ${line}) `,
56
+ title: thread.id ? ` Thread #${thread.id} (line ${line}) ` : ` New comment on line ${line} `,
134
57
  flexDirection: "column",
135
58
  padding: 1,
136
59
  });
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",