revspec 0.1.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/.github/workflows/ci.yml +18 -0
- package/README.md +90 -0
- package/bin/revspec.ts +109 -0
- package/bun.lock +213 -0
- package/docs/superpowers/plans/2026-03-14-spectral-v1-implementation.md +2139 -0
- package/docs/superpowers/specs/2026-03-14-spec-review-tool-design.md +331 -0
- package/docs/superpowers/specs/2026-03-14-spec-review-tool-design.review.json +141 -0
- package/docs/superpowers/specs/claude-code-integration-notes.md +26 -0
- package/package.json +21 -0
- package/scripts/release.sh +76 -0
- package/src/protocol/merge.ts +52 -0
- package/src/protocol/read.ts +25 -0
- package/src/protocol/types.ts +55 -0
- package/src/protocol/write.ts +10 -0
- package/src/state/review-state.ts +136 -0
- package/src/tui/app.ts +691 -0
- package/src/tui/comment-input.ts +189 -0
- package/src/tui/confirm.ts +93 -0
- package/src/tui/help.ts +134 -0
- package/src/tui/pager.ts +158 -0
- package/src/tui/search.ts +119 -0
- package/src/tui/status-bar.ts +76 -0
- package/src/tui/theme.ts +34 -0
- package/src/tui/thread-list.ts +145 -0
- package/test/cli.test.ts +151 -0
- package/test/opentui-smoke.test.ts +12 -0
- package/test/protocol/merge.test.ts +100 -0
- package/test/protocol/read.test.ts +92 -0
- package/test/protocol/types.test.ts +95 -0
- package/test/protocol/write.test.ts +72 -0
- package/test/state/review-state.test.ts +326 -0
- package/test/tui/pager.test.ts +184 -0
- package/tsconfig.json +14 -0
package/src/tui/app.ts
ADDED
|
@@ -0,0 +1,691 @@
|
|
|
1
|
+
import { readFileSync } from "fs";
|
|
2
|
+
import {
|
|
3
|
+
createCliRenderer,
|
|
4
|
+
BoxRenderable,
|
|
5
|
+
type CliRenderer,
|
|
6
|
+
type KeyEvent,
|
|
7
|
+
} from "@opentui/core";
|
|
8
|
+
import { readReviewFile, readDraftFile } from "../protocol/read";
|
|
9
|
+
import { writeDraftFile } from "../protocol/write";
|
|
10
|
+
import { mergeDraftIntoReview } from "../protocol/merge";
|
|
11
|
+
import type { Thread } from "../protocol/types";
|
|
12
|
+
import { ReviewState } from "../state/review-state";
|
|
13
|
+
import { buildPagerContent, createPager, togglePagerMode, ensureLineMode, type PagerComponents } from "./pager";
|
|
14
|
+
import {
|
|
15
|
+
buildTopBarText,
|
|
16
|
+
buildBottomBarText,
|
|
17
|
+
createTopBar,
|
|
18
|
+
createBottomBar,
|
|
19
|
+
type TopBarComponents,
|
|
20
|
+
type BottomBarComponents,
|
|
21
|
+
} from "./status-bar";
|
|
22
|
+
import { createCommentInput } from "./comment-input";
|
|
23
|
+
// thread-expand removed — merged into comment-input
|
|
24
|
+
import { createSearch } from "./search";
|
|
25
|
+
import { createThreadList } from "./thread-list";
|
|
26
|
+
import { createConfirm } from "./confirm";
|
|
27
|
+
import { createHelp } from "./help";
|
|
28
|
+
|
|
29
|
+
export async function runTui(
|
|
30
|
+
specFile: string,
|
|
31
|
+
reviewPath: string,
|
|
32
|
+
draftPath: string
|
|
33
|
+
): Promise<void> {
|
|
34
|
+
// 1. Read spec file into lines
|
|
35
|
+
const specContent = readFileSync(specFile, "utf8");
|
|
36
|
+
const specLines = specContent.split("\n");
|
|
37
|
+
|
|
38
|
+
// 2. Load existing review + draft, merge threads
|
|
39
|
+
const existingReview = readReviewFile(reviewPath);
|
|
40
|
+
const existingDraft = readDraftFile(draftPath);
|
|
41
|
+
|
|
42
|
+
let threads: Thread[] = [];
|
|
43
|
+
if (existingReview) {
|
|
44
|
+
threads = existingReview.threads.map((t) => ({
|
|
45
|
+
...t,
|
|
46
|
+
messages: [...t.messages],
|
|
47
|
+
}));
|
|
48
|
+
}
|
|
49
|
+
if (existingDraft && existingDraft.threads) {
|
|
50
|
+
// Merge draft threads into review threads
|
|
51
|
+
const merged = mergeDraftIntoReview(existingReview, existingDraft, specFile);
|
|
52
|
+
threads = merged.threads;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// 3. Create ReviewState
|
|
56
|
+
const state = new ReviewState(specLines, threads);
|
|
57
|
+
|
|
58
|
+
// 4. Create renderer
|
|
59
|
+
const renderer = await createCliRenderer({
|
|
60
|
+
useAlternateScreen: true,
|
|
61
|
+
exitOnCtrlC: false,
|
|
62
|
+
useMouse: false,
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
// 5. Build layout: top bar, pager, bottom bar in a column
|
|
66
|
+
const rootBox = new BoxRenderable(renderer, {
|
|
67
|
+
width: "100%",
|
|
68
|
+
height: "100%",
|
|
69
|
+
flexDirection: "column",
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
const topBar: TopBarComponents = createTopBar(renderer);
|
|
73
|
+
const pager: PagerComponents = createPager(renderer);
|
|
74
|
+
const bottomBar: BottomBarComponents = createBottomBar(renderer);
|
|
75
|
+
|
|
76
|
+
rootBox.add(topBar.bar);
|
|
77
|
+
rootBox.add(pager.scrollBox);
|
|
78
|
+
rootBox.add(bottomBar.bar);
|
|
79
|
+
|
|
80
|
+
renderer.root.add(rootBox);
|
|
81
|
+
|
|
82
|
+
// 6. Initial render
|
|
83
|
+
function refreshPager(): void {
|
|
84
|
+
if (pager.mode === "line") {
|
|
85
|
+
pager.lineNode.content = buildPagerContent(state, searchQuery);
|
|
86
|
+
} else {
|
|
87
|
+
pager.markdownNode.content = state.specLines.join("\n");
|
|
88
|
+
}
|
|
89
|
+
topBar.bar.content = buildTopBarText(specFile, state);
|
|
90
|
+
bottomBar.bar.content = buildBottomBarText(commandBuffer, pager.mode);
|
|
91
|
+
renderer.requestRender();
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Search state — remembered query for n/N cycling
|
|
95
|
+
let searchQuery: string | null = null;
|
|
96
|
+
|
|
97
|
+
// Command mode state
|
|
98
|
+
let commandBuffer: string | null = null;
|
|
99
|
+
|
|
100
|
+
// Track unsaved changes
|
|
101
|
+
let hasUnsavedChanges = false;
|
|
102
|
+
|
|
103
|
+
// Bracket-pending state for ]t / [t navigation
|
|
104
|
+
let bracketPending: "]" | "[" | null = null;
|
|
105
|
+
let bracketPendingTimer: ReturnType<typeof setTimeout> | null = null;
|
|
106
|
+
|
|
107
|
+
// Delete-pending state: first `d` sets timer, second `d` within 500ms executes
|
|
108
|
+
let deletePendingTimer: ReturnType<typeof setTimeout> | null = null;
|
|
109
|
+
|
|
110
|
+
// g-pending state: first `g` sets timer, second `g` within 500ms goes to top
|
|
111
|
+
let gPendingTimer: ReturnType<typeof setTimeout> | null = null;
|
|
112
|
+
|
|
113
|
+
// Overlay state — when an overlay is active, normal keybindings are blocked.
|
|
114
|
+
// The overlay's own key handlers manage its lifecycle.
|
|
115
|
+
type ActiveOverlay = {
|
|
116
|
+
container: BoxRenderable;
|
|
117
|
+
cleanup: () => void;
|
|
118
|
+
} | null;
|
|
119
|
+
let activeOverlay: ActiveOverlay = null;
|
|
120
|
+
|
|
121
|
+
// Helper: dismiss the current overlay and return to normal mode
|
|
122
|
+
function dismissOverlay(): void {
|
|
123
|
+
if (activeOverlay) {
|
|
124
|
+
activeOverlay.cleanup();
|
|
125
|
+
renderer.root.remove(activeOverlay.container.id);
|
|
126
|
+
activeOverlay = null;
|
|
127
|
+
refreshPager();
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Helper: show an overlay
|
|
132
|
+
function showOverlay(overlay: { container: BoxRenderable; cleanup: () => void }): void {
|
|
133
|
+
activeOverlay = overlay;
|
|
134
|
+
renderer.root.add(overlay.container);
|
|
135
|
+
renderer.requestRender();
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Helper: save draft file
|
|
139
|
+
function saveDraft(): void {
|
|
140
|
+
const draft = state.toDraft();
|
|
141
|
+
writeDraftFile(draftPath, draft);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Helper: scroll pager to ensure cursor line is visible
|
|
145
|
+
function ensureCursorVisible(): void {
|
|
146
|
+
// Each line in the pager is 1 row of text.
|
|
147
|
+
// The cursor line index (0-based) in the pager is (state.cursorLine - 1).
|
|
148
|
+
const cursorRow = state.cursorLine - 1;
|
|
149
|
+
const viewportHeight = Math.max(1, renderer.height - 2); // minus top + bottom bar
|
|
150
|
+
|
|
151
|
+
const currentScroll = pager.scrollBox.scrollTop;
|
|
152
|
+
if (cursorRow < currentScroll) {
|
|
153
|
+
pager.scrollBox.scrollTo(cursorRow);
|
|
154
|
+
} else if (cursorRow >= currentScroll + viewportHeight) {
|
|
155
|
+
pager.scrollBox.scrollTo(cursorRow - viewportHeight + 1);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Helper: get page size (terminal height minus bars)
|
|
160
|
+
function pageSize(): number {
|
|
161
|
+
return Math.max(1, renderer.height - 2);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Process command buffer input
|
|
165
|
+
function processCommand(cmd: string, resolve: () => void): boolean {
|
|
166
|
+
if (cmd === "w") {
|
|
167
|
+
saveDraft();
|
|
168
|
+
hasUnsavedChanges = false;
|
|
169
|
+
// Show "saved" feedback briefly
|
|
170
|
+
bottomBar.bar.content = " \u2714 saved";
|
|
171
|
+
renderer.requestRender();
|
|
172
|
+
setTimeout(() => {
|
|
173
|
+
refreshPager();
|
|
174
|
+
}, 1200);
|
|
175
|
+
return false; // don't exit
|
|
176
|
+
}
|
|
177
|
+
if (cmd === "q") {
|
|
178
|
+
// Block if there are unsaved changes
|
|
179
|
+
if (hasUnsavedChanges) {
|
|
180
|
+
bottomBar.bar.content = " \u26a0 Unsaved changes — use :wq to save and quit, or :q! to discard";
|
|
181
|
+
renderer.requestRender();
|
|
182
|
+
setTimeout(() => { refreshPager(); }, 2000);
|
|
183
|
+
return false;
|
|
184
|
+
}
|
|
185
|
+
return true; // exit (already saved or no changes)
|
|
186
|
+
}
|
|
187
|
+
if (cmd === "wq") {
|
|
188
|
+
saveDraft();
|
|
189
|
+
hasUnsavedChanges = false;
|
|
190
|
+
return true; // save and exit
|
|
191
|
+
}
|
|
192
|
+
if (cmd === "q!") {
|
|
193
|
+
return true; // exit without saving
|
|
194
|
+
}
|
|
195
|
+
return false; // unknown command, ignore
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// --- Overlay launchers ---
|
|
199
|
+
|
|
200
|
+
function showCommentInput(): void {
|
|
201
|
+
const existingThread = state.threadAtLine(state.cursorLine);
|
|
202
|
+
const overlay = createCommentInput({
|
|
203
|
+
renderer,
|
|
204
|
+
line: state.cursorLine,
|
|
205
|
+
existingThread,
|
|
206
|
+
onSubmit: (text: string) => {
|
|
207
|
+
if (existingThread) {
|
|
208
|
+
state.replyToThread(existingThread.id, text);
|
|
209
|
+
} else {
|
|
210
|
+
state.addComment(state.cursorLine, text);
|
|
211
|
+
}
|
|
212
|
+
hasUnsavedChanges = true;
|
|
213
|
+
dismissOverlay();
|
|
214
|
+
},
|
|
215
|
+
onResolve: () => {
|
|
216
|
+
if (existingThread) {
|
|
217
|
+
state.resolveThread(existingThread.id);
|
|
218
|
+
hasUnsavedChanges = true;
|
|
219
|
+
}
|
|
220
|
+
dismissOverlay();
|
|
221
|
+
},
|
|
222
|
+
onCancel: () => {
|
|
223
|
+
dismissOverlay();
|
|
224
|
+
},
|
|
225
|
+
});
|
|
226
|
+
showOverlay(overlay);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
function showSearchOverlay(): void {
|
|
231
|
+
const overlay = createSearch({
|
|
232
|
+
renderer,
|
|
233
|
+
specLines: state.specLines,
|
|
234
|
+
cursorLine: state.cursorLine,
|
|
235
|
+
onResult: (lineNumber: number, query: string) => {
|
|
236
|
+
searchQuery = query;
|
|
237
|
+
state.cursorLine = lineNumber;
|
|
238
|
+
dismissOverlay();
|
|
239
|
+
ensureCursorVisible();
|
|
240
|
+
refreshPager();
|
|
241
|
+
},
|
|
242
|
+
onCancel: () => {
|
|
243
|
+
searchQuery = null;
|
|
244
|
+
dismissOverlay();
|
|
245
|
+
},
|
|
246
|
+
});
|
|
247
|
+
showOverlay(overlay);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function showThreadListOverlay(): void {
|
|
251
|
+
const overlay = createThreadList({
|
|
252
|
+
renderer,
|
|
253
|
+
threads: state.threads,
|
|
254
|
+
onSelect: (lineNumber: number) => {
|
|
255
|
+
state.cursorLine = lineNumber;
|
|
256
|
+
dismissOverlay();
|
|
257
|
+
ensureCursorVisible();
|
|
258
|
+
refreshPager();
|
|
259
|
+
},
|
|
260
|
+
onCancel: () => {
|
|
261
|
+
dismissOverlay();
|
|
262
|
+
},
|
|
263
|
+
});
|
|
264
|
+
showOverlay(overlay);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function showHelpOverlay(): void {
|
|
268
|
+
const overlay = createHelp({
|
|
269
|
+
renderer,
|
|
270
|
+
onClose: () => {
|
|
271
|
+
dismissOverlay();
|
|
272
|
+
},
|
|
273
|
+
});
|
|
274
|
+
showOverlay(overlay);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// Helper: find next search match from current line in given direction, wrapping
|
|
278
|
+
function findNextMatch(
|
|
279
|
+
lines: string[],
|
|
280
|
+
query: string,
|
|
281
|
+
currentLine: number,
|
|
282
|
+
direction: 1 | -1
|
|
283
|
+
): number | null {
|
|
284
|
+
const q = query.toLowerCase();
|
|
285
|
+
const total = lines.length;
|
|
286
|
+
for (let offset = 1; offset <= total; offset++) {
|
|
287
|
+
const i = ((currentLine - 1) + offset * direction + total) % total;
|
|
288
|
+
if (lines[i].toLowerCase().includes(q)) {
|
|
289
|
+
return i + 1; // 1-based
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
return null;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
refreshPager();
|
|
296
|
+
renderer.start();
|
|
297
|
+
|
|
298
|
+
// 7. Set up keybinding handler
|
|
299
|
+
return new Promise<void>((resolve) => {
|
|
300
|
+
renderer.keyInput.on("keypress", (key: KeyEvent) => {
|
|
301
|
+
// If an overlay is active, only handle Ctrl+C to force dismiss.
|
|
302
|
+
// All other keys pass through to the overlay's own handlers
|
|
303
|
+
// (e.g., TextareaRenderable for typing in comment input).
|
|
304
|
+
if (activeOverlay) {
|
|
305
|
+
if (key.ctrl && key.name === "c") {
|
|
306
|
+
dismissOverlay();
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
// Don't block — let the key propagate to focused renderables
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// If in command mode, buffer keypresses
|
|
314
|
+
if (commandBuffer !== null) {
|
|
315
|
+
if (key.name === "return") {
|
|
316
|
+
const cmd = commandBuffer;
|
|
317
|
+
commandBuffer = null;
|
|
318
|
+
const shouldExit = processCommand(cmd, resolve);
|
|
319
|
+
if (shouldExit) {
|
|
320
|
+
renderer.destroy();
|
|
321
|
+
resolve();
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
// Don't refreshPager here — processCommand handles its own bar updates
|
|
325
|
+
// (e.g., :w shows "saved" briefly before refreshing via setTimeout)
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
if (key.name === "escape") {
|
|
329
|
+
commandBuffer = null;
|
|
330
|
+
refreshPager();
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
if (key.name === "backspace") {
|
|
334
|
+
if (commandBuffer.length > 0) {
|
|
335
|
+
commandBuffer = commandBuffer.slice(0, -1);
|
|
336
|
+
} else {
|
|
337
|
+
commandBuffer = null;
|
|
338
|
+
}
|
|
339
|
+
refreshPager();
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
// Append printable characters
|
|
343
|
+
if (key.sequence && key.sequence.length === 1 && !key.ctrl && !key.meta) {
|
|
344
|
+
commandBuffer += key.sequence;
|
|
345
|
+
refreshPager();
|
|
346
|
+
}
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// Ctrl+C to exit
|
|
351
|
+
if (key.ctrl && key.name === "c") {
|
|
352
|
+
if (hasUnsavedChanges) {
|
|
353
|
+
saveDraft();
|
|
354
|
+
}
|
|
355
|
+
renderer.destroy();
|
|
356
|
+
resolve();
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// Escape clears search highlights
|
|
361
|
+
if (key.name === "escape") {
|
|
362
|
+
if (searchQuery) {
|
|
363
|
+
searchQuery = null;
|
|
364
|
+
refreshPager();
|
|
365
|
+
}
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// Normal mode keybindings
|
|
370
|
+
switch (key.name) {
|
|
371
|
+
case "j":
|
|
372
|
+
case "down": {
|
|
373
|
+
if (pager.mode === "markdown") {
|
|
374
|
+
pager.scrollBox.scrollBy(1);
|
|
375
|
+
renderer.requestRender();
|
|
376
|
+
} else {
|
|
377
|
+
if (state.cursorLine < state.lineCount) {
|
|
378
|
+
state.cursorLine++;
|
|
379
|
+
ensureCursorVisible();
|
|
380
|
+
refreshPager();
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
break;
|
|
384
|
+
}
|
|
385
|
+
case "k":
|
|
386
|
+
case "up": {
|
|
387
|
+
if (pager.mode === "markdown") {
|
|
388
|
+
pager.scrollBox.scrollBy(-1);
|
|
389
|
+
renderer.requestRender();
|
|
390
|
+
} else {
|
|
391
|
+
if (state.cursorLine > 1) {
|
|
392
|
+
state.cursorLine--;
|
|
393
|
+
ensureCursorVisible();
|
|
394
|
+
refreshPager();
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
break;
|
|
398
|
+
}
|
|
399
|
+
case "d": {
|
|
400
|
+
// Ctrl+D — half page down
|
|
401
|
+
if (key.ctrl) {
|
|
402
|
+
if (deletePendingTimer) { clearTimeout(deletePendingTimer); deletePendingTimer = null; }
|
|
403
|
+
const half = Math.max(1, Math.floor(pageSize() / 2));
|
|
404
|
+
if (pager.mode === "markdown") {
|
|
405
|
+
pager.scrollBox.scrollBy(half);
|
|
406
|
+
renderer.requestRender();
|
|
407
|
+
} else {
|
|
408
|
+
state.cursorLine = Math.min(state.cursorLine + half, state.lineCount);
|
|
409
|
+
ensureCursorVisible();
|
|
410
|
+
refreshPager();
|
|
411
|
+
}
|
|
412
|
+
} else {
|
|
413
|
+
// d without ctrl — delete draft comment (dd = double-tap within 500ms)
|
|
414
|
+
ensureLineMode(pager);
|
|
415
|
+
refreshPager();
|
|
416
|
+
const thread = state.threadAtLine(state.cursorLine);
|
|
417
|
+
if (!thread) break;
|
|
418
|
+
if (deletePendingTimer) {
|
|
419
|
+
// Second d within 500ms — execute delete
|
|
420
|
+
clearTimeout(deletePendingTimer);
|
|
421
|
+
deletePendingTimer = null;
|
|
422
|
+
const hadHumanMsg = thread.messages.some((m) => m.author === "human");
|
|
423
|
+
if (hadHumanMsg) {
|
|
424
|
+
state.deleteLastDraftMessage(thread.id);
|
|
425
|
+
hasUnsavedChanges = true;
|
|
426
|
+
refreshPager();
|
|
427
|
+
bottomBar.bar.content = " \u2714 Deleted draft comment";
|
|
428
|
+
renderer.requestRender();
|
|
429
|
+
setTimeout(() => { refreshPager(); }, 1500);
|
|
430
|
+
} else {
|
|
431
|
+
bottomBar.bar.content = " No human message to delete";
|
|
432
|
+
renderer.requestRender();
|
|
433
|
+
setTimeout(() => { refreshPager(); }, 1500);
|
|
434
|
+
}
|
|
435
|
+
} else {
|
|
436
|
+
// First d — show hint and start timer
|
|
437
|
+
bottomBar.bar.content = " Press d again to delete";
|
|
438
|
+
renderer.requestRender();
|
|
439
|
+
deletePendingTimer = setTimeout(() => {
|
|
440
|
+
deletePendingTimer = null;
|
|
441
|
+
refreshPager();
|
|
442
|
+
}, 500);
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
break;
|
|
446
|
+
}
|
|
447
|
+
case "u": {
|
|
448
|
+
// Ctrl+U — half page up
|
|
449
|
+
if (key.ctrl) {
|
|
450
|
+
const half = Math.max(1, Math.floor(pageSize() / 2));
|
|
451
|
+
if (pager.mode === "markdown") {
|
|
452
|
+
pager.scrollBox.scrollBy(-half);
|
|
453
|
+
renderer.requestRender();
|
|
454
|
+
} else {
|
|
455
|
+
state.cursorLine = Math.max(state.cursorLine - half, 1);
|
|
456
|
+
ensureCursorVisible();
|
|
457
|
+
refreshPager();
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
break;
|
|
461
|
+
}
|
|
462
|
+
case "n": {
|
|
463
|
+
if (!key.shift) {
|
|
464
|
+
if (searchQuery) {
|
|
465
|
+
const match = findNextMatch(state.specLines, searchQuery, state.cursorLine, 1);
|
|
466
|
+
if (match !== null) {
|
|
467
|
+
state.cursorLine = match;
|
|
468
|
+
ensureCursorVisible();
|
|
469
|
+
}
|
|
470
|
+
} else {
|
|
471
|
+
bottomBar.bar.content = " No active search \u2014 use / to search";
|
|
472
|
+
renderer.requestRender();
|
|
473
|
+
setTimeout(() => { refreshPager(); }, 1500);
|
|
474
|
+
}
|
|
475
|
+
} else {
|
|
476
|
+
// Shift+N = prev search match
|
|
477
|
+
if (searchQuery) {
|
|
478
|
+
const match = findNextMatch(state.specLines, searchQuery, state.cursorLine, -1);
|
|
479
|
+
if (match !== null) {
|
|
480
|
+
state.cursorLine = match;
|
|
481
|
+
ensureCursorVisible();
|
|
482
|
+
}
|
|
483
|
+
} else {
|
|
484
|
+
bottomBar.bar.content = " No active search \u2014 use / to search";
|
|
485
|
+
renderer.requestRender();
|
|
486
|
+
setTimeout(() => { refreshPager(); }, 1500);
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
refreshPager();
|
|
490
|
+
break;
|
|
491
|
+
}
|
|
492
|
+
case "m": {
|
|
493
|
+
// Toggle markdown / line mode with scroll position sync
|
|
494
|
+
const wasMarkdown = pager.mode === "markdown";
|
|
495
|
+
togglePagerMode(pager);
|
|
496
|
+
if (wasMarkdown) {
|
|
497
|
+
// Markdown → Line: sync scroll position to cursor
|
|
498
|
+
state.cursorLine = Math.max(1, pager.scrollBox.scrollTop + 1);
|
|
499
|
+
refreshPager();
|
|
500
|
+
ensureCursorVisible();
|
|
501
|
+
} else {
|
|
502
|
+
// Line → Markdown: approximate scroll to cursor position
|
|
503
|
+
refreshPager();
|
|
504
|
+
pager.scrollBox.scrollTo(state.cursorLine - 1);
|
|
505
|
+
renderer.requestRender();
|
|
506
|
+
}
|
|
507
|
+
break;
|
|
508
|
+
}
|
|
509
|
+
case "c": {
|
|
510
|
+
// Comment: new or reply — auto-switch to line mode
|
|
511
|
+
ensureLineMode(pager);
|
|
512
|
+
refreshPager();
|
|
513
|
+
showCommentInput();
|
|
514
|
+
break;
|
|
515
|
+
}
|
|
516
|
+
case "l": {
|
|
517
|
+
// Thread list
|
|
518
|
+
ensureLineMode(pager);
|
|
519
|
+
refreshPager();
|
|
520
|
+
showThreadListOverlay();
|
|
521
|
+
break;
|
|
522
|
+
}
|
|
523
|
+
case "r": {
|
|
524
|
+
ensureLineMode(pager);
|
|
525
|
+
refreshPager();
|
|
526
|
+
if (!key.shift) {
|
|
527
|
+
// Resolve thread at cursor
|
|
528
|
+
const thread = state.threadAtLine(state.cursorLine);
|
|
529
|
+
if (thread) {
|
|
530
|
+
const wasResolved = thread.status === "resolved";
|
|
531
|
+
state.resolveThread(thread.id);
|
|
532
|
+
hasUnsavedChanges = true;
|
|
533
|
+
refreshPager();
|
|
534
|
+
const msg = wasResolved
|
|
535
|
+
? ` \u21a9 Reopened thread #${thread.id}`
|
|
536
|
+
: ` \u2714 Resolved thread #${thread.id}`;
|
|
537
|
+
bottomBar.bar.content = msg;
|
|
538
|
+
renderer.requestRender();
|
|
539
|
+
setTimeout(() => { refreshPager(); }, 1500);
|
|
540
|
+
}
|
|
541
|
+
} else {
|
|
542
|
+
// Shift+R = resolve all pending
|
|
543
|
+
const { pending } = state.activeThreadCount();
|
|
544
|
+
state.resolveAllPending();
|
|
545
|
+
hasUnsavedChanges = true;
|
|
546
|
+
refreshPager();
|
|
547
|
+
bottomBar.bar.content = ` \u2714 Resolved ${pending} pending thread(s)`;
|
|
548
|
+
renderer.requestRender();
|
|
549
|
+
setTimeout(() => { refreshPager(); }, 1500);
|
|
550
|
+
}
|
|
551
|
+
break;
|
|
552
|
+
}
|
|
553
|
+
case "g": {
|
|
554
|
+
if (key.shift) {
|
|
555
|
+
// G (shift+g) — go to last line / scroll to bottom
|
|
556
|
+
if (pager.mode === "markdown") {
|
|
557
|
+
pager.scrollBox.scrollTo(pager.scrollBox.scrollHeight);
|
|
558
|
+
renderer.requestRender();
|
|
559
|
+
} else {
|
|
560
|
+
state.cursorLine = state.lineCount;
|
|
561
|
+
ensureCursorVisible();
|
|
562
|
+
refreshPager();
|
|
563
|
+
}
|
|
564
|
+
} else {
|
|
565
|
+
// g — first of gg sequence
|
|
566
|
+
if (gPendingTimer) {
|
|
567
|
+
// Second g within 500ms — go to first line / scroll to top
|
|
568
|
+
clearTimeout(gPendingTimer);
|
|
569
|
+
gPendingTimer = null;
|
|
570
|
+
if (pager.mode === "markdown") {
|
|
571
|
+
pager.scrollBox.scrollTo(0);
|
|
572
|
+
renderer.requestRender();
|
|
573
|
+
} else {
|
|
574
|
+
state.cursorLine = 1;
|
|
575
|
+
ensureCursorVisible();
|
|
576
|
+
refreshPager();
|
|
577
|
+
}
|
|
578
|
+
} else {
|
|
579
|
+
gPendingTimer = setTimeout(() => {
|
|
580
|
+
gPendingTimer = null;
|
|
581
|
+
}, 500);
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
break;
|
|
585
|
+
}
|
|
586
|
+
case "a": {
|
|
587
|
+
// Approve
|
|
588
|
+
ensureLineMode(pager);
|
|
589
|
+
refreshPager();
|
|
590
|
+
if (state.canApprove()) {
|
|
591
|
+
const confirmOverlay = createConfirm({
|
|
592
|
+
renderer,
|
|
593
|
+
message: "Approve spec and proceed to implementation? [y/n]",
|
|
594
|
+
onConfirm: () => {
|
|
595
|
+
dismissOverlay();
|
|
596
|
+
writeDraftFile(draftPath, { approved: true });
|
|
597
|
+
renderer.destroy();
|
|
598
|
+
resolve();
|
|
599
|
+
},
|
|
600
|
+
onCancel: () => {
|
|
601
|
+
dismissOverlay();
|
|
602
|
+
},
|
|
603
|
+
});
|
|
604
|
+
showOverlay(confirmOverlay);
|
|
605
|
+
return;
|
|
606
|
+
} else {
|
|
607
|
+
// Show why approval is blocked
|
|
608
|
+
const { open, pending } = state.activeThreadCount();
|
|
609
|
+
const total = open + pending;
|
|
610
|
+
const msg =
|
|
611
|
+
total === 0
|
|
612
|
+
? "No threads to approve"
|
|
613
|
+
: `${total} thread${total !== 1 ? "s" : ""} still open/pending`;
|
|
614
|
+
bottomBar.bar.content = ` \u26a0 ${msg}`;
|
|
615
|
+
renderer.requestRender();
|
|
616
|
+
setTimeout(() => {
|
|
617
|
+
refreshPager();
|
|
618
|
+
}, 2000);
|
|
619
|
+
}
|
|
620
|
+
break;
|
|
621
|
+
}
|
|
622
|
+
default: {
|
|
623
|
+
// Handle bracket-pending sequences (]t / [t)
|
|
624
|
+
if (bracketPending !== null) {
|
|
625
|
+
const pending = bracketPending;
|
|
626
|
+
bracketPending = null;
|
|
627
|
+
if (bracketPendingTimer) { clearTimeout(bracketPendingTimer); bracketPendingTimer = null; }
|
|
628
|
+
if (key.name === "t" || key.sequence === "t") {
|
|
629
|
+
if (pending === "]") {
|
|
630
|
+
const next = state.nextActiveThread();
|
|
631
|
+
if (next !== null) {
|
|
632
|
+
state.cursorLine = next;
|
|
633
|
+
ensureCursorVisible();
|
|
634
|
+
refreshPager();
|
|
635
|
+
}
|
|
636
|
+
} else {
|
|
637
|
+
const prev = state.prevActiveThread();
|
|
638
|
+
if (prev !== null) {
|
|
639
|
+
state.cursorLine = prev;
|
|
640
|
+
ensureCursorVisible();
|
|
641
|
+
refreshPager();
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
refreshPager(); // clear the bracket hint
|
|
646
|
+
break;
|
|
647
|
+
}
|
|
648
|
+
// Check for "]" or "[" to start bracket sequence
|
|
649
|
+
if (key.sequence === "]") {
|
|
650
|
+
bracketPending = "]";
|
|
651
|
+
bottomBar.bar.content = " ]...";
|
|
652
|
+
renderer.requestRender();
|
|
653
|
+
bracketPendingTimer = setTimeout(() => {
|
|
654
|
+
bracketPending = null;
|
|
655
|
+
bracketPendingTimer = null;
|
|
656
|
+
refreshPager();
|
|
657
|
+
}, 500);
|
|
658
|
+
break;
|
|
659
|
+
}
|
|
660
|
+
if (key.sequence === "[") {
|
|
661
|
+
bracketPending = "[";
|
|
662
|
+
bottomBar.bar.content = " [...";
|
|
663
|
+
renderer.requestRender();
|
|
664
|
+
bracketPendingTimer = setTimeout(() => {
|
|
665
|
+
bracketPending = null;
|
|
666
|
+
bracketPendingTimer = null;
|
|
667
|
+
refreshPager();
|
|
668
|
+
}, 500);
|
|
669
|
+
break;
|
|
670
|
+
}
|
|
671
|
+
// Check for "?" to show help overlay
|
|
672
|
+
if (key.sequence === "?") {
|
|
673
|
+
showHelpOverlay();
|
|
674
|
+
break;
|
|
675
|
+
}
|
|
676
|
+
// Check for "/" to enter search mode
|
|
677
|
+
if (key.sequence === "/") {
|
|
678
|
+
showSearchOverlay();
|
|
679
|
+
break;
|
|
680
|
+
}
|
|
681
|
+
// Check for ":" to enter command mode
|
|
682
|
+
if (key.sequence === ":") {
|
|
683
|
+
commandBuffer = "";
|
|
684
|
+
refreshPager();
|
|
685
|
+
}
|
|
686
|
+
break;
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
});
|
|
690
|
+
});
|
|
691
|
+
}
|