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.
Files changed (49) hide show
  1. package/README.md +60 -68
  2. package/bin/revspec.ts +4 -38
  3. package/package.json +15 -1
  4. package/skills/revspec/SKILL.md +38 -31
  5. package/src/cli/reply.ts +1 -1
  6. package/src/cli/watch.ts +122 -58
  7. package/src/protocol/live-events.ts +6 -16
  8. package/src/state/review-state.ts +37 -24
  9. package/src/tui/app.ts +145 -108
  10. package/src/tui/comment-input.ts +9 -13
  11. package/src/tui/confirm.ts +4 -6
  12. package/src/tui/help.ts +13 -16
  13. package/src/tui/spinner.ts +81 -0
  14. package/src/tui/status-bar.ts +9 -6
  15. package/src/tui/thread-list.ts +62 -22
  16. package/src/tui/ui/keymap.ts +55 -0
  17. package/.github/workflows/ci.yml +0 -18
  18. package/CLAUDE.md +0 -29
  19. package/bun.lock +0 -216
  20. package/docs/superpowers/plans/2026-03-14-live-ai-integration.md +0 -1877
  21. package/docs/superpowers/plans/2026-03-14-spectral-v1-implementation.md +0 -2139
  22. package/docs/superpowers/plans/2026-03-15-ui-refactor.md +0 -1025
  23. package/docs/superpowers/specs/2026-03-14-live-ai-integration-design.md +0 -518
  24. package/docs/superpowers/specs/2026-03-14-live-ai-integration-design.review.json +0 -65
  25. package/docs/superpowers/specs/2026-03-14-spec-review-tool-design.md +0 -331
  26. package/docs/superpowers/specs/2026-03-14-spec-review-tool-design.review.json +0 -141
  27. package/docs/superpowers/specs/claude-code-integration-notes.md +0 -26
  28. package/scripts/install-skill.sh +0 -20
  29. package/scripts/release.sh +0 -52
  30. package/test/e2e/__snapshots__/snapshot.test.ts.snap +0 -31
  31. package/test/e2e/fixtures/spec.md +0 -36
  32. package/test/e2e/harness.ts +0 -80
  33. package/test/e2e/snapshot.test.ts +0 -182
  34. package/test/integration/cli-reply.test.ts +0 -140
  35. package/test/integration/cli-watch.test.ts +0 -216
  36. package/test/integration/cli.test.ts +0 -160
  37. package/test/integration/e2e-live.test.ts +0 -171
  38. package/test/integration/live-interaction.test.ts +0 -398
  39. package/test/integration/opentui-smoke.test.ts +0 -12
  40. package/test/unit/protocol/live-events.test.ts +0 -509
  41. package/test/unit/protocol/live-merge.test.ts +0 -167
  42. package/test/unit/protocol/merge.test.ts +0 -100
  43. package/test/unit/protocol/read.test.ts +0 -92
  44. package/test/unit/protocol/types.test.ts +0 -95
  45. package/test/unit/protocol/write.test.ts +0 -72
  46. package/test/unit/state/review-state.test.ts +0 -399
  47. package/test/unit/tui/pager.test.ts +0 -159
  48. package/test/unit/tui/ui/keybinds.test.ts +0 -71
  49. 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. Load existing review, merge threads
45
- const existingReview = readReviewFile(reviewPath);
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.live.jsonl`;
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: merge JSONL into review JSON and clean up
172
- // Track whether we have unmerged changes since last :w
173
- let lastMergedOffset = 0;
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
- // Returns: "exit" to exit (caller should destroy+resolve), "merged" if already handled, "stay" to keep running
222
- function processCommand(cmd: string, resolve: () => void): "exit" | "merged" | "stay" {
223
- if (cmd === "w") {
224
- // Merge JSONL -> JSON, stay open
225
- doMerge();
226
- setBottomBarMessage(bottomBar, " \u2714 Merged to review JSON");
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
- appendEvent(jsonlPath, { type: "session-end", author: "reviewer", ts: Date.now() });
245
- liveWatcher.stop();
246
- keybinds.destroy();
196
+ exitTui(resolve, "session-end");
247
197
  return "exit";
248
198
  }
249
199
  if (cmd === "q!") {
250
- // Exit without merging
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
- return "stay"; // unknown command, ignore
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: "T", action: "thread-list" },
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: "a", action: "approve" },
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
- renderer.destroy();
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" — don't refreshPager here, processCommand handles its own bar updates
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 — quit without merging (same as :q!)
457
+ // Ctrl+C to exit
476
458
  if (key.ctrl && key.name === "c") {
477
- appendEvent(jsonlPath, { type: "session-end", author: "reviewer", ts: Date.now() });
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) break;
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
- if (state.canApprove()) {
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
- appendEvent(jsonlPath, { type: "approve", author: "reviewer", ts: Date.now() });
649
- mergeAndExit(resolve);
679
+ exitTui(resolve, "approve");
650
680
  },
651
681
  onCancel: () => {
652
682
  dismissOverlay();
653
683
  },
654
684
  });
655
685
  showOverlay(confirmOverlay);
656
- } else {
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.nextActiveThread();
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.prevActiveThread();
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":
@@ -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
- { key: "NORMAL", action: "" },
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 insert mode, scroll conversation to bottom
251
+ // Start in appropriate mode
260
252
  setTimeout(() => {
261
- textarea.focus();
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(() => {
@@ -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 === "n") {
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 threads, a to approve the spec.",
78
- " Use :wq to save and quit when done reviewing.",
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
- " Opens in INSERT mode — type and press Tab to send.",
83
- " Press Esc for NORMAL mode — scroll with j/k/gg/G,",
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
- " T List threads",
105
- " a Approve spec",
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
- " :w Merge to review JSON",
110
- " :wq Merge and quit",
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 Quit without merging",
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
+ }
@@ -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, type Hint } from "./ui/hint-bar";
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: Hint[] = [
87
- { key: "j/k", action: "navigate" },
88
- { key: "c", action: "comment" },
87
+ const hints = [
88
+ PAGER_HINTS.navigate,
89
+ PAGER_HINTS.comment,
89
90
  ];
90
91
  if (hasThread) {
91
- hints.push({ key: "r", action: "resolve" });
92
+ hints.push(PAGER_HINTS.resolve);
92
93
  }
93
- hints.push({ key: "?", action: "help" });
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