revspec 0.6.0 → 0.7.1
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/README.md +60 -68
- package/bin/revspec.ts +4 -38
- package/package.json +15 -1
- package/skills/revspec/SKILL.md +38 -31
- package/src/cli/reply.ts +1 -1
- package/src/cli/watch.ts +122 -58
- package/src/protocol/live-events.ts +6 -16
- package/src/state/review-state.ts +37 -24
- package/src/tui/app.ts +145 -108
- package/src/tui/comment-input.ts +9 -13
- package/src/tui/confirm.ts +4 -6
- package/src/tui/help.ts +13 -16
- package/src/tui/spinner.ts +81 -0
- package/src/tui/status-bar.ts +9 -6
- package/src/tui/thread-list.ts +62 -22
- package/src/tui/ui/keymap.ts +55 -0
- package/.github/workflows/ci.yml +0 -18
- package/CLAUDE.md +0 -29
- package/bun.lock +0 -216
- package/docs/superpowers/plans/2026-03-14-live-ai-integration.md +0 -1877
- package/docs/superpowers/plans/2026-03-14-spectral-v1-implementation.md +0 -2139
- package/docs/superpowers/plans/2026-03-15-ui-refactor.md +0 -1025
- package/docs/superpowers/specs/2026-03-14-live-ai-integration-design.md +0 -518
- package/docs/superpowers/specs/2026-03-14-live-ai-integration-design.review.json +0 -65
- package/docs/superpowers/specs/2026-03-14-spec-review-tool-design.md +0 -331
- package/docs/superpowers/specs/2026-03-14-spec-review-tool-design.review.json +0 -141
- package/docs/superpowers/specs/claude-code-integration-notes.md +0 -26
- package/scripts/install-skill.sh +0 -20
- package/scripts/release.sh +0 -52
- package/test/e2e/__snapshots__/snapshot.test.ts.snap +0 -31
- package/test/e2e/fixtures/spec.md +0 -36
- package/test/e2e/harness.ts +0 -80
- package/test/e2e/snapshot.test.ts +0 -182
- package/test/integration/cli-reply.test.ts +0 -140
- package/test/integration/cli-watch.test.ts +0 -216
- package/test/integration/cli.test.ts +0 -160
- package/test/integration/e2e-live.test.ts +0 -171
- package/test/integration/live-interaction.test.ts +0 -398
- package/test/integration/opentui-smoke.test.ts +0 -12
- package/test/unit/protocol/live-events.test.ts +0 -509
- package/test/unit/protocol/live-merge.test.ts +0 -167
- package/test/unit/protocol/merge.test.ts +0 -100
- package/test/unit/protocol/read.test.ts +0 -92
- package/test/unit/protocol/types.test.ts +0 -95
- package/test/unit/protocol/write.test.ts +0 -72
- package/test/unit/state/review-state.test.ts +0 -399
- package/test/unit/tui/pager.test.ts +0 -159
- package/test/unit/tui/ui/keybinds.test.ts +0 -71
- package/tsconfig.json +0 -14
package/src/tui/app.ts
CHANGED
|
@@ -6,10 +6,7 @@ import {
|
|
|
6
6
|
type CliRenderer,
|
|
7
7
|
type KeyEvent,
|
|
8
8
|
} from "@opentui/core";
|
|
9
|
-
import { readReviewFile } from "../protocol/read";
|
|
10
|
-
import { writeReviewFile } from "../protocol/write";
|
|
11
9
|
import { appendEvent, readEventsFromOffset, replayEventsToThreads } from "../protocol/live-events";
|
|
12
|
-
import { mergeJsonlIntoReview } from "../protocol/live-merge";
|
|
13
10
|
import type { Thread } from "../protocol/types";
|
|
14
11
|
import { ReviewState } from "../state/review-state";
|
|
15
12
|
import { createLiveWatcher, type LiveWatcher } from "./live-watcher";
|
|
@@ -24,43 +21,28 @@ import {
|
|
|
24
21
|
type BottomBarComponents,
|
|
25
22
|
} from "./status-bar";
|
|
26
23
|
import { createCommentInput } from "./comment-input";
|
|
27
|
-
// thread-expand removed — merged into comment-input
|
|
28
24
|
import { createSearch } from "./search";
|
|
29
25
|
import { createThreadList } from "./thread-list";
|
|
30
26
|
import { createConfirm } from "./confirm";
|
|
31
27
|
import { createHelp } from "./help";
|
|
28
|
+
import { createSpinner } from "./spinner";
|
|
32
29
|
import { createKeybindRegistry, type KeyBinding } from "./ui/keybinds";
|
|
33
30
|
|
|
34
31
|
export async function runTui(
|
|
35
32
|
specFile: string,
|
|
36
|
-
reviewPath: string,
|
|
37
|
-
draftPath: string,
|
|
38
33
|
version?: string
|
|
39
34
|
): Promise<void> {
|
|
40
35
|
// 1. Read spec file into lines
|
|
41
36
|
const specContent = readFileSync(specFile, "utf8");
|
|
42
37
|
const specLines = specContent.split("\n");
|
|
43
38
|
|
|
44
|
-
// 2.
|
|
45
|
-
const
|
|
46
|
-
|
|
47
|
-
let threads: Thread[] = [];
|
|
48
|
-
if (existingReview) {
|
|
49
|
-
threads = existingReview.threads.map((t) => ({
|
|
50
|
-
...t,
|
|
51
|
-
messages: [...t.messages],
|
|
52
|
-
}));
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
// 3. Create ReviewState
|
|
56
|
-
const state = new ReviewState(specLines, threads);
|
|
39
|
+
// 2. Create ReviewState
|
|
40
|
+
const state = new ReviewState(specLines, []);
|
|
57
41
|
|
|
58
42
|
// 4. Derive JSONL path and set up live protocol
|
|
59
43
|
const dir = dirname(specFile);
|
|
60
44
|
const base = basename(specFile, ".md");
|
|
61
|
-
const jsonlPath = `${dir}/${base}.review.
|
|
62
|
-
const reviewPathForMerge = `${dir}/${base}.review.json`;
|
|
63
|
-
|
|
45
|
+
const jsonlPath = `${dir}/${base}.review.jsonl`;
|
|
64
46
|
// Crash recovery: replay JSONL events if file exists
|
|
65
47
|
if (existsSync(jsonlPath)) {
|
|
66
48
|
const { events } = readEventsFromOffset(jsonlPath, 0);
|
|
@@ -141,6 +123,9 @@ export async function runTui(
|
|
|
141
123
|
// Command mode state
|
|
142
124
|
let commandBuffer: string | null = null;
|
|
143
125
|
|
|
126
|
+
// Active spec poll interval (for submit spinner leak prevention)
|
|
127
|
+
let activeSpecPoll: ReturnType<typeof setInterval> | null = null;
|
|
128
|
+
|
|
144
129
|
// Overlay state — when an overlay is active, normal keybindings are blocked.
|
|
145
130
|
// The overlay's own key handlers manage its lifecycle.
|
|
146
131
|
type ActiveOverlay = {
|
|
@@ -168,29 +153,9 @@ export async function runTui(
|
|
|
168
153
|
renderer.requestRender();
|
|
169
154
|
}
|
|
170
155
|
|
|
171
|
-
// Helper:
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
function hasPendingChanges(): boolean {
|
|
176
|
-
if (!existsSync(jsonlPath)) return false;
|
|
177
|
-
const { size } = statSync(jsonlPath);
|
|
178
|
-
return size > lastMergedOffset;
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
function doMerge(): void {
|
|
182
|
-
const existingReviewForMerge = readReviewFile(reviewPathForMerge);
|
|
183
|
-
const merged = mergeJsonlIntoReview(jsonlPath, existingReviewForMerge, specFile);
|
|
184
|
-
writeReviewFile(reviewPathForMerge, merged);
|
|
185
|
-
if (existsSync(jsonlPath)) {
|
|
186
|
-
lastMergedOffset = statSync(jsonlPath).size;
|
|
187
|
-
}
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
function mergeAndExit(resolve: () => void): void {
|
|
191
|
-
doMerge();
|
|
192
|
-
// Signal to watch process that session has ended
|
|
193
|
-
appendEvent(jsonlPath, { type: "session-end", author: "reviewer", ts: Date.now() });
|
|
156
|
+
// Helper: exit the TUI cleanly
|
|
157
|
+
function exitTui(resolve: () => void, eventType: "session-end" | "approve"): void {
|
|
158
|
+
appendEvent(jsonlPath, { type: eventType, author: "reviewer", ts: Date.now() });
|
|
194
159
|
liveWatcher.stop();
|
|
195
160
|
keybinds.destroy();
|
|
196
161
|
renderer.destroy();
|
|
@@ -218,39 +183,21 @@ export async function runTui(
|
|
|
218
183
|
}
|
|
219
184
|
|
|
220
185
|
// Process command buffer input
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
renderer.requestRender();
|
|
228
|
-
setTimeout(() => { refreshPager(); }, 1200);
|
|
229
|
-
return "stay";
|
|
230
|
-
}
|
|
231
|
-
if (cmd === "wq" || cmd === "qw") {
|
|
232
|
-
// Merge and exit
|
|
233
|
-
mergeAndExit(resolve);
|
|
234
|
-
return "merged";
|
|
235
|
-
}
|
|
236
|
-
if (cmd === "q") {
|
|
237
|
-
// Exit only if merged (no pending changes)
|
|
238
|
-
if (hasPendingChanges()) {
|
|
239
|
-
setBottomBarMessage(bottomBar, " Unmerged changes. Use :w to save or :q! to discard");
|
|
186
|
+
function processCommand(cmd: string, resolve: () => void): "exit" | "stay" {
|
|
187
|
+
if (cmd === "q" || cmd === "wq" || cmd === "qw") {
|
|
188
|
+
const { open, pending } = state.activeThreadCount();
|
|
189
|
+
const total = open + pending;
|
|
190
|
+
if (total > 0) {
|
|
191
|
+
setBottomBarMessage(bottomBar, ` \u26a0 ${total} unresolved thread(s). Use :q! to force quit`);
|
|
240
192
|
renderer.requestRender();
|
|
241
193
|
setTimeout(() => { refreshPager(); }, 2000);
|
|
242
194
|
return "stay";
|
|
243
195
|
}
|
|
244
|
-
|
|
245
|
-
liveWatcher.stop();
|
|
246
|
-
keybinds.destroy();
|
|
196
|
+
exitTui(resolve, "session-end");
|
|
247
197
|
return "exit";
|
|
248
198
|
}
|
|
249
199
|
if (cmd === "q!") {
|
|
250
|
-
|
|
251
|
-
appendEvent(jsonlPath, { type: "session-end", author: "reviewer", ts: Date.now() });
|
|
252
|
-
liveWatcher.stop();
|
|
253
|
-
keybinds.destroy();
|
|
200
|
+
exitTui(resolve, "session-end");
|
|
254
201
|
return "exit";
|
|
255
202
|
}
|
|
256
203
|
// :{N} — jump to line number
|
|
@@ -261,7 +208,10 @@ export async function runTui(
|
|
|
261
208
|
refreshPager();
|
|
262
209
|
return "stay";
|
|
263
210
|
}
|
|
264
|
-
|
|
211
|
+
setBottomBarMessage(bottomBar, ` Unknown command: ${cmd}`);
|
|
212
|
+
renderer.requestRender();
|
|
213
|
+
setTimeout(() => { refreshPager(); }, 1500);
|
|
214
|
+
return "stay";
|
|
265
215
|
}
|
|
266
216
|
|
|
267
217
|
// --- Overlay launchers ---
|
|
@@ -362,6 +312,39 @@ export async function runTui(
|
|
|
362
312
|
showOverlay(overlay);
|
|
363
313
|
}
|
|
364
314
|
|
|
315
|
+
// Helper: gate that checks for unresolved threads.
|
|
316
|
+
// If unresolved, shows confirm popup to resolve all.
|
|
317
|
+
// Calls onProceed() when all threads are resolved.
|
|
318
|
+
function unresolvedGate(onProceed: () => void): void {
|
|
319
|
+
if (state.canApprove()) {
|
|
320
|
+
onProceed();
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
const { open, pending } = state.activeThreadCount();
|
|
324
|
+
const total = open + pending;
|
|
325
|
+
const confirmOverlay = createConfirm({
|
|
326
|
+
renderer,
|
|
327
|
+
title: "Unresolved Threads",
|
|
328
|
+
message: `${total} thread(s) still unresolved. Resolve all and continue?`,
|
|
329
|
+
onConfirm: () => {
|
|
330
|
+
dismissOverlay();
|
|
331
|
+
const unresolved = state.threads.filter(
|
|
332
|
+
t => t.status !== "resolved" && t.status !== "outdated"
|
|
333
|
+
);
|
|
334
|
+
state.resolveAll();
|
|
335
|
+
for (const t of unresolved) {
|
|
336
|
+
appendEvent(jsonlPath, { type: "resolve", threadId: t.id, author: "reviewer", ts: Date.now() });
|
|
337
|
+
}
|
|
338
|
+
refreshPager();
|
|
339
|
+
onProceed();
|
|
340
|
+
},
|
|
341
|
+
onCancel: () => {
|
|
342
|
+
dismissOverlay();
|
|
343
|
+
},
|
|
344
|
+
});
|
|
345
|
+
showOverlay(confirmOverlay);
|
|
346
|
+
}
|
|
347
|
+
|
|
365
348
|
// Helper: find next search match from current line in given direction, wrapping
|
|
366
349
|
function findNextMatch(
|
|
367
350
|
lines: string[],
|
|
@@ -397,11 +380,12 @@ export async function runTui(
|
|
|
397
380
|
{ key: "n", action: "search-next" },
|
|
398
381
|
{ key: "N", action: "search-prev" },
|
|
399
382
|
{ key: "c", action: "comment" },
|
|
400
|
-
{ key: "
|
|
383
|
+
{ key: "t", action: "thread-list" },
|
|
401
384
|
{ key: "r", action: "resolve" },
|
|
402
385
|
{ key: "R", action: "resolve-all" },
|
|
403
386
|
{ key: "dd", action: "delete-draft" },
|
|
404
|
-
{ key: "
|
|
387
|
+
{ key: "S", action: "submit" },
|
|
388
|
+
{ key: "A", action: "approve" },
|
|
405
389
|
{ key: "]t", action: "next-thread" },
|
|
406
390
|
{ key: "[t", action: "prev-thread" },
|
|
407
391
|
{ key: "]r", action: "next-unread" },
|
|
@@ -424,6 +408,10 @@ export async function runTui(
|
|
|
424
408
|
// (e.g., TextareaRenderable for typing in comment input).
|
|
425
409
|
if (activeOverlay) {
|
|
426
410
|
if (key.ctrl && key.name === "c") {
|
|
411
|
+
if (activeSpecPoll) {
|
|
412
|
+
clearInterval(activeSpecPoll);
|
|
413
|
+
activeSpecPoll = null;
|
|
414
|
+
}
|
|
427
415
|
dismissOverlay();
|
|
428
416
|
return;
|
|
429
417
|
}
|
|
@@ -438,16 +426,10 @@ export async function runTui(
|
|
|
438
426
|
commandBuffer = null;
|
|
439
427
|
const result = processCommand(cmd, resolve);
|
|
440
428
|
if (result === "exit") {
|
|
441
|
-
|
|
442
|
-
resolve();
|
|
443
|
-
return;
|
|
444
|
-
}
|
|
445
|
-
if (result === "merged") {
|
|
446
|
-
// mergeAndExit already called destroy+resolve
|
|
429
|
+
// exitTui already called destroy+resolve
|
|
447
430
|
return;
|
|
448
431
|
}
|
|
449
|
-
// "stay" —
|
|
450
|
-
// (e.g., :w shows "saved" briefly before refreshing via setTimeout)
|
|
432
|
+
// "stay" — processCommand handles its own bar updates
|
|
451
433
|
return;
|
|
452
434
|
}
|
|
453
435
|
if (key.name === "escape") {
|
|
@@ -472,13 +454,9 @@ export async function runTui(
|
|
|
472
454
|
return;
|
|
473
455
|
}
|
|
474
456
|
|
|
475
|
-
// Ctrl+C to exit
|
|
457
|
+
// Ctrl+C to exit
|
|
476
458
|
if (key.ctrl && key.name === "c") {
|
|
477
|
-
|
|
478
|
-
liveWatcher.stop();
|
|
479
|
-
keybinds.destroy();
|
|
480
|
-
renderer.destroy();
|
|
481
|
-
resolve();
|
|
459
|
+
exitTui(resolve, "session-end");
|
|
482
460
|
return;
|
|
483
461
|
}
|
|
484
462
|
|
|
@@ -599,6 +577,10 @@ export async function runTui(
|
|
|
599
577
|
setBottomBarMessage(bottomBar, msg);
|
|
600
578
|
renderer.requestRender();
|
|
601
579
|
setTimeout(() => { refreshPager(); }, 1500);
|
|
580
|
+
} else {
|
|
581
|
+
setBottomBarMessage(bottomBar, " No thread on this line");
|
|
582
|
+
renderer.requestRender();
|
|
583
|
+
setTimeout(() => { refreshPager(); }, 1500);
|
|
602
584
|
}
|
|
603
585
|
break;
|
|
604
586
|
}
|
|
@@ -617,7 +599,12 @@ export async function runTui(
|
|
|
617
599
|
}
|
|
618
600
|
case "delete-draft": {
|
|
619
601
|
const thread = state.threadAtLine(state.cursorLine);
|
|
620
|
-
if (!thread)
|
|
602
|
+
if (!thread) {
|
|
603
|
+
setBottomBarMessage(bottomBar, " No thread on this line");
|
|
604
|
+
renderer.requestRender();
|
|
605
|
+
setTimeout(() => { refreshPager(); }, 1500);
|
|
606
|
+
break;
|
|
607
|
+
}
|
|
621
608
|
const deleteOverlay = createConfirm({
|
|
622
609
|
renderer,
|
|
623
610
|
title: "Delete Thread",
|
|
@@ -638,48 +625,90 @@ export async function runTui(
|
|
|
638
625
|
showOverlay(deleteOverlay);
|
|
639
626
|
break;
|
|
640
627
|
}
|
|
628
|
+
case "submit":
|
|
629
|
+
unresolvedGate(() => {
|
|
630
|
+
appendEvent(jsonlPath, { type: "submit", author: "reviewer", ts: Date.now() });
|
|
631
|
+
|
|
632
|
+
const spinnerOverlay = createSpinner({
|
|
633
|
+
renderer,
|
|
634
|
+
message: "Waiting for agent to update spec...",
|
|
635
|
+
onCancel: () => {
|
|
636
|
+
clearInterval(activeSpecPoll!);
|
|
637
|
+
activeSpecPoll = null;
|
|
638
|
+
dismissOverlay();
|
|
639
|
+
},
|
|
640
|
+
onTimeout: () => {
|
|
641
|
+
clearInterval(activeSpecPoll!);
|
|
642
|
+
activeSpecPoll = null;
|
|
643
|
+
dismissOverlay();
|
|
644
|
+
setBottomBarMessage(bottomBar, " \u26a0 Agent did not update spec. Press S to retry.");
|
|
645
|
+
renderer.requestRender();
|
|
646
|
+
setTimeout(() => { refreshPager(); }, 3000);
|
|
647
|
+
},
|
|
648
|
+
});
|
|
649
|
+
showOverlay(spinnerOverlay);
|
|
650
|
+
|
|
651
|
+
activeSpecPoll = setInterval(() => {
|
|
652
|
+
try {
|
|
653
|
+
const currentMtime = statSync(specFile).mtimeMs;
|
|
654
|
+
if (currentMtime !== specMtime) {
|
|
655
|
+
clearInterval(activeSpecPoll!);
|
|
656
|
+
activeSpecPoll = null;
|
|
657
|
+
const newContent = readFileSync(specFile, "utf8");
|
|
658
|
+
state.reset(newContent.split("\n"));
|
|
659
|
+
specMtime = currentMtime;
|
|
660
|
+
specMtimeChanged = false;
|
|
661
|
+
liveWatcher.stop();
|
|
662
|
+
liveWatcher.start();
|
|
663
|
+
dismissOverlay();
|
|
664
|
+
searchQuery = null;
|
|
665
|
+
ensureCursorVisible();
|
|
666
|
+
refreshPager();
|
|
667
|
+
}
|
|
668
|
+
} catch {}
|
|
669
|
+
}, 500);
|
|
670
|
+
});
|
|
671
|
+
break;
|
|
641
672
|
case "approve":
|
|
642
|
-
|
|
673
|
+
unresolvedGate(() => {
|
|
643
674
|
const confirmOverlay = createConfirm({
|
|
644
675
|
renderer,
|
|
645
676
|
message: "Approve spec and proceed to implementation?",
|
|
646
677
|
onConfirm: () => {
|
|
647
678
|
dismissOverlay();
|
|
648
|
-
|
|
649
|
-
mergeAndExit(resolve);
|
|
679
|
+
exitTui(resolve, "approve");
|
|
650
680
|
},
|
|
651
681
|
onCancel: () => {
|
|
652
682
|
dismissOverlay();
|
|
653
683
|
},
|
|
654
684
|
});
|
|
655
685
|
showOverlay(confirmOverlay);
|
|
656
|
-
}
|
|
657
|
-
const { open, pending } = state.activeThreadCount();
|
|
658
|
-
const total = open + pending;
|
|
659
|
-
const msg = total === 0
|
|
660
|
-
? "No threads to approve"
|
|
661
|
-
: `${total} thread${total !== 1 ? "s" : ""} still open/pending`;
|
|
662
|
-
setBottomBarMessage(bottomBar, ` \u26a0 ${msg}`);
|
|
663
|
-
renderer.requestRender();
|
|
664
|
-
setTimeout(() => { refreshPager(); }, 2000);
|
|
665
|
-
}
|
|
686
|
+
});
|
|
666
687
|
break;
|
|
667
688
|
case "next-thread": {
|
|
668
|
-
const next = state.
|
|
689
|
+
const next = state.nextThread();
|
|
669
690
|
if (next !== null) {
|
|
670
691
|
state.cursorLine = next;
|
|
671
692
|
ensureCursorVisible();
|
|
693
|
+
refreshPager();
|
|
694
|
+
} else {
|
|
695
|
+
setBottomBarMessage(bottomBar, " No threads");
|
|
696
|
+
renderer.requestRender();
|
|
697
|
+
setTimeout(() => { refreshPager(); }, 1500);
|
|
672
698
|
}
|
|
673
|
-
refreshPager();
|
|
674
699
|
break;
|
|
675
700
|
}
|
|
676
701
|
case "prev-thread": {
|
|
677
|
-
const prev = state.
|
|
702
|
+
const prev = state.prevThread();
|
|
678
703
|
if (prev !== null) {
|
|
679
704
|
state.cursorLine = prev;
|
|
680
705
|
ensureCursorVisible();
|
|
706
|
+
refreshPager();
|
|
707
|
+
} else {
|
|
708
|
+
setBottomBarMessage(bottomBar, " No threads");
|
|
709
|
+
renderer.requestRender();
|
|
710
|
+
setTimeout(() => { refreshPager(); }, 1500);
|
|
681
711
|
}
|
|
682
|
-
refreshPager();
|
|
683
712
|
break;
|
|
684
713
|
}
|
|
685
714
|
case "next-unread": {
|
|
@@ -687,8 +716,12 @@ export async function runTui(
|
|
|
687
716
|
if (nextLine !== null) {
|
|
688
717
|
state.cursorLine = nextLine;
|
|
689
718
|
ensureCursorVisible();
|
|
719
|
+
refreshPager();
|
|
720
|
+
} else {
|
|
721
|
+
setBottomBarMessage(bottomBar, " No unread replies");
|
|
722
|
+
renderer.requestRender();
|
|
723
|
+
setTimeout(() => { refreshPager(); }, 1500);
|
|
690
724
|
}
|
|
691
|
-
refreshPager();
|
|
692
725
|
break;
|
|
693
726
|
}
|
|
694
727
|
case "prev-unread": {
|
|
@@ -696,8 +729,12 @@ export async function runTui(
|
|
|
696
729
|
if (prevLine !== null) {
|
|
697
730
|
state.cursorLine = prevLine;
|
|
698
731
|
ensureCursorVisible();
|
|
732
|
+
refreshPager();
|
|
733
|
+
} else {
|
|
734
|
+
setBottomBarMessage(bottomBar, " No unread replies");
|
|
735
|
+
renderer.requestRender();
|
|
736
|
+
setTimeout(() => { refreshPager(); }, 1500);
|
|
699
737
|
}
|
|
700
|
-
refreshPager();
|
|
701
738
|
break;
|
|
702
739
|
}
|
|
703
740
|
case "help":
|
package/src/tui/comment-input.ts
CHANGED
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
import type { Thread, Message } from "../protocol/types";
|
|
9
9
|
import { theme } from "./ui/theme";
|
|
10
10
|
import { createDialog } from "./ui/dialog";
|
|
11
|
+
import { THREAD_NORMAL_HINTS, THREAD_INSERT_HINTS } from "./ui/keymap";
|
|
11
12
|
|
|
12
13
|
export interface CommentInputOptions {
|
|
13
14
|
renderer: CliRenderer;
|
|
@@ -46,17 +47,8 @@ function createThreadView(
|
|
|
46
47
|
? `Thread #${thread.id} (line ${line})`
|
|
47
48
|
: `New comment on line ${line}`;
|
|
48
49
|
|
|
49
|
-
const normalHints =
|
|
50
|
-
|
|
51
|
-
{ key: "c", action: "reply" },
|
|
52
|
-
{ key: "r", action: "resolve" },
|
|
53
|
-
{ key: "q", action: "close" },
|
|
54
|
-
];
|
|
55
|
-
const insertHints = [
|
|
56
|
-
{ key: "INSERT", action: "" },
|
|
57
|
-
{ key: "Tab", action: "send" },
|
|
58
|
-
{ key: "Esc", action: "normal" },
|
|
59
|
-
];
|
|
50
|
+
const normalHints = THREAD_NORMAL_HINTS;
|
|
51
|
+
const insertHints = THREAD_INSERT_HINTS;
|
|
60
52
|
|
|
61
53
|
// --- State ---
|
|
62
54
|
let mode: "normal" | "insert" = "insert";
|
|
@@ -256,9 +248,13 @@ function createThreadView(
|
|
|
256
248
|
renderer.requestRender();
|
|
257
249
|
}
|
|
258
250
|
|
|
259
|
-
// Start in
|
|
251
|
+
// Start in appropriate mode
|
|
260
252
|
setTimeout(() => {
|
|
261
|
-
|
|
253
|
+
if (thread.messages.length > 0) {
|
|
254
|
+
enterNormal(); // Existing thread: let user read conversation first
|
|
255
|
+
} else {
|
|
256
|
+
textarea.focus(); // New thread: start typing immediately
|
|
257
|
+
}
|
|
262
258
|
scrollBox.scrollTo(scrollBox.scrollHeight);
|
|
263
259
|
renderer.requestRender();
|
|
264
260
|
setTimeout(() => {
|
package/src/tui/confirm.ts
CHANGED
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
} from "@opentui/core";
|
|
6
6
|
import { theme } from "./ui/theme";
|
|
7
7
|
import { createDialog } from "./ui/dialog";
|
|
8
|
+
import { CONFIRM_HINTS } from "./ui/keymap";
|
|
8
9
|
|
|
9
10
|
export interface ConfirmOptions {
|
|
10
11
|
renderer: CliRenderer;
|
|
@@ -36,10 +37,7 @@ export function createConfirm(opts: ConfirmOptions): ConfirmOverlay {
|
|
|
36
37
|
left: "25%",
|
|
37
38
|
borderColor: theme.warning,
|
|
38
39
|
onDismiss: onCancel,
|
|
39
|
-
hints:
|
|
40
|
-
{ key: "y", action: "yes" },
|
|
41
|
-
{ key: "n/Esc", action: "no" },
|
|
42
|
-
],
|
|
40
|
+
hints: CONFIRM_HINTS,
|
|
43
41
|
});
|
|
44
42
|
|
|
45
43
|
const msgText = new TextRenderable(renderer, {
|
|
@@ -52,13 +50,13 @@ export function createConfirm(opts: ConfirmOptions): ConfirmOverlay {
|
|
|
52
50
|
dialog.content.add(msgText);
|
|
53
51
|
|
|
54
52
|
const extraKeyHandler = (key: KeyEvent) => {
|
|
55
|
-
if (key.name === "y") {
|
|
53
|
+
if (key.name === "y" || key.name === "return") {
|
|
56
54
|
key.preventDefault();
|
|
57
55
|
key.stopPropagation();
|
|
58
56
|
onConfirm();
|
|
59
57
|
return;
|
|
60
58
|
}
|
|
61
|
-
if (key.name === "
|
|
59
|
+
if (key.name === "q") {
|
|
62
60
|
key.preventDefault();
|
|
63
61
|
key.stopPropagation();
|
|
64
62
|
onCancel();
|
package/src/tui/help.ts
CHANGED
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
} from "@opentui/core";
|
|
7
7
|
import { theme } from "./ui/theme";
|
|
8
8
|
import { createDialog } from "./ui/dialog";
|
|
9
|
+
import { HELP_HINTS } from "./ui/keymap";
|
|
9
10
|
|
|
10
11
|
export interface HelpOverlay {
|
|
11
12
|
container: import("@opentui/core").BoxRenderable;
|
|
@@ -55,10 +56,7 @@ export function createHelp(opts: {
|
|
|
55
56
|
left: "20%",
|
|
56
57
|
borderColor: theme.info,
|
|
57
58
|
onDismiss: onClose,
|
|
58
|
-
hints:
|
|
59
|
-
{ key: "j/k", action: "navigate" },
|
|
60
|
-
{ key: "q/?/Esc", action: "close" },
|
|
61
|
-
],
|
|
59
|
+
hints: HELP_HINTS,
|
|
62
60
|
});
|
|
63
61
|
|
|
64
62
|
// Version header
|
|
@@ -74,14 +72,14 @@ export function createHelp(opts: {
|
|
|
74
72
|
addHelpSection(dialog.content, renderer, "Quick Start", [
|
|
75
73
|
" Navigate to a line and press c to comment.",
|
|
76
74
|
" The AI replies in real-time via the thread popup.",
|
|
77
|
-
" Press r to resolve
|
|
78
|
-
"
|
|
75
|
+
" Press r to resolve, S to submit for rewrite.",
|
|
76
|
+
" Press A to approve when done.",
|
|
79
77
|
]);
|
|
80
78
|
|
|
81
79
|
addHelpSection(dialog.content, renderer, "Thread Popup", [
|
|
82
|
-
"
|
|
83
|
-
"
|
|
84
|
-
" c to reply, r to resolve, q to close.",
|
|
80
|
+
" New thread: INSERT mode — type and Tab to send.",
|
|
81
|
+
" Existing thread: NORMAL mode — read conversation,",
|
|
82
|
+
" c to reply, r to resolve, q/Esc to close.",
|
|
85
83
|
]);
|
|
86
84
|
|
|
87
85
|
addHelpSection(dialog.content, renderer, "Navigation", [
|
|
@@ -101,17 +99,16 @@ export function createHelp(opts: {
|
|
|
101
99
|
" r Resolve thread (toggle)",
|
|
102
100
|
" R Resolve all pending",
|
|
103
101
|
" dd Delete thread",
|
|
104
|
-
"
|
|
105
|
-
"
|
|
102
|
+
" t List threads",
|
|
103
|
+
" S Submit for rewrite",
|
|
104
|
+
" A Approve spec",
|
|
106
105
|
]);
|
|
107
106
|
|
|
108
107
|
addHelpSection(dialog.content, renderer, "Commands", [
|
|
109
|
-
" :
|
|
110
|
-
" :
|
|
111
|
-
" :q Quit (blocks if unmerged)",
|
|
112
|
-
" :q! Quit without merging",
|
|
108
|
+
" :q/:wq Quit (warns if unresolved)",
|
|
109
|
+
" :q! Force quit",
|
|
113
110
|
" :{N} Jump to line N",
|
|
114
|
-
" Ctrl+C
|
|
111
|
+
" Ctrl+C Force quit",
|
|
115
112
|
]);
|
|
116
113
|
|
|
117
114
|
// Trailing blank line
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import {
|
|
2
|
+
BoxRenderable,
|
|
3
|
+
TextRenderable,
|
|
4
|
+
type CliRenderer,
|
|
5
|
+
type KeyEvent,
|
|
6
|
+
} from "@opentui/core";
|
|
7
|
+
import { theme } from "./ui/theme";
|
|
8
|
+
|
|
9
|
+
const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
10
|
+
|
|
11
|
+
export interface SpinnerOverlay {
|
|
12
|
+
container: BoxRenderable;
|
|
13
|
+
cleanup: () => void;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function createSpinner(opts: {
|
|
17
|
+
renderer: CliRenderer;
|
|
18
|
+
message: string;
|
|
19
|
+
timeoutMs?: number;
|
|
20
|
+
onCancel: () => void;
|
|
21
|
+
onTimeout: () => void;
|
|
22
|
+
}): SpinnerOverlay {
|
|
23
|
+
const { renderer, message, onCancel, onTimeout, timeoutMs = 120_000 } = opts;
|
|
24
|
+
|
|
25
|
+
const container = new BoxRenderable(renderer, {
|
|
26
|
+
position: "absolute",
|
|
27
|
+
top: "40%",
|
|
28
|
+
left: "25%",
|
|
29
|
+
width: "50%",
|
|
30
|
+
height: 5,
|
|
31
|
+
zIndex: 100,
|
|
32
|
+
backgroundColor: theme.backgroundPanel,
|
|
33
|
+
border: true,
|
|
34
|
+
borderStyle: "single",
|
|
35
|
+
borderColor: theme.blue,
|
|
36
|
+
title: " Submitting ",
|
|
37
|
+
flexDirection: "column",
|
|
38
|
+
paddingLeft: 2,
|
|
39
|
+
paddingRight: 2,
|
|
40
|
+
paddingTop: 1,
|
|
41
|
+
alignItems: "center",
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
const text = new TextRenderable(renderer, {
|
|
45
|
+
content: `${SPINNER_FRAMES[0]} ${message}`,
|
|
46
|
+
width: "100%",
|
|
47
|
+
height: 1,
|
|
48
|
+
fg: theme.text,
|
|
49
|
+
wrapMode: "none",
|
|
50
|
+
});
|
|
51
|
+
container.add(text);
|
|
52
|
+
|
|
53
|
+
let frame = 0;
|
|
54
|
+
const spinInterval = setInterval(() => {
|
|
55
|
+
frame = (frame + 1) % SPINNER_FRAMES.length;
|
|
56
|
+
text.content = `${SPINNER_FRAMES[frame]} ${message}`;
|
|
57
|
+
renderer.requestRender();
|
|
58
|
+
}, 80);
|
|
59
|
+
|
|
60
|
+
const timeout = setTimeout(() => {
|
|
61
|
+
onTimeout();
|
|
62
|
+
}, timeoutMs);
|
|
63
|
+
|
|
64
|
+
const keyHandler = (key: KeyEvent) => {
|
|
65
|
+
if (key.ctrl && key.name === "c") {
|
|
66
|
+
key.preventDefault();
|
|
67
|
+
key.stopPropagation();
|
|
68
|
+
onCancel();
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
renderer.keyInput.on("keypress", keyHandler);
|
|
72
|
+
|
|
73
|
+
return {
|
|
74
|
+
container,
|
|
75
|
+
cleanup() {
|
|
76
|
+
clearInterval(spinInterval);
|
|
77
|
+
clearTimeout(timeout);
|
|
78
|
+
renderer.keyInput.off("keypress", keyHandler);
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
}
|
package/src/tui/status-bar.ts
CHANGED
|
@@ -2,7 +2,8 @@ import { BoxRenderable, TextRenderable, TextNodeRenderable, TextAttributes, type
|
|
|
2
2
|
import type { ReviewState } from "../state/review-state";
|
|
3
3
|
import { basename } from "path";
|
|
4
4
|
import { theme } from "./ui/theme";
|
|
5
|
-
import { buildHints
|
|
5
|
+
import { buildHints } from "./ui/hint-bar";
|
|
6
|
+
import { PAGER_HINTS } from "./ui/keymap";
|
|
6
7
|
|
|
7
8
|
export interface TopBarComponents {
|
|
8
9
|
box: BoxRenderable;
|
|
@@ -83,14 +84,16 @@ export function buildBottomBar(bar: BottomBarComponents, commandBuffer: string |
|
|
|
83
84
|
t.add(TextNodeRenderable.fromString(` :${commandBuffer}`, { fg: theme.text }));
|
|
84
85
|
return;
|
|
85
86
|
}
|
|
86
|
-
const hints
|
|
87
|
-
|
|
88
|
-
|
|
87
|
+
const hints = [
|
|
88
|
+
PAGER_HINTS.navigate,
|
|
89
|
+
PAGER_HINTS.comment,
|
|
89
90
|
];
|
|
90
91
|
if (hasThread) {
|
|
91
|
-
hints.push(
|
|
92
|
+
hints.push(PAGER_HINTS.resolve);
|
|
92
93
|
}
|
|
93
|
-
hints.push(
|
|
94
|
+
hints.push(PAGER_HINTS.submit);
|
|
95
|
+
hints.push(PAGER_HINTS.approve);
|
|
96
|
+
hints.push(PAGER_HINTS.help);
|
|
94
97
|
buildHints(t, hints);
|
|
95
98
|
}
|
|
96
99
|
|