revspec 0.6.0 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/README.md +60 -68
  2. package/bin/revspec.ts +4 -38
  3. package/package.json +15 -1
  4. package/skills/revspec/SKILL.md +38 -31
  5. package/src/cli/reply.ts +1 -1
  6. package/src/cli/watch.ts +122 -58
  7. package/src/protocol/live-events.ts +6 -16
  8. package/src/state/review-state.ts +37 -24
  9. package/src/tui/app.ts +145 -108
  10. package/src/tui/comment-input.ts +9 -13
  11. package/src/tui/confirm.ts +4 -6
  12. package/src/tui/help.ts +13 -16
  13. package/src/tui/spinner.ts +81 -0
  14. package/src/tui/status-bar.ts +9 -6
  15. package/src/tui/thread-list.ts +62 -22
  16. package/src/tui/ui/keymap.ts +55 -0
  17. package/.github/workflows/ci.yml +0 -18
  18. package/CLAUDE.md +0 -29
  19. package/bun.lock +0 -216
  20. package/docs/superpowers/plans/2026-03-14-live-ai-integration.md +0 -1877
  21. package/docs/superpowers/plans/2026-03-14-spectral-v1-implementation.md +0 -2139
  22. package/docs/superpowers/plans/2026-03-15-ui-refactor.md +0 -1025
  23. package/docs/superpowers/specs/2026-03-14-live-ai-integration-design.md +0 -518
  24. package/docs/superpowers/specs/2026-03-14-live-ai-integration-design.review.json +0 -65
  25. package/docs/superpowers/specs/2026-03-14-spec-review-tool-design.md +0 -331
  26. package/docs/superpowers/specs/2026-03-14-spec-review-tool-design.review.json +0 -141
  27. package/docs/superpowers/specs/claude-code-integration-notes.md +0 -26
  28. package/scripts/install-skill.sh +0 -20
  29. package/scripts/release.sh +0 -52
  30. package/test/e2e/__snapshots__/snapshot.test.ts.snap +0 -31
  31. package/test/e2e/fixtures/spec.md +0 -36
  32. package/test/e2e/harness.ts +0 -80
  33. package/test/e2e/snapshot.test.ts +0 -182
  34. package/test/integration/cli-reply.test.ts +0 -140
  35. package/test/integration/cli-watch.test.ts +0 -216
  36. package/test/integration/cli.test.ts +0 -160
  37. package/test/integration/e2e-live.test.ts +0 -171
  38. package/test/integration/live-interaction.test.ts +0 -398
  39. package/test/integration/opentui-smoke.test.ts +0 -12
  40. package/test/unit/protocol/live-events.test.ts +0 -509
  41. package/test/unit/protocol/live-merge.test.ts +0 -167
  42. package/test/unit/protocol/merge.test.ts +0 -100
  43. package/test/unit/protocol/read.test.ts +0 -92
  44. package/test/unit/protocol/types.test.ts +0 -95
  45. package/test/unit/protocol/write.test.ts +0 -72
  46. package/test/unit/state/review-state.test.ts +0 -399
  47. package/test/unit/tui/pager.test.ts +0 -159
  48. package/test/unit/tui/ui/keybinds.test.ts +0 -71
  49. package/tsconfig.json +0 -14
@@ -1,1025 +0,0 @@
1
- # UI Refactor: Adopt OpenCode Patterns
2
-
3
- > **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
4
-
5
- **Goal:** Replace ad-hoc UI code with a reusable `ui/` layer borrowed from opencode patterns, refactor keybindings from monolithic switch to registry, rewrite all overlays to use shared dialog factory.
6
-
7
- **Architecture:** Create `src/tui/ui/` with theme, dialog, hint-bar, keybinds, and markdown modules. Overlays become thin wrappers around `createDialog()`. App.ts keybinding switch becomes an action map dispatched by a keybind registry. Pager keeps TextNodeRenderable approach but uses extracted markdown parser.
8
-
9
- **Tech Stack:** Bun, TypeScript, @opentui/core (BoxRenderable, TextRenderable, TextNodeRenderable, ScrollBoxRenderable, TextareaRenderable, SelectRenderable)
10
-
11
- **Reference:** opencode at `~/repo/opencode/packages/opencode/src/cli/cmd/tui/` for patterns
12
-
13
- ---
14
-
15
- ## File Structure
16
-
17
- **New files:**
18
- - `src/tui/ui/theme.ts` — Expanded semantic theme (replaces `src/tui/theme.ts`)
19
- - `src/tui/ui/dialog.ts` — Universal dialog factory with backdrop, border, hint bar
20
- - `src/tui/ui/hint-bar.ts` — Reusable `[key] action` hint bar builder
21
- - `src/tui/ui/keybinds.ts` — Keybind registry with sequence support (gg, dd, ]t)
22
- - `src/tui/ui/markdown.ts` — Extracted inline markdown parser + table rendering
23
-
24
- **Modified files:**
25
- - `src/tui/app.ts` — Use keybind registry + dialog system, remove switch statement
26
- - `src/tui/pager.ts` — Import from ui/markdown and ui/theme instead of local
27
- - `src/tui/status-bar.ts` — Import from ui/theme and ui/hint-bar
28
- - `src/tui/comment-input.ts` — Rewrite using createDialog()
29
- - `src/tui/thread-list.ts` — Rewrite using createDialog()
30
- - `src/tui/help.ts` — Rewrite using createDialog()
31
- - `src/tui/confirm.ts` — Rewrite using createDialog()
32
- - `src/tui/search.ts` — Import from ui/theme (stays as bottom bar, not dialog)
33
-
34
- **Deleted files:**
35
- - `src/tui/theme.ts` — Replaced by ui/theme.ts
36
-
37
- ---
38
-
39
- ## Chunk 1: UI Primitives
40
-
41
- ### Task 1: Expanded Theme
42
-
43
- **Files:**
44
- - Create: `src/tui/ui/theme.ts`
45
-
46
- - [ ] **Step 1: Create ui/theme.ts with semantic color roles**
47
-
48
- ```typescript
49
- // Catppuccin Mocha base + semantic roles matching opencode patterns
50
- export const theme = {
51
- // Surfaces
52
- base: "#1e1e2e",
53
- backgroundPanel: "#313244", // dialog/overlay backgrounds (was surface0)
54
- backgroundElement: "#45475a", // hover/active states (was surface1)
55
-
56
- // Text hierarchy
57
- text: "#cdd6f4",
58
- textMuted: "#a6adc8", // secondary info (was subtext)
59
- textDim: "#6c7086", // hints, line numbers (was overlay)
60
-
61
- // Semantic accents
62
- blue: "#89b4fa",
63
- green: "#a6e3a1",
64
- red: "#f38ba8",
65
- yellow: "#f9e2af",
66
- mauve: "#cba6f7",
67
-
68
- // Borders
69
- border: "#45475a", // default border color
70
- borderAccent: "#89b4fa", // active/focused border
71
-
72
- // Status (matching opencode)
73
- success: "#a6e3a1",
74
- warning: "#f9e2af",
75
- error: "#f38ba8",
76
- info: "#89b4fa",
77
- } as const;
78
-
79
- export const STATUS_ICONS: Record<string, string> = {
80
- open: "\u258c", // ▌
81
- pending: "\u258c", // ▌
82
- resolved: "\u2713", // ✓
83
- outdated: "\u258c", // ▌
84
- };
85
-
86
- // SplitBorder chars (opencode pattern — left vertical line only)
87
- export const SPLIT_BORDER = {
88
- topLeft: " ",
89
- topRight: " ",
90
- bottomLeft: " ",
91
- bottomRight: " ",
92
- horizontal: " ",
93
- vertical: "\u2503", // ┃
94
- } as const;
95
- ```
96
-
97
- - [ ] **Step 2: Run tests**
98
-
99
- Run: `bun test`
100
- Expected: PASS (no tests reference theme internals directly)
101
-
102
- - [ ] **Step 3: Commit**
103
-
104
- ```bash
105
- git add src/tui/ui/theme.ts
106
- git commit -m "feat(ui): add expanded semantic theme"
107
- ```
108
-
109
- ### Task 2: Hint Bar Builder
110
-
111
- **Files:**
112
- - Create: `src/tui/ui/hint-bar.ts`
113
-
114
- - [ ] **Step 1: Create hint-bar.ts**
115
-
116
- ```typescript
117
- import { TextRenderable, TextNodeRenderable } from "@opentui/core";
118
- import { theme } from "./theme";
119
-
120
- export interface Hint {
121
- key: string;
122
- action: string;
123
- }
124
-
125
- /**
126
- * Build styled hint bar content: [key] action [key] action
127
- * Clears existing content and adds TextNodeRenderable children.
128
- */
129
- export function buildHints(text: TextRenderable, hints: Hint[]): void {
130
- text.clear();
131
- text.add(TextNodeRenderable.fromString(" ", {}));
132
- for (let i = 0; i < hints.length; i++) {
133
- const h = hints[i];
134
- text.add(TextNodeRenderable.fromString(`[${h.key}]`, { fg: theme.blue }));
135
- text.add(TextNodeRenderable.fromString(` ${h.action}`, { fg: theme.textMuted }));
136
- if (i < hints.length - 1) {
137
- text.add(TextNodeRenderable.fromString(" ", {}));
138
- }
139
- }
140
- }
141
- ```
142
-
143
- - [ ] **Step 2: Commit**
144
-
145
- ```bash
146
- git add src/tui/ui/hint-bar.ts
147
- git commit -m "feat(ui): add reusable hint bar builder"
148
- ```
149
-
150
- ### Task 3: Dialog Factory
151
-
152
- **Files:**
153
- - Create: `src/tui/ui/dialog.ts`
154
-
155
- - [ ] **Step 1: Create dialog.ts**
156
-
157
- Dialog factory inspired by opencode — creates a BoxRenderable with backdrop, border, scroll content area, and hint bar. Wires Esc/Ctrl+C dismissal automatically.
158
-
159
- ```typescript
160
- import {
161
- BoxRenderable,
162
- ScrollBoxRenderable,
163
- TextRenderable,
164
- type CliRenderer,
165
- type KeyEvent,
166
- } from "@opentui/core";
167
- import { theme, SPLIT_BORDER } from "./theme";
168
- import { buildHints, type Hint } from "./hint-bar";
169
-
170
- export interface DialogOptions {
171
- renderer: CliRenderer;
172
- title: string;
173
- width?: string | number; // default "80%"
174
- height?: string | number; // default "85%"
175
- top?: string | number; // default "5%"
176
- left?: string | number; // default "10%"
177
- borderColor?: string; // default theme.border
178
- onDismiss: () => void;
179
- hints?: Hint[];
180
- }
181
-
182
- export interface DialogComponents {
183
- /** The outermost container — add to renderer.root */
184
- container: BoxRenderable;
185
- /** Scrollable content area — add children here */
186
- content: ScrollBoxRenderable;
187
- /** Hint bar at bottom — update via setHints() */
188
- hintText: TextRenderable;
189
- /** Update hint bar content */
190
- setHints: (hints: Hint[]) => void;
191
- /** Cleanup key listeners */
192
- cleanup: () => void;
193
- }
194
-
195
- export function createDialog(opts: DialogOptions): DialogComponents {
196
- const {
197
- renderer, title, onDismiss,
198
- width = "80%", height = "85%",
199
- top = "5%", left = "10%",
200
- borderColor = theme.border,
201
- hints = [],
202
- } = opts;
203
-
204
- // Container with left-border accent (opencode SplitBorder pattern)
205
- const container = new BoxRenderable(renderer, {
206
- position: "absolute",
207
- top,
208
- left,
209
- width,
210
- height,
211
- zIndex: 100,
212
- backgroundColor: theme.backgroundPanel,
213
- border: true,
214
- borderStyle: "single",
215
- borderColor,
216
- customBorderChars: SPLIT_BORDER,
217
- title: ` ${title} `,
218
- flexDirection: "column",
219
- paddingLeft: 1,
220
- paddingRight: 1,
221
- paddingTop: 1,
222
- });
223
-
224
- // Scrollable content area
225
- const content = new ScrollBoxRenderable(renderer, {
226
- width: "100%",
227
- flexGrow: 1,
228
- flexShrink: 1,
229
- scrollY: true,
230
- scrollX: false,
231
- });
232
- container.add(content);
233
-
234
- // Hint bar at bottom
235
- const hintBox = new BoxRenderable(renderer, {
236
- width: "100%",
237
- height: 1,
238
- flexShrink: 0,
239
- backgroundColor: theme.backgroundElement,
240
- });
241
- const hintText = new TextRenderable(renderer, {
242
- content: "",
243
- width: "100%",
244
- fg: theme.textMuted,
245
- wrapMode: "none",
246
- truncate: true,
247
- });
248
- hintBox.add(hintText);
249
- container.add(hintBox);
250
-
251
- if (hints.length > 0) {
252
- buildHints(hintText, hints);
253
- }
254
-
255
- function setHints(newHints: Hint[]): void {
256
- buildHints(hintText, newHints);
257
- renderer.requestRender();
258
- }
259
-
260
- // Esc/Ctrl+C dismissal
261
- const keyHandler = (key: KeyEvent) => {
262
- if (key.name === "escape" || (key.ctrl && key.name === "c")) {
263
- key.preventDefault();
264
- key.stopPropagation();
265
- onDismiss();
266
- }
267
- };
268
- renderer.keyInput.on("keypress", keyHandler);
269
-
270
- function cleanup(): void {
271
- renderer.keyInput.off("keypress", keyHandler);
272
- }
273
-
274
- return { container, content, hintText, setHints, cleanup };
275
- }
276
- ```
277
-
278
- - [ ] **Step 2: Commit**
279
-
280
- ```bash
281
- git add src/tui/ui/dialog.ts
282
- git commit -m "feat(ui): add dialog factory with backdrop and hint bar"
283
- ```
284
-
285
- ### Task 4: Keybind Registry
286
-
287
- **Files:**
288
- - Create: `src/tui/ui/keybinds.ts`
289
-
290
- - [ ] **Step 1: Create keybinds.ts**
291
-
292
- Registry-based keybind matching with sequence support (gg, dd, ]t, [r). Replaces the 280-line switch statement + pending timers in app.ts.
293
-
294
- ```typescript
295
- import type { KeyEvent } from "@opentui/core";
296
-
297
- export interface KeyBinding {
298
- /** Key pattern: "j", "G" (shift), "C-d" (ctrl), "gg" / "dd" / "]t" (sequence) */
299
- key: string;
300
- action: string;
301
- }
302
-
303
- interface SequenceState {
304
- first: string;
305
- timer: ReturnType<typeof setTimeout>;
306
- }
307
-
308
- export interface KeybindRegistry {
309
- match: (key: KeyEvent) => string | null;
310
- /** Get pending sequence prefix for display (e.g., "]...") */
311
- pending: () => string | null;
312
- destroy: () => void;
313
- }
314
-
315
- export function createKeybindRegistry(bindings: KeyBinding[], timeout = 500): KeybindRegistry {
316
- let sequence: SequenceState | null = null;
317
-
318
- // Separate single-key and sequence bindings
319
- const singleBindings = new Map<string, string>();
320
- const sequenceBindings = new Map<string, string>(); // "gg" → action
321
-
322
- for (const b of bindings) {
323
- if (b.key.length === 2 && !b.key.startsWith("C-")) {
324
- sequenceBindings.set(b.key, b.action);
325
- } else {
326
- singleBindings.set(b.key, b.action);
327
- }
328
- }
329
-
330
- // Collect all valid sequence first chars
331
- const sequenceStarters = new Set<string>();
332
- for (const key of sequenceBindings.keys()) {
333
- sequenceStarters.add(key[0]);
334
- }
335
-
336
- function keyToString(key: KeyEvent): string {
337
- if (key.ctrl && key.name) return `C-${key.name}`;
338
- if (key.shift && key.name) return key.name.toUpperCase();
339
- return key.sequence || key.name || "";
340
- }
341
-
342
- function match(key: KeyEvent): string | null {
343
- const keyStr = keyToString(key);
344
-
345
- // Check if we're in a sequence
346
- if (sequence) {
347
- const seq = sequence.first + keyStr;
348
- clearTimeout(sequence.timer);
349
- sequence = null;
350
-
351
- const action = sequenceBindings.get(seq);
352
- if (action) return action;
353
- // Invalid second key — fall through to single match
354
- }
355
-
356
- // Check if this starts a sequence
357
- if (sequenceStarters.has(keyStr)) {
358
- // But also check if there's a ctrl version (C-d should not start "dd" sequence)
359
- if (key.ctrl) {
360
- const ctrlKey = `C-${key.name}`;
361
- const action = singleBindings.get(ctrlKey);
362
- if (action) return action;
363
- }
364
-
365
- sequence = {
366
- first: keyStr,
367
- timer: setTimeout(() => { sequence = null; }, timeout),
368
- };
369
- return null; // waiting for second key
370
- }
371
-
372
- // Single key match
373
- // Check ctrl variants first
374
- if (key.ctrl && key.name) {
375
- const action = singleBindings.get(`C-${key.name}`);
376
- if (action) return action;
377
- }
378
-
379
- // Check shift variants
380
- if (key.shift && key.name) {
381
- const upper = key.name.toUpperCase();
382
- const action = singleBindings.get(upper);
383
- if (action) return action;
384
- }
385
-
386
- // Plain key
387
- const action = singleBindings.get(keyStr);
388
- if (action) return action;
389
-
390
- return null;
391
- }
392
-
393
- function pendingStr(): string | null {
394
- if (!sequence) return null;
395
- return `${sequence.first}...`;
396
- }
397
-
398
- function destroy(): void {
399
- if (sequence) {
400
- clearTimeout(sequence.timer);
401
- sequence = null;
402
- }
403
- }
404
-
405
- return { match, pending: pendingStr, destroy };
406
- }
407
- ```
408
-
409
- - [ ] **Step 2: Write test for keybind registry**
410
-
411
- Create: `test/tui/ui/keybinds.test.ts`
412
-
413
- ```typescript
414
- import { describe, expect, it } from "bun:test";
415
- import { createKeybindRegistry } from "../../../src/tui/ui/keybinds";
416
-
417
- function makeKey(name: string, opts: { ctrl?: boolean; shift?: boolean; sequence?: string } = {}): any {
418
- return { name, ctrl: opts.ctrl ?? false, shift: opts.shift ?? false, sequence: opts.sequence ?? name };
419
- }
420
-
421
- describe("createKeybindRegistry", () => {
422
- it("matches single keys", () => {
423
- const reg = createKeybindRegistry([
424
- { key: "j", action: "down" },
425
- { key: "k", action: "up" },
426
- ]);
427
- expect(reg.match(makeKey("j"))).toBe("down");
428
- expect(reg.match(makeKey("k"))).toBe("up");
429
- expect(reg.match(makeKey("x"))).toBeNull();
430
- reg.destroy();
431
- });
432
-
433
- it("matches ctrl keys", () => {
434
- const reg = createKeybindRegistry([
435
- { key: "C-d", action: "half-page-down" },
436
- ]);
437
- expect(reg.match(makeKey("d", { ctrl: true }))).toBe("half-page-down");
438
- expect(reg.match(makeKey("d"))).toBeNull();
439
- reg.destroy();
440
- });
441
-
442
- it("matches shift keys", () => {
443
- const reg = createKeybindRegistry([
444
- { key: "G", action: "goto-bottom" },
445
- { key: "R", action: "resolve-all" },
446
- ]);
447
- expect(reg.match(makeKey("g", { shift: true }))).toBe("goto-bottom");
448
- expect(reg.match(makeKey("r", { shift: true }))).toBe("resolve-all");
449
- reg.destroy();
450
- });
451
-
452
- it("matches two-key sequences", () => {
453
- const reg = createKeybindRegistry([
454
- { key: "gg", action: "goto-top" },
455
- { key: "dd", action: "delete" },
456
- ]);
457
- // First key returns null (pending)
458
- expect(reg.match(makeKey("g"))).toBeNull();
459
- expect(reg.pending()).toBe("g...");
460
- // Second key completes
461
- expect(reg.match(makeKey("g"))).toBe("goto-top");
462
- expect(reg.pending()).toBeNull();
463
- reg.destroy();
464
- });
465
-
466
- it("clears sequence on invalid second key", () => {
467
- const reg = createKeybindRegistry([
468
- { key: "gg", action: "goto-top" },
469
- { key: "j", action: "down" },
470
- ]);
471
- expect(reg.match(makeKey("g"))).toBeNull();
472
- // Invalid second key for "g?" sequence
473
- expect(reg.match(makeKey("x"))).toBeNull();
474
- // Should work normally now
475
- expect(reg.match(makeKey("j"))).toBe("down");
476
- reg.destroy();
477
- });
478
-
479
- it("handles bracket sequences", () => {
480
- const reg = createKeybindRegistry([
481
- { key: "]t", action: "next-thread" },
482
- { key: "[t", action: "prev-thread" },
483
- ]);
484
- expect(reg.match(makeKey("]", { sequence: "]" }))).toBeNull();
485
- expect(reg.match(makeKey("t"))).toBe("next-thread");
486
- reg.destroy();
487
- });
488
- });
489
- ```
490
-
491
- - [ ] **Step 3: Run test**
492
-
493
- Run: `bun test test/tui/ui/keybinds.test.ts`
494
- Expected: PASS
495
-
496
- - [ ] **Step 4: Commit**
497
-
498
- ```bash
499
- git add src/tui/ui/keybinds.ts test/tui/ui/keybinds.test.ts
500
- git commit -m "feat(ui): add keybind registry with sequence support"
501
- ```
502
-
503
- ### Task 5: Extract Markdown Parser
504
-
505
- **Files:**
506
- - Create: `src/tui/ui/markdown.ts`
507
- - Modify: `src/tui/pager.ts`
508
-
509
- - [ ] **Step 1: Extract markdown + table code from pager.ts to ui/markdown.ts**
510
-
511
- Move these functions from pager.ts → ui/markdown.ts:
512
- - `StyledSegment` interface
513
- - `parseInlineMarkdown()`
514
- - `parseMarkdownLine()`
515
- - `addSegments()`
516
- - `SEPARATOR_RE`, `parseTableCells()`, `displayWidth()`
517
- - `TableBlock` interface, `collectTable()`
518
- - `renderTableSeparator()`, `renderTableRow()`, `renderTableBorder()`
519
-
520
- Update imports: use `theme` from `./theme` (ui/theme.ts).
521
-
522
- Export all public functions.
523
-
524
- - [ ] **Step 2: Update pager.ts imports**
525
-
526
- Replace local function definitions with:
527
- ```typescript
528
- import { parseMarkdownLine, addSegments, collectTable, renderTableBorder, renderTableSeparator, renderTableRow, parseTableCells, type TableBlock } from "./ui/markdown";
529
- import { theme, STATUS_ICONS } from "./ui/theme";
530
- ```
531
-
532
- - [ ] **Step 3: Run tests**
533
-
534
- Run: `bun test`
535
- Expected: PASS (all 163 tests)
536
-
537
- - [ ] **Step 4: Commit**
538
-
539
- ```bash
540
- git add src/tui/ui/markdown.ts src/tui/pager.ts
541
- git commit -m "refactor: extract markdown parser to ui/markdown"
542
- ```
543
-
544
- ---
545
-
546
- ## Chunk 2: Rewrite Overlays + App
547
-
548
- ### Task 6: Rewrite Help Overlay
549
-
550
- **Files:**
551
- - Modify: `src/tui/help.ts`
552
-
553
- - [ ] **Step 1: Rewrite using createDialog()**
554
-
555
- ```typescript
556
- import { TextRenderable, type CliRenderer, type KeyEvent } from "@opentui/core";
557
- import { createDialog, type DialogComponents } from "./ui/dialog";
558
- import { theme } from "./ui/theme";
559
-
560
- export interface HelpOverlay {
561
- container: import("@opentui/core").BoxRenderable;
562
- cleanup: () => void;
563
- }
564
-
565
- export function createHelp(opts: {
566
- renderer: CliRenderer;
567
- version: string;
568
- onClose: () => void;
569
- }): HelpOverlay {
570
- const { renderer, version, onClose } = opts;
571
-
572
- const dialog = createDialog({
573
- renderer,
574
- title: "Help",
575
- width: "60%",
576
- height: Math.min(26, renderer.height - 2),
577
- top: "10%",
578
- left: "20%",
579
- borderColor: theme.info,
580
- onDismiss: onClose,
581
- hints: [
582
- { key: "q/?/Esc", action: "close" },
583
- { key: "j/k", action: "scroll" },
584
- ],
585
- });
586
-
587
- const helpText = [
588
- "",
589
- ` revspec v${version}`,
590
- "",
591
- " Navigation",
592
- " j/k Down/up",
593
- " gg Go to first line",
594
- " G Go to last line",
595
- " Ctrl+d/u Half page down/up",
596
- " / Search",
597
- " n/N Next/prev search match",
598
- " Esc Clear search highlights",
599
- " ]t/[t Next/prev thread",
600
- " ]r/[r Next/prev unread thread",
601
- "",
602
- " Review",
603
- " c Comment / view thread / reply",
604
- " r Resolve thread",
605
- " R Resolve all pending",
606
- " dd Delete draft comment",
607
- " l List threads",
608
- " a Approve spec",
609
- "",
610
- " Commands",
611
- " :w Save review",
612
- " :q Save and quit",
613
- " :wq Save and quit",
614
- " :q! Quit without saving",
615
- "",
616
- ].join("\n");
617
-
618
- const content = new TextRenderable(renderer, {
619
- content: helpText,
620
- width: "100%",
621
- fg: theme.text,
622
- wrapMode: "none",
623
- });
624
- dialog.content.add(content);
625
-
626
- // Additional key handler for q/? and j/k scroll
627
- const extraKeyHandler = (key: KeyEvent) => {
628
- if (key.name === "q" || key.sequence === "?") {
629
- key.preventDefault();
630
- key.stopPropagation();
631
- onClose();
632
- return;
633
- }
634
- if (key.name === "j" || key.name === "down") {
635
- key.preventDefault();
636
- key.stopPropagation();
637
- dialog.content.scrollBy(1);
638
- renderer.requestRender();
639
- return;
640
- }
641
- if (key.name === "k" || key.name === "up") {
642
- key.preventDefault();
643
- key.stopPropagation();
644
- dialog.content.scrollBy(-1);
645
- renderer.requestRender();
646
- return;
647
- }
648
- };
649
- renderer.keyInput.on("keypress", extraKeyHandler);
650
-
651
- return {
652
- container: dialog.container,
653
- cleanup() {
654
- dialog.cleanup();
655
- renderer.keyInput.off("keypress", extraKeyHandler);
656
- },
657
- };
658
- }
659
- ```
660
-
661
- - [ ] **Step 2: Run tests + manual test**
662
-
663
- Run: `bun test`
664
-
665
- - [ ] **Step 3: Commit**
666
-
667
- ```bash
668
- git add src/tui/help.ts
669
- git commit -m "refactor: rewrite help overlay using createDialog()"
670
- ```
671
-
672
- ### Task 7: Rewrite Confirm Dialog
673
-
674
- **Files:**
675
- - Modify: `src/tui/confirm.ts`
676
-
677
- - [ ] **Step 1: Rewrite using createDialog()**
678
-
679
- ```typescript
680
- import { TextRenderable, type CliRenderer, type KeyEvent } from "@opentui/core";
681
- import { createDialog } from "./ui/dialog";
682
- import { theme } from "./ui/theme";
683
-
684
- export interface ConfirmOverlay {
685
- container: import("@opentui/core").BoxRenderable;
686
- cleanup: () => void;
687
- }
688
-
689
- export function createConfirm(opts: {
690
- renderer: CliRenderer;
691
- message: string;
692
- onConfirm: () => void;
693
- onCancel: () => void;
694
- }): ConfirmOverlay {
695
- const { renderer, message, onConfirm, onCancel } = opts;
696
-
697
- const dialog = createDialog({
698
- renderer,
699
- title: "Confirm",
700
- width: "50%",
701
- height: 7,
702
- top: "35%",
703
- left: "25%",
704
- borderColor: theme.warning,
705
- onDismiss: onCancel,
706
- hints: [
707
- { key: "y", action: "yes" },
708
- { key: "n/Esc", action: "no" },
709
- ],
710
- });
711
-
712
- const msgText = new TextRenderable(renderer, {
713
- content: ` ${message}`,
714
- width: "100%",
715
- fg: theme.text,
716
- wrapMode: "word",
717
- });
718
- dialog.content.add(msgText);
719
-
720
- const extraKeyHandler = (key: KeyEvent) => {
721
- if (key.name === "y") {
722
- key.preventDefault();
723
- key.stopPropagation();
724
- onConfirm();
725
- return;
726
- }
727
- if (key.name === "n") {
728
- key.preventDefault();
729
- key.stopPropagation();
730
- onCancel();
731
- return;
732
- }
733
- };
734
- renderer.keyInput.on("keypress", extraKeyHandler);
735
-
736
- return {
737
- container: dialog.container,
738
- cleanup() {
739
- dialog.cleanup();
740
- renderer.keyInput.off("keypress", extraKeyHandler);
741
- },
742
- };
743
- }
744
- ```
745
-
746
- - [ ] **Step 2: Commit**
747
-
748
- ```bash
749
- git add src/tui/confirm.ts
750
- git commit -m "refactor: rewrite confirm dialog using createDialog()"
751
- ```
752
-
753
- ### Task 8: Rewrite Thread List
754
-
755
- **Files:**
756
- - Modify: `src/tui/thread-list.ts`
757
-
758
- - [ ] **Step 1: Rewrite using createDialog()**
759
-
760
- Keep SelectRenderable for the list, but use createDialog() for the container.
761
-
762
- ```typescript
763
- import {
764
- SelectRenderable,
765
- SelectRenderableEvents,
766
- type CliRenderer,
767
- } from "@opentui/core";
768
- import type { Thread } from "../protocol/types";
769
- import { createDialog } from "./ui/dialog";
770
- import { theme, STATUS_ICONS } from "./ui/theme";
771
-
772
- export interface ThreadListOverlay {
773
- container: import("@opentui/core").BoxRenderable;
774
- cleanup: () => void;
775
- }
776
-
777
- const MAX_PREVIEW_LENGTH = 50;
778
-
779
- function previewText(thread: Thread): string {
780
- if (thread.messages.length === 0) return "(empty)";
781
- const last = thread.messages[thread.messages.length - 1];
782
- const text = last.text.replace(/\n/g, " ");
783
- if (text.length <= MAX_PREVIEW_LENGTH) return text;
784
- return text.slice(0, MAX_PREVIEW_LENGTH - 1) + "\u2026";
785
- }
786
-
787
- export function createThreadList(opts: {
788
- renderer: CliRenderer;
789
- threads: Thread[];
790
- onSelect: (lineNumber: number) => void;
791
- onCancel: () => void;
792
- }): ThreadListOverlay {
793
- const { renderer, threads, onSelect, onCancel } = opts;
794
- const activeThreads = threads.filter(t => t.status === "open" || t.status === "pending");
795
-
796
- const dialog = createDialog({
797
- renderer,
798
- title: `Threads (${activeThreads.length} active)`,
799
- width: "70%",
800
- height: "60%",
801
- top: "15%",
802
- left: "15%",
803
- borderColor: theme.mauve,
804
- onDismiss: onCancel,
805
- hints: [
806
- { key: "j/k", action: "navigate" },
807
- { key: "Enter", action: "jump" },
808
- { key: "Esc", action: "close" },
809
- ],
810
- });
811
-
812
- if (activeThreads.length > 0) {
813
- const selectOptions = activeThreads.map(t => ({
814
- name: `${STATUS_ICONS[t.status]} #${t.id} line ${t.line}: ${previewText(t)}`,
815
- description: `${t.status} - ${t.messages.length} message(s)`,
816
- value: t.line,
817
- }));
818
-
819
- const select = new SelectRenderable(renderer, {
820
- width: "100%",
821
- flexGrow: 1,
822
- options: selectOptions,
823
- selectedIndex: 0,
824
- backgroundColor: theme.backgroundPanel,
825
- textColor: theme.text,
826
- focusedBackgroundColor: theme.backgroundPanel,
827
- focusedTextColor: theme.text,
828
- selectedBackgroundColor: theme.backgroundElement,
829
- selectedTextColor: theme.mauve,
830
- descriptionColor: theme.textDim,
831
- selectedDescriptionColor: theme.textMuted,
832
- showDescription: true,
833
- wrapSelection: true,
834
- });
835
-
836
- dialog.content.add(select);
837
- renderer.focusRenderable(select);
838
-
839
- select.on(SelectRenderableEvents.ITEM_SELECTED, () => {
840
- const selected = select.getSelectedOption();
841
- if (selected?.value != null) onSelect(selected.value as number);
842
- });
843
- }
844
-
845
- return {
846
- container: dialog.container,
847
- cleanup: dialog.cleanup,
848
- };
849
- }
850
- ```
851
-
852
- - [ ] **Step 2: Commit**
853
-
854
- ```bash
855
- git add src/tui/thread-list.ts
856
- git commit -m "refactor: rewrite thread-list using createDialog()"
857
- ```
858
-
859
- ### Task 9: Rewrite Comment Input
860
-
861
- **Files:**
862
- - Modify: `src/tui/comment-input.ts`
863
-
864
- - [ ] **Step 1: Rewrite using createDialog()**
865
-
866
- Keep the thread conversation view (message boxes, normal/insert mode, textarea) but use createDialog() for the container. The dialog's built-in Esc handler needs to be overridden since Esc switches modes in insert mode.
867
-
868
- This is the most complex overlay — keep the existing thread rendering logic (renderMessage, appendToConversation, normal/insert mode) but replace the manual BoxRenderable container with createDialog().
869
-
870
- Key changes:
871
- - Use `createDialog()` for container + hint bar
872
- - Use `dialog.setHints()` to switch between normal/insert hints
873
- - The dialog's Esc handler calls onCancel in normal mode; in insert mode, Esc switches to normal mode (handled by the extra key handler)
874
- - Keep all thread rendering, message appending, scrolling logic
875
-
876
- - [ ] **Step 2: Run tests + manual test**
877
-
878
- Run: `bun test`
879
-
880
- - [ ] **Step 3: Commit**
881
-
882
- ```bash
883
- git add src/tui/comment-input.ts
884
- git commit -m "refactor: rewrite comment-input using createDialog()"
885
- ```
886
-
887
- ### Task 10: Update Imports Across Codebase
888
-
889
- **Files:**
890
- - Modify: `src/tui/pager.ts` — import theme from ui/theme
891
- - Modify: `src/tui/status-bar.ts` — import theme from ui/theme, use buildHints
892
- - Modify: `src/tui/search.ts` — import theme from ui/theme
893
- - Delete: `src/tui/theme.ts`
894
-
895
- - [ ] **Step 1: Update all theme imports**
896
-
897
- Change all `import { theme } from "./theme"` to `import { theme } from "./ui/theme"` in:
898
- - pager.ts
899
- - status-bar.ts
900
- - search.ts
901
-
902
- - [ ] **Step 2: Update status-bar.ts to use buildHints**
903
-
904
- Replace the manual hint building in `buildBottomBar` with `buildHints()` from ui/hint-bar.
905
-
906
- - [ ] **Step 3: Delete src/tui/theme.ts**
907
-
908
- - [ ] **Step 4: Run tests**
909
-
910
- Run: `bun test`
911
- Expected: PASS
912
-
913
- - [ ] **Step 5: Commit**
914
-
915
- ```bash
916
- git add -A
917
- git commit -m "refactor: consolidate theme imports, delete old theme.ts"
918
- ```
919
-
920
- ### Task 11: Refactor app.ts Keybindings
921
-
922
- **Files:**
923
- - Modify: `src/tui/app.ts`
924
-
925
- - [ ] **Step 1: Replace switch statement with keybind registry**
926
-
927
- In app.ts, replace the `switch (key.name)` block (lines 454-734) and the pending timer state variables with:
928
-
929
- 1. Define bindings array:
930
- ```typescript
931
- const bindings: KeyBinding[] = [
932
- { key: "j", action: "cursor-down" },
933
- { key: "k", action: "cursor-up" },
934
- { key: "C-d", action: "half-page-down" },
935
- { key: "C-u", action: "half-page-up" },
936
- { key: "G", action: "goto-bottom" },
937
- { key: "gg", action: "goto-top" },
938
- { key: "n", action: "search-next" },
939
- { key: "N", action: "search-prev" },
940
- { key: "c", action: "comment" },
941
- { key: "l", action: "thread-list" },
942
- { key: "r", action: "resolve" },
943
- { key: "R", action: "resolve-all" },
944
- { key: "dd", action: "delete-draft" },
945
- { key: "a", action: "approve" },
946
- { key: "]t", action: "next-thread" },
947
- { key: "[t", action: "prev-thread" },
948
- { key: "]r", action: "next-unread" },
949
- { key: "[r", action: "prev-unread" },
950
- { key: "?", action: "help" },
951
- { key: "/", action: "search" },
952
- { key: ":", action: "command-mode" },
953
- ];
954
- ```
955
-
956
- 2. Create registry: `const keybinds = createKeybindRegistry(bindings);`
957
-
958
- 3. Replace the switch with action dispatch:
959
- ```typescript
960
- const action = keybinds.match(key);
961
- if (!action) {
962
- // Show pending sequence hint if waiting
963
- const p = keybinds.pending();
964
- if (p) {
965
- bottomBar.text.content = ` ${p}`;
966
- renderer.requestRender();
967
- }
968
- return;
969
- }
970
-
971
- const actions: Record<string, () => void> = {
972
- "cursor-down": () => { ... },
973
- "cursor-up": () => { ... },
974
- // ... all actions
975
- };
976
-
977
- actions[action]?.();
978
- ```
979
-
980
- 4. Remove the pending timer state vars (bracketPending, deletePendingTimer, gPendingTimer) — now handled by the registry.
981
-
982
- - [ ] **Step 2: Run tests**
983
-
984
- Run: `bun test`
985
- Expected: PASS
986
-
987
- - [ ] **Step 3: Commit**
988
-
989
- ```bash
990
- git add src/tui/app.ts
991
- git commit -m "refactor: replace switch statement with keybind registry"
992
- ```
993
-
994
- ### Task 12: Final Cleanup
995
-
996
- - [ ] **Step 1: Run full test suite**
997
-
998
- Run: `bun test`
999
- Expected: All 163+ tests pass
1000
-
1001
- - [ ] **Step 2: Manual test with revspec**
1002
-
1003
- ```bash
1004
- bun link
1005
- revspec /tmp/test-spec.md
1006
- ```
1007
-
1008
- Verify:
1009
- - Pager renders with inline markdown
1010
- - Help overlay (?) opens with new dialog style
1011
- - Comment input (c) works with thread view
1012
- - Thread list (l) shows active threads
1013
- - Confirm dialog (a) shows approve prompt
1014
- - Search (/) works from bottom bar
1015
- - Keybindings: j/k, gg, G, Ctrl+d/u, ]t/[t, dd all work
1016
- - :q exits cleanly
1017
-
1018
- - [ ] **Step 3: Commit any fixes**
1019
-
1020
- - [ ] **Step 4: Final commit**
1021
-
1022
- ```bash
1023
- git add -A
1024
- git commit -m "chore: UI refactor complete — opencode patterns adopted"
1025
- ```