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/CLAUDE.md +10 -2
- package/README.md +86 -29
- package/bin/revspec.ts +2 -1
- package/package.json +1 -1
- package/scripts/install-skill.sh +20 -0
- package/scripts/release.sh +5 -6
- package/skills/revspec/SKILL.md +137 -0
- package/src/protocol/live-events.ts +3 -2
- package/src/tui/app.ts +51 -111
- package/src/tui/help.ts +5 -5
- package/src/tui/pager.ts +394 -81
- package/src/tui/status-bar.ts +85 -33
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 {
|
|
16
|
+
import { buildPagerNodes, createPager, countExtraVisualLines, type PagerComponents } from "./pager";
|
|
17
17
|
import {
|
|
18
|
-
|
|
19
|
-
|
|
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:
|
|
103
|
+
// 6. Build layout (opencode pattern): flex column, scrollbox fills middle
|
|
103
104
|
const rootBox = new BoxRenderable(renderer, {
|
|
104
|
-
|
|
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.
|
|
114
|
+
rootBox.add(topBar.box);
|
|
114
115
|
rootBox.add(pager.scrollBox);
|
|
115
|
-
rootBox.add(bottomBar.
|
|
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
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
-
//
|
|
213
|
-
|
|
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.
|
|
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.
|
|
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 (
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
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 (
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
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
|
-
|
|
492
|
-
|
|
493
|
-
|
|
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.
|
|
495
|
+
bottomBar.text.content = " \u2714 Deleted draft comment";
|
|
515
496
|
renderer.requestRender();
|
|
516
497
|
setTimeout(() => { refreshPager(); }, 1500);
|
|
517
498
|
} else {
|
|
518
|
-
bottomBar.
|
|
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.
|
|
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
|
-
|
|
539
|
-
|
|
540
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
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
|
|
605
|
+
// Second g within 500ms — go to first line
|
|
659
606
|
clearTimeout(gPendingTimer);
|
|
660
607
|
gPendingTimer = null;
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
"
|
|
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",
|