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/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/cli/reply.ts +4 -1
- package/src/cli/watch.ts +6 -0
- package/src/protocol/live-events.ts +5 -3
- package/src/tui/app.ts +64 -114
- package/src/tui/comment-input.ts +5 -82
- 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
|
|
|
@@ -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
|
-
//
|
|
211
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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 —
|
|
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
|
-
|
|
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 (
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
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 (
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
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
|
-
|
|
482
|
-
|
|
483
|
-
|
|
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.
|
|
495
|
+
bottomBar.text.content = " \u2714 Deleted draft comment";
|
|
505
496
|
renderer.requestRender();
|
|
506
497
|
setTimeout(() => { refreshPager(); }, 1500);
|
|
507
498
|
} else {
|
|
508
|
-
bottomBar.
|
|
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.
|
|
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
|
-
|
|
529
|
-
|
|
530
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
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
|
|
605
|
+
// Second g within 500ms — go to first line
|
|
649
606
|
clearTimeout(gPendingTimer);
|
|
650
607
|
gPendingTimer = null;
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
708
|
+
bottomBar.text.content = " [...";
|
|
759
709
|
renderer.requestRender();
|
|
760
710
|
bracketPendingTimer = setTimeout(() => {
|
|
761
711
|
bracketPending = null;
|
package/src/tui/comment-input.ts
CHANGED
|
@@ -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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
// ---
|
|
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
|
-
"
|
|
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",
|