revspec 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. package/CLAUDE.md +3 -1
  2. package/README.md +36 -11
  3. package/bun.lock +3 -0
  4. package/package.json +6 -3
  5. package/src/tui/app.ts +30 -6
  6. package/src/tui/comment-input.ts +14 -3
  7. package/src/tui/help.ts +76 -16
  8. package/src/tui/pager.ts +4 -2
  9. package/src/tui/search.ts +9 -4
  10. package/src/tui/status-bar.ts +1 -3
  11. package/test/e2e/__snapshots__/snapshot.test.ts.snap +31 -0
  12. package/test/e2e/fixtures/spec.md +36 -0
  13. package/test/e2e/harness.ts +80 -0
  14. package/test/e2e/snapshot.test.ts +182 -0
  15. package/test/{cli-reply.test.ts → integration/cli-reply.test.ts} +2 -2
  16. package/test/{cli-watch.test.ts → integration/cli-watch.test.ts} +2 -2
  17. package/test/{cli.test.ts → integration/cli.test.ts} +3 -3
  18. package/test/{e2e-live.test.ts → integration/e2e-live.test.ts} +4 -4
  19. package/test/{live-interaction.test.ts → integration/live-interaction.test.ts} +4 -4
  20. package/test/{protocol → unit/protocol}/live-events.test.ts +1 -1
  21. package/test/{protocol → unit/protocol}/live-merge.test.ts +3 -3
  22. package/test/{protocol → unit/protocol}/merge.test.ts +2 -2
  23. package/test/{protocol → unit/protocol}/read.test.ts +2 -2
  24. package/test/{protocol → unit/protocol}/types.test.ts +1 -1
  25. package/test/{protocol → unit/protocol}/write.test.ts +2 -2
  26. package/test/{state → unit/state}/review-state.test.ts +2 -2
  27. package/test/{tui → unit/tui}/pager.test.ts +3 -3
  28. package/test/{tui → unit/tui}/ui/keybinds.test.ts +1 -1
  29. /package/test/{opentui-smoke.test.ts → integration/opentui-smoke.test.ts} +0 -0
package/CLAUDE.md CHANGED
@@ -3,7 +3,9 @@
3
3
  - Tech: Bun + TypeScript + @opentui/core
4
4
  - npm: `revspec` | GitHub: icyrainz/revspec
5
5
  - Run: `bun run bin/revspec.ts <file.md>`
6
- - Test: `bun test`
6
+ - Test: `bun run test` (~3s, excludes E2E)
7
+ - E2E: `bun run test:e2e` (~25s, bun-pty snapshots — only run before release, update with `--update-snapshots`)
8
+ - All: `bun run test:all` (everything)
7
9
  - Release: `./scripts/release.sh` (version is set manually in package.json)
8
10
  - Dev: `bun link` to symlink local build to global `revspec` command
9
11
 
package/README.md CHANGED
@@ -35,7 +35,7 @@ Revspec renders markdown in-place (toggle with `m`):
35
35
 
36
36
  - **Headings** — colored and bold, `#`–`######`
37
37
  - **Inline** — bold (`**`/`__`), italic (`*`/`_`), bold-italic (`***`), strikethrough (`~~`), `code`, [links](url)
38
- - **Fenced code blocks** — fence markers dimmed, body in green
38
+ - **Fenced code blocks** — ` ``` ` markers dimmed, body in green
39
39
  - **Tables** — box-drawing borders, header row bolded, auto-column-widths
40
40
  - **Lists** — unordered (`•`), ordered, task lists (`☐`/`☑`)
41
41
  - **Blockquotes** — bar gutter, italicized text
@@ -44,26 +44,41 @@ Revspec renders markdown in-place (toggle with `m`):
44
44
 
45
45
  ### Keybindings
46
46
 
47
+ **Navigation**
48
+
47
49
  | Key | Action |
48
50
  |-----|--------|
49
51
  | `j/k` | Move cursor down/up |
50
52
  | `gg` / `G` | Go to top / bottom |
51
53
  | `Ctrl+D/U` | Half page down/up |
52
- | `m` | Toggle markdown / line mode |
53
- | `c` | Open thread / comment on line |
54
- | `r` | Resolve thread (toggle) |
55
- | `R` | Resolve all pending |
56
- | `dd` | Delete draft comment (double-tap) |
57
- | `/` | Search |
54
+ | `zz` | Center cursor line in viewport |
55
+ | `/` | Search (smartcase: lowercase = case-insensitive, any uppercase = case-sensitive) |
58
56
  | `n/N` | Next/prev search match |
57
+ | `Esc` | Clear search highlights |
59
58
  | `]t/[t` | Next/prev thread |
60
59
  | `]r/[r` | Next/prev unread AI reply |
61
- | `l` | List threads |
60
+
61
+ **Review**
62
+
63
+ | Key | Action |
64
+ |-----|--------|
65
+ | `c` | Open thread / comment on line |
66
+ | `r` | Resolve thread (toggle) |
67
+ | `R` | Resolve all pending |
68
+ | `dd` | Delete thread (with confirm) |
69
+ | `T` | List threads |
62
70
  | `a` | Approve spec |
71
+
72
+ **Commands**
73
+
74
+ | Key | Action |
75
+ |-----|--------|
63
76
  | `:w` | Merge changes to review JSON |
64
- | `:wq` | Merge and quit |
65
- | `:q` | Quit (only if merged) |
77
+ | `:wq` / `:qw` | Merge and quit |
78
+ | `:q` | Quit (blocks if unsaved) |
66
79
  | `:q!` | Quit without merging |
80
+ | `:{N}` | Jump to line N (e.g. `:42`) |
81
+ | `Ctrl+C` | Quit without merging |
67
82
  | `?` | Help |
68
83
 
69
84
  ### Thread popup
@@ -71,7 +86,7 @@ Revspec renders markdown in-place (toggle with `m`):
71
86
  The thread popup has two modes:
72
87
 
73
88
  - **Insert mode** — type your comment, `Tab` sends, `Esc` switches to normal mode
74
- - **Normal mode** — `j/k` and `Ctrl+D/U` scroll the conversation history, `c` to reply, `r` to resolve, `Esc` to close
89
+ - **Normal mode** — `j/k` and `Ctrl+D/U` scroll the conversation, `gg/G` top/bottom, `c` to reply, `r` to resolve, `Esc` to close
75
90
 
76
91
  ## Live AI Integration
77
92
 
@@ -125,6 +140,16 @@ Install the `/revspec` skill for Claude Code:
125
140
 
126
141
  Then use `/revspec` in Claude Code after generating a spec.
127
142
 
143
+ ## Testing
144
+
145
+ ```bash
146
+ bun test # Run all tests (~70s)
147
+ bun test test/e2e # E2E snapshot tests only (~66s)
148
+ bun test --update-snapshots # Regenerate snapshots after UI changes
149
+ ```
150
+
151
+ E2E tests use `bun-pty` to spawn revspec in a pseudo-terminal (80x24), send keystrokes, capture plain-text screen output, and compare against saved snapshots. Covers: navigation, search, overlays (help, comment, thread list, confirm), thread creation/resolve/delete, command mode, and context-sensitive hints.
152
+
128
153
  ## Protocol
129
154
 
130
155
  Communication happens through a JSONL file (`spec.review.live.jsonl`) — append-only, both sides write to it. On session end, events are merged into `spec.review.json`.
package/bun.lock CHANGED
@@ -9,6 +9,7 @@
9
9
  },
10
10
  "devDependencies": {
11
11
  "@types/bun": "latest",
12
+ "bun-pty": "^0.4.8",
12
13
  },
13
14
  "peerDependencies": {
14
15
  "typescript": "^5",
@@ -110,6 +111,8 @@
110
111
 
111
112
  "bun-ffi-structs": ["bun-ffi-structs@0.1.2", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-Lh1oQAYHDcnesJauieA4UNkWGXY9hYck7OA5IaRwE3Bp6K2F2pJSNYqq+hIy7P3uOvo3km3oxS8304g5gDMl/w=="],
112
113
 
114
+ "bun-pty": ["bun-pty@0.4.8", "", {}, "sha512-rO70Mrbr13+jxHHHu2YBkk2pNqrJE5cJn29WE++PUr+GFA0hq/VgtQPZANJ8dJo6d7XImvBk37Innt8GM7O28w=="],
115
+
113
116
  "bun-types": ["bun-types@1.3.10", "", { "dependencies": { "@types/node": "*" } }, "sha512-tcpfCCl6XWo6nCVnpcVrxQ+9AYN1iqMIzgrSKYMB/fjLtV2eyAVEg7AxQJuCq/26R6HpKWykQXuSOq/21RYcbg=="],
114
117
 
115
118
  "bun-webgpu": ["bun-webgpu@0.1.5", "", { "dependencies": { "@webgpu/types": "^0.1.60" }, "optionalDependencies": { "bun-webgpu-darwin-arm64": "^0.1.5", "bun-webgpu-darwin-x64": "^0.1.5", "bun-webgpu-linux-x64": "^0.1.5", "bun-webgpu-win32-x64": "^0.1.5" } }, "sha512-91/K6S5whZKX7CWAm9AylhyKrLGRz6BUiiPiM/kXadSnD4rffljCD/q9cNFftm5YXhx4MvLqw33yEilxogJvwA=="],
package/package.json CHANGED
@@ -1,16 +1,19 @@
1
1
  {
2
2
  "name": "revspec",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "revspec": "./bin/revspec.ts"
7
7
  },
8
8
  "scripts": {
9
9
  "start": "bun run bin/revspec.ts",
10
- "test": "bun test"
10
+ "test": "bun test test/unit test/integration",
11
+ "test:e2e": "bun test test/e2e",
12
+ "test:all": "bun test"
11
13
  },
12
14
  "devDependencies": {
13
- "@types/bun": "latest"
15
+ "@types/bun": "latest",
16
+ "bun-pty": "^0.4.8"
14
17
  },
15
18
  "peerDependencies": {
16
19
  "typescript": "^5"
package/src/tui/app.ts CHANGED
@@ -253,6 +253,14 @@ export async function runTui(
253
253
  keybinds.destroy();
254
254
  return "exit";
255
255
  }
256
+ // :{N} — jump to line number
257
+ const lineNum = parseInt(cmd, 10);
258
+ if (!isNaN(lineNum) && lineNum > 0) {
259
+ state.cursorLine = Math.min(lineNum, state.lineCount);
260
+ ensureCursorVisible();
261
+ refreshPager();
262
+ return "stay";
263
+ }
256
264
  return "stay"; // unknown command, ignore
257
265
  }
258
266
 
@@ -361,11 +369,14 @@ export async function runTui(
361
369
  currentLine: number,
362
370
  direction: 1 | -1
363
371
  ): number | null {
364
- const q = query.toLowerCase();
372
+ // Smartcase: if query has any uppercase, case-sensitive
373
+ const caseSensitive = query !== query.toLowerCase();
374
+ const q = caseSensitive ? query : query.toLowerCase();
365
375
  const total = lines.length;
366
376
  for (let offset = 1; offset <= total; offset++) {
367
377
  const i = ((currentLine - 1) + offset * direction + total) % total;
368
- if (lines[i].toLowerCase().includes(q)) {
378
+ const line = caseSensitive ? lines[i] : lines[i].toLowerCase();
379
+ if (line.includes(q)) {
369
380
  return i + 1; // 1-based
370
381
  }
371
382
  }
@@ -386,7 +397,7 @@ export async function runTui(
386
397
  { key: "n", action: "search-next" },
387
398
  { key: "N", action: "search-prev" },
388
399
  { key: "c", action: "comment" },
389
- { key: "l", action: "thread-list" },
400
+ { key: "T", action: "thread-list" },
390
401
  { key: "r", action: "resolve" },
391
402
  { key: "R", action: "resolve-all" },
392
403
  { key: "dd", action: "delete-draft" },
@@ -395,11 +406,12 @@ export async function runTui(
395
406
  { key: "[t", action: "prev-thread" },
396
407
  { key: "]r", action: "next-unread" },
397
408
  { key: "[r", action: "prev-unread" },
409
+ { key: "zz", action: "center-cursor" },
398
410
  { key: "?", action: "help" },
399
411
  { key: "/", action: "search" },
400
412
  { key: ":", action: "command-mode" },
401
413
  ];
402
- const keybinds = createKeybindRegistry(bindings);
414
+ const keybinds = createKeybindRegistry(bindings, 300);
403
415
 
404
416
  refreshPager();
405
417
  renderer.start();
@@ -460,9 +472,13 @@ export async function runTui(
460
472
  return;
461
473
  }
462
474
 
463
- // Ctrl+C to exit — merge and quit
475
+ // Ctrl+C to exit — quit without merging (same as :q!)
464
476
  if (key.ctrl && key.name === "c") {
465
- mergeAndExit(resolve);
477
+ appendEvent(jsonlPath, { type: "session-end", author: "reviewer", ts: Date.now() });
478
+ liveWatcher.stop();
479
+ keybinds.destroy();
480
+ renderer.destroy();
481
+ resolve();
466
482
  return;
467
483
  }
468
484
 
@@ -527,6 +543,14 @@ export async function runTui(
527
543
  ensureCursorVisible();
528
544
  refreshPager();
529
545
  break;
546
+ case "center-cursor": {
547
+ const extra = countExtraVisualLines(state.specLines, state.cursorLine - 1);
548
+ const cursorRow = state.cursorLine - 1 + extra;
549
+ const halfView = Math.floor(pageSize() / 2);
550
+ pager.scrollBox.scrollTo(Math.max(0, cursorRow - halfView));
551
+ refreshPager();
552
+ break;
553
+ }
530
554
  case "search-next":
531
555
  if (searchQuery) {
532
556
  const match = findNextMatch(state.specLines, searchQuery, state.cursorLine, 1);
@@ -50,16 +50,17 @@ function createThreadView(
50
50
  { key: "NORMAL", action: "" },
51
51
  { key: "c", action: "reply" },
52
52
  { key: "r", action: "resolve" },
53
- { key: "Esc/q", action: "close" },
53
+ { key: "q", action: "close" },
54
54
  ];
55
55
  const insertHints = [
56
56
  { key: "INSERT", action: "" },
57
57
  { key: "Tab", action: "send" },
58
- { key: "Esc", action: "back" },
58
+ { key: "Esc", action: "normal" },
59
59
  ];
60
60
 
61
61
  // --- State ---
62
62
  let mode: "normal" | "insert" = "insert";
63
+ let pendingG: ReturnType<typeof setTimeout> | null = null;
63
64
 
64
65
  // Build the textarea now (we need it in the key handler closure)
65
66
  const textarea = new TextareaRenderable(renderer, {
@@ -151,10 +152,19 @@ function createThreadView(
151
152
  case "g":
152
153
  if (key.shift) {
153
154
  // G = go to bottom
155
+ if (pendingG) { clearTimeout(pendingG); pendingG = null; }
154
156
  scrollBox.scrollTo(scrollBox.scrollHeight);
155
157
  renderer.requestRender();
158
+ } else if (pendingG) {
159
+ // gg = go to top
160
+ clearTimeout(pendingG);
161
+ pendingG = null;
162
+ scrollBox.scrollTo(0);
163
+ renderer.requestRender();
164
+ } else {
165
+ // First g — wait for second
166
+ pendingG = setTimeout(() => { pendingG = null; }, 300);
156
167
  }
157
- // TODO: gg = go to top (needs double-tap tracking)
158
168
  return;
159
169
  }
160
170
  };
@@ -273,6 +283,7 @@ function createThreadView(
273
283
  return {
274
284
  container: dialog.container,
275
285
  cleanup() {
286
+ if (pendingG) clearTimeout(pendingG);
276
287
  renderer.keyInput.off("keypress", keyHandler);
277
288
  dialog.cleanup();
278
289
  textarea.destroy();
package/src/tui/help.ts CHANGED
@@ -50,14 +50,14 @@ export function createHelp(opts: {
50
50
  renderer,
51
51
  title: "Help",
52
52
  width: "60%",
53
- height: Math.min(26, renderer.height - 2),
53
+ height: Math.min(34, renderer.height - 2),
54
54
  top: "10%",
55
55
  left: "20%",
56
56
  borderColor: theme.info,
57
57
  onDismiss: onClose,
58
58
  hints: [
59
+ { key: "j/k", action: "navigate" },
59
60
  { key: "q/?/Esc", action: "close" },
60
- { key: "j/k", action: "scroll" },
61
61
  ],
62
62
  });
63
63
 
@@ -71,41 +71,59 @@ export function createHelp(opts: {
71
71
  wrapMode: "none",
72
72
  }));
73
73
 
74
+ addHelpSection(dialog.content, renderer, "Quick Start", [
75
+ " Navigate to a line and press c to comment.",
76
+ " The AI replies in real-time via the thread popup.",
77
+ " Press r to resolve threads, a to approve the spec.",
78
+ " Use :wq to save and quit when done reviewing.",
79
+ ]);
80
+
81
+ addHelpSection(dialog.content, renderer, "Thread Popup", [
82
+ " Opens in INSERT mode — type and press Tab to send.",
83
+ " Press Esc for NORMAL mode — scroll with j/k/gg/G,",
84
+ " c to reply, r to resolve, q to close.",
85
+ ]);
86
+
74
87
  addHelpSection(dialog.content, renderer, "Navigation", [
75
88
  " j/k Down/up",
76
- " gg Go to first line / scroll to top",
77
- " G Go to last line / scroll to bottom",
89
+ " gg/G Top/bottom",
78
90
  " Ctrl+d/u Half page down/up",
79
- " / Search",
80
- " n/N Next/prev search match",
81
- " Esc Clear search highlights",
91
+ " zz Center cursor line",
92
+ " / Search (smartcase)",
93
+ " n/N Next/prev match",
94
+ " Esc Clear search",
82
95
  " ]t/[t Next/prev thread",
83
- " ]r/[r Next/prev unread thread",
96
+ " ]r/[r Next/prev unread",
84
97
  ]);
85
98
 
86
99
  addHelpSection(dialog.content, renderer, "Review", [
87
- " c Comment / view thread / reply",
88
- " r Resolve thread",
100
+ " c Comment / view thread",
101
+ " r Resolve thread (toggle)",
89
102
  " R Resolve all pending",
90
- " dd Delete thread (with confirm)",
91
- " l List threads",
103
+ " dd Delete thread",
104
+ " T List threads",
92
105
  " a Approve spec",
93
106
  ]);
94
107
 
95
108
  addHelpSection(dialog.content, renderer, "Commands", [
96
- " :w Show save status",
97
- " :q Quit (blocks if unsaved)",
98
- " :wq Save and quit",
99
- " :q! Quit without saving",
109
+ " :w Merge to review JSON",
110
+ " :wq Merge and quit",
111
+ " :q Quit (blocks if unmerged)",
112
+ " :q! Quit without merging",
113
+ " :{N} Jump to line N",
114
+ " Ctrl+C Quit without merging",
100
115
  ]);
101
116
 
102
117
  // Trailing blank line
103
118
  dialog.content.add(new TextRenderable(renderer, { content: "", width: "100%", height: 1, wrapMode: "none" }));
104
119
 
120
+ let pendingG: ReturnType<typeof setTimeout> | null = null;
121
+
105
122
  const extraKeyHandler = (key: KeyEvent) => {
106
123
  if (key.name === "q" || key.sequence === "?") {
107
124
  key.preventDefault();
108
125
  key.stopPropagation();
126
+ if (pendingG) { clearTimeout(pendingG); pendingG = null; }
109
127
  onClose();
110
128
  return;
111
129
  }
@@ -123,6 +141,47 @@ export function createHelp(opts: {
123
141
  renderer.requestRender();
124
142
  return;
125
143
  }
144
+ // G = goto bottom
145
+ if (key.name === "g" && key.shift) {
146
+ key.preventDefault();
147
+ key.stopPropagation();
148
+ if (pendingG) { clearTimeout(pendingG); pendingG = null; }
149
+ dialog.content.scrollTo(dialog.content.scrollHeight);
150
+ renderer.requestRender();
151
+ return;
152
+ }
153
+ // gg = goto top
154
+ if (key.name === "g" && !key.shift && !key.ctrl) {
155
+ key.preventDefault();
156
+ key.stopPropagation();
157
+ if (pendingG) {
158
+ clearTimeout(pendingG);
159
+ pendingG = null;
160
+ dialog.content.scrollTo(0);
161
+ renderer.requestRender();
162
+ } else {
163
+ pendingG = setTimeout(() => { pendingG = null; }, 300);
164
+ }
165
+ return;
166
+ }
167
+ // Ctrl+D = half page down
168
+ if (key.ctrl && key.name === "d") {
169
+ key.preventDefault();
170
+ key.stopPropagation();
171
+ const half = Math.max(1, Math.floor((renderer.height - 4) / 2));
172
+ dialog.content.scrollTo(Math.min(dialog.content.scrollTop + half, dialog.content.scrollHeight));
173
+ renderer.requestRender();
174
+ return;
175
+ }
176
+ // Ctrl+U = half page up
177
+ if (key.ctrl && key.name === "u") {
178
+ key.preventDefault();
179
+ key.stopPropagation();
180
+ const half = Math.max(1, Math.floor((renderer.height - 4) / 2));
181
+ dialog.content.scrollTo(Math.max(dialog.content.scrollTop - half, 0));
182
+ renderer.requestRender();
183
+ return;
184
+ }
126
185
  };
127
186
 
128
187
  renderer.keyInput.on("keypress", extraKeyHandler);
@@ -130,6 +189,7 @@ export function createHelp(opts: {
130
189
  return {
131
190
  container: dialog.container,
132
191
  cleanup() {
192
+ if (pendingG) clearTimeout(pendingG);
133
193
  dialog.cleanup();
134
194
  renderer.keyInput.off("keypress", extraKeyHandler);
135
195
  },
package/src/tui/pager.ts CHANGED
@@ -35,7 +35,8 @@ export function buildPagerContent(state: ReviewState, searchQuery?: string | nul
35
35
  const prefix = isCursor ? ">" : " ";
36
36
  let specText = state.specLines[i];
37
37
  if (searchQuery) {
38
- const regex = new RegExp(searchQuery.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "gi");
38
+ const csSensitive = searchQuery !== searchQuery.toLowerCase();
39
+ const regex = new RegExp(searchQuery.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), csSensitive ? "g" : "gi");
39
40
  specText = specText.replace(regex, (match) => `>>${match}<<`);
40
41
  }
41
42
  let indicator = " ";
@@ -173,7 +174,8 @@ export function buildPagerNodes(lineNode: TextRenderable, state: ReviewState, se
173
174
  } else if (searchQuery) {
174
175
  // When searching, show colored match segments (no markdown styling)
175
176
  const escaped = searchQuery.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
176
- const searchRegex = new RegExp(`(${escaped})`, "gi");
177
+ const caseSensitive = searchQuery !== searchQuery.toLowerCase();
178
+ const searchRegex = new RegExp(`(${escaped})`, caseSensitive ? "g" : "gi");
177
179
  const parts = specText.split(searchRegex);
178
180
  for (let p = 0; p < parts.length; p++) {
179
181
  const part = parts[p];
package/src/tui/search.ts CHANGED
@@ -83,18 +83,23 @@ export function createSearch(opts: SearchOptions): SearchOverlay {
83
83
  if (key.name === "return") {
84
84
  key.preventDefault();
85
85
  key.stopPropagation();
86
- const query = input.value.trim().toLowerCase();
87
- if (query.length === 0) {
86
+ const raw = input.value.trim();
87
+ if (raw.length === 0) {
88
88
  onCancel();
89
89
  return;
90
90
  }
91
91
 
92
+ // Smartcase: if query has any uppercase, case-sensitive; otherwise case-insensitive
93
+ const caseSensitive = raw !== raw.toLowerCase();
94
+ const query = caseSensitive ? raw : raw.toLowerCase();
95
+
92
96
  // Search forward from cursor, wrapping around
93
97
  const total = specLines.length;
94
98
  for (let offset = 1; offset <= total; offset++) {
95
99
  const i = (cursorLine - 1 + offset) % total;
96
- if (specLines[i].toLowerCase().includes(query)) {
97
- onResult(i + 1, query); // 1-based line number + query
100
+ const line = caseSensitive ? specLines[i] : specLines[i].toLowerCase();
101
+ if (line.includes(query)) {
102
+ onResult(i + 1, raw); // 1-based line number + original query (preserves case for n/N)
98
103
  return;
99
104
  }
100
105
  }
@@ -84,14 +84,12 @@ export function buildBottomBar(bar: BottomBarComponents, commandBuffer: string |
84
84
  return;
85
85
  }
86
86
  const hints: Hint[] = [
87
- { key: "j/k", action: "move" },
87
+ { key: "j/k", action: "navigate" },
88
88
  { key: "c", action: "comment" },
89
89
  ];
90
90
  if (hasThread) {
91
91
  hints.push({ key: "r", action: "resolve" });
92
- hints.push({ key: "dd", action: "delete thread" });
93
92
  }
94
- hints.push({ key: "/", action: "search" });
95
93
  hints.push({ key: "?", action: "help" });
96
94
  buildHints(t, hints);
97
95
  }
@@ -0,0 +1,31 @@
1
+ // Bun Snapshot v1, https://bun.sh/docs/test/snapshots
2
+
3
+ exports[`revspec E2E snapshots initial render 1`] = `" spec.md · No active threads · L1/37 > 1 Auth System Design █ 2 3 Overview 4 5 Users authenticate via OAuth2 with Google and GitHub providers. 6 7 Endpoints 8 9 • POST /auth/login — initiates OAuth flow 10 • GET /auth/callback — handles provider callback 11 • POST /auth/refresh — refreshes expired tokens 12 13 Token Storage 14 15 Tokens are stored in Redis as JSON blobs. See Redis docs. 16 ┌──────────────┬───────────┬─────┐ 17 │ Key │ Format │ TTL │ 18 ├──────────────┼───────────┼─────┤ 19 │ session:{id} │ JSON blob │ 24h │ └──────────────┴───────────┴─────┘ [j/k] navigate [c] comment [?] help ██████████ 20"`;
4
+
5
+ exports[`revspec E2E snapshots G scrolls to bottom 1`] = `" spec.md · No active threads · L1/37 > 1 Auth System Design █ 2 3 Overview 4 5 Users authenticate via OAuth2 with Google and GitHub providers. 6 7 Endpoints 8 9 • POST /auth/login — initiates OAuth flow 10 • GET /auth/callback — handles provider callback 11 • POST /auth/refresh — refreshes expired tokens 12 13 Token Storage 14 15 Tokens are stored in Redis as JSON blobs. See Redis docs. 16 ┌──────────────┬───────────┬─────┐ 17 │ Key │ Format │ TTL │ 18 ├──────────────┼───────────┼─────┤ 19 │ session:{id} │ JSON blob │ 24h │ └──────────────┴───────────┴─────┘ [j/k] navigate [c] comment [?] help ██████████ 20 37/37 17 │ Key │ Format │ TTL │ 18├──────────────┼───────────┼─────┤ 19│ session:{id} │ JSON blob │ 24h │ └──────────────┴───────────┴─────┘ 20 21Tasks 22 23☑ Implement login 24☐ Add rate limitg 25 26│ Note: tokens are not encrypted at rest. 27█28Code Example █29█30\`\`\`typescript █31const token: string = await auth.login();█ 32 \`\`\` █33 █34────────────────────────────────────────█35 █ 36 End of spec. █> 37 █"`;
6
+
7
+ exports[`revspec E2E snapshots j/k moves cursor 1`] = `" spec.md · No active threads · L1/37 > 1 Auth System Design █ 2 3 Overview 4 5 Users authenticate via OAuth2 with Google and GitHub providers. 6 7 Endpoints 8 9 • POST /auth/login — initiates OAuth flow 10 • GET /auth/callback — handles provider callback 11 • POST /auth/refresh — refreshes expired tokens 12 13 Token Storage 14 15 Tokens are stored in Redis as JSON blobs. See Redis docs. 16 ┌──────────────┬───────────┬─────┐ 17 │ Key │ Format │ TTL │ 18 ├──────────────┼───────────┼─────┤ 19 │ session:{id} │ JSON blob │ 24h │ └──────────────┴───────────┴─────┘ [j/k] navigate [c] comment [?] help ██████████ 20 6 1 Auth System Design> 6"`;
8
+
9
+ exports[`revspec E2E snapshots Ctrl+D half page down 1`] = `" spec.md · No active threads · L1/37 > 1 Auth System Design █ 2 3 Overview 4 5 Users authenticate via OAuth2 with Google and GitHub providers. 6 7 Endpoints 8 9 • POST /auth/login — initiates OAuth flow 10 • GET /auth/callback — handles provider callback 11 • POST /auth/refresh — refreshes expired tokens 12 13 Token Storage 14 15 Tokens are stored in Redis as JSON blobs. See Redis docs. 16 ┌──────────────┬───────────┬─────┐ 17 │ Key │ Format │ TTL │ 18 ├──────────────┼───────────┼─────┤ 19 │ session:{id} │ JSON blob │ 24h │ └──────────────┴───────────┴─────┘ [j/k] navigate [c] comment [?] help ██████████ 20 2/37 1 Auth System Design> 12"`;
10
+
11
+ exports[`revspec E2E snapshots / search highlights matches 1`] = `" spec.md · No active threads · L1/37 > 1 Auth System Design █ 2 3 Overview 4 5 Users authenticate via OAuth2 with Google and GitHub providers. 6 7 Endpoints 8 9 • POST /auth/login — initiates OAuth flow 10 • GET /auth/callback — handles provider callback 11 • POST /auth/refresh — refreshes expired tokens 12 13 Token Storage 14 15 Tokens are stored in Redis as JSON blobs. See Redis docs. 16 ┌──────────────┬───────────┬─────┐ 17 │ Key │ Format │ TTL │ 18 ├──────────────┼───────────┼─────┤ 19 │ session:{id} │ JSON blob │ 24h │ └──────────────┴───────────┴─────┘ [j/k] navigate [c] comment [?] help ██████████ 20 / Search..."`;
12
+
13
+ exports[`revspec E2E snapshots n jumps to next match 1`] = `" spec.md · No active threads · L1/37 > 1 Auth System Design █ 2 3 Overview 4 5 Users authenticate via OAuth2 with Google and GitHub providers. 6 7 Endpoints 8 9 • POST /auth/login — initiates OAuth flow 10 • GET /auth/callback — handles provider callback 11 • POST /auth/refresh — refreshes expired tokens 12 13 Token Storage 14 15 Tokens are stored in Redis as JSON blobs. See Redis docs. 16 ┌──────────────┬───────────┬─────┐ 17 │ Key │ Format │ TTL │ 18 ├──────────────┼───────────┼─────┤ 19 │ session:{id} │ JSON blob │ 24h │ └──────────────┴───────────┴─────┘ [j/k] navigate [c] comment [?] help ██████████ 20 / Search... n"`;
14
+
15
+ exports[`revspec E2E snapshots ? opens help 1`] = `" spec.md · No active threads · L1/37 > 1 Auth System Design █ 2 3 Overview 4 5 Users authenticate via OAuth2 with Google and GitHub providers. 6 7 Endpoints 8 9 • POST /auth/login — initiates OAuth flow 10 • GET /auth/callback — handles provider callback 11 • POST /auth/refresh — refreshes expired tokens 12 13 Token Storage 14 15 Tokens are stored in Redis as JSON blobs. See Redis docs. 16 ┌──────────────┬───────────┬─────┐ 17 │ Key │ Format │ TTL │ 18 ├──────────────┼───────────┼─────┤ 19 │ session:{id} │ JSON blob │ 24h │ └──────────────┴───────────┴─────┘ [j/k] navigate [c] comment [?] help ██████████ 20 ┌─ Help ───────────────────────────────────────┐│ ││ ▀ ││ revspec vX.Y.Z ││ ││ Quick Start ││ Navigate to a line and press c to comment ││ The AI replies in real-time via the threa ││ Press r to resolve threads, a to approve ││ Use :wq to save and quit when done review ││ ││ Thread Popup ││ Opens in INSERT mode — type and press Tab ││ Press Esc for NORMAL mode — scroll with j ││ c to reply, r to resolve, q to close. ││ ││ Navigation ││ j/k Down/up ││ gg/G Top/bottom ││ Ctrl+d/u Half page down/up ││ [j/k] navigate [q/?/Esc] close │└──────────────────────────────────────────────┘"`;
16
+
17
+ exports[`revspec E2E snapshots c opens comment input 1`] = `" spec.md · No active threads · L1/37 > 1 Auth System Design █ 2 3 Overview 4 5 Users authenticate via OAuth2 with Google and GitHub providers. 6 7 Endpoints 8 9 • POST /auth/login — initiates OAuth flow 10 • GET /auth/callback — handles provider callback 11 • POST /auth/refresh — refreshes expired tokens 12 13 Token Storage 14 15 Tokens are stored in Redis as JSON blobs. See Redis docs. 16 ┌──────────────┬───────────┬─────┐ 17 │ Key │ Format │ TTL │ 18 ├──────────────┼───────────┼─────┤ 19 │ session:{id} │ JSON blob │ 24h │ └──────────────┴───────────┴─────┘ [j/k] navigate [c] comment [?] help ██████████ 20 4 1 Auth System Design> 4 ┌─ New comment on line 4 ──────────────────────────────────────┐│ ││ █ ││ █ ││ █ ││ █ ││ █ ││ █ ││ ││ ││ ││ ││ ││ [INSERT] [Tab] send [Esc] normal ││ ──────────────────────────────────────── ││ Press c to reply... ││ ││ ││ ││ │└──────────────────────────────────────────────────────────────┘"`;
18
+
19
+ exports[`revspec E2E snapshots Tab submits comment 1`] = `" spec.md · No active threads · L1/37 > 1 Auth System Design █ 2 3 Overview 4 5 Users authenticate via OAuth2 with Google and GitHub providers. 6 7 Endpoints 8 9 • POST /auth/login — initiates OAuth flow 10 • GET /auth/callback — handles provider callback 11 • POST /auth/refresh — refreshes expired tokens 12 13 Token Storage 14 15 Tokens are stored in Redis as JSON blobs. See Redis docs. 16 ┌──────────────┬───────────┬─────┐ 17 │ Key │ Format │ TTL │ 18 ├──────────────┼───────────┼─────┤ 19 │ session:{id} │ JSON blob │ 24h │ └──────────────┴───────────┴─────┘ [j/k] navigate [c] comment [?] help ██████████ 20 4 1 Auth System Design> 4 ┌─ New comment on line 4 ──────────────────────────────────────┐│ ││ █ ││ █ ││ █ ││ █ ││ █ ││ █ ││ ││ ││ ││ ││ ││ [INSERT] [Tab] send [Esc] normal ││ ──────────────────────────────────────── ││ Press c to reply... ││ ││ ││ ││ │└──────────────────────────────────────────────────────────────┘ 1 open · L4/37 Thread #t1 (line 4)──│You YYYY-MM-DD HH:MM:SS▌ │This is a test commentNORMALc] reply [r] resolve [q] closerrsolve [?] help"`;
20
+
21
+ exports[`revspec E2E snapshots Esc switches to normal mode 1`] = `" spec.md · No active threads · L1/37 > 1 Auth System Design █ 2 3 Overview 4 5 Users authenticate via OAuth2 with Google and GitHub providers. 6 7 Endpoints 8 9 • POST /auth/login — initiates OAuth flow 10 • GET /auth/callback — handles provider callback 11 • POST /auth/refresh — refreshes expired tokens 12 13 Token Storage 14 15 Tokens are stored in Redis as JSON blobs. See Redis docs. 16 ┌──────────────┬───────────┬─────┐ 17 │ Key │ Format │ TTL │ 18 ├──────────────┼───────────┼─────┤ 19 │ session:{id} │ JSON blob │ 24h │ └──────────────┴───────────┴─────┘ [j/k] navigate [c] comment [?] help ██████████ 20 4 1 Auth System Design> 4 ┌─ New comment on line 4 ──────────────────────────────────────┐│ ││ █ ││ █ ││ █ ││ █ ││ █ ││ █ ││ ││ ││ ││ ││ ││ [INSERT] [Tab] send [Esc] normal ││ ──────────────────────────────────────── ││ Press c to reply... ││ ││ ││ ││ │└──────────────────────────────────────────────────────────────┘ NORMALc] reply [r] resolve [q] close"`;
22
+
23
+ exports[`revspec E2E snapshots thread gutter indicator 1`] = `" spec.md · No active threads · L1/37 > 1 Auth System Design █ 2 3 Overview 4 5 Users authenticate via OAuth2 with Google and GitHub providers. 6 7 Endpoints 8 9 • POST /auth/login — initiates OAuth flow 10 • GET /auth/callback — handles provider callback 11 • POST /auth/refresh — refreshes expired tokens 12 13 Token Storage 14 15 Tokens are stored in Redis as JSON blobs. See Redis docs. 16 ┌──────────────┬───────────┬─────┐ 17 │ Key │ Format │ TTL │ 18 ├──────────────┼───────────┼─────┤ 19 │ session:{id} │ JSON blob │ 24h │ └──────────────┴───────────┴─────┘ [j/k] navigate [c] comment [?] help ██████████ 20 4 1 Auth System Design> 4 ┌─ New comment on line 4 ──────────────────────────────────────┐│ ││ █ ││ █ ││ █ ││ █ ││ █ ││ █ ││ ││ ││ ││ ││ ││ [INSERT] [Tab] send [Esc] normal ││ ──────────────────────────────────────── ││ Press c to reply... ││ ││ ││ ││ │└──────────────────────────────────────────────────────────────┘ 1 open · L4/37 Thread #t1 (line 4)──│You YYYY-MM-DD HH:MM:SS▌ │Test threadNORMALc] reply [r] resolve [q] closerrsolve [?] helputh System Design verview « Test thread sers authenticate via OAuth2 with Google and GitHub providers. ndpoints POST /auth/login — initiates OAuth flow GET /auth/callback — handles provider callback POST /auth/refresh — refreshes expired tokens oken Storage okens are stored in Redis as JSON blobs. See Redis docs. ──────────────┬───────────┬─────┐ Key │ Format │ TTL │ ──────────────┼───────────┼─────┤ session:{id} │ JSON blob │ 24h │ ──────────────┴───────────┴─────┘"`;
24
+
25
+ exports[`revspec E2E snapshots T opens thread list 1`] = `" spec.md · No active threads · L1/37 > 1 Auth System Design █ 2 3 Overview 4 5 Users authenticate via OAuth2 with Google and GitHub providers. 6 7 Endpoints 8 9 • POST /auth/login — initiates OAuth flow 10 • GET /auth/callback — handles provider callback 11 • POST /auth/refresh — refreshes expired tokens 12 13 Token Storage 14 15 Tokens are stored in Redis as JSON blobs. See Redis docs. 16 ┌──────────────┬───────────┬─────┐ 17 │ Key │ Format │ TTL │ 18 ├──────────────┼───────────┼─────┤ 19 │ session:{id} │ JSON blob │ 24h │ └──────────────┴───────────┴─────┘ [j/k] navigate [c] comment [?] help ██████████ 20 ┌─ Threads (0 active) ─────────────────────────────────┐│ ││ No active threads. Press [Esc] to close. █ ││ █ ││ █ ││ █ ││ █ ││ ││ ││ ││ ││ ││ [j/k] navigate [Enter] jump [Esc] close │└──────────────────────────────────────────────────────┘"`;
26
+
27
+ exports[`revspec E2E snapshots r resolves thread 1`] = `" spec.md · No active threads · L1/37 > 1 Auth System Design █ 2 3 Overview 4 5 Users authenticate via OAuth2 with Google and GitHub providers. 6 7 Endpoints 8 9 • POST /auth/login — initiates OAuth flow 10 • GET /auth/callback — handles provider callback 11 • POST /auth/refresh — refreshes expired tokens 12 13 Token Storage 14 15 Tokens are stored in Redis as JSON blobs. See Redis docs. 16 ┌──────────────┬───────────┬─────┐ 17 │ Key │ Format │ TTL │ 18 ├──────────────┼───────────┼─────┤ 19 │ session:{id} │ JSON blob │ 24h │ └──────────────┴───────────┴─────┘ [j/k] navigate [c] comment [?] help ██████████ 20 4 1 Auth System Design> 4 ┌─ New comment on line 4 ──────────────────────────────────────┐│ ││ █ ││ █ ││ █ ││ █ ││ █ ││ █ ││ ││ ││ ││ ││ ││ [INSERT] [Tab] send [Esc] normal ││ ──────────────────────────────────────── ││ Press c to reply... ││ ││ ││ ││ │└──────────────────────────────────────────────────────────────┘ 1 open · L4/37 Thread #t1 (line 4)──│You YYYY-MM-DD HH:MM:SS▌ │Comment to resolveNORMALc] reply [r] resolve [q] closerrsolve [?] helputh System Design verview « Comment to resolve sers authenticate via OAuth2 with Google and GitHub providers. ndpoints POST /auth/login — initiates OAuth flow GET /auth/callback — handles provider callback POST /auth/refresh — refreshes expired tokens oken Storage okens are stored in Redis as JSON blobs. See Redis docs. ──────────────┬───────────┬─────┐ Key │ Format │ TTL │ ──────────────┼───────────┼─────┤ session:{id} │ JSON blob │ 24h │ ──────────────┴───────────┴─────┘ No active threads · L4/37✓✔ Resolved thread #t1"`;
28
+
29
+ exports[`revspec E2E snapshots dd y deletes thread 1`] = `" spec.md · No active threads · L1/37 > 1 Auth System Design █ 2 3 Overview 4 5 Users authenticate via OAuth2 with Google and GitHub providers. 6 7 Endpoints 8 9 • POST /auth/login — initiates OAuth flow 10 • GET /auth/callback — handles provider callback 11 • POST /auth/refresh — refreshes expired tokens 12 13 Token Storage 14 15 Tokens are stored in Redis as JSON blobs. See Redis docs. 16 ┌──────────────┬───────────┬─────┐ 17 │ Key │ Format │ TTL │ 18 ├──────────────┼───────────┼─────┤ 19 │ session:{id} │ JSON blob │ 24h │ └──────────────┴───────────┴─────┘ [j/k] navigate [c] comment [?] help ██████████ 20 4 1 Auth System Design> 4 ┌─ New comment on line 4 ──────────────────────────────────────┐│ ││ █ ││ █ ││ █ ││ █ ││ █ ││ █ ││ ││ ││ ││ ││ ││ [INSERT] [Tab] send [Esc] normal ││ ──────────────────────────────────────── ││ Press c to reply... ││ ││ ││ ││ │└──────────────────────────────────────────────────────────────┘ 1 open · L4/37 Thread #t1 (line 4)──│You YYYY-MM-DD HH:MM:SS▌ │Comment to deleteNORMALc] reply [r] resolve [q] closerrsolve [?] helputh System Design verview « Comment to delete sers authenticate via OAuth2 with Google and GitHub providers. ndpoints POST /auth/login — initiates OAuth flow GET /auth/callback — handles provider callback POST /auth/refresh — refreshes expired tokens oken Storage okens are stored in Redis as JSON blobs. See Redis docs. ──────────────┬───────────┬─────┐ Key │ Format │ TTL │ ──────────────┼───────────┼─────┤ session:{id} │ JSON blob │ 24h │ ──────────────┴───────────┴─────┘ ┌─ Delete Thread ──────────────────────┐│ ││ Delete thread #t1 on line 4? ││ ││ ││ ││ ││ [y] yes [n/Esc] no │└──────────────────────────────────────┘d... No active threads · L4/37 login — initiates OAuth flow allback — handles provider callback refresh — refreshes expired tokens ored in Redis as JSON blobs. See Redis d ✔ Deleted thread #t1"`;
30
+
31
+ exports[`revspec E2E snapshots :w merges review 1`] = `" spec.md · No active threads · L1/37 > 1 Auth System Design █ 2 3 Overview 4 5 Users authenticate via OAuth2 with Google and GitHub providers. 6 7 Endpoints 8 9 • POST /auth/login — initiates OAuth flow 10 • GET /auth/callback — handles provider callback 11 • POST /auth/refresh — refreshes expired tokens 12 13 Token Storage 14 15 Tokens are stored in Redis as JSON blobs. See Redis docs. 16 ┌──────────────┬───────────┬─────┐ 17 │ Key │ Format │ TTL │ 18 ├──────────────┼───────────┼─────┤ 19 │ session:{id} │ JSON blob │ 24h │ └──────────────┴───────────┴─────┘ [j/k] navigate [c] comment [?] help ██████████ 20 :w"`;
@@ -0,0 +1,36 @@
1
+ # Auth System Design
2
+
3
+ ## Overview
4
+
5
+ Users authenticate via **OAuth2** with `Google` and *GitHub* providers.
6
+
7
+ ## Endpoints
8
+
9
+ - POST /auth/login — initiates OAuth flow
10
+ - GET /auth/callback — handles provider callback
11
+ - POST /auth/refresh — refreshes ~~expired~~ tokens
12
+
13
+ ## Token Storage
14
+
15
+ Tokens are stored in Redis as JSON blobs. See [Redis docs](https://redis.io).
16
+
17
+ | Key | Format | TTL |
18
+ |---|---|---|
19
+ | `session:{id}` | JSON blob | 24h |
20
+
21
+ ## Tasks
22
+
23
+ - [x] Implement login
24
+ - [ ] Add rate limiting
25
+
26
+ > Note: tokens are **not encrypted** at rest.
27
+
28
+ ### Code Example
29
+
30
+ ```typescript
31
+ const token: string = await auth.login();
32
+ ```
33
+
34
+ ---
35
+
36
+ End of spec.
@@ -0,0 +1,80 @@
1
+ import { spawn, type IPty } from "bun-pty";
2
+ import { resolve, dirname, basename, join } from "path";
3
+ import { unlinkSync, existsSync } from "fs";
4
+
5
+ const CLI = resolve(import.meta.dir, "../../bin/revspec.ts");
6
+
7
+ function stripAnsi(str: string): string {
8
+ return str
9
+ .replace(/\x1bP(?:[^\x1b]|\x1b[^\\])*\x1b\\/g, "")
10
+ .replace(/\x1b\[[0-9;?>=!]*[ -/]*[A-Za-z@`~]/g, "")
11
+ .replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, "")
12
+ .replace(/\x1b[()][0-9A-Z]/g, "")
13
+ .replace(/\x1b[>=<]/g, "")
14
+ .replace(/\x1b./g, "")
15
+ .replace(/\r/g, "")
16
+ .replace(/\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}/g, "YYYY-MM-DD HH:MM:SS")
17
+ .replace(/revspec v\d+\.\d+\.\d+/g, "revspec vX.Y.Z");
18
+ }
19
+
20
+ export interface TuiHarness {
21
+ sendKeys: (keys: string) => void;
22
+ wait: (ms?: number) => Promise<void>;
23
+ capture: () => string;
24
+ contains: (text: string) => boolean;
25
+ quit: () => Promise<void>;
26
+ cleanReviewFiles: () => void;
27
+ }
28
+
29
+ export async function createHarness(specFile: string, opts?: { cols?: number; rows?: number }): Promise<TuiHarness> {
30
+ const cols = opts?.cols ?? 80;
31
+ const rows = opts?.rows ?? 24;
32
+ const specPath = resolve(specFile);
33
+ let buffer = "";
34
+
35
+ const pty = spawn("bun", ["run", CLI, specPath], {
36
+ name: "xterm-256color",
37
+ cols,
38
+ rows,
39
+ env: { ...process.env, TERM: "xterm-256color", NO_COLOR: undefined, FORCE_COLOR: undefined },
40
+ });
41
+
42
+ pty.onData((data: string) => { buffer += data; });
43
+
44
+ function sendKeys(keys: string): void { pty.write(keys); }
45
+
46
+ function wait(ms = 50): Promise<void> {
47
+ return new Promise((resolve) => setTimeout(resolve, ms));
48
+ }
49
+
50
+ function capture(): string {
51
+ const clean = stripAnsi(buffer);
52
+ const lines = clean.split("\n");
53
+ return lines.slice(Math.max(0, lines.length - rows), lines.length).join("\n").trimEnd();
54
+ }
55
+
56
+ function contains(text: string): boolean { return capture().includes(text); }
57
+
58
+ function cleanReviewFiles(): void {
59
+ const dir = dirname(specPath);
60
+ const base = basename(specPath, ".md");
61
+ for (const ext of [".review.json", ".review.live.jsonl", ".review.live.offset", ".review.live.lock", ".review.draft.json"]) {
62
+ const f = join(dir, `${base}${ext}`);
63
+ try { if (existsSync(f)) unlinkSync(f); } catch {}
64
+ }
65
+ }
66
+
67
+ async function quit(): Promise<void> {
68
+ sendKeys("\x1b");
69
+ await wait(30);
70
+ sendKeys(":q!\n");
71
+ await wait(100);
72
+ try { pty.kill(); } catch {}
73
+ cleanReviewFiles();
74
+ }
75
+
76
+ // Wait for initial render
77
+ await wait(150);
78
+
79
+ return { sendKeys, wait, capture, contains, quit, cleanReviewFiles };
80
+ }
@@ -0,0 +1,182 @@
1
+ import { describe, test, expect, afterEach } from "bun:test";
2
+ import { createHarness, type TuiHarness } from "./harness";
3
+ import { resolve } from "path";
4
+
5
+ const SPEC = resolve(import.meta.dir, "fixtures/spec.md");
6
+ const S = 350; // sequence wait (gg/dd need 300ms timeout)
7
+
8
+ describe("revspec E2E snapshots", () => {
9
+ let harness: TuiHarness | null = null;
10
+
11
+ afterEach(async () => {
12
+ if (harness) { await harness.quit(); harness = null; }
13
+ });
14
+
15
+ test("initial render", async () => {
16
+ harness = await createHarness(SPEC);
17
+ expect(harness.capture()).toMatchSnapshot();
18
+ });
19
+
20
+ test("G scrolls to bottom", async () => {
21
+ harness = await createHarness(SPEC);
22
+ harness.sendKeys("G");
23
+ await harness.wait();
24
+ expect(harness.capture()).toMatchSnapshot();
25
+ });
26
+
27
+ test("j/k moves cursor", async () => {
28
+ harness = await createHarness(SPEC);
29
+ harness.sendKeys("jjjjj");
30
+ await harness.wait();
31
+ expect(harness.capture()).toMatchSnapshot();
32
+ });
33
+
34
+ test("Ctrl+D half page down", async () => {
35
+ harness = await createHarness(SPEC);
36
+ harness.sendKeys("\x04");
37
+ await harness.wait();
38
+ expect(harness.capture()).toMatchSnapshot();
39
+ });
40
+
41
+ test("/ search highlights matches", async () => {
42
+ harness = await createHarness(SPEC);
43
+ harness.sendKeys("/token\n");
44
+ await harness.wait();
45
+ expect(harness.capture()).toMatchSnapshot();
46
+ });
47
+
48
+ test("n jumps to next match", async () => {
49
+ harness = await createHarness(SPEC);
50
+ harness.sendKeys("/token\n");
51
+ await harness.wait();
52
+ harness.sendKeys("n");
53
+ await harness.wait();
54
+ expect(harness.capture()).toMatchSnapshot();
55
+ });
56
+
57
+ test("? opens help", async () => {
58
+ harness = await createHarness(SPEC);
59
+ harness.sendKeys("?");
60
+ await harness.wait();
61
+ expect(harness.capture()).toMatchSnapshot();
62
+ });
63
+
64
+ test("c opens comment input", async () => {
65
+ harness = await createHarness(SPEC);
66
+ harness.sendKeys("jjj");
67
+ await harness.wait();
68
+ harness.sendKeys("c");
69
+ await harness.wait();
70
+ expect(harness.capture()).toMatchSnapshot();
71
+ });
72
+
73
+ test("Tab submits comment", async () => {
74
+ harness = await createHarness(SPEC);
75
+ harness.sendKeys("jjj");
76
+ await harness.wait();
77
+ harness.sendKeys("c");
78
+ await harness.wait();
79
+ harness.sendKeys("This is a test comment\t");
80
+ await harness.wait();
81
+ expect(harness.capture()).toMatchSnapshot();
82
+ });
83
+
84
+ test("Esc switches to normal mode", async () => {
85
+ harness = await createHarness(SPEC);
86
+ harness.sendKeys("jjj");
87
+ await harness.wait();
88
+ harness.sendKeys("c");
89
+ await harness.wait();
90
+ harness.sendKeys("\x1b");
91
+ await harness.wait();
92
+ expect(harness.capture()).toMatchSnapshot();
93
+ });
94
+
95
+ test("thread gutter indicator", async () => {
96
+ harness = await createHarness(SPEC);
97
+ harness.sendKeys("jjj");
98
+ await harness.wait();
99
+ harness.sendKeys("c");
100
+ await harness.wait();
101
+ harness.sendKeys("Test thread\t");
102
+ await harness.wait();
103
+ harness.sendKeys("\x1b");
104
+ await harness.wait();
105
+ harness.sendKeys("q");
106
+ await harness.wait();
107
+ expect(harness.capture()).toMatchSnapshot();
108
+ });
109
+
110
+ test("T opens thread list", async () => {
111
+ harness = await createHarness(SPEC);
112
+ harness.sendKeys("T");
113
+ await harness.wait();
114
+ expect(harness.capture()).toMatchSnapshot();
115
+ });
116
+
117
+ test("r resolves thread", async () => {
118
+ harness = await createHarness(SPEC);
119
+ harness.sendKeys("jjj");
120
+ await harness.wait();
121
+ harness.sendKeys("c");
122
+ await harness.wait();
123
+ harness.sendKeys("Comment to resolve\t");
124
+ await harness.wait();
125
+ harness.sendKeys("\x1b");
126
+ await harness.wait();
127
+ harness.sendKeys("q");
128
+ await harness.wait();
129
+ harness.sendKeys("r");
130
+ await harness.wait();
131
+ expect(harness.capture()).toMatchSnapshot();
132
+ });
133
+
134
+ test("dd y deletes thread", async () => {
135
+ harness = await createHarness(SPEC);
136
+ harness.sendKeys("jjj");
137
+ await harness.wait();
138
+ harness.sendKeys("c");
139
+ await harness.wait();
140
+ harness.sendKeys("Comment to delete\t");
141
+ await harness.wait();
142
+ harness.sendKeys("\x1b");
143
+ await harness.wait();
144
+ harness.sendKeys("q");
145
+ await harness.wait();
146
+ harness.sendKeys("dd");
147
+ await harness.wait(S);
148
+ harness.sendKeys("y");
149
+ await harness.wait();
150
+ expect(harness.capture()).toMatchSnapshot();
151
+ });
152
+
153
+ test(":w merges review", async () => {
154
+ harness = await createHarness(SPEC);
155
+ harness.sendKeys(":w\n");
156
+ await harness.wait();
157
+ expect(harness.capture()).toMatchSnapshot();
158
+ });
159
+
160
+ test("bottom bar shows resolve on thread line", async () => {
161
+ harness = await createHarness(SPEC);
162
+ harness.sendKeys("jjj");
163
+ await harness.wait();
164
+ harness.sendKeys("c");
165
+ await harness.wait();
166
+ harness.sendKeys("hint test\t");
167
+ await harness.wait();
168
+ harness.sendKeys("\x1b");
169
+ await harness.wait();
170
+ harness.sendKeys("q");
171
+ await harness.wait();
172
+ expect(harness.contains("resolve")).toBe(true);
173
+ });
174
+
175
+ test("bottom bar hides resolve on non-thread line", async () => {
176
+ harness = await createHarness(SPEC);
177
+ const lines = harness.capture().split("\n");
178
+ const bottom = lines[lines.length - 1] || "";
179
+ expect(bottom).toContain("navigate");
180
+ expect(bottom).not.toContain("resolve");
181
+ });
182
+ });
@@ -2,9 +2,9 @@ import { describe, it, expect, afterEach } from "bun:test";
2
2
  import { mkdtempSync, rmSync, writeFileSync } from "fs";
3
3
  import { join, resolve } from "path";
4
4
  import { tmpdir } from "os";
5
- import { appendEvent, readEventsFromOffset } from "../src/protocol/live-events";
5
+ import { appendEvent, readEventsFromOffset } from "../../src/protocol/live-events";
6
6
 
7
- const CLI = resolve(import.meta.dir, "../bin/revspec.ts");
7
+ const CLI = resolve(import.meta.dir, "../../bin/revspec.ts");
8
8
 
9
9
  interface SpawnResult {
10
10
  exitCode: number;
@@ -2,9 +2,9 @@ import { describe, it, expect, afterEach } from "bun:test";
2
2
  import { mkdtempSync, rmSync, writeFileSync } from "fs";
3
3
  import { join, resolve } from "path";
4
4
  import { tmpdir } from "os";
5
- import { appendEvent, readEventsFromOffset } from "../src/protocol/live-events";
5
+ import { appendEvent, readEventsFromOffset } from "../../src/protocol/live-events";
6
6
 
7
- const CLI = resolve(import.meta.dir, "../bin/revspec.ts");
7
+ const CLI = resolve(import.meta.dir, "../../bin/revspec.ts");
8
8
 
9
9
  interface SpawnResult {
10
10
  exitCode: number;
@@ -2,10 +2,10 @@ import { describe, it, expect, afterEach } from "bun:test";
2
2
  import { mkdtempSync, rmSync, writeFileSync } from "fs";
3
3
  import { join, resolve } from "path";
4
4
  import { tmpdir } from "os";
5
- import { appendEvent } from "../src/protocol/live-events";
6
- import { writeReviewFile } from "../src/protocol/write";
5
+ import { appendEvent } from "../../src/protocol/live-events";
6
+ import { writeReviewFile } from "../../src/protocol/write";
7
7
 
8
- const CLI = resolve(import.meta.dir, "../bin/revspec.ts");
8
+ const CLI = resolve(import.meta.dir, "../../bin/revspec.ts");
9
9
 
10
10
  interface SpawnResult {
11
11
  exitCode: number;
@@ -2,11 +2,11 @@ import { describe, it, expect, beforeEach, afterEach } from "bun:test"
2
2
  import { mkdtempSync, rmSync, writeFileSync, existsSync } from "fs"
3
3
  import { join } from "path"
4
4
  import { tmpdir } from "os"
5
- import { appendEvent, readEventsFromOffset } from "../src/protocol/live-events"
6
- import { mergeJsonlIntoReview } from "../src/protocol/live-merge"
7
- import { writeReviewFile } from "../src/protocol/write"
5
+ import { appendEvent, readEventsFromOffset } from "../../src/protocol/live-events"
6
+ import { mergeJsonlIntoReview } from "../../src/protocol/live-merge"
7
+ import { writeReviewFile } from "../../src/protocol/write"
8
8
 
9
- const CLI = join(import.meta.dir, "..", "bin", "revspec.ts")
9
+ const CLI = join(import.meta.dir, "..", "..", "bin", "revspec.ts")
10
10
 
11
11
  describe("E2E: live review loop", () => {
12
12
  let dir: string
@@ -2,11 +2,11 @@ import { describe, it, expect, beforeEach, afterEach } from "bun:test"
2
2
  import { mkdtempSync, rmSync, writeFileSync, existsSync, readFileSync } from "fs"
3
3
  import { join, resolve } from "path"
4
4
  import { tmpdir } from "os"
5
- import { appendEvent, readEventsFromOffset, replayEventsToThreads } from "../src/protocol/live-events"
6
- import { mergeJsonlIntoReview } from "../src/protocol/live-merge"
7
- import { ReviewState } from "../src/state/review-state"
5
+ import { appendEvent, readEventsFromOffset, replayEventsToThreads } from "../../src/protocol/live-events"
6
+ import { mergeJsonlIntoReview } from "../../src/protocol/live-merge"
7
+ import { ReviewState } from "../../src/state/review-state"
8
8
 
9
- const CLI = resolve(import.meta.dir, "../bin/revspec.ts")
9
+ const CLI = resolve(import.meta.dir, "../../bin/revspec.ts")
10
10
 
11
11
  interface SpawnResult {
12
12
  exitCode: number
@@ -8,7 +8,7 @@ import {
8
8
  readEventsFromOffset,
9
9
  replayEventsToThreads,
10
10
  type LiveEvent,
11
- } from "../../src/protocol/live-events";
11
+ } from "../../../src/protocol/live-events";
12
12
 
13
13
  function tmpDir() {
14
14
  return mkdtempSync(join(tmpdir(), "revspec-test-"));
@@ -2,9 +2,9 @@ import { describe, it, expect, beforeEach, afterEach } from "bun:test";
2
2
  import { mkdtempSync, rmSync } from "fs";
3
3
  import { join } from "path";
4
4
  import { tmpdir } from "os";
5
- import { mergeJsonlIntoReview } from "../../src/protocol/live-merge";
6
- import { appendEvent } from "../../src/protocol/live-events";
7
- import type { ReviewFile } from "../../src/protocol/types";
5
+ import { mergeJsonlIntoReview } from "../../../src/protocol/live-merge";
6
+ import { appendEvent } from "../../../src/protocol/live-events";
7
+ import type { ReviewFile } from "../../../src/protocol/types";
8
8
 
9
9
  function tmpDir() {
10
10
  return mkdtempSync(join(tmpdir(), "revspec-test-"));
@@ -1,6 +1,6 @@
1
1
  import { describe, expect, it } from "bun:test";
2
- import { mergeDraftIntoReview } from "../../src/protocol/merge";
3
- import type { ReviewFile, DraftFile, Thread } from "../../src/protocol/types";
2
+ import { mergeDraftIntoReview } from "../../../src/protocol/merge";
3
+ import type { ReviewFile, DraftFile, Thread } from "../../../src/protocol/types";
4
4
 
5
5
  const baseReview: ReviewFile = {
6
6
  file: "spec.md",
@@ -1,8 +1,8 @@
1
1
  import { describe, expect, it, afterEach } from "bun:test";
2
2
  import { mkdtempSync, rmSync, writeFileSync } from "fs";
3
3
  import { join } from "path";
4
- import { readReviewFile, readDraftFile } from "../../src/protocol/read";
5
- import type { ReviewFile, DraftFile } from "../../src/protocol/types";
4
+ import { readReviewFile, readDraftFile } from "../../../src/protocol/read";
5
+ import type { ReviewFile, DraftFile } from "../../../src/protocol/types";
6
6
 
7
7
  function tmpDir() {
8
8
  return mkdtempSync("/tmp/revspec-test-");
@@ -3,7 +3,7 @@ import {
3
3
  isValidStatus,
4
4
  isValidThread,
5
5
  isValidReviewFile,
6
- } from "../../src/protocol/types";
6
+ } from "../../../src/protocol/types";
7
7
 
8
8
  describe("isValidStatus", () => {
9
9
  it("accepts open", () => expect(isValidStatus("open")).toBe(true));
@@ -1,8 +1,8 @@
1
1
  import { describe, expect, it } from "bun:test";
2
2
  import { mkdtempSync, rmSync, readFileSync } from "fs";
3
3
  import { join } from "path";
4
- import { writeReviewFile, writeDraftFile } from "../../src/protocol/write";
5
- import type { ReviewFile, DraftFile } from "../../src/protocol/types";
4
+ import { writeReviewFile, writeDraftFile } from "../../../src/protocol/write";
5
+ import type { ReviewFile, DraftFile } from "../../../src/protocol/types";
6
6
 
7
7
  function tmpDir() {
8
8
  return mkdtempSync("/tmp/revspec-test-");
@@ -1,6 +1,6 @@
1
1
  import { describe, expect, it, beforeEach } from "bun:test";
2
- import { ReviewState } from "../../src/state/review-state";
3
- import type { Thread } from "../../src/protocol/types";
2
+ import { ReviewState } from "../../../src/state/review-state";
3
+ import type { Thread } from "../../../src/protocol/types";
4
4
 
5
5
  const SPEC = ["line one", "line two", "line three", "line four", "line five"];
6
6
 
@@ -1,7 +1,7 @@
1
1
  import { describe, expect, it } from "bun:test";
2
- import { buildPagerContent } from "../../src/tui/pager";
3
- import { ReviewState } from "../../src/state/review-state";
4
- import type { Thread } from "../../src/protocol/types";
2
+ import { buildPagerContent } from "../../../src/tui/pager";
3
+ import { ReviewState } from "../../../src/state/review-state";
4
+ import type { Thread } from "../../../src/protocol/types";
5
5
 
6
6
  const SPEC = ["# Title", "Some text", "More text", "Final line"];
7
7
 
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, it } from "bun:test";
2
- import { createKeybindRegistry } from "../../../src/tui/ui/keybinds";
2
+ import { createKeybindRegistry } from "../../../../src/tui/ui/keybinds";
3
3
 
4
4
  function makeKey(name: string, opts: { ctrl?: boolean; shift?: boolean; sequence?: string } = {}): any {
5
5
  return { name, ctrl: opts.ctrl ?? false, shift: opts.shift ?? false, sequence: opts.sequence ?? name };