revspec 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/ci.yml +18 -0
- package/README.md +90 -0
- package/bin/revspec.ts +109 -0
- package/bun.lock +213 -0
- package/docs/superpowers/plans/2026-03-14-spectral-v1-implementation.md +2139 -0
- package/docs/superpowers/specs/2026-03-14-spec-review-tool-design.md +331 -0
- package/docs/superpowers/specs/2026-03-14-spec-review-tool-design.review.json +141 -0
- package/docs/superpowers/specs/claude-code-integration-notes.md +26 -0
- package/package.json +21 -0
- package/scripts/release.sh +76 -0
- package/src/protocol/merge.ts +52 -0
- package/src/protocol/read.ts +25 -0
- package/src/protocol/types.ts +55 -0
- package/src/protocol/write.ts +10 -0
- package/src/state/review-state.ts +136 -0
- package/src/tui/app.ts +691 -0
- package/src/tui/comment-input.ts +189 -0
- package/src/tui/confirm.ts +93 -0
- package/src/tui/help.ts +134 -0
- package/src/tui/pager.ts +158 -0
- package/src/tui/search.ts +119 -0
- package/src/tui/status-bar.ts +76 -0
- package/src/tui/theme.ts +34 -0
- package/src/tui/thread-list.ts +145 -0
- package/test/cli.test.ts +151 -0
- package/test/opentui-smoke.test.ts +12 -0
- package/test/protocol/merge.test.ts +100 -0
- package/test/protocol/read.test.ts +92 -0
- package/test/protocol/types.test.ts +95 -0
- package/test/protocol/write.test.ts +72 -0
- package/test/state/review-state.test.ts +326 -0
- package/test/tui/pager.test.ts +184 -0
- package/tsconfig.json +14 -0
|
@@ -0,0 +1,2139 @@
|
|
|
1
|
+
# Revspec v1 Implementation Plan
|
|
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:** Build a CLI + TUI pager that lets humans review AI-generated spec documents by adding line-anchored comments, outputting structured JSON for AI consumption.
|
|
6
|
+
|
|
7
|
+
**Architecture:** A Bun-based CLI entry point that manages the review file lifecycle (draft/merge/approve), rendering a full-screen TUI pager built with OpenTUI. The TUI displays the spec with line numbers, thread status indicators, and inline comment hints. The JSON protocol defines threads with `open`/`pending`/`resolved`/`outdated` statuses.
|
|
8
|
+
|
|
9
|
+
**Tech Stack:** Bun, TypeScript, @opentui/core
|
|
10
|
+
|
|
11
|
+
**Spec:** `docs/superpowers/specs/2026-03-14-spec-review-tool-design.md`
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## File Structure
|
|
16
|
+
|
|
17
|
+
```
|
|
18
|
+
spectral/
|
|
19
|
+
├── package.json
|
|
20
|
+
├── tsconfig.json
|
|
21
|
+
├── bunfig.toml
|
|
22
|
+
├── bin/
|
|
23
|
+
│ └── revspec.ts # CLI entry point — arg parsing, file lifecycle, stdout output
|
|
24
|
+
├── src/
|
|
25
|
+
│ ├── protocol/
|
|
26
|
+
│ │ ├── types.ts # ReviewFile, Thread, Message, Status types
|
|
27
|
+
│ │ ├── read.ts # Read and validate review/draft JSON files
|
|
28
|
+
│ │ ├── write.ts # Write review/draft JSON files
|
|
29
|
+
│ │ └── merge.ts # Merge draft threads into review file
|
|
30
|
+
│ ├── tui/
|
|
31
|
+
│ │ ├── app.ts # Top-level TUI app — creates renderer, manages state
|
|
32
|
+
│ │ ├── pager.ts # Scrollable spec view with line numbers + thread indicators
|
|
33
|
+
│ │ ├── thread-expand.ts # Thread expand overlay (shows full thread messages)
|
|
34
|
+
│ │ ├── comment-input.ts # Inline text input for new comments / replies
|
|
35
|
+
│ │ ├── thread-list.ts # List all open/pending threads (jump-to)
|
|
36
|
+
│ │ ├── search.ts # / search overlay
|
|
37
|
+
│ │ ├── status-bar.ts # Top bar (filename, thread counts) + bottom bar (keybinding hints)
|
|
38
|
+
│ │ └── confirm.ts # Confirmation dialog (:q submit confirmation)
|
|
39
|
+
│ └── state/
|
|
40
|
+
│ ├── review-state.ts # Central state: spec lines, threads, draft changes, cursor position
|
|
41
|
+
├── test/
|
|
42
|
+
│ ├── protocol/
|
|
43
|
+
│ │ ├── types.test.ts
|
|
44
|
+
│ │ ├── read.test.ts
|
|
45
|
+
│ │ ├── write.test.ts
|
|
46
|
+
│ │ └── merge.test.ts
|
|
47
|
+
│ ├── state/
|
|
48
|
+
│ │ └── review-state.test.ts
|
|
49
|
+
│ ├── tui/
|
|
50
|
+
│ │ └── pager.test.ts # Tests for buildPagerContent (pure function)
|
|
51
|
+
│ └── cli.test.ts # Integration test: CLI arg parsing, file lifecycle, stdout
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
---
|
|
55
|
+
|
|
56
|
+
## Chunk 0: OpenTUI API Verification
|
|
57
|
+
|
|
58
|
+
### Task 0: Verify OpenTUI APIs
|
|
59
|
+
|
|
60
|
+
Before building the TUI, verify the APIs we depend on actually exist. OpenTUI is new and the docs may not match the shipped package.
|
|
61
|
+
|
|
62
|
+
- [ ] **Step 1: Install and verify imports**
|
|
63
|
+
|
|
64
|
+
```typescript
|
|
65
|
+
// test/opentui-smoke.test.ts
|
|
66
|
+
import { describe, expect, it } from "bun:test";
|
|
67
|
+
|
|
68
|
+
describe("OpenTUI API availability", () => {
|
|
69
|
+
it("core imports exist", async () => {
|
|
70
|
+
const core = await import("@opentui/core");
|
|
71
|
+
expect(core.createCliRenderer).toBeDefined();
|
|
72
|
+
expect(core.TextRenderable).toBeDefined();
|
|
73
|
+
expect(core.BoxRenderable).toBeDefined();
|
|
74
|
+
expect(core.ScrollBoxRenderable).toBeDefined();
|
|
75
|
+
expect(core.InputRenderable).toBeDefined();
|
|
76
|
+
expect(core.SelectRenderable).toBeDefined();
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Run: `bun test test/opentui-smoke.test.ts`
|
|
82
|
+
|
|
83
|
+
If any import fails, check the actual exports with `bun repl` and adapt the plan accordingly. This test should be run FIRST before writing any TUI code.
|
|
84
|
+
|
|
85
|
+
- [ ] **Step 2: Commit**
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
git add test/opentui-smoke.test.ts
|
|
89
|
+
git commit -m "test: verify OpenTUI API availability"
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
---
|
|
93
|
+
|
|
94
|
+
## Chunk 1: Project Setup + Protocol Layer
|
|
95
|
+
|
|
96
|
+
### Task 1: Project Scaffolding
|
|
97
|
+
|
|
98
|
+
**Files:**
|
|
99
|
+
- Create: `package.json`
|
|
100
|
+
- Create: `tsconfig.json`
|
|
101
|
+
- Create: `bunfig.toml`
|
|
102
|
+
- Create: `bin/revspec.ts`
|
|
103
|
+
|
|
104
|
+
- [ ] **Step 1: Initialize Bun project**
|
|
105
|
+
|
|
106
|
+
```bash
|
|
107
|
+
cd /Users/tuephan/repo/spectral
|
|
108
|
+
bun init -y
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
- [ ] **Step 2: Install OpenTUI**
|
|
112
|
+
|
|
113
|
+
```bash
|
|
114
|
+
bun add @opentui/core
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
- [ ] **Step 3: Update package.json**
|
|
118
|
+
|
|
119
|
+
Set the `bin` field and project metadata:
|
|
120
|
+
|
|
121
|
+
```json
|
|
122
|
+
{
|
|
123
|
+
"name": "revspec",
|
|
124
|
+
"version": "0.1.0",
|
|
125
|
+
"description": "Review tool for AI-generated spec documents",
|
|
126
|
+
"bin": {
|
|
127
|
+
"revspec": "./bin/revspec.ts"
|
|
128
|
+
},
|
|
129
|
+
"scripts": {
|
|
130
|
+
"start": "bun run bin/revspec.ts",
|
|
131
|
+
"test": "bun test"
|
|
132
|
+
},
|
|
133
|
+
"dependencies": {
|
|
134
|
+
"@opentui/core": "latest"
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
- [ ] **Step 4: Create tsconfig.json**
|
|
140
|
+
|
|
141
|
+
```json
|
|
142
|
+
{
|
|
143
|
+
"compilerOptions": {
|
|
144
|
+
"lib": ["ESNext"],
|
|
145
|
+
"target": "ESNext",
|
|
146
|
+
"module": "ESNext",
|
|
147
|
+
"moduleResolution": "bundler",
|
|
148
|
+
"strict": true,
|
|
149
|
+
"skipLibCheck": true,
|
|
150
|
+
"outDir": "dist",
|
|
151
|
+
"rootDir": ".",
|
|
152
|
+
"types": ["bun-types"]
|
|
153
|
+
},
|
|
154
|
+
"include": ["src/**/*.ts", "bin/**/*.ts", "test/**/*.ts"]
|
|
155
|
+
}
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
- [ ] **Step 5: Create minimal CLI entry point**
|
|
159
|
+
|
|
160
|
+
```typescript
|
|
161
|
+
// bin/revspec.ts
|
|
162
|
+
#!/usr/bin/env bun
|
|
163
|
+
|
|
164
|
+
const args = process.argv.slice(2);
|
|
165
|
+
|
|
166
|
+
if (args.length === 0 || args.includes("--help") || args.includes("-h")) {
|
|
167
|
+
console.log("Usage: revspec <file.md> [--tui|--nvim|--web]");
|
|
168
|
+
process.exit(0);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const specFile = args.find((a) => !a.startsWith("--"));
|
|
172
|
+
if (!specFile) {
|
|
173
|
+
console.error("Error: No spec file provided");
|
|
174
|
+
process.exit(1);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
console.log(`revspec: would review ${specFile}`);
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
- [ ] **Step 6: Verify it runs**
|
|
181
|
+
|
|
182
|
+
Run: `bun run bin/revspec.ts test.md`
|
|
183
|
+
Expected: `revspec: would review test.md`
|
|
184
|
+
|
|
185
|
+
- [ ] **Step 7: Commit**
|
|
186
|
+
|
|
187
|
+
```bash
|
|
188
|
+
git add package.json tsconfig.json bunfig.toml bin/revspec.ts bun.lock
|
|
189
|
+
git commit -m "feat: scaffold Bun project with OpenTUI dependency"
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
---
|
|
193
|
+
|
|
194
|
+
### Task 2: Protocol Types
|
|
195
|
+
|
|
196
|
+
**Files:**
|
|
197
|
+
- Create: `src/protocol/types.ts`
|
|
198
|
+
- Create: `test/protocol/types.test.ts`
|
|
199
|
+
|
|
200
|
+
- [ ] **Step 1: Write the failing test**
|
|
201
|
+
|
|
202
|
+
```typescript
|
|
203
|
+
// test/protocol/types.test.ts
|
|
204
|
+
import { describe, expect, it } from "bun:test";
|
|
205
|
+
import {
|
|
206
|
+
type ReviewFile,
|
|
207
|
+
type Thread,
|
|
208
|
+
type Message,
|
|
209
|
+
type Status,
|
|
210
|
+
isValidStatus,
|
|
211
|
+
isValidThread,
|
|
212
|
+
isValidReviewFile,
|
|
213
|
+
} from "../src/protocol/types";
|
|
214
|
+
|
|
215
|
+
describe("Status", () => {
|
|
216
|
+
it("validates known statuses", () => {
|
|
217
|
+
expect(isValidStatus("open")).toBe(true);
|
|
218
|
+
expect(isValidStatus("pending")).toBe(true);
|
|
219
|
+
expect(isValidStatus("resolved")).toBe(true);
|
|
220
|
+
expect(isValidStatus("outdated")).toBe(true);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it("rejects unknown statuses", () => {
|
|
224
|
+
expect(isValidStatus("addressed")).toBe(false);
|
|
225
|
+
expect(isValidStatus("")).toBe(false);
|
|
226
|
+
expect(isValidStatus("OPEN")).toBe(false);
|
|
227
|
+
});
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
describe("isValidThread", () => {
|
|
231
|
+
it("validates a minimal thread", () => {
|
|
232
|
+
const thread: Thread = {
|
|
233
|
+
id: "1",
|
|
234
|
+
line: 12,
|
|
235
|
+
status: "open",
|
|
236
|
+
messages: [{ author: "human", text: "fix this" }],
|
|
237
|
+
};
|
|
238
|
+
expect(isValidThread(thread)).toBe(true);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it("rejects thread without line", () => {
|
|
242
|
+
expect(isValidThread({ id: "1", status: "open", messages: [] })).toBe(false);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it("rejects thread without messages", () => {
|
|
246
|
+
expect(isValidThread({ id: "1", line: 1, status: "open" })).toBe(false);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it("rejects thread with invalid status", () => {
|
|
250
|
+
expect(
|
|
251
|
+
isValidThread({ id: "1", line: 1, status: "bad", messages: [] })
|
|
252
|
+
).toBe(false);
|
|
253
|
+
});
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
describe("isValidReviewFile", () => {
|
|
257
|
+
it("validates a review file", () => {
|
|
258
|
+
const review: ReviewFile = {
|
|
259
|
+
file: "spec.md",
|
|
260
|
+
threads: [
|
|
261
|
+
{
|
|
262
|
+
id: "1",
|
|
263
|
+
line: 1,
|
|
264
|
+
status: "open",
|
|
265
|
+
messages: [{ author: "human", text: "comment" }],
|
|
266
|
+
},
|
|
267
|
+
],
|
|
268
|
+
};
|
|
269
|
+
expect(isValidReviewFile(review)).toBe(true);
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it("validates empty threads", () => {
|
|
273
|
+
expect(isValidReviewFile({ file: "spec.md", threads: [] })).toBe(true);
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
it("rejects missing file field", () => {
|
|
277
|
+
expect(isValidReviewFile({ threads: [] })).toBe(false);
|
|
278
|
+
});
|
|
279
|
+
});
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
- [ ] **Step 2: Run test to verify it fails**
|
|
283
|
+
|
|
284
|
+
Run: `bun test test/protocol/types.test.ts`
|
|
285
|
+
Expected: FAIL — module not found
|
|
286
|
+
|
|
287
|
+
- [ ] **Step 3: Write the implementation**
|
|
288
|
+
|
|
289
|
+
```typescript
|
|
290
|
+
// src/protocol/types.ts
|
|
291
|
+
|
|
292
|
+
export type Status = "open" | "pending" | "resolved" | "outdated";
|
|
293
|
+
|
|
294
|
+
export interface Message {
|
|
295
|
+
author: "human" | "ai";
|
|
296
|
+
text: string;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
export interface Thread {
|
|
300
|
+
id: string;
|
|
301
|
+
line: number;
|
|
302
|
+
status: Status;
|
|
303
|
+
messages: Message[];
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
export interface ReviewFile {
|
|
307
|
+
file: string;
|
|
308
|
+
threads: Thread[];
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// Draft can contain new threads or an approval signal
|
|
312
|
+
export interface DraftFile {
|
|
313
|
+
approved?: boolean;
|
|
314
|
+
threads?: Thread[];
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const VALID_STATUSES: Set<string> = new Set(["open", "pending", "resolved", "outdated"]);
|
|
318
|
+
|
|
319
|
+
export function isValidStatus(status: unknown): status is Status {
|
|
320
|
+
return typeof status === "string" && VALID_STATUSES.has(status);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
export function isValidThread(obj: unknown): obj is Thread {
|
|
324
|
+
if (typeof obj !== "object" || obj === null) return false;
|
|
325
|
+
const t = obj as Record<string, unknown>;
|
|
326
|
+
return (
|
|
327
|
+
typeof t.id === "string" &&
|
|
328
|
+
typeof t.line === "number" &&
|
|
329
|
+
Number.isInteger(t.line) &&
|
|
330
|
+
t.line >= 1 &&
|
|
331
|
+
isValidStatus(t.status) &&
|
|
332
|
+
Array.isArray(t.messages)
|
|
333
|
+
);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
export function isValidReviewFile(obj: unknown): obj is ReviewFile {
|
|
337
|
+
if (typeof obj !== "object" || obj === null) return false;
|
|
338
|
+
const r = obj as Record<string, unknown>;
|
|
339
|
+
return (
|
|
340
|
+
typeof r.file === "string" &&
|
|
341
|
+
Array.isArray(r.threads) &&
|
|
342
|
+
(r.threads as unknown[]).every(isValidThread)
|
|
343
|
+
);
|
|
344
|
+
}
|
|
345
|
+
```
|
|
346
|
+
|
|
347
|
+
- [ ] **Step 4: Run tests to verify they pass**
|
|
348
|
+
|
|
349
|
+
Run: `bun test test/protocol/types.test.ts`
|
|
350
|
+
Expected: All PASS
|
|
351
|
+
|
|
352
|
+
- [ ] **Step 5: Commit**
|
|
353
|
+
|
|
354
|
+
```bash
|
|
355
|
+
git add src/protocol/types.ts test/protocol/types.test.ts
|
|
356
|
+
git commit -m "feat: add protocol types with validation"
|
|
357
|
+
```
|
|
358
|
+
|
|
359
|
+
---
|
|
360
|
+
|
|
361
|
+
### Task 3: Protocol Read/Write
|
|
362
|
+
|
|
363
|
+
**Files:**
|
|
364
|
+
- Create: `src/protocol/read.ts`
|
|
365
|
+
- Create: `src/protocol/write.ts`
|
|
366
|
+
- Create: `test/protocol/read.test.ts`
|
|
367
|
+
- Create: `test/protocol/write.test.ts`
|
|
368
|
+
|
|
369
|
+
- [ ] **Step 1: Write failing tests for read**
|
|
370
|
+
|
|
371
|
+
```typescript
|
|
372
|
+
// test/protocol/read.test.ts
|
|
373
|
+
import { describe, expect, it, beforeEach, afterEach } from "bun:test";
|
|
374
|
+
import { readReviewFile, readDraftFile } from "../src/protocol/read";
|
|
375
|
+
import { mkdtempSync, writeFileSync, rmSync } from "fs";
|
|
376
|
+
import { join } from "path";
|
|
377
|
+
import { tmpdir } from "os";
|
|
378
|
+
|
|
379
|
+
describe("readReviewFile", () => {
|
|
380
|
+
let dir: string;
|
|
381
|
+
|
|
382
|
+
beforeEach(() => {
|
|
383
|
+
dir = mkdtempSync(join(tmpdir(), "revspec-test-"));
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
afterEach(() => {
|
|
387
|
+
rmSync(dir, { recursive: true });
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
it("returns null for missing file", () => {
|
|
391
|
+
expect(readReviewFile(join(dir, "missing.json"))).toBeNull();
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
it("reads a valid review file", () => {
|
|
395
|
+
const path = join(dir, "review.json");
|
|
396
|
+
const data = { file: "spec.md", threads: [] };
|
|
397
|
+
writeFileSync(path, JSON.stringify(data));
|
|
398
|
+
const result = readReviewFile(path);
|
|
399
|
+
expect(result).toEqual(data);
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
it("returns null for invalid JSON", () => {
|
|
403
|
+
const path = join(dir, "bad.json");
|
|
404
|
+
writeFileSync(path, "not json{{{");
|
|
405
|
+
expect(readReviewFile(path)).toBeNull();
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
it("returns null for valid JSON but invalid schema", () => {
|
|
409
|
+
const path = join(dir, "bad-schema.json");
|
|
410
|
+
writeFileSync(path, JSON.stringify({ bad: "data" }));
|
|
411
|
+
expect(readReviewFile(path)).toBeNull();
|
|
412
|
+
});
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
describe("readDraftFile", () => {
|
|
416
|
+
let dir: string;
|
|
417
|
+
|
|
418
|
+
beforeEach(() => {
|
|
419
|
+
dir = mkdtempSync(join(tmpdir(), "revspec-test-"));
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
afterEach(() => {
|
|
423
|
+
rmSync(dir, { recursive: true });
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
it("returns null for missing file", () => {
|
|
427
|
+
expect(readDraftFile(join(dir, "missing.json"))).toBeNull();
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
it("reads an approval draft", () => {
|
|
431
|
+
const path = join(dir, "draft.json");
|
|
432
|
+
writeFileSync(path, JSON.stringify({ approved: true }));
|
|
433
|
+
const result = readDraftFile(path);
|
|
434
|
+
expect(result).toEqual({ approved: true });
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
it("reads a draft with threads", () => {
|
|
438
|
+
const path = join(dir, "draft.json");
|
|
439
|
+
const data = {
|
|
440
|
+
threads: [
|
|
441
|
+
{ id: "1", line: 5, status: "open", messages: [{ author: "human", text: "hi" }] },
|
|
442
|
+
],
|
|
443
|
+
};
|
|
444
|
+
writeFileSync(path, JSON.stringify(data));
|
|
445
|
+
const result = readDraftFile(path);
|
|
446
|
+
expect(result?.threads).toHaveLength(1);
|
|
447
|
+
});
|
|
448
|
+
});
|
|
449
|
+
```
|
|
450
|
+
|
|
451
|
+
- [ ] **Step 2: Run tests to verify they fail**
|
|
452
|
+
|
|
453
|
+
Run: `bun test test/protocol/read.test.ts`
|
|
454
|
+
Expected: FAIL — module not found
|
|
455
|
+
|
|
456
|
+
- [ ] **Step 3: Implement read**
|
|
457
|
+
|
|
458
|
+
```typescript
|
|
459
|
+
// src/protocol/read.ts
|
|
460
|
+
import { existsSync, readFileSync } from "fs";
|
|
461
|
+
import { isValidReviewFile, type ReviewFile, type DraftFile } from "./types";
|
|
462
|
+
|
|
463
|
+
export function readReviewFile(path: string): ReviewFile | null {
|
|
464
|
+
if (!existsSync(path)) return null;
|
|
465
|
+
try {
|
|
466
|
+
const raw = readFileSync(path, "utf-8");
|
|
467
|
+
const parsed = JSON.parse(raw);
|
|
468
|
+
return isValidReviewFile(parsed) ? parsed : null;
|
|
469
|
+
} catch {
|
|
470
|
+
return null;
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
export function readDraftFile(path: string): DraftFile | null {
|
|
475
|
+
if (!existsSync(path)) return null;
|
|
476
|
+
try {
|
|
477
|
+
const raw = readFileSync(path, "utf-8");
|
|
478
|
+
return JSON.parse(raw) as DraftFile;
|
|
479
|
+
} catch {
|
|
480
|
+
return null;
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
```
|
|
484
|
+
|
|
485
|
+
- [ ] **Step 4: Run read tests**
|
|
486
|
+
|
|
487
|
+
Run: `bun test test/protocol/read.test.ts`
|
|
488
|
+
Expected: All PASS
|
|
489
|
+
|
|
490
|
+
- [ ] **Step 5: Write failing tests for write**
|
|
491
|
+
|
|
492
|
+
```typescript
|
|
493
|
+
// test/protocol/write.test.ts
|
|
494
|
+
import { describe, expect, it, beforeEach, afterEach } from "bun:test";
|
|
495
|
+
import { writeReviewFile, writeDraftFile } from "../src/protocol/write";
|
|
496
|
+
import { readFileSync, mkdtempSync, rmSync } from "fs";
|
|
497
|
+
import { join } from "path";
|
|
498
|
+
import { tmpdir } from "os";
|
|
499
|
+
|
|
500
|
+
describe("writeReviewFile", () => {
|
|
501
|
+
let dir: string;
|
|
502
|
+
|
|
503
|
+
beforeEach(() => {
|
|
504
|
+
dir = mkdtempSync(join(tmpdir(), "revspec-test-"));
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
afterEach(() => {
|
|
508
|
+
rmSync(dir, { recursive: true });
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
it("writes valid JSON", () => {
|
|
512
|
+
const path = join(dir, "review.json");
|
|
513
|
+
writeReviewFile(path, { file: "spec.md", threads: [] });
|
|
514
|
+
const raw = readFileSync(path, "utf-8");
|
|
515
|
+
expect(JSON.parse(raw)).toEqual({ file: "spec.md", threads: [] });
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
it("writes pretty-printed JSON", () => {
|
|
519
|
+
const path = join(dir, "review.json");
|
|
520
|
+
writeReviewFile(path, { file: "spec.md", threads: [] });
|
|
521
|
+
const raw = readFileSync(path, "utf-8");
|
|
522
|
+
expect(raw).toContain("\n");
|
|
523
|
+
});
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
describe("writeDraftFile", () => {
|
|
527
|
+
let dir: string;
|
|
528
|
+
|
|
529
|
+
beforeEach(() => {
|
|
530
|
+
dir = mkdtempSync(join(tmpdir(), "revspec-test-"));
|
|
531
|
+
});
|
|
532
|
+
|
|
533
|
+
afterEach(() => {
|
|
534
|
+
rmSync(dir, { recursive: true });
|
|
535
|
+
});
|
|
536
|
+
|
|
537
|
+
it("writes approval draft", () => {
|
|
538
|
+
const path = join(dir, "draft.json");
|
|
539
|
+
writeDraftFile(path, { approved: true });
|
|
540
|
+
const raw = readFileSync(path, "utf-8");
|
|
541
|
+
expect(JSON.parse(raw)).toEqual({ approved: true });
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
it("writes draft with threads", () => {
|
|
545
|
+
const path = join(dir, "draft.json");
|
|
546
|
+
const data = {
|
|
547
|
+
threads: [
|
|
548
|
+
{ id: "1", line: 5, status: "open" as const, messages: [{ author: "human" as const, text: "hi" }] },
|
|
549
|
+
],
|
|
550
|
+
};
|
|
551
|
+
writeDraftFile(path, data);
|
|
552
|
+
const raw = readFileSync(path, "utf-8");
|
|
553
|
+
expect(JSON.parse(raw).threads).toHaveLength(1);
|
|
554
|
+
});
|
|
555
|
+
});
|
|
556
|
+
```
|
|
557
|
+
|
|
558
|
+
- [ ] **Step 6: Run write tests to verify they fail**
|
|
559
|
+
|
|
560
|
+
Run: `bun test test/protocol/write.test.ts`
|
|
561
|
+
Expected: FAIL — module not found
|
|
562
|
+
|
|
563
|
+
- [ ] **Step 7: Implement write**
|
|
564
|
+
|
|
565
|
+
```typescript
|
|
566
|
+
// src/protocol/write.ts
|
|
567
|
+
import { writeFileSync } from "fs";
|
|
568
|
+
import type { ReviewFile, DraftFile } from "./types";
|
|
569
|
+
|
|
570
|
+
export function writeReviewFile(path: string, review: ReviewFile): void {
|
|
571
|
+
writeFileSync(path, JSON.stringify(review, null, 2) + "\n");
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
export function writeDraftFile(path: string, draft: DraftFile): void {
|
|
575
|
+
writeFileSync(path, JSON.stringify(draft, null, 2) + "\n");
|
|
576
|
+
}
|
|
577
|
+
```
|
|
578
|
+
|
|
579
|
+
- [ ] **Step 8: Run all write tests**
|
|
580
|
+
|
|
581
|
+
Run: `bun test test/protocol/write.test.ts`
|
|
582
|
+
Expected: All PASS
|
|
583
|
+
|
|
584
|
+
- [ ] **Step 9: Commit**
|
|
585
|
+
|
|
586
|
+
```bash
|
|
587
|
+
git add src/protocol/read.ts src/protocol/write.ts test/protocol/read.test.ts test/protocol/write.test.ts
|
|
588
|
+
git commit -m "feat: add protocol read/write with validation"
|
|
589
|
+
```
|
|
590
|
+
|
|
591
|
+
---
|
|
592
|
+
|
|
593
|
+
### Task 4: Protocol Merge
|
|
594
|
+
|
|
595
|
+
**Files:**
|
|
596
|
+
- Create: `src/protocol/merge.ts`
|
|
597
|
+
- Create: `test/protocol/merge.test.ts`
|
|
598
|
+
|
|
599
|
+
- [ ] **Step 1: Write failing tests**
|
|
600
|
+
|
|
601
|
+
```typescript
|
|
602
|
+
// test/protocol/merge.test.ts
|
|
603
|
+
import { describe, expect, it } from "bun:test";
|
|
604
|
+
import { mergeDraftIntoReview } from "../src/protocol/merge";
|
|
605
|
+
import type { ReviewFile, DraftFile } from "../src/protocol/types";
|
|
606
|
+
|
|
607
|
+
describe("mergeDraftIntoReview", () => {
|
|
608
|
+
it("adds new threads from draft to empty review", () => {
|
|
609
|
+
const review: ReviewFile = { file: "spec.md", threads: [] };
|
|
610
|
+
const draft: DraftFile = {
|
|
611
|
+
threads: [
|
|
612
|
+
{ id: "1", line: 5, status: "open", messages: [{ author: "human", text: "fix" }] },
|
|
613
|
+
],
|
|
614
|
+
};
|
|
615
|
+
const result = mergeDraftIntoReview(review, draft);
|
|
616
|
+
expect(result.threads).toHaveLength(1);
|
|
617
|
+
expect(result.threads[0].id).toBe("1");
|
|
618
|
+
});
|
|
619
|
+
|
|
620
|
+
it("appends messages to existing thread", () => {
|
|
621
|
+
const review: ReviewFile = {
|
|
622
|
+
file: "spec.md",
|
|
623
|
+
threads: [
|
|
624
|
+
{
|
|
625
|
+
id: "1",
|
|
626
|
+
line: 5,
|
|
627
|
+
status: "pending",
|
|
628
|
+
messages: [
|
|
629
|
+
{ author: "human", text: "fix" },
|
|
630
|
+
{ author: "ai", text: "done" },
|
|
631
|
+
],
|
|
632
|
+
},
|
|
633
|
+
],
|
|
634
|
+
};
|
|
635
|
+
const draft: DraftFile = {
|
|
636
|
+
threads: [
|
|
637
|
+
{
|
|
638
|
+
id: "1",
|
|
639
|
+
line: 5,
|
|
640
|
+
status: "open",
|
|
641
|
+
messages: [
|
|
642
|
+
{ author: "human", text: "fix" },
|
|
643
|
+
{ author: "ai", text: "done" },
|
|
644
|
+
{ author: "human", text: "not quite, try again" },
|
|
645
|
+
],
|
|
646
|
+
},
|
|
647
|
+
],
|
|
648
|
+
};
|
|
649
|
+
const result = mergeDraftIntoReview(review, draft);
|
|
650
|
+
expect(result.threads).toHaveLength(1);
|
|
651
|
+
expect(result.threads[0].messages).toHaveLength(3);
|
|
652
|
+
expect(result.threads[0].status).toBe("open");
|
|
653
|
+
});
|
|
654
|
+
|
|
655
|
+
it("handles mix of new and existing threads", () => {
|
|
656
|
+
const review: ReviewFile = {
|
|
657
|
+
file: "spec.md",
|
|
658
|
+
threads: [
|
|
659
|
+
{ id: "1", line: 5, status: "resolved", messages: [{ author: "human", text: "ok" }] },
|
|
660
|
+
],
|
|
661
|
+
};
|
|
662
|
+
const draft: DraftFile = {
|
|
663
|
+
threads: [
|
|
664
|
+
{ id: "2", line: 10, status: "open", messages: [{ author: "human", text: "new" }] },
|
|
665
|
+
],
|
|
666
|
+
};
|
|
667
|
+
const result = mergeDraftIntoReview(review, draft);
|
|
668
|
+
expect(result.threads).toHaveLength(2);
|
|
669
|
+
});
|
|
670
|
+
|
|
671
|
+
it("returns review unchanged if draft has no threads", () => {
|
|
672
|
+
const review: ReviewFile = { file: "spec.md", threads: [] };
|
|
673
|
+
const draft: DraftFile = {};
|
|
674
|
+
const result = mergeDraftIntoReview(review, draft);
|
|
675
|
+
expect(result.threads).toHaveLength(0);
|
|
676
|
+
});
|
|
677
|
+
|
|
678
|
+
it("creates new review if none exists", () => {
|
|
679
|
+
const draft: DraftFile = {
|
|
680
|
+
threads: [
|
|
681
|
+
{ id: "1", line: 1, status: "open", messages: [{ author: "human", text: "hi" }] },
|
|
682
|
+
],
|
|
683
|
+
};
|
|
684
|
+
const result = mergeDraftIntoReview(null, draft, "spec.md");
|
|
685
|
+
expect(result.threads).toHaveLength(1);
|
|
686
|
+
expect(result.file).toBe("spec.md");
|
|
687
|
+
});
|
|
688
|
+
});
|
|
689
|
+
```
|
|
690
|
+
|
|
691
|
+
- [ ] **Step 2: Run tests to verify they fail**
|
|
692
|
+
|
|
693
|
+
Run: `bun test test/protocol/merge.test.ts`
|
|
694
|
+
Expected: FAIL — module not found
|
|
695
|
+
|
|
696
|
+
- [ ] **Step 3: Implement merge**
|
|
697
|
+
|
|
698
|
+
```typescript
|
|
699
|
+
// src/protocol/merge.ts
|
|
700
|
+
import type { ReviewFile, DraftFile } from "./types";
|
|
701
|
+
|
|
702
|
+
export function mergeDraftIntoReview(
|
|
703
|
+
review: ReviewFile | null,
|
|
704
|
+
draft: DraftFile,
|
|
705
|
+
specFile: string = ""
|
|
706
|
+
): ReviewFile {
|
|
707
|
+
const base: ReviewFile = review ?? { file: specFile, threads: [] };
|
|
708
|
+
|
|
709
|
+
if (!draft.threads || draft.threads.length === 0) {
|
|
710
|
+
return base;
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
const threadMap = new Map(base.threads.map((t) => [t.id, t]));
|
|
714
|
+
|
|
715
|
+
for (const draftThread of draft.threads) {
|
|
716
|
+
const existing = threadMap.get(draftThread.id);
|
|
717
|
+
if (existing) {
|
|
718
|
+
// Append only new messages (draft contains full thread history)
|
|
719
|
+
const newMessages = draftThread.messages.slice(existing.messages.length);
|
|
720
|
+
existing.messages.push(...newMessages);
|
|
721
|
+
existing.status = draftThread.status;
|
|
722
|
+
} else {
|
|
723
|
+
threadMap.set(draftThread.id, { ...draftThread });
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
return {
|
|
728
|
+
file: base.file,
|
|
729
|
+
threads: Array.from(threadMap.values()),
|
|
730
|
+
};
|
|
731
|
+
}
|
|
732
|
+
```
|
|
733
|
+
|
|
734
|
+
- [ ] **Step 4: Run tests**
|
|
735
|
+
|
|
736
|
+
Run: `bun test test/protocol/merge.test.ts`
|
|
737
|
+
Expected: All PASS
|
|
738
|
+
|
|
739
|
+
- [ ] **Step 5: Commit**
|
|
740
|
+
|
|
741
|
+
```bash
|
|
742
|
+
git add src/protocol/merge.ts test/protocol/merge.test.ts
|
|
743
|
+
git commit -m "feat: add draft-to-review merge logic"
|
|
744
|
+
```
|
|
745
|
+
|
|
746
|
+
---
|
|
747
|
+
|
|
748
|
+
## Chunk 2: CLI Entry Point
|
|
749
|
+
|
|
750
|
+
### Task 5: CLI Argument Parsing + File Lifecycle
|
|
751
|
+
|
|
752
|
+
**Files:**
|
|
753
|
+
- Modify: `bin/revspec.ts`
|
|
754
|
+
- Create: `test/cli.test.ts`
|
|
755
|
+
|
|
756
|
+
- [ ] **Step 1: Write failing integration tests**
|
|
757
|
+
|
|
758
|
+
```typescript
|
|
759
|
+
// test/cli.test.ts
|
|
760
|
+
import { describe, expect, it, beforeEach, afterEach } from "bun:test";
|
|
761
|
+
import { mkdtempSync, writeFileSync, readFileSync, rmSync, existsSync } from "fs";
|
|
762
|
+
import { join } from "path";
|
|
763
|
+
import { tmpdir } from "os";
|
|
764
|
+
|
|
765
|
+
describe("CLI", () => {
|
|
766
|
+
let dir: string;
|
|
767
|
+
|
|
768
|
+
beforeEach(() => {
|
|
769
|
+
dir = mkdtempSync(join(tmpdir(), "revspec-cli-"));
|
|
770
|
+
});
|
|
771
|
+
|
|
772
|
+
afterEach(() => {
|
|
773
|
+
rmSync(dir, { recursive: true });
|
|
774
|
+
});
|
|
775
|
+
|
|
776
|
+
it("exits 1 for missing spec file", async () => {
|
|
777
|
+
const proc = Bun.spawn(["bun", "run", "bin/revspec.ts", join(dir, "missing.md")], {
|
|
778
|
+
stdout: "pipe",
|
|
779
|
+
stderr: "pipe",
|
|
780
|
+
env: { ...process.env, REVSPEC_SKIP_TUI: "1" },
|
|
781
|
+
});
|
|
782
|
+
const exitCode = await proc.exited;
|
|
783
|
+
expect(exitCode).toBe(1);
|
|
784
|
+
});
|
|
785
|
+
|
|
786
|
+
it("exits 0 with no output when no review file exists", async () => {
|
|
787
|
+
const specPath = join(dir, "spec.md");
|
|
788
|
+
writeFileSync(specPath, "# Test Spec\n");
|
|
789
|
+
const proc = Bun.spawn(["bun", "run", "bin/revspec.ts", specPath], {
|
|
790
|
+
stdout: "pipe",
|
|
791
|
+
stderr: "pipe",
|
|
792
|
+
env: { ...process.env, REVSPEC_SKIP_TUI: "1" },
|
|
793
|
+
});
|
|
794
|
+
const exitCode = await proc.exited;
|
|
795
|
+
const stdout = await new Response(proc.stdout).text();
|
|
796
|
+
expect(exitCode).toBe(0);
|
|
797
|
+
expect(stdout.trim()).toBe("");
|
|
798
|
+
});
|
|
799
|
+
|
|
800
|
+
it("outputs APPROVED when draft has approved flag", async () => {
|
|
801
|
+
const specPath = join(dir, "spec.md");
|
|
802
|
+
const reviewPath = join(dir, "spec.review.json");
|
|
803
|
+
const draftPath = join(dir, "spec.review.draft.json");
|
|
804
|
+
writeFileSync(specPath, "# Test Spec\n");
|
|
805
|
+
writeFileSync(reviewPath, JSON.stringify({ file: specPath, threads: [] }));
|
|
806
|
+
writeFileSync(draftPath, JSON.stringify({ approved: true }));
|
|
807
|
+
const proc = Bun.spawn(["bun", "run", "bin/revspec.ts", specPath], {
|
|
808
|
+
stdout: "pipe",
|
|
809
|
+
stderr: "pipe",
|
|
810
|
+
env: { ...process.env, REVSPEC_SKIP_TUI: "1" },
|
|
811
|
+
});
|
|
812
|
+
const exitCode = await proc.exited;
|
|
813
|
+
const stdout = await new Response(proc.stdout).text();
|
|
814
|
+
expect(exitCode).toBe(0);
|
|
815
|
+
expect(stdout.trim()).toStartWith("APPROVED:");
|
|
816
|
+
});
|
|
817
|
+
|
|
818
|
+
it("merges draft into review file", async () => {
|
|
819
|
+
const specPath = join(dir, "spec.md");
|
|
820
|
+
const reviewPath = join(dir, "spec.review.json");
|
|
821
|
+
const draftPath = join(dir, "spec.review.draft.json");
|
|
822
|
+
writeFileSync(specPath, "# Test Spec\n");
|
|
823
|
+
writeFileSync(reviewPath, JSON.stringify({ file: specPath, threads: [] }));
|
|
824
|
+
writeFileSync(
|
|
825
|
+
draftPath,
|
|
826
|
+
JSON.stringify({
|
|
827
|
+
threads: [{ id: "1", line: 1, status: "open", messages: [{ author: "human", text: "hi" }] }],
|
|
828
|
+
})
|
|
829
|
+
);
|
|
830
|
+
const proc = Bun.spawn(["bun", "run", "bin/revspec.ts", specPath], {
|
|
831
|
+
stdout: "pipe",
|
|
832
|
+
stderr: "pipe",
|
|
833
|
+
env: { ...process.env, REVSPEC_SKIP_TUI: "1" },
|
|
834
|
+
});
|
|
835
|
+
await proc.exited;
|
|
836
|
+
expect(existsSync(draftPath)).toBe(false);
|
|
837
|
+
const review = JSON.parse(readFileSync(reviewPath, "utf-8"));
|
|
838
|
+
expect(review.threads).toHaveLength(1);
|
|
839
|
+
});
|
|
840
|
+
|
|
841
|
+
it("warns and deletes corrupted draft file", async () => {
|
|
842
|
+
const specPath = join(dir, "spec.md");
|
|
843
|
+
const draftPath = join(dir, "spec.review.draft.json");
|
|
844
|
+
writeFileSync(specPath, "# Test Spec\n");
|
|
845
|
+
writeFileSync(draftPath, "not valid json{{{");
|
|
846
|
+
const proc = Bun.spawn(["bun", "run", "bin/revspec.ts", specPath], {
|
|
847
|
+
stdout: "pipe",
|
|
848
|
+
stderr: "pipe",
|
|
849
|
+
env: { ...process.env, REVSPEC_SKIP_TUI: "1" },
|
|
850
|
+
});
|
|
851
|
+
await proc.exited;
|
|
852
|
+
const stderr = await new Response(proc.stderr).text();
|
|
853
|
+
expect(stderr).toContain("corrupted");
|
|
854
|
+
expect(existsSync(draftPath)).toBe(false);
|
|
855
|
+
});
|
|
856
|
+
|
|
857
|
+
it("prints nothing when human adds no comments (no prior review)", async () => {
|
|
858
|
+
const specPath = join(dir, "spec.md");
|
|
859
|
+
writeFileSync(specPath, "# Test Spec\n");
|
|
860
|
+
const proc = Bun.spawn(["bun", "run", "bin/revspec.ts", specPath], {
|
|
861
|
+
stdout: "pipe",
|
|
862
|
+
stderr: "pipe",
|
|
863
|
+
env: { ...process.env, REVSPEC_SKIP_TUI: "1" },
|
|
864
|
+
});
|
|
865
|
+
const exitCode = await proc.exited;
|
|
866
|
+
const stdout = await new Response(proc.stdout).text();
|
|
867
|
+
expect(exitCode).toBe(0);
|
|
868
|
+
expect(stdout.trim()).toBe("");
|
|
869
|
+
});
|
|
870
|
+
});
|
|
871
|
+
```
|
|
872
|
+
|
|
873
|
+
- [ ] **Step 2: Run tests to verify they fail**
|
|
874
|
+
|
|
875
|
+
Run: `bun test test/cli.test.ts`
|
|
876
|
+
Expected: FAIL
|
|
877
|
+
|
|
878
|
+
- [ ] **Step 3: Implement the full CLI**
|
|
879
|
+
|
|
880
|
+
```typescript
|
|
881
|
+
// bin/revspec.ts
|
|
882
|
+
#!/usr/bin/env bun
|
|
883
|
+
import { existsSync, unlinkSync } from "fs";
|
|
884
|
+
import { resolve, dirname, basename } from "path";
|
|
885
|
+
import { readReviewFile, readDraftFile } from "../src/protocol/read";
|
|
886
|
+
import { writeReviewFile } from "../src/protocol/write";
|
|
887
|
+
import { mergeDraftIntoReview } from "../src/protocol/merge";
|
|
888
|
+
|
|
889
|
+
const args = process.argv.slice(2);
|
|
890
|
+
|
|
891
|
+
if (args.length === 0 || args.includes("--help") || args.includes("-h")) {
|
|
892
|
+
console.log("Usage: revspec <file.md> [--tui|--nvim|--web]");
|
|
893
|
+
process.exit(0);
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
const specFile = resolve(args.find((a) => !a.startsWith("--"))!);
|
|
897
|
+
|
|
898
|
+
// Step 1: Validate spec file
|
|
899
|
+
if (!existsSync(specFile)) {
|
|
900
|
+
console.error(`Error: File not found: ${specFile}`);
|
|
901
|
+
process.exit(1);
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
// Derive paths
|
|
905
|
+
const dir = dirname(specFile);
|
|
906
|
+
const base = basename(specFile, ".md");
|
|
907
|
+
const reviewPath = resolve(dir, `${base}.review.json`);
|
|
908
|
+
const draftPath = resolve(dir, `${base}.review.draft.json`);
|
|
909
|
+
|
|
910
|
+
// Step 2: Check for existing draft (resume or corrupted)
|
|
911
|
+
let existingDraft = readDraftFile(draftPath);
|
|
912
|
+
if (existsSync(draftPath) && existingDraft === null) {
|
|
913
|
+
console.error("Warning: Draft file corrupted, starting fresh");
|
|
914
|
+
unlinkSync(draftPath);
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
// Step 3: Launch TUI (unless REVSPEC_SKIP_TUI is set for testing)
|
|
918
|
+
if (!process.env.REVSPEC_SKIP_TUI) {
|
|
919
|
+
// TUI will be implemented in Chunk 3
|
|
920
|
+
const { runTui } = await import("../src/tui/app");
|
|
921
|
+
await runTui(specFile, reviewPath, draftPath);
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
// Step 5: Read draft and process
|
|
925
|
+
const draft = readDraftFile(draftPath);
|
|
926
|
+
|
|
927
|
+
if (draft?.approved) {
|
|
928
|
+
// Approval — clean up draft, output APPROVED
|
|
929
|
+
if (existsSync(draftPath)) unlinkSync(draftPath);
|
|
930
|
+
console.log(`APPROVED: ${reviewPath}`);
|
|
931
|
+
process.exit(0);
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
let hasNewComments = false;
|
|
935
|
+
if (draft?.threads && draft.threads.length > 0) {
|
|
936
|
+
// Merge draft into review
|
|
937
|
+
const review = readReviewFile(reviewPath);
|
|
938
|
+
const merged = mergeDraftIntoReview(review, draft, specFile);
|
|
939
|
+
writeReviewFile(reviewPath, merged);
|
|
940
|
+
if (existsSync(draftPath)) unlinkSync(draftPath);
|
|
941
|
+
hasNewComments = true;
|
|
942
|
+
} else {
|
|
943
|
+
if (existsSync(draftPath)) unlinkSync(draftPath);
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
// Step 6: Output — only print path if there are actionable threads
|
|
947
|
+
if (hasNewComments || (existsSync(reviewPath) && readReviewFile(reviewPath)?.threads.some(
|
|
948
|
+
(t) => t.status === "open" || t.status === "pending"
|
|
949
|
+
))) {
|
|
950
|
+
console.log(reviewPath);
|
|
951
|
+
}
|
|
952
|
+
// Otherwise: no output — human closed without commenting or all threads resolved
|
|
953
|
+
|
|
954
|
+
process.exit(0);
|
|
955
|
+
```
|
|
956
|
+
|
|
957
|
+
- [ ] **Step 4: Create a stub TUI module**
|
|
958
|
+
|
|
959
|
+
```typescript
|
|
960
|
+
// src/tui/app.ts
|
|
961
|
+
export async function runTui(
|
|
962
|
+
specFile: string,
|
|
963
|
+
reviewPath: string,
|
|
964
|
+
draftPath: string
|
|
965
|
+
): Promise<void> {
|
|
966
|
+
// Stub — will be implemented in Chunk 3
|
|
967
|
+
console.log("TUI not yet implemented");
|
|
968
|
+
}
|
|
969
|
+
```
|
|
970
|
+
|
|
971
|
+
- [ ] **Step 5: Run CLI tests**
|
|
972
|
+
|
|
973
|
+
Run: `bun test test/cli.test.ts`
|
|
974
|
+
Expected: All PASS
|
|
975
|
+
|
|
976
|
+
- [ ] **Step 6: Run all tests**
|
|
977
|
+
|
|
978
|
+
Run: `bun test`
|
|
979
|
+
Expected: All PASS
|
|
980
|
+
|
|
981
|
+
- [ ] **Step 7: Commit**
|
|
982
|
+
|
|
983
|
+
```bash
|
|
984
|
+
git add bin/revspec.ts src/tui/app.ts test/cli.test.ts
|
|
985
|
+
git commit -m "feat: implement CLI entry point with file lifecycle"
|
|
986
|
+
```
|
|
987
|
+
|
|
988
|
+
---
|
|
989
|
+
|
|
990
|
+
## Chunk 3: TUI — State Management
|
|
991
|
+
|
|
992
|
+
### Task 6: Review State
|
|
993
|
+
|
|
994
|
+
**Files:**
|
|
995
|
+
- Create: `src/state/review-state.ts`
|
|
996
|
+
- Create: `test/state/review-state.test.ts`
|
|
997
|
+
|
|
998
|
+
- [ ] **Step 1: Write failing tests**
|
|
999
|
+
|
|
1000
|
+
```typescript
|
|
1001
|
+
// test/state/review-state.test.ts
|
|
1002
|
+
import { describe, expect, it } from "bun:test";
|
|
1003
|
+
import { ReviewState } from "../src/state/review-state";
|
|
1004
|
+
|
|
1005
|
+
describe("ReviewState", () => {
|
|
1006
|
+
const specLines = ["# Title", "", "## Section", "Some content", "More content"];
|
|
1007
|
+
|
|
1008
|
+
it("initializes with spec lines and no threads", () => {
|
|
1009
|
+
const state = new ReviewState(specLines, []);
|
|
1010
|
+
expect(state.lineCount).toBe(5);
|
|
1011
|
+
expect(state.threads).toHaveLength(0);
|
|
1012
|
+
expect(state.cursorLine).toBe(1);
|
|
1013
|
+
});
|
|
1014
|
+
|
|
1015
|
+
it("adds a new thread", () => {
|
|
1016
|
+
const state = new ReviewState(specLines, []);
|
|
1017
|
+
state.addComment(3, "needs more detail");
|
|
1018
|
+
expect(state.threads).toHaveLength(1);
|
|
1019
|
+
expect(state.threads[0].line).toBe(3);
|
|
1020
|
+
expect(state.threads[0].status).toBe("open");
|
|
1021
|
+
expect(state.threads[0].messages[0].text).toBe("needs more detail");
|
|
1022
|
+
});
|
|
1023
|
+
|
|
1024
|
+
it("replies to existing thread (flips to open)", () => {
|
|
1025
|
+
const state = new ReviewState(specLines, [
|
|
1026
|
+
{
|
|
1027
|
+
id: "1",
|
|
1028
|
+
line: 3,
|
|
1029
|
+
status: "pending",
|
|
1030
|
+
messages: [
|
|
1031
|
+
{ author: "human", text: "fix" },
|
|
1032
|
+
{ author: "ai", text: "done" },
|
|
1033
|
+
],
|
|
1034
|
+
},
|
|
1035
|
+
]);
|
|
1036
|
+
state.replyToThread("1", "not quite");
|
|
1037
|
+
expect(state.threads[0].messages).toHaveLength(3);
|
|
1038
|
+
expect(state.threads[0].status).toBe("open");
|
|
1039
|
+
});
|
|
1040
|
+
|
|
1041
|
+
it("resolves a thread", () => {
|
|
1042
|
+
const state = new ReviewState(specLines, [
|
|
1043
|
+
{ id: "1", line: 3, status: "pending", messages: [{ author: "human", text: "fix" }] },
|
|
1044
|
+
]);
|
|
1045
|
+
state.resolveThread("1");
|
|
1046
|
+
expect(state.threads[0].status).toBe("resolved");
|
|
1047
|
+
});
|
|
1048
|
+
|
|
1049
|
+
it("batch resolves all pending threads", () => {
|
|
1050
|
+
const state = new ReviewState(specLines, [
|
|
1051
|
+
{ id: "1", line: 1, status: "pending", messages: [{ author: "human", text: "a" }] },
|
|
1052
|
+
{ id: "2", line: 2, status: "pending", messages: [{ author: "human", text: "b" }] },
|
|
1053
|
+
{ id: "3", line: 3, status: "open", messages: [{ author: "human", text: "c" }] },
|
|
1054
|
+
]);
|
|
1055
|
+
state.resolveAllPending();
|
|
1056
|
+
expect(state.threads[0].status).toBe("resolved");
|
|
1057
|
+
expect(state.threads[1].status).toBe("resolved");
|
|
1058
|
+
expect(state.threads[2].status).toBe("open");
|
|
1059
|
+
});
|
|
1060
|
+
|
|
1061
|
+
it("gets thread at line", () => {
|
|
1062
|
+
const state = new ReviewState(specLines, [
|
|
1063
|
+
{ id: "1", line: 3, status: "open", messages: [{ author: "human", text: "hi" }] },
|
|
1064
|
+
]);
|
|
1065
|
+
expect(state.threadAtLine(3)?.id).toBe("1");
|
|
1066
|
+
expect(state.threadAtLine(4)).toBeNull();
|
|
1067
|
+
});
|
|
1068
|
+
|
|
1069
|
+
it("navigates to next/prev open thread", () => {
|
|
1070
|
+
const state = new ReviewState(specLines, [
|
|
1071
|
+
{ id: "1", line: 1, status: "resolved", messages: [{ author: "human", text: "a" }] },
|
|
1072
|
+
{ id: "2", line: 3, status: "open", messages: [{ author: "human", text: "b" }] },
|
|
1073
|
+
{ id: "3", line: 5, status: "pending", messages: [{ author: "human", text: "c" }] },
|
|
1074
|
+
]);
|
|
1075
|
+
state.cursorLine = 1;
|
|
1076
|
+
const next = state.nextActiveThread();
|
|
1077
|
+
expect(next).toBe(3);
|
|
1078
|
+
state.cursorLine = 3;
|
|
1079
|
+
const next2 = state.nextActiveThread();
|
|
1080
|
+
expect(next2).toBe(5);
|
|
1081
|
+
});
|
|
1082
|
+
|
|
1083
|
+
it("canApprove returns true when all resolved/outdated", () => {
|
|
1084
|
+
const state = new ReviewState(specLines, [
|
|
1085
|
+
{ id: "1", line: 1, status: "resolved", messages: [{ author: "human", text: "a" }] },
|
|
1086
|
+
{ id: "2", line: 3, status: "outdated", messages: [{ author: "human", text: "b" }] },
|
|
1087
|
+
]);
|
|
1088
|
+
expect(state.canApprove()).toBe(true);
|
|
1089
|
+
});
|
|
1090
|
+
|
|
1091
|
+
it("canApprove returns false with open threads", () => {
|
|
1092
|
+
const state = new ReviewState(specLines, [
|
|
1093
|
+
{ id: "1", line: 1, status: "open", messages: [{ author: "human", text: "a" }] },
|
|
1094
|
+
]);
|
|
1095
|
+
expect(state.canApprove()).toBe(false);
|
|
1096
|
+
});
|
|
1097
|
+
|
|
1098
|
+
it("canApprove returns false with no threads (prevents empty approval)", () => {
|
|
1099
|
+
const state = new ReviewState(specLines, []);
|
|
1100
|
+
expect(state.canApprove()).toBe(false);
|
|
1101
|
+
});
|
|
1102
|
+
|
|
1103
|
+
it("generates next thread id", () => {
|
|
1104
|
+
const state = new ReviewState(specLines, [
|
|
1105
|
+
{ id: "3", line: 1, status: "open", messages: [{ author: "human", text: "a" }] },
|
|
1106
|
+
]);
|
|
1107
|
+
expect(state.nextThreadId()).toBe("4");
|
|
1108
|
+
});
|
|
1109
|
+
|
|
1110
|
+
it("deletes most recent human draft message", () => {
|
|
1111
|
+
const state = new ReviewState(specLines, []);
|
|
1112
|
+
state.addComment(3, "first");
|
|
1113
|
+
state.deleteLastDraftMessage("1");
|
|
1114
|
+
expect(state.threads).toHaveLength(0);
|
|
1115
|
+
});
|
|
1116
|
+
});
|
|
1117
|
+
```
|
|
1118
|
+
|
|
1119
|
+
- [ ] **Step 2: Run tests to verify they fail**
|
|
1120
|
+
|
|
1121
|
+
Run: `bun test test/state/review-state.test.ts`
|
|
1122
|
+
Expected: FAIL
|
|
1123
|
+
|
|
1124
|
+
- [ ] **Step 3: Implement ReviewState**
|
|
1125
|
+
|
|
1126
|
+
```typescript
|
|
1127
|
+
// src/state/review-state.ts
|
|
1128
|
+
import type { Thread, Message } from "../protocol/types";
|
|
1129
|
+
|
|
1130
|
+
export class ReviewState {
|
|
1131
|
+
specLines: string[];
|
|
1132
|
+
threads: Thread[];
|
|
1133
|
+
cursorLine: number = 1;
|
|
1134
|
+
private draftThreadIds: Set<string> = new Set();
|
|
1135
|
+
|
|
1136
|
+
constructor(specLines: string[], threads: Thread[]) {
|
|
1137
|
+
this.specLines = specLines;
|
|
1138
|
+
this.threads = [...threads];
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
get lineCount(): number {
|
|
1142
|
+
return this.specLines.length;
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
nextThreadId(): string {
|
|
1146
|
+
const maxId = this.threads.reduce(
|
|
1147
|
+
(max, t) => Math.max(max, parseInt(t.id, 10) || 0),
|
|
1148
|
+
0
|
|
1149
|
+
);
|
|
1150
|
+
return String(maxId + 1);
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
addComment(line: number, text: string): void {
|
|
1154
|
+
const id = this.nextThreadId();
|
|
1155
|
+
this.threads.push({
|
|
1156
|
+
id,
|
|
1157
|
+
line,
|
|
1158
|
+
status: "open",
|
|
1159
|
+
messages: [{ author: "human", text }],
|
|
1160
|
+
});
|
|
1161
|
+
this.draftThreadIds.add(id);
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
replyToThread(threadId: string, text: string): void {
|
|
1165
|
+
const thread = this.threads.find((t) => t.id === threadId);
|
|
1166
|
+
if (!thread) return;
|
|
1167
|
+
thread.messages.push({ author: "human", text });
|
|
1168
|
+
thread.status = "open";
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
resolveThread(threadId: string): void {
|
|
1172
|
+
const thread = this.threads.find((t) => t.id === threadId);
|
|
1173
|
+
if (!thread) return;
|
|
1174
|
+
thread.status = "resolved";
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
resolveAllPending(): void {
|
|
1178
|
+
for (const thread of this.threads) {
|
|
1179
|
+
if (thread.status === "pending") {
|
|
1180
|
+
thread.status = "resolved";
|
|
1181
|
+
}
|
|
1182
|
+
}
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
threadAtLine(line: number): Thread | null {
|
|
1186
|
+
return this.threads.find((t) => t.line === line) ?? null;
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
nextActiveThread(): number | null {
|
|
1190
|
+
const active = this.threads
|
|
1191
|
+
.filter((t) => t.status === "open" || t.status === "pending")
|
|
1192
|
+
.sort((a, b) => a.line - b.line);
|
|
1193
|
+
const next = active.find((t) => t.line > this.cursorLine);
|
|
1194
|
+
return next?.line ?? active[0]?.line ?? null;
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
prevActiveThread(): number | null {
|
|
1198
|
+
const active = this.threads
|
|
1199
|
+
.filter((t) => t.status === "open" || t.status === "pending")
|
|
1200
|
+
.sort((a, b) => b.line - a.line);
|
|
1201
|
+
const prev = active.find((t) => t.line < this.cursorLine);
|
|
1202
|
+
return prev?.line ?? active[0]?.line ?? null;
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
canApprove(): boolean {
|
|
1206
|
+
// Must have at least one thread to approve (prevents approving without review)
|
|
1207
|
+
if (this.threads.length === 0) return false;
|
|
1208
|
+
return this.threads.every(
|
|
1209
|
+
(t) => t.status === "resolved" || t.status === "outdated"
|
|
1210
|
+
);
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
activeThreadCount(): { open: number; pending: number } {
|
|
1214
|
+
let open = 0;
|
|
1215
|
+
let pending = 0;
|
|
1216
|
+
for (const t of this.threads) {
|
|
1217
|
+
if (t.status === "open") open++;
|
|
1218
|
+
if (t.status === "pending") pending++;
|
|
1219
|
+
}
|
|
1220
|
+
return { open, pending };
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
deleteLastDraftMessage(threadId: string): void {
|
|
1224
|
+
const thread = this.threads.find((t) => t.id === threadId);
|
|
1225
|
+
if (!thread) return;
|
|
1226
|
+
// Remove last human message
|
|
1227
|
+
for (let i = thread.messages.length - 1; i >= 0; i--) {
|
|
1228
|
+
if (thread.messages[i].author === "human") {
|
|
1229
|
+
thread.messages.splice(i, 1);
|
|
1230
|
+
break;
|
|
1231
|
+
}
|
|
1232
|
+
}
|
|
1233
|
+
// If no messages left, remove the thread
|
|
1234
|
+
if (thread.messages.length === 0) {
|
|
1235
|
+
this.threads = this.threads.filter((t) => t.id !== threadId);
|
|
1236
|
+
}
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
toDraft(): { threads: Thread[] } {
|
|
1240
|
+
return { threads: this.threads };
|
|
1241
|
+
}
|
|
1242
|
+
}
|
|
1243
|
+
```
|
|
1244
|
+
|
|
1245
|
+
- [ ] **Step 4: Run tests**
|
|
1246
|
+
|
|
1247
|
+
Run: `bun test test/state/review-state.test.ts`
|
|
1248
|
+
Expected: All PASS
|
|
1249
|
+
|
|
1250
|
+
- [ ] **Step 5: Commit**
|
|
1251
|
+
|
|
1252
|
+
```bash
|
|
1253
|
+
git add src/state/review-state.ts test/state/review-state.test.ts
|
|
1254
|
+
git commit -m "feat: add ReviewState with thread management"
|
|
1255
|
+
```
|
|
1256
|
+
|
|
1257
|
+
---
|
|
1258
|
+
|
|
1259
|
+
## Chunk 4: TUI — Rendering + Interaction
|
|
1260
|
+
|
|
1261
|
+
### Task 7: TUI App Shell
|
|
1262
|
+
|
|
1263
|
+
**Files:**
|
|
1264
|
+
- Modify: `src/tui/app.ts`
|
|
1265
|
+
- Create: `src/tui/pager.ts`
|
|
1266
|
+
- Create: `src/tui/status-bar.ts`
|
|
1267
|
+
|
|
1268
|
+
- [ ] **Step 1: Implement the pager view**
|
|
1269
|
+
|
|
1270
|
+
```typescript
|
|
1271
|
+
// src/tui/pager.ts
|
|
1272
|
+
import { type CliRenderer, BoxRenderable, TextRenderable, ScrollBoxRenderable } from "@opentui/core";
|
|
1273
|
+
import type { ReviewState } from "../state/review-state";
|
|
1274
|
+
|
|
1275
|
+
const STATUS_ICONS: Record<string, string> = {
|
|
1276
|
+
open: "💬",
|
|
1277
|
+
pending: "🔵",
|
|
1278
|
+
resolved: "✔",
|
|
1279
|
+
outdated: "⚠",
|
|
1280
|
+
};
|
|
1281
|
+
|
|
1282
|
+
export function buildPagerContent(state: ReviewState): string {
|
|
1283
|
+
const lines: string[] = [];
|
|
1284
|
+
for (let i = 0; i < state.specLines.length; i++) {
|
|
1285
|
+
const lineNum = i + 1;
|
|
1286
|
+
const numStr = String(lineNum).padStart(4, " ");
|
|
1287
|
+
const thread = state.threadAtLine(lineNum);
|
|
1288
|
+
const indicator = thread ? ` ${STATUS_ICONS[thread.status] ?? ""}` : "";
|
|
1289
|
+
const hint =
|
|
1290
|
+
thread && thread.messages.length > 0
|
|
1291
|
+
? ` ${thread.messages[thread.messages.length - 1].text.slice(0, 40)}`
|
|
1292
|
+
: "";
|
|
1293
|
+
lines.push(`${numStr} ${state.specLines[i]}${indicator}${hint}`);
|
|
1294
|
+
}
|
|
1295
|
+
return lines.join("\n");
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1298
|
+
export function createPager(
|
|
1299
|
+
renderer: CliRenderer,
|
|
1300
|
+
state: ReviewState
|
|
1301
|
+
): ScrollBoxRenderable {
|
|
1302
|
+
const content = buildPagerContent(state);
|
|
1303
|
+
const scrollBox = new ScrollBoxRenderable(renderer, {
|
|
1304
|
+
id: "pager",
|
|
1305
|
+
width: "100%",
|
|
1306
|
+
flexGrow: 1,
|
|
1307
|
+
borderStyle: "single",
|
|
1308
|
+
});
|
|
1309
|
+
const text = new TextRenderable(renderer, {
|
|
1310
|
+
id: "pager-text",
|
|
1311
|
+
content,
|
|
1312
|
+
width: "100%",
|
|
1313
|
+
});
|
|
1314
|
+
scrollBox.add(text);
|
|
1315
|
+
return scrollBox;
|
|
1316
|
+
}
|
|
1317
|
+
```
|
|
1318
|
+
|
|
1319
|
+
- [ ] **Step 2: Implement the status bar**
|
|
1320
|
+
|
|
1321
|
+
```typescript
|
|
1322
|
+
// src/tui/status-bar.ts
|
|
1323
|
+
import { type CliRenderer, TextRenderable, BoxRenderable } from "@opentui/core";
|
|
1324
|
+
import type { ReviewState } from "../state/review-state";
|
|
1325
|
+
|
|
1326
|
+
export function createTopBar(
|
|
1327
|
+
renderer: CliRenderer,
|
|
1328
|
+
specFile: string,
|
|
1329
|
+
state: ReviewState
|
|
1330
|
+
): TextRenderable {
|
|
1331
|
+
const counts = state.activeThreadCount();
|
|
1332
|
+
const resolved = state.threads.filter((t) => t.status === "resolved").length;
|
|
1333
|
+
const summary = [
|
|
1334
|
+
counts.open > 0 ? `${counts.open} open` : null,
|
|
1335
|
+
counts.pending > 0 ? `${counts.pending} pending` : null,
|
|
1336
|
+
resolved > 0 ? `${resolved} resolved` : null,
|
|
1337
|
+
]
|
|
1338
|
+
.filter(Boolean)
|
|
1339
|
+
.join(", ");
|
|
1340
|
+
|
|
1341
|
+
return new TextRenderable(renderer, {
|
|
1342
|
+
id: "top-bar",
|
|
1343
|
+
content: `${specFile} [Review] ${summary ? `Threads: ${summary}` : "No threads"}`,
|
|
1344
|
+
width: "100%",
|
|
1345
|
+
height: 1,
|
|
1346
|
+
fg: "#000000",
|
|
1347
|
+
bg: "#CCCCCC",
|
|
1348
|
+
});
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1351
|
+
export function createBottomBar(renderer: CliRenderer): TextRenderable {
|
|
1352
|
+
return new TextRenderable(renderer, {
|
|
1353
|
+
id: "bottom-bar",
|
|
1354
|
+
content: "j/k scroll /search c comment e expand r resolve R resolve-all a approve :w save :q submit :q! quit",
|
|
1355
|
+
width: "100%",
|
|
1356
|
+
height: 1,
|
|
1357
|
+
fg: "#888888",
|
|
1358
|
+
});
|
|
1359
|
+
}
|
|
1360
|
+
```
|
|
1361
|
+
|
|
1362
|
+
- [ ] **Step 3: Implement the TUI app**
|
|
1363
|
+
|
|
1364
|
+
```typescript
|
|
1365
|
+
// src/tui/app.ts
|
|
1366
|
+
import { createCliRenderer, type KeyEvent } from "@opentui/core";
|
|
1367
|
+
import { readFileSync } from "fs";
|
|
1368
|
+
import { readReviewFile, readDraftFile } from "../protocol/read";
|
|
1369
|
+
import { writeDraftFile } from "../protocol/write";
|
|
1370
|
+
import { ReviewState } from "../state/review-state";
|
|
1371
|
+
import { buildPagerContent, createPager } from "./pager";
|
|
1372
|
+
import { createTopBar, createBottomBar } from "./status-bar";
|
|
1373
|
+
|
|
1374
|
+
export async function runTui(
|
|
1375
|
+
specFile: string,
|
|
1376
|
+
reviewPath: string,
|
|
1377
|
+
draftPath: string
|
|
1378
|
+
): Promise<void> {
|
|
1379
|
+
// Load spec
|
|
1380
|
+
const specContent = readFileSync(specFile, "utf-8");
|
|
1381
|
+
const specLines = specContent.split("\n");
|
|
1382
|
+
|
|
1383
|
+
// Load existing review + draft
|
|
1384
|
+
const review = readReviewFile(reviewPath);
|
|
1385
|
+
const draft = readDraftFile(draftPath);
|
|
1386
|
+
const threads = review?.threads ?? draft?.threads ?? [];
|
|
1387
|
+
|
|
1388
|
+
// Create state
|
|
1389
|
+
const state = new ReviewState(specLines, threads);
|
|
1390
|
+
|
|
1391
|
+
// Create renderer
|
|
1392
|
+
const renderer = await createCliRenderer({
|
|
1393
|
+
exitOnCtrlC: true,
|
|
1394
|
+
useAlternateScreen: true,
|
|
1395
|
+
});
|
|
1396
|
+
|
|
1397
|
+
// Build UI
|
|
1398
|
+
const topBar = createTopBar(renderer, specFile, state);
|
|
1399
|
+
const pager = createPager(renderer, state);
|
|
1400
|
+
const bottomBar = createBottomBar(renderer);
|
|
1401
|
+
|
|
1402
|
+
renderer.root.add(topBar);
|
|
1403
|
+
renderer.root.add(pager);
|
|
1404
|
+
renderer.root.add(bottomBar);
|
|
1405
|
+
|
|
1406
|
+
// Track command mode for :w, :q, :q!
|
|
1407
|
+
let commandBuffer = "";
|
|
1408
|
+
|
|
1409
|
+
// Keybinding handler
|
|
1410
|
+
renderer.keyInput.on("keypress", (key: KeyEvent) => {
|
|
1411
|
+
// Command mode (:w, :q, :q!)
|
|
1412
|
+
if (commandBuffer.startsWith(":")) {
|
|
1413
|
+
commandBuffer += key.sequence ?? "";
|
|
1414
|
+
if (commandBuffer === ":w") {
|
|
1415
|
+
writeDraftFile(draftPath, state.toDraft());
|
|
1416
|
+
commandBuffer = "";
|
|
1417
|
+
} else if (commandBuffer === ":q!") {
|
|
1418
|
+
commandBuffer = "";
|
|
1419
|
+
renderer.destroy();
|
|
1420
|
+
return;
|
|
1421
|
+
} else if (commandBuffer === ":q") {
|
|
1422
|
+
writeDraftFile(draftPath, state.toDraft());
|
|
1423
|
+
commandBuffer = "";
|
|
1424
|
+
renderer.destroy();
|
|
1425
|
+
return;
|
|
1426
|
+
} else if (commandBuffer.length > 3) {
|
|
1427
|
+
commandBuffer = "";
|
|
1428
|
+
}
|
|
1429
|
+
return;
|
|
1430
|
+
}
|
|
1431
|
+
|
|
1432
|
+
if (key.sequence === ":") {
|
|
1433
|
+
commandBuffer = ":";
|
|
1434
|
+
return;
|
|
1435
|
+
}
|
|
1436
|
+
|
|
1437
|
+
// Navigation
|
|
1438
|
+
if (key.name === "j" || key.name === "down") {
|
|
1439
|
+
if (state.cursorLine < state.lineCount) state.cursorLine++;
|
|
1440
|
+
} else if (key.name === "k" || key.name === "up") {
|
|
1441
|
+
if (state.cursorLine > 1) state.cursorLine--;
|
|
1442
|
+
} else if (key.name === "space") {
|
|
1443
|
+
// Page down — move cursor by terminal height
|
|
1444
|
+
const pageSize = (renderer.root.height ?? 20) - 4; // minus bars
|
|
1445
|
+
state.cursorLine = Math.min(state.lineCount, state.cursorLine + pageSize);
|
|
1446
|
+
} else if (key.name === "b") {
|
|
1447
|
+
// Page up
|
|
1448
|
+
const pageSize = (renderer.root.height ?? 20) - 4;
|
|
1449
|
+
state.cursorLine = Math.max(1, state.cursorLine - pageSize);
|
|
1450
|
+
} else if (key.name === "n") {
|
|
1451
|
+
const next = state.nextActiveThread();
|
|
1452
|
+
if (next) state.cursorLine = next;
|
|
1453
|
+
} else if (key.name === "N") {
|
|
1454
|
+
const prev = state.prevActiveThread();
|
|
1455
|
+
if (prev) state.cursorLine = prev;
|
|
1456
|
+
}
|
|
1457
|
+
// Thread actions
|
|
1458
|
+
else if (key.name === "r") {
|
|
1459
|
+
const thread = state.threadAtLine(state.cursorLine);
|
|
1460
|
+
if (thread) state.resolveThread(thread.id);
|
|
1461
|
+
} else if (key.name === "R") {
|
|
1462
|
+
state.resolveAllPending();
|
|
1463
|
+
} else if (key.name === "d") {
|
|
1464
|
+
const thread = state.threadAtLine(state.cursorLine);
|
|
1465
|
+
if (thread) state.deleteLastDraftMessage(thread.id);
|
|
1466
|
+
} else if (key.name === "a") {
|
|
1467
|
+
if (state.canApprove()) {
|
|
1468
|
+
writeDraftFile(draftPath, { approved: true });
|
|
1469
|
+
renderer.destroy();
|
|
1470
|
+
return;
|
|
1471
|
+
}
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1474
|
+
// c (comment), e (expand), / (search), l (list)
|
|
1475
|
+
// Wired in Task 8 and Task 9 as overlay components
|
|
1476
|
+
|
|
1477
|
+
// Re-render pager after any state change
|
|
1478
|
+
refreshPager(pager, topBar, state, specFile, renderer);
|
|
1479
|
+
});
|
|
1480
|
+
}
|
|
1481
|
+
|
|
1482
|
+
// Re-render the pager content and status bar after state mutations
|
|
1483
|
+
function refreshPager(
|
|
1484
|
+
pager: any, topBar: any, state: ReviewState,
|
|
1485
|
+
specFile: string, renderer: any
|
|
1486
|
+
): void {
|
|
1487
|
+
// Rebuild pager text content
|
|
1488
|
+
const textNode = pager.children?.[0];
|
|
1489
|
+
if (textNode?.setContent) {
|
|
1490
|
+
textNode.setContent(buildPagerContent(state));
|
|
1491
|
+
}
|
|
1492
|
+
// Update status bar
|
|
1493
|
+
const counts = state.activeThreadCount();
|
|
1494
|
+
const resolved = state.threads.filter((t) => t.status === "resolved").length;
|
|
1495
|
+
const summary = [
|
|
1496
|
+
counts.open > 0 ? `${counts.open} open` : null,
|
|
1497
|
+
counts.pending > 0 ? `${counts.pending} pending` : null,
|
|
1498
|
+
resolved > 0 ? `${resolved} resolved` : null,
|
|
1499
|
+
].filter(Boolean).join(", ");
|
|
1500
|
+
if (topBar.setContent) {
|
|
1501
|
+
topBar.setContent(`${specFile} [Review] ${summary ? `Threads: ${summary}` : "No threads"}`);
|
|
1502
|
+
}
|
|
1503
|
+
}
|
|
1504
|
+
```
|
|
1505
|
+
|
|
1506
|
+
- [ ] **Step 4: Write unit tests for buildPagerContent**
|
|
1507
|
+
|
|
1508
|
+
```typescript
|
|
1509
|
+
// test/tui/pager.test.ts
|
|
1510
|
+
import { describe, expect, it } from "bun:test";
|
|
1511
|
+
import { buildPagerContent } from "../../src/tui/pager";
|
|
1512
|
+
import { ReviewState } from "../../src/state/review-state";
|
|
1513
|
+
|
|
1514
|
+
describe("buildPagerContent", () => {
|
|
1515
|
+
it("renders lines with line numbers", () => {
|
|
1516
|
+
const state = new ReviewState(["# Title", "Content"], []);
|
|
1517
|
+
const output = buildPagerContent(state);
|
|
1518
|
+
expect(output).toContain(" 1");
|
|
1519
|
+
expect(output).toContain("# Title");
|
|
1520
|
+
expect(output).toContain(" 2");
|
|
1521
|
+
});
|
|
1522
|
+
|
|
1523
|
+
it("shows status indicators for threads", () => {
|
|
1524
|
+
const state = new ReviewState(["line one", "line two", "line three"], [
|
|
1525
|
+
{ id: "1", line: 2, status: "open", messages: [{ author: "human", text: "fix this" }] },
|
|
1526
|
+
]);
|
|
1527
|
+
const output = buildPagerContent(state);
|
|
1528
|
+
expect(output).toContain("💬");
|
|
1529
|
+
expect(output).toContain("fix this");
|
|
1530
|
+
});
|
|
1531
|
+
|
|
1532
|
+
it("shows different icons for different statuses", () => {
|
|
1533
|
+
const state = new ReviewState(["a", "b", "c", "d"], [
|
|
1534
|
+
{ id: "1", line: 1, status: "resolved", messages: [{ author: "human", text: "ok" }] },
|
|
1535
|
+
{ id: "2", line: 2, status: "pending", messages: [{ author: "ai", text: "done" }] },
|
|
1536
|
+
{ id: "3", line: 3, status: "outdated", messages: [{ author: "human", text: "old" }] },
|
|
1537
|
+
]);
|
|
1538
|
+
const output = buildPagerContent(state);
|
|
1539
|
+
expect(output).toContain("✔");
|
|
1540
|
+
expect(output).toContain("🔵");
|
|
1541
|
+
expect(output).toContain("⚠");
|
|
1542
|
+
});
|
|
1543
|
+
|
|
1544
|
+
it("truncates long comment text", () => {
|
|
1545
|
+
const longText = "a".repeat(100);
|
|
1546
|
+
const state = new ReviewState(["line"], [
|
|
1547
|
+
{ id: "1", line: 1, status: "open", messages: [{ author: "human", text: longText }] },
|
|
1548
|
+
]);
|
|
1549
|
+
const output = buildPagerContent(state);
|
|
1550
|
+
expect(output.length).toBeLessThan(200);
|
|
1551
|
+
});
|
|
1552
|
+
});
|
|
1553
|
+
```
|
|
1554
|
+
|
|
1555
|
+
- [ ] **Step 5: Run pager tests**
|
|
1556
|
+
|
|
1557
|
+
Run: `bun test test/tui/pager.test.ts`
|
|
1558
|
+
Expected: All PASS
|
|
1559
|
+
|
|
1560
|
+
- [ ] **Step 6: Run the app manually to verify**
|
|
1561
|
+
|
|
1562
|
+
Run: `bun run bin/revspec.ts docs/superpowers/specs/2026-03-14-spec-review-tool-design.md`
|
|
1563
|
+
Expected: Full-screen TUI with spec content, line numbers, status bar
|
|
1564
|
+
|
|
1565
|
+
- [ ] **Step 7: Commit**
|
|
1566
|
+
|
|
1567
|
+
```bash
|
|
1568
|
+
git add src/tui/app.ts src/tui/pager.ts src/tui/status-bar.ts test/tui/pager.test.ts
|
|
1569
|
+
git commit -m "feat: implement TUI shell with pager, status bar, basic keybindings"
|
|
1570
|
+
```
|
|
1571
|
+
|
|
1572
|
+
---
|
|
1573
|
+
|
|
1574
|
+
### Task 8: Comment Input Overlay
|
|
1575
|
+
|
|
1576
|
+
**Files:**
|
|
1577
|
+
- Create: `src/tui/comment-input.ts`
|
|
1578
|
+
- Modify: `src/tui/app.ts`
|
|
1579
|
+
|
|
1580
|
+
- [ ] **Step 1: Implement comment input overlay**
|
|
1581
|
+
|
|
1582
|
+
```typescript
|
|
1583
|
+
// src/tui/comment-input.ts
|
|
1584
|
+
import {
|
|
1585
|
+
type CliRenderer,
|
|
1586
|
+
BoxRenderable,
|
|
1587
|
+
TextRenderable,
|
|
1588
|
+
TextareaRenderable,
|
|
1589
|
+
} from "@opentui/core";
|
|
1590
|
+
|
|
1591
|
+
interface CommentInputOptions {
|
|
1592
|
+
renderer: CliRenderer;
|
|
1593
|
+
line: number;
|
|
1594
|
+
existingThreadId?: string;
|
|
1595
|
+
onSubmit: (text: string) => void;
|
|
1596
|
+
onCancel: () => void;
|
|
1597
|
+
}
|
|
1598
|
+
|
|
1599
|
+
export function showCommentInput(opts: CommentInputOptions): BoxRenderable {
|
|
1600
|
+
const { renderer, line, existingThreadId, onSubmit, onCancel } = opts;
|
|
1601
|
+
|
|
1602
|
+
const container = new BoxRenderable(renderer, {
|
|
1603
|
+
id: "comment-input",
|
|
1604
|
+
width: "80%",
|
|
1605
|
+
height: 6,
|
|
1606
|
+
position: "absolute",
|
|
1607
|
+
left: "center",
|
|
1608
|
+
top: "center",
|
|
1609
|
+
borderStyle: "double",
|
|
1610
|
+
backgroundColor: "#1a1a2e",
|
|
1611
|
+
padding: 1,
|
|
1612
|
+
zIndex: 100,
|
|
1613
|
+
flexDirection: "column",
|
|
1614
|
+
});
|
|
1615
|
+
|
|
1616
|
+
const label = new TextRenderable(renderer, {
|
|
1617
|
+
id: "comment-label",
|
|
1618
|
+
content: existingThreadId
|
|
1619
|
+
? `Reply to thread #${existingThreadId} (line ${line})`
|
|
1620
|
+
: `New comment on line ${line}`,
|
|
1621
|
+
fg: "#CCCCCC",
|
|
1622
|
+
height: 1,
|
|
1623
|
+
});
|
|
1624
|
+
|
|
1625
|
+
const input = new TextareaRenderable(renderer, {
|
|
1626
|
+
id: "comment-textarea",
|
|
1627
|
+
width: "100%",
|
|
1628
|
+
height: 3,
|
|
1629
|
+
flexGrow: 1,
|
|
1630
|
+
borderStyle: "single",
|
|
1631
|
+
focused: true,
|
|
1632
|
+
});
|
|
1633
|
+
|
|
1634
|
+
const hint = new TextRenderable(renderer, {
|
|
1635
|
+
id: "comment-hint",
|
|
1636
|
+
content: "[Ctrl+Enter] submit [Enter] newline [Esc] cancel",
|
|
1637
|
+
fg: "#666666",
|
|
1638
|
+
height: 1,
|
|
1639
|
+
});
|
|
1640
|
+
|
|
1641
|
+
container.add(label);
|
|
1642
|
+
container.add(input);
|
|
1643
|
+
container.add(hint);
|
|
1644
|
+
|
|
1645
|
+
// Handle submit/cancel via key events on the input
|
|
1646
|
+
renderer.keyInput.on("keypress", function handleCommentKey(key) {
|
|
1647
|
+
if (key.name === "escape") {
|
|
1648
|
+
renderer.keyInput.off("keypress", handleCommentKey);
|
|
1649
|
+
onCancel();
|
|
1650
|
+
} else if (key.name === "return" && key.ctrl) {
|
|
1651
|
+
const text = input.getValue?.() ?? "";
|
|
1652
|
+
if (text.trim()) {
|
|
1653
|
+
renderer.keyInput.off("keypress", handleCommentKey);
|
|
1654
|
+
onSubmit(text.trim());
|
|
1655
|
+
}
|
|
1656
|
+
}
|
|
1657
|
+
});
|
|
1658
|
+
|
|
1659
|
+
return container;
|
|
1660
|
+
}
|
|
1661
|
+
```
|
|
1662
|
+
|
|
1663
|
+
- [ ] **Step 2: Wire comment input into app.ts keybinding handler**
|
|
1664
|
+
|
|
1665
|
+
Add to the keybinding handler in `src/tui/app.ts`, in the `else if (key.name === "c")` block:
|
|
1666
|
+
|
|
1667
|
+
```typescript
|
|
1668
|
+
else if (key.name === "c") {
|
|
1669
|
+
const existingThread = state.threadAtLine(state.cursorLine);
|
|
1670
|
+
const overlay = showCommentInput({
|
|
1671
|
+
renderer,
|
|
1672
|
+
line: state.cursorLine,
|
|
1673
|
+
existingThreadId: existingThread?.id,
|
|
1674
|
+
onSubmit: (text) => {
|
|
1675
|
+
if (existingThread) {
|
|
1676
|
+
state.replyToThread(existingThread.id, text);
|
|
1677
|
+
} else {
|
|
1678
|
+
state.addComment(state.cursorLine, text);
|
|
1679
|
+
}
|
|
1680
|
+
renderer.root.remove(overlay);
|
|
1681
|
+
// Refresh pager content
|
|
1682
|
+
},
|
|
1683
|
+
onCancel: () => {
|
|
1684
|
+
renderer.root.remove(overlay);
|
|
1685
|
+
},
|
|
1686
|
+
});
|
|
1687
|
+
renderer.root.add(overlay);
|
|
1688
|
+
}
|
|
1689
|
+
```
|
|
1690
|
+
|
|
1691
|
+
- [ ] **Step 3: Test manually**
|
|
1692
|
+
|
|
1693
|
+
Run: `bun run bin/revspec.ts docs/superpowers/specs/2026-03-14-spec-review-tool-design.md`
|
|
1694
|
+
Press `c` on any line → comment input overlay appears
|
|
1695
|
+
Type a comment → Ctrl+Enter → comment saved, overlay closes
|
|
1696
|
+
Press Escape → overlay closes without saving
|
|
1697
|
+
|
|
1698
|
+
- [ ] **Step 4: Commit**
|
|
1699
|
+
|
|
1700
|
+
```bash
|
|
1701
|
+
git add src/tui/comment-input.ts src/tui/app.ts
|
|
1702
|
+
git commit -m "feat: add comment input overlay with Ctrl+Enter submit"
|
|
1703
|
+
```
|
|
1704
|
+
|
|
1705
|
+
---
|
|
1706
|
+
|
|
1707
|
+
### Task 9: Thread Expand + Search + Thread List
|
|
1708
|
+
|
|
1709
|
+
**Files:**
|
|
1710
|
+
- Create: `src/tui/thread-expand.ts`
|
|
1711
|
+
- Create: `src/tui/search.ts`
|
|
1712
|
+
- Create: `src/tui/thread-list.ts`
|
|
1713
|
+
- Modify: `src/tui/app.ts`
|
|
1714
|
+
|
|
1715
|
+
- [ ] **Step 1: Implement thread expand**
|
|
1716
|
+
|
|
1717
|
+
```typescript
|
|
1718
|
+
// src/tui/thread-expand.ts
|
|
1719
|
+
import { type CliRenderer, BoxRenderable, TextRenderable } from "@opentui/core";
|
|
1720
|
+
import type { Thread } from "../protocol/types";
|
|
1721
|
+
|
|
1722
|
+
interface ThreadExpandOptions {
|
|
1723
|
+
renderer: CliRenderer;
|
|
1724
|
+
thread: Thread;
|
|
1725
|
+
onResolve: () => void;
|
|
1726
|
+
onContinue: () => void;
|
|
1727
|
+
onClose: () => void;
|
|
1728
|
+
}
|
|
1729
|
+
|
|
1730
|
+
export function showThreadExpand(opts: ThreadExpandOptions): BoxRenderable {
|
|
1731
|
+
const { renderer, thread, onResolve, onContinue, onClose } = opts;
|
|
1732
|
+
|
|
1733
|
+
const messagesText = thread.messages
|
|
1734
|
+
.map((m) => {
|
|
1735
|
+
const icon = m.author === "human" ? "👤" : "🤖";
|
|
1736
|
+
return `${icon} ${m.text}`;
|
|
1737
|
+
})
|
|
1738
|
+
.join("\n\n");
|
|
1739
|
+
|
|
1740
|
+
const container = new BoxRenderable(renderer, {
|
|
1741
|
+
id: "thread-expand",
|
|
1742
|
+
width: "80%",
|
|
1743
|
+
height: "60%",
|
|
1744
|
+
position: "absolute",
|
|
1745
|
+
left: "center",
|
|
1746
|
+
top: "center",
|
|
1747
|
+
borderStyle: "double",
|
|
1748
|
+
backgroundColor: "#1a1a2e",
|
|
1749
|
+
padding: 1,
|
|
1750
|
+
zIndex: 100,
|
|
1751
|
+
flexDirection: "column",
|
|
1752
|
+
});
|
|
1753
|
+
|
|
1754
|
+
const title = new TextRenderable(renderer, {
|
|
1755
|
+
id: "thread-title",
|
|
1756
|
+
content: `Thread #${thread.id} (${thread.status}) — line ${thread.line}`,
|
|
1757
|
+
fg: "#CCCCCC",
|
|
1758
|
+
height: 1,
|
|
1759
|
+
});
|
|
1760
|
+
|
|
1761
|
+
const body = new TextRenderable(renderer, {
|
|
1762
|
+
id: "thread-body",
|
|
1763
|
+
content: messagesText,
|
|
1764
|
+
flexGrow: 1,
|
|
1765
|
+
fg: "#FFFFFF",
|
|
1766
|
+
});
|
|
1767
|
+
|
|
1768
|
+
const actions = new TextRenderable(renderer, {
|
|
1769
|
+
id: "thread-actions",
|
|
1770
|
+
content: "[r]esolve [c]ontinue [q]uit",
|
|
1771
|
+
fg: "#666666",
|
|
1772
|
+
height: 1,
|
|
1773
|
+
});
|
|
1774
|
+
|
|
1775
|
+
container.add(title);
|
|
1776
|
+
container.add(body);
|
|
1777
|
+
container.add(actions);
|
|
1778
|
+
|
|
1779
|
+
renderer.keyInput.on("keypress", function handleExpandKey(key) {
|
|
1780
|
+
if (key.name === "r") {
|
|
1781
|
+
renderer.keyInput.off("keypress", handleExpandKey);
|
|
1782
|
+
onResolve();
|
|
1783
|
+
} else if (key.name === "c") {
|
|
1784
|
+
renderer.keyInput.off("keypress", handleExpandKey);
|
|
1785
|
+
onContinue();
|
|
1786
|
+
} else if (key.name === "q" || key.name === "escape") {
|
|
1787
|
+
renderer.keyInput.off("keypress", handleExpandKey);
|
|
1788
|
+
onClose();
|
|
1789
|
+
}
|
|
1790
|
+
});
|
|
1791
|
+
|
|
1792
|
+
return container;
|
|
1793
|
+
}
|
|
1794
|
+
```
|
|
1795
|
+
|
|
1796
|
+
- [ ] **Step 2: Implement search**
|
|
1797
|
+
|
|
1798
|
+
```typescript
|
|
1799
|
+
// src/tui/search.ts
|
|
1800
|
+
import { type CliRenderer, BoxRenderable, InputRenderable, TextRenderable } from "@opentui/core";
|
|
1801
|
+
|
|
1802
|
+
interface SearchOptions {
|
|
1803
|
+
renderer: CliRenderer;
|
|
1804
|
+
specLines: string[];
|
|
1805
|
+
cursorLine: number;
|
|
1806
|
+
onResult: (lineNumber: number) => void;
|
|
1807
|
+
onCancel: () => void;
|
|
1808
|
+
}
|
|
1809
|
+
|
|
1810
|
+
export function showSearch(opts: SearchOptions): BoxRenderable {
|
|
1811
|
+
const { renderer, specLines, cursorLine, onResult, onCancel } = opts;
|
|
1812
|
+
|
|
1813
|
+
const container = new BoxRenderable(renderer, {
|
|
1814
|
+
id: "search-box",
|
|
1815
|
+
width: "60%",
|
|
1816
|
+
height: 3,
|
|
1817
|
+
position: "absolute",
|
|
1818
|
+
left: "center",
|
|
1819
|
+
top: 2,
|
|
1820
|
+
borderStyle: "single",
|
|
1821
|
+
backgroundColor: "#1a1a2e",
|
|
1822
|
+
padding: 0,
|
|
1823
|
+
zIndex: 100,
|
|
1824
|
+
flexDirection: "row",
|
|
1825
|
+
});
|
|
1826
|
+
|
|
1827
|
+
const label = new TextRenderable(renderer, {
|
|
1828
|
+
id: "search-label",
|
|
1829
|
+
content: "/",
|
|
1830
|
+
width: 2,
|
|
1831
|
+
fg: "#CCCCCC",
|
|
1832
|
+
});
|
|
1833
|
+
|
|
1834
|
+
const input = new InputRenderable(renderer, {
|
|
1835
|
+
id: "search-input",
|
|
1836
|
+
flexGrow: 1,
|
|
1837
|
+
height: 1,
|
|
1838
|
+
focused: true,
|
|
1839
|
+
onEnter: (query: string) => {
|
|
1840
|
+
if (!query.trim()) {
|
|
1841
|
+
onCancel();
|
|
1842
|
+
return;
|
|
1843
|
+
}
|
|
1844
|
+
const lowerQuery = query.toLowerCase();
|
|
1845
|
+
// Search forward from current cursor position, wrapping around
|
|
1846
|
+
for (let offset = 0; offset < specLines.length; offset++) {
|
|
1847
|
+
const i = (cursorLine - 1 + offset) % specLines.length;
|
|
1848
|
+
if (specLines[i].toLowerCase().includes(lowerQuery)) {
|
|
1849
|
+
onResult(i + 1);
|
|
1850
|
+
return;
|
|
1851
|
+
}
|
|
1852
|
+
}
|
|
1853
|
+
onCancel(); // No match found
|
|
1854
|
+
},
|
|
1855
|
+
});
|
|
1856
|
+
|
|
1857
|
+
container.add(label);
|
|
1858
|
+
container.add(input);
|
|
1859
|
+
|
|
1860
|
+
renderer.keyInput.on("keypress", function handleSearchKey(key) {
|
|
1861
|
+
if (key.name === "escape") {
|
|
1862
|
+
renderer.keyInput.off("keypress", handleSearchKey);
|
|
1863
|
+
onCancel();
|
|
1864
|
+
}
|
|
1865
|
+
});
|
|
1866
|
+
|
|
1867
|
+
return container;
|
|
1868
|
+
}
|
|
1869
|
+
```
|
|
1870
|
+
|
|
1871
|
+
- [ ] **Step 3: Implement thread list**
|
|
1872
|
+
|
|
1873
|
+
```typescript
|
|
1874
|
+
// src/tui/thread-list.ts
|
|
1875
|
+
import { type CliRenderer, BoxRenderable, TextRenderable, SelectRenderable } from "@opentui/core";
|
|
1876
|
+
import type { Thread } from "../protocol/types";
|
|
1877
|
+
|
|
1878
|
+
interface ThreadListOptions {
|
|
1879
|
+
renderer: CliRenderer;
|
|
1880
|
+
threads: Thread[];
|
|
1881
|
+
onSelect: (lineNumber: number) => void;
|
|
1882
|
+
onClose: () => void;
|
|
1883
|
+
}
|
|
1884
|
+
|
|
1885
|
+
export function showThreadList(opts: ThreadListOptions): BoxRenderable {
|
|
1886
|
+
const { renderer, threads, onSelect, onClose } = opts;
|
|
1887
|
+
|
|
1888
|
+
const activeThreads = threads.filter(
|
|
1889
|
+
(t) => t.status === "open" || t.status === "pending"
|
|
1890
|
+
);
|
|
1891
|
+
|
|
1892
|
+
const container = new BoxRenderable(renderer, {
|
|
1893
|
+
id: "thread-list",
|
|
1894
|
+
width: "70%",
|
|
1895
|
+
height: "60%",
|
|
1896
|
+
position: "absolute",
|
|
1897
|
+
left: "center",
|
|
1898
|
+
top: "center",
|
|
1899
|
+
borderStyle: "double",
|
|
1900
|
+
backgroundColor: "#1a1a2e",
|
|
1901
|
+
padding: 1,
|
|
1902
|
+
zIndex: 100,
|
|
1903
|
+
flexDirection: "column",
|
|
1904
|
+
});
|
|
1905
|
+
|
|
1906
|
+
const title = new TextRenderable(renderer, {
|
|
1907
|
+
id: "list-title",
|
|
1908
|
+
content: `Open/Pending Threads (${activeThreads.length})`,
|
|
1909
|
+
fg: "#CCCCCC",
|
|
1910
|
+
height: 1,
|
|
1911
|
+
});
|
|
1912
|
+
|
|
1913
|
+
const items = activeThreads.map((t) => {
|
|
1914
|
+
const icon = t.status === "open" ? "💬" : "🔵";
|
|
1915
|
+
const preview = t.messages[t.messages.length - 1]?.text.slice(0, 50) ?? "";
|
|
1916
|
+
return `${icon} #${t.id} line ${t.line}: ${preview}`;
|
|
1917
|
+
});
|
|
1918
|
+
|
|
1919
|
+
const select = new SelectRenderable(renderer, {
|
|
1920
|
+
id: "list-select",
|
|
1921
|
+
items,
|
|
1922
|
+
flexGrow: 1,
|
|
1923
|
+
focused: true,
|
|
1924
|
+
onSelect: (index: number) => {
|
|
1925
|
+
if (index >= 0 && index < activeThreads.length) {
|
|
1926
|
+
onSelect(activeThreads[index].line);
|
|
1927
|
+
}
|
|
1928
|
+
},
|
|
1929
|
+
});
|
|
1930
|
+
|
|
1931
|
+
container.add(title);
|
|
1932
|
+
container.add(select);
|
|
1933
|
+
|
|
1934
|
+
renderer.keyInput.on("keypress", function handleListKey(key) {
|
|
1935
|
+
if (key.name === "escape" || key.name === "q") {
|
|
1936
|
+
renderer.keyInput.off("keypress", handleListKey);
|
|
1937
|
+
onClose();
|
|
1938
|
+
}
|
|
1939
|
+
});
|
|
1940
|
+
|
|
1941
|
+
return container;
|
|
1942
|
+
}
|
|
1943
|
+
```
|
|
1944
|
+
|
|
1945
|
+
- [ ] **Step 4: Wire all overlays into app.ts**
|
|
1946
|
+
|
|
1947
|
+
Add the `e`, `/`, `l` keybindings to `src/tui/app.ts` following the same pattern as `c`.
|
|
1948
|
+
|
|
1949
|
+
- [ ] **Step 5: Test manually**
|
|
1950
|
+
|
|
1951
|
+
Run the TUI and verify:
|
|
1952
|
+
- `e` on a thread line → expand overlay with messages
|
|
1953
|
+
- `/` → search input, Enter jumps to first match
|
|
1954
|
+
- `l` → thread list, Enter jumps to selected thread
|
|
1955
|
+
|
|
1956
|
+
- [ ] **Step 6: Commit**
|
|
1957
|
+
|
|
1958
|
+
```bash
|
|
1959
|
+
git add src/tui/thread-expand.ts src/tui/search.ts src/tui/thread-list.ts src/tui/app.ts
|
|
1960
|
+
git commit -m "feat: add thread expand, search, and thread list overlays"
|
|
1961
|
+
```
|
|
1962
|
+
|
|
1963
|
+
---
|
|
1964
|
+
|
|
1965
|
+
### Task 10: Confirm Dialog + Final Wiring
|
|
1966
|
+
|
|
1967
|
+
**Files:**
|
|
1968
|
+
- Create: `src/tui/confirm.ts`
|
|
1969
|
+
- Modify: `src/tui/app.ts`
|
|
1970
|
+
|
|
1971
|
+
- [ ] **Step 1: Implement confirmation dialog**
|
|
1972
|
+
|
|
1973
|
+
```typescript
|
|
1974
|
+
// src/tui/confirm.ts
|
|
1975
|
+
import { type CliRenderer, BoxRenderable, TextRenderable } from "@opentui/core";
|
|
1976
|
+
|
|
1977
|
+
interface ConfirmOptions {
|
|
1978
|
+
renderer: CliRenderer;
|
|
1979
|
+
message: string;
|
|
1980
|
+
onConfirm: () => void;
|
|
1981
|
+
onCancel: () => void;
|
|
1982
|
+
}
|
|
1983
|
+
|
|
1984
|
+
export function showConfirm(opts: ConfirmOptions): BoxRenderable {
|
|
1985
|
+
const { renderer, message, onConfirm, onCancel } = opts;
|
|
1986
|
+
|
|
1987
|
+
const container = new BoxRenderable(renderer, {
|
|
1988
|
+
id: "confirm",
|
|
1989
|
+
width: "50%",
|
|
1990
|
+
height: 5,
|
|
1991
|
+
position: "absolute",
|
|
1992
|
+
left: "center",
|
|
1993
|
+
top: "center",
|
|
1994
|
+
borderStyle: "double",
|
|
1995
|
+
backgroundColor: "#1a1a2e",
|
|
1996
|
+
padding: 1,
|
|
1997
|
+
zIndex: 200,
|
|
1998
|
+
flexDirection: "column",
|
|
1999
|
+
});
|
|
2000
|
+
|
|
2001
|
+
const text = new TextRenderable(renderer, {
|
|
2002
|
+
id: "confirm-text",
|
|
2003
|
+
content: message,
|
|
2004
|
+
fg: "#FFFFFF",
|
|
2005
|
+
});
|
|
2006
|
+
|
|
2007
|
+
const hint = new TextRenderable(renderer, {
|
|
2008
|
+
id: "confirm-hint",
|
|
2009
|
+
content: "[y]es [n]o",
|
|
2010
|
+
fg: "#666666",
|
|
2011
|
+
height: 1,
|
|
2012
|
+
});
|
|
2013
|
+
|
|
2014
|
+
container.add(text);
|
|
2015
|
+
container.add(hint);
|
|
2016
|
+
|
|
2017
|
+
renderer.keyInput.on("keypress", function handleConfirm(key) {
|
|
2018
|
+
if (key.name === "y") {
|
|
2019
|
+
renderer.keyInput.off("keypress", handleConfirm);
|
|
2020
|
+
onConfirm();
|
|
2021
|
+
} else if (key.name === "n" || key.name === "escape") {
|
|
2022
|
+
renderer.keyInput.off("keypress", handleConfirm);
|
|
2023
|
+
onCancel();
|
|
2024
|
+
}
|
|
2025
|
+
});
|
|
2026
|
+
|
|
2027
|
+
return container;
|
|
2028
|
+
}
|
|
2029
|
+
```
|
|
2030
|
+
|
|
2031
|
+
- [ ] **Step 2: Wire `:q` to show confirmation**
|
|
2032
|
+
|
|
2033
|
+
Update the `:q` handler in `src/tui/app.ts`:
|
|
2034
|
+
|
|
2035
|
+
```typescript
|
|
2036
|
+
} else if (commandBuffer === ":q") {
|
|
2037
|
+
commandBuffer = "";
|
|
2038
|
+
const threadCount = state.threads.filter(
|
|
2039
|
+
(t) => t.status === "open" || t.status === "pending"
|
|
2040
|
+
).length;
|
|
2041
|
+
const msg = threadCount > 0
|
|
2042
|
+
? `Submit review with ${threadCount} open/pending thread(s)? [y/n]`
|
|
2043
|
+
: "Submit review? [y/n]";
|
|
2044
|
+
const overlay = showConfirm({
|
|
2045
|
+
renderer,
|
|
2046
|
+
message: msg,
|
|
2047
|
+
onConfirm: () => {
|
|
2048
|
+
writeDraftFile(draftPath, state.toDraft());
|
|
2049
|
+
renderer.root.remove(overlay);
|
|
2050
|
+
renderer.destroy();
|
|
2051
|
+
},
|
|
2052
|
+
onCancel: () => {
|
|
2053
|
+
renderer.root.remove(overlay);
|
|
2054
|
+
},
|
|
2055
|
+
});
|
|
2056
|
+
renderer.root.add(overlay);
|
|
2057
|
+
}
|
|
2058
|
+
```
|
|
2059
|
+
|
|
2060
|
+
- [ ] **Step 3: Test the full flow manually**
|
|
2061
|
+
|
|
2062
|
+
1. `bun run bin/revspec.ts <spec-file>`
|
|
2063
|
+
2. Navigate with `j/k`
|
|
2064
|
+
3. Add comment with `c`
|
|
2065
|
+
4. Expand thread with `e`
|
|
2066
|
+
5. Search with `/`
|
|
2067
|
+
6. Resolve with `r`
|
|
2068
|
+
7. `:w` to save draft
|
|
2069
|
+
8. `:q` to submit (confirmation dialog appears)
|
|
2070
|
+
9. `:q!` to quit without saving
|
|
2071
|
+
|
|
2072
|
+
- [ ] **Step 4: Run all tests**
|
|
2073
|
+
|
|
2074
|
+
Run: `bun test`
|
|
2075
|
+
Expected: All PASS
|
|
2076
|
+
|
|
2077
|
+
- [ ] **Step 5: Commit**
|
|
2078
|
+
|
|
2079
|
+
```bash
|
|
2080
|
+
git add src/tui/confirm.ts src/tui/app.ts
|
|
2081
|
+
git commit -m "feat: add confirmation dialog, complete TUI keybinding wiring"
|
|
2082
|
+
```
|
|
2083
|
+
|
|
2084
|
+
---
|
|
2085
|
+
|
|
2086
|
+
## Chunk 5: Polish + Distribution
|
|
2087
|
+
|
|
2088
|
+
### Task 11: Make it executable
|
|
2089
|
+
|
|
2090
|
+
**Files:**
|
|
2091
|
+
- Modify: `package.json`
|
|
2092
|
+
|
|
2093
|
+
- [ ] **Step 1: Link the binary locally**
|
|
2094
|
+
|
|
2095
|
+
```bash
|
|
2096
|
+
bun link
|
|
2097
|
+
```
|
|
2098
|
+
|
|
2099
|
+
- [ ] **Step 2: Verify it runs as a command**
|
|
2100
|
+
|
|
2101
|
+
Run: `revspec --help`
|
|
2102
|
+
Expected: Usage message
|
|
2103
|
+
|
|
2104
|
+
- [ ] **Step 3: Test the full review cycle end-to-end**
|
|
2105
|
+
|
|
2106
|
+
1. Create a test spec file
|
|
2107
|
+
2. Run `revspec test-spec.md`
|
|
2108
|
+
3. Add comments, save, quit
|
|
2109
|
+
4. Verify `.review.json` exists with correct structure
|
|
2110
|
+
5. Run `revspec test-spec.md` again — verify threads load
|
|
2111
|
+
6. Approve — verify `APPROVED:` output
|
|
2112
|
+
|
|
2113
|
+
- [ ] **Step 4: Commit**
|
|
2114
|
+
|
|
2115
|
+
```bash
|
|
2116
|
+
git add package.json
|
|
2117
|
+
git commit -m "feat: make revspec executable via bun link"
|
|
2118
|
+
```
|
|
2119
|
+
|
|
2120
|
+
---
|
|
2121
|
+
|
|
2122
|
+
### Task 12: Final test pass + cleanup
|
|
2123
|
+
|
|
2124
|
+
- [ ] **Step 1: Run full test suite**
|
|
2125
|
+
|
|
2126
|
+
Run: `bun test`
|
|
2127
|
+
Expected: All PASS
|
|
2128
|
+
|
|
2129
|
+
- [ ] **Step 2: Clean up any TODOs or stub code**
|
|
2130
|
+
|
|
2131
|
+
Search for `TODO` and remove or implement.
|
|
2132
|
+
|
|
2133
|
+
- [ ] **Step 3: Commit and push**
|
|
2134
|
+
|
|
2135
|
+
```bash
|
|
2136
|
+
git add -A
|
|
2137
|
+
git commit -m "chore: cleanup TODOs, final test pass"
|
|
2138
|
+
git push
|
|
2139
|
+
```
|