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.
- package/README.md +84 -67
- package/bin/revspec.ts +4 -38
- package/package.json +20 -3
- package/skills/revspec/SKILL.md +38 -31
- package/src/cli/reply.ts +1 -1
- package/src/cli/watch.ts +69 -41
- package/src/protocol/live-events.ts +6 -16
- package/src/state/review-state.ts +37 -24
- package/src/tui/app.ts +168 -107
- package/src/tui/comment-input.ts +21 -14
- package/src/tui/confirm.ts +4 -6
- package/src/tui/help.ts +77 -20
- package/src/tui/pager.ts +4 -2
- package/src/tui/search.ts +9 -4
- package/src/tui/spinner.ts +81 -0
- package/src/tui/status-bar.ts +9 -8
- 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 -27
- package/bun.lock +0 -213
- 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/cli-reply.test.ts +0 -140
- package/test/cli-watch.test.ts +0 -216
- package/test/cli.test.ts +0 -160
- package/test/e2e-live.test.ts +0 -171
- package/test/live-interaction.test.ts +0 -398
- package/test/opentui-smoke.test.ts +0 -12
- package/test/protocol/live-events.test.ts +0 -509
- package/test/protocol/live-merge.test.ts +0 -167
- package/test/protocol/merge.test.ts +0 -100
- package/test/protocol/read.test.ts +0 -92
- package/test/protocol/types.test.ts +0 -95
- package/test/protocol/write.test.ts +0 -72
- package/test/state/review-state.test.ts +0 -399
- package/test/tui/pager.test.ts +0 -159
- package/test/tui/ui/keybinds.test.ts +0 -71
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
72
|
-
|
|
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
|
-
|
|
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
|
|
84
|
-
return
|
|
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
|
-
|
|
88
|
-
|
|
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
|
-
|
|
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
|
|
100
|
-
return
|
|
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.
|
|
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,42 +183,35 @@ 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
|
|
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
|
-
|
|
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
|
-
|
|
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: "
|
|
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: "
|
|
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
|
-
|
|
430
|
-
resolve();
|
|
429
|
+
// exitTui already called destroy+resolve
|
|
431
430
|
return;
|
|
432
431
|
}
|
|
433
|
-
|
|
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
|
|
457
|
+
// Ctrl+C to exit
|
|
464
458
|
if (key.ctrl && key.name === "c") {
|
|
465
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
625
|
-
mergeAndExit(resolve);
|
|
679
|
+
exitTui(resolve, "approve");
|
|
626
680
|
},
|
|
627
681
|
onCancel: () => {
|
|
628
682
|
dismissOverlay();
|
|
629
683
|
},
|
|
630
684
|
});
|
|
631
685
|
showOverlay(confirmOverlay);
|
|
632
|
-
}
|
|
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.
|
|
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.
|
|
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":
|
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,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
|
-
|
|
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
|
|
251
|
+
// Start in appropriate mode
|
|
250
252
|
setTimeout(() => {
|
|
251
|
-
|
|
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();
|