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/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
+ }