revspec 0.5.0 → 0.7.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.
Files changed (47) hide show
  1. package/README.md +84 -67
  2. package/bin/revspec.ts +4 -38
  3. package/package.json +20 -3
  4. package/skills/revspec/SKILL.md +38 -31
  5. package/src/cli/reply.ts +1 -1
  6. package/src/cli/watch.ts +69 -41
  7. package/src/protocol/live-events.ts +6 -16
  8. package/src/state/review-state.ts +37 -24
  9. package/src/tui/app.ts +168 -107
  10. package/src/tui/comment-input.ts +21 -14
  11. package/src/tui/confirm.ts +4 -6
  12. package/src/tui/help.ts +77 -20
  13. package/src/tui/pager.ts +4 -2
  14. package/src/tui/search.ts +9 -4
  15. package/src/tui/spinner.ts +81 -0
  16. package/src/tui/status-bar.ts +9 -8
  17. package/src/tui/thread-list.ts +62 -22
  18. package/src/tui/ui/keymap.ts +55 -0
  19. package/.github/workflows/ci.yml +0 -18
  20. package/CLAUDE.md +0 -27
  21. package/bun.lock +0 -213
  22. package/docs/superpowers/plans/2026-03-14-live-ai-integration.md +0 -1877
  23. package/docs/superpowers/plans/2026-03-14-spectral-v1-implementation.md +0 -2139
  24. package/docs/superpowers/plans/2026-03-15-ui-refactor.md +0 -1025
  25. package/docs/superpowers/specs/2026-03-14-live-ai-integration-design.md +0 -518
  26. package/docs/superpowers/specs/2026-03-14-live-ai-integration-design.review.json +0 -65
  27. package/docs/superpowers/specs/2026-03-14-spec-review-tool-design.md +0 -331
  28. package/docs/superpowers/specs/2026-03-14-spec-review-tool-design.review.json +0 -141
  29. package/docs/superpowers/specs/claude-code-integration-notes.md +0 -26
  30. package/scripts/install-skill.sh +0 -20
  31. package/scripts/release.sh +0 -52
  32. package/test/cli-reply.test.ts +0 -140
  33. package/test/cli-watch.test.ts +0 -216
  34. package/test/cli.test.ts +0 -160
  35. package/test/e2e-live.test.ts +0 -171
  36. package/test/live-interaction.test.ts +0 -398
  37. package/test/opentui-smoke.test.ts +0 -12
  38. package/test/protocol/live-events.test.ts +0 -509
  39. package/test/protocol/live-merge.test.ts +0 -167
  40. package/test/protocol/merge.test.ts +0 -100
  41. package/test/protocol/read.test.ts +0 -92
  42. package/test/protocol/types.test.ts +0 -95
  43. package/test/protocol/write.test.ts +0 -72
  44. package/test/state/review-state.test.ts +0 -399
  45. package/test/tui/pager.test.ts +0 -159
  46. package/test/tui/ui/keybinds.test.ts +0 -71
  47. package/tsconfig.json +0 -14
@@ -9,7 +9,8 @@ export type LiveEventType =
9
9
  | "approve"
10
10
  | "delete"
11
11
  | "round"
12
- | "session-end";
12
+ | "session-end"
13
+ | "submit";
13
14
 
14
15
  export interface LiveEvent {
15
16
  type: LiveEventType;
@@ -30,6 +31,7 @@ const VALID_LIVE_EVENT_TYPES: readonly LiveEventType[] = [
30
31
  "delete",
31
32
  "round",
32
33
  "session-end",
34
+ "submit",
33
35
  ];
34
36
 
35
37
  export function isValidLiveEvent(value: unknown): value is LiveEvent {
@@ -49,7 +51,7 @@ export function isValidLiveEvent(value: unknown): value is LiveEvent {
49
51
  if (typeof v.author !== "string") return false;
50
52
 
51
53
  // threadId required for all except approve, round, and session-end
52
- if (v.type !== "approve" && v.type !== "round" && v.type !== "session-end") {
54
+ if (v.type !== "approve" && v.type !== "round" && v.type !== "session-end" && v.type !== "submit") {
53
55
  if (typeof v.threadId !== "string") return false;
54
56
  }
55
57
 
@@ -186,25 +188,13 @@ export function replayEventsToThreads(events: LiveEvent[]): Thread[] {
186
188
 
187
189
  case "delete": {
188
190
  if (!event.threadId) break;
189
- const thread = threadsMap.get(event.threadId);
190
- if (!thread) break;
191
- // Remove the last reviewer message
192
- for (let i = thread.messages.length - 1; i >= 0; i--) {
193
- if (thread.messages[i].author === "reviewer") {
194
- thread.messages.splice(i, 1);
195
- break;
196
- }
197
- }
198
- // Re-derive status from the new last message
199
- const lastMsg = thread.messages[thread.messages.length - 1];
200
- if (lastMsg) {
201
- thread.status = lastMsg.author === "owner" ? "pending" : "open";
202
- }
191
+ threadsMap.delete(event.threadId);
203
192
  break;
204
193
  }
205
194
 
206
195
  case "approve":
207
196
  case "round":
197
+ case "submit":
208
198
  // Skip these event types
209
199
  break;
210
200
  }
@@ -1,5 +1,16 @@
1
+ import { randomBytes } from "crypto";
1
2
  import type { Thread, Message } from "../protocol/types";
2
3
 
4
+ function nanoid(size = 8): string {
5
+ const alphabet = "0123456789abcdefghijklmnopqrstuvwxyz";
6
+ const bytes = randomBytes(size);
7
+ let id = "";
8
+ for (let i = 0; i < size; i++) {
9
+ id += alphabet[bytes[i] % alphabet.length];
10
+ }
11
+ return id;
12
+ }
13
+
3
14
  export class ReviewState {
4
15
  specLines: string[];
5
16
  threads: Thread[];
@@ -20,12 +31,7 @@ export class ReviewState {
20
31
  }
21
32
 
22
33
  nextThreadId(): string {
23
- if (this.threads.length === 0) return "t1";
24
- const highest = this.threads.reduce((max, t) => {
25
- const n = parseInt(t.id.replace(/^t/, ""), 10);
26
- return isNaN(n) ? max : Math.max(max, n);
27
- }, 0);
28
- return `t${highest + 1}`;
34
+ return nanoid();
29
35
  }
30
36
 
31
37
  addComment(line: number, text: string): void {
@@ -64,40 +70,47 @@ export class ReviewState {
64
70
  }
65
71
  }
66
72
 
73
+ resolveAll(): void {
74
+ for (const thread of this.threads) {
75
+ if (thread.status !== "resolved" && thread.status !== "outdated") {
76
+ thread.status = "resolved";
77
+ }
78
+ }
79
+ }
80
+
81
+ reset(newSpecLines: string[]): void {
82
+ this.specLines = newSpecLines;
83
+ this.threads = [];
84
+ this.cursorLine = 1;
85
+ this._unreadThreadIds.clear();
86
+ }
87
+
67
88
  threadAtLine(line: number): Thread | null {
68
89
  return this.threads.find((t) => t.line === line) ?? null;
69
90
  }
70
91
 
71
- nextActiveThread(): number | null {
72
- const active = this.threads.filter(
73
- (t) => t.status === "open" || t.status === "pending"
74
- );
75
- if (active.length === 0) return null;
92
+ nextThread(): number | null {
93
+ if (this.threads.length === 0) return null;
76
94
 
77
- // Look for first active thread after cursor (strictly after)
78
- const after = active.filter((t) => t.line > this.cursorLine);
95
+ const after = this.threads.filter((t) => t.line > this.cursorLine);
79
96
  if (after.length > 0) {
80
97
  return after.reduce((min, t) => (t.line < min.line ? t : min)).line;
81
98
  }
82
99
 
83
- // Wrap: return the lowest-line active thread
84
- return active.reduce((min, t) => (t.line < min.line ? t : min)).line;
100
+ // Wrap: return the lowest-line thread
101
+ return this.threads.reduce((min, t) => (t.line < min.line ? t : min)).line;
85
102
  }
86
103
 
87
- prevActiveThread(): number | null {
88
- const active = this.threads.filter(
89
- (t) => t.status === "open" || t.status === "pending"
90
- );
91
- if (active.length === 0) return null;
104
+ prevThread(): number | null {
105
+ if (this.threads.length === 0) return null;
92
106
 
93
- // Look for last active thread before cursor (strictly before)
94
- const before = active.filter((t) => t.line < this.cursorLine);
107
+ const before = this.threads.filter((t) => t.line < this.cursorLine);
95
108
  if (before.length > 0) {
96
109
  return before.reduce((max, t) => (t.line > max.line ? t : max)).line;
97
110
  }
98
111
 
99
- // Wrap: return the highest-line active thread
100
- return active.reduce((max, t) => (t.line > max.line ? t : max)).line;
112
+ // Wrap: return the highest-line thread
113
+ return this.threads.reduce((max, t) => (t.line > max.line ? t : max)).line;
101
114
  }
102
115
 
103
116
  canApprove(): boolean {
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,42 +183,35 @@ 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
- return "stay"; // unknown command, ignore
203
+ // :{N} jump to line number
204
+ const lineNum = parseInt(cmd, 10);
205
+ if (!isNaN(lineNum) && lineNum > 0) {
206
+ state.cursorLine = Math.min(lineNum, state.lineCount);
207
+ ensureCursorVisible();
208
+ refreshPager();
209
+ return "stay";
210
+ }
211
+ setBottomBarMessage(bottomBar, ` Unknown command: ${cmd}`);
212
+ renderer.requestRender();
213
+ setTimeout(() => { refreshPager(); }, 1500);
214
+ return "stay";
257
215
  }
258
216
 
259
217
  // --- Overlay launchers ---
@@ -354,6 +312,39 @@ export async function runTui(
354
312
  showOverlay(overlay);
355
313
  }
356
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
+
357
348
  // Helper: find next search match from current line in given direction, wrapping
358
349
  function findNextMatch(
359
350
  lines: string[],
@@ -361,11 +352,14 @@ export async function runTui(
361
352
  currentLine: number,
362
353
  direction: 1 | -1
363
354
  ): number | null {
364
- const q = query.toLowerCase();
355
+ // Smartcase: if query has any uppercase, case-sensitive
356
+ const caseSensitive = query !== query.toLowerCase();
357
+ const q = caseSensitive ? query : query.toLowerCase();
365
358
  const total = lines.length;
366
359
  for (let offset = 1; offset <= total; offset++) {
367
360
  const i = ((currentLine - 1) + offset * direction + total) % total;
368
- if (lines[i].toLowerCase().includes(q)) {
361
+ const line = caseSensitive ? lines[i] : lines[i].toLowerCase();
362
+ if (line.includes(q)) {
369
363
  return i + 1; // 1-based
370
364
  }
371
365
  }
@@ -386,20 +380,22 @@ export async function runTui(
386
380
  { key: "n", action: "search-next" },
387
381
  { key: "N", action: "search-prev" },
388
382
  { key: "c", action: "comment" },
389
- { key: "l", action: "thread-list" },
383
+ { key: "t", action: "thread-list" },
390
384
  { key: "r", action: "resolve" },
391
385
  { key: "R", action: "resolve-all" },
392
386
  { key: "dd", action: "delete-draft" },
393
- { key: "a", action: "approve" },
387
+ { key: "S", action: "submit" },
388
+ { key: "A", action: "approve" },
394
389
  { key: "]t", action: "next-thread" },
395
390
  { key: "[t", action: "prev-thread" },
396
391
  { key: "]r", action: "next-unread" },
397
392
  { key: "[r", action: "prev-unread" },
393
+ { key: "zz", action: "center-cursor" },
398
394
  { key: "?", action: "help" },
399
395
  { key: "/", action: "search" },
400
396
  { key: ":", action: "command-mode" },
401
397
  ];
402
- const keybinds = createKeybindRegistry(bindings);
398
+ const keybinds = createKeybindRegistry(bindings, 300);
403
399
 
404
400
  refreshPager();
405
401
  renderer.start();
@@ -412,6 +408,10 @@ export async function runTui(
412
408
  // (e.g., TextareaRenderable for typing in comment input).
413
409
  if (activeOverlay) {
414
410
  if (key.ctrl && key.name === "c") {
411
+ if (activeSpecPoll) {
412
+ clearInterval(activeSpecPoll);
413
+ activeSpecPoll = null;
414
+ }
415
415
  dismissOverlay();
416
416
  return;
417
417
  }
@@ -426,16 +426,10 @@ export async function runTui(
426
426
  commandBuffer = null;
427
427
  const result = processCommand(cmd, resolve);
428
428
  if (result === "exit") {
429
- renderer.destroy();
430
- resolve();
429
+ // exitTui already called destroy+resolve
431
430
  return;
432
431
  }
433
- if (result === "merged") {
434
- // mergeAndExit already called destroy+resolve
435
- return;
436
- }
437
- // "stay" — don't refreshPager here, processCommand handles its own bar updates
438
- // (e.g., :w shows "saved" briefly before refreshing via setTimeout)
432
+ // "stay" — processCommand handles its own bar updates
439
433
  return;
440
434
  }
441
435
  if (key.name === "escape") {
@@ -460,9 +454,9 @@ export async function runTui(
460
454
  return;
461
455
  }
462
456
 
463
- // Ctrl+C to exit — merge and quit
457
+ // Ctrl+C to exit
464
458
  if (key.ctrl && key.name === "c") {
465
- mergeAndExit(resolve);
459
+ exitTui(resolve, "session-end");
466
460
  return;
467
461
  }
468
462
 
@@ -527,6 +521,14 @@ export async function runTui(
527
521
  ensureCursorVisible();
528
522
  refreshPager();
529
523
  break;
524
+ case "center-cursor": {
525
+ const extra = countExtraVisualLines(state.specLines, state.cursorLine - 1);
526
+ const cursorRow = state.cursorLine - 1 + extra;
527
+ const halfView = Math.floor(pageSize() / 2);
528
+ pager.scrollBox.scrollTo(Math.max(0, cursorRow - halfView));
529
+ refreshPager();
530
+ break;
531
+ }
530
532
  case "search-next":
531
533
  if (searchQuery) {
532
534
  const match = findNextMatch(state.specLines, searchQuery, state.cursorLine, 1);
@@ -575,6 +577,10 @@ export async function runTui(
575
577
  setBottomBarMessage(bottomBar, msg);
576
578
  renderer.requestRender();
577
579
  setTimeout(() => { refreshPager(); }, 1500);
580
+ } else {
581
+ setBottomBarMessage(bottomBar, " No thread on this line");
582
+ renderer.requestRender();
583
+ setTimeout(() => { refreshPager(); }, 1500);
578
584
  }
579
585
  break;
580
586
  }
@@ -593,7 +599,12 @@ export async function runTui(
593
599
  }
594
600
  case "delete-draft": {
595
601
  const thread = state.threadAtLine(state.cursorLine);
596
- if (!thread) break;
602
+ if (!thread) {
603
+ setBottomBarMessage(bottomBar, " No thread on this line");
604
+ renderer.requestRender();
605
+ setTimeout(() => { refreshPager(); }, 1500);
606
+ break;
607
+ }
597
608
  const deleteOverlay = createConfirm({
598
609
  renderer,
599
610
  title: "Delete Thread",
@@ -614,48 +625,90 @@ export async function runTui(
614
625
  showOverlay(deleteOverlay);
615
626
  break;
616
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;
617
672
  case "approve":
618
- if (state.canApprove()) {
673
+ unresolvedGate(() => {
619
674
  const confirmOverlay = createConfirm({
620
675
  renderer,
621
676
  message: "Approve spec and proceed to implementation?",
622
677
  onConfirm: () => {
623
678
  dismissOverlay();
624
- appendEvent(jsonlPath, { type: "approve", author: "reviewer", ts: Date.now() });
625
- mergeAndExit(resolve);
679
+ exitTui(resolve, "approve");
626
680
  },
627
681
  onCancel: () => {
628
682
  dismissOverlay();
629
683
  },
630
684
  });
631
685
  showOverlay(confirmOverlay);
632
- } else {
633
- const { open, pending } = state.activeThreadCount();
634
- const total = open + pending;
635
- const msg = total === 0
636
- ? "No threads to approve"
637
- : `${total} thread${total !== 1 ? "s" : ""} still open/pending`;
638
- setBottomBarMessage(bottomBar, ` \u26a0 ${msg}`);
639
- renderer.requestRender();
640
- setTimeout(() => { refreshPager(); }, 2000);
641
- }
686
+ });
642
687
  break;
643
688
  case "next-thread": {
644
- const next = state.nextActiveThread();
689
+ const next = state.nextThread();
645
690
  if (next !== null) {
646
691
  state.cursorLine = next;
647
692
  ensureCursorVisible();
693
+ refreshPager();
694
+ } else {
695
+ setBottomBarMessage(bottomBar, " No threads");
696
+ renderer.requestRender();
697
+ setTimeout(() => { refreshPager(); }, 1500);
648
698
  }
649
- refreshPager();
650
699
  break;
651
700
  }
652
701
  case "prev-thread": {
653
- const prev = state.prevActiveThread();
702
+ const prev = state.prevThread();
654
703
  if (prev !== null) {
655
704
  state.cursorLine = prev;
656
705
  ensureCursorVisible();
706
+ refreshPager();
707
+ } else {
708
+ setBottomBarMessage(bottomBar, " No threads");
709
+ renderer.requestRender();
710
+ setTimeout(() => { refreshPager(); }, 1500);
657
711
  }
658
- refreshPager();
659
712
  break;
660
713
  }
661
714
  case "next-unread": {
@@ -663,8 +716,12 @@ export async function runTui(
663
716
  if (nextLine !== null) {
664
717
  state.cursorLine = nextLine;
665
718
  ensureCursorVisible();
719
+ refreshPager();
720
+ } else {
721
+ setBottomBarMessage(bottomBar, " No unread replies");
722
+ renderer.requestRender();
723
+ setTimeout(() => { refreshPager(); }, 1500);
666
724
  }
667
- refreshPager();
668
725
  break;
669
726
  }
670
727
  case "prev-unread": {
@@ -672,8 +729,12 @@ export async function runTui(
672
729
  if (prevLine !== null) {
673
730
  state.cursorLine = prevLine;
674
731
  ensureCursorVisible();
732
+ refreshPager();
733
+ } else {
734
+ setBottomBarMessage(bottomBar, " No unread replies");
735
+ renderer.requestRender();
736
+ setTimeout(() => { refreshPager(); }, 1500);
675
737
  }
676
- refreshPager();
677
738
  break;
678
739
  }
679
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,20 +47,12 @@ 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: "Esc/q", action: "close" },
54
- ];
55
- const insertHints = [
56
- { key: "INSERT", action: "" },
57
- { key: "Tab", action: "send" },
58
- { key: "Esc", action: "back" },
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";
55
+ let pendingG: ReturnType<typeof setTimeout> | null = null;
63
56
 
64
57
  // Build the textarea now (we need it in the key handler closure)
65
58
  const textarea = new TextareaRenderable(renderer, {
@@ -151,10 +144,19 @@ function createThreadView(
151
144
  case "g":
152
145
  if (key.shift) {
153
146
  // G = go to bottom
147
+ if (pendingG) { clearTimeout(pendingG); pendingG = null; }
154
148
  scrollBox.scrollTo(scrollBox.scrollHeight);
155
149
  renderer.requestRender();
150
+ } else if (pendingG) {
151
+ // gg = go to top
152
+ clearTimeout(pendingG);
153
+ pendingG = null;
154
+ scrollBox.scrollTo(0);
155
+ renderer.requestRender();
156
+ } else {
157
+ // First g — wait for second
158
+ pendingG = setTimeout(() => { pendingG = null; }, 300);
156
159
  }
157
- // TODO: gg = go to top (needs double-tap tracking)
158
160
  return;
159
161
  }
160
162
  };
@@ -246,9 +248,13 @@ function createThreadView(
246
248
  renderer.requestRender();
247
249
  }
248
250
 
249
- // Start in insert mode, scroll conversation to bottom
251
+ // Start in appropriate mode
250
252
  setTimeout(() => {
251
- 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
+ }
252
258
  scrollBox.scrollTo(scrollBox.scrollHeight);
253
259
  renderer.requestRender();
254
260
  setTimeout(() => {
@@ -273,6 +279,7 @@ function createThreadView(
273
279
  return {
274
280
  container: dialog.container,
275
281
  cleanup() {
282
+ if (pendingG) clearTimeout(pendingG);
276
283
  renderer.keyInput.off("keypress", keyHandler);
277
284
  dialog.cleanup();
278
285
  textarea.destroy();