revspec 0.1.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +19 -0
- package/README.md +6 -2
- package/bin/revspec.ts +41 -56
- package/docs/superpowers/plans/2026-03-14-live-ai-integration.md +1877 -0
- package/docs/superpowers/specs/2026-03-14-live-ai-integration-design.md +518 -0
- package/docs/superpowers/specs/2026-03-14-live-ai-integration-design.review.json +65 -0
- package/package.json +1 -1
- package/scripts/release.sh +12 -35
- package/src/cli/reply.ts +53 -0
- package/src/cli/watch.ts +378 -0
- package/src/protocol/live-events.ts +215 -0
- package/src/protocol/live-merge.ts +66 -0
- package/src/protocol/types.ts +2 -1
- package/src/state/review-state.ts +47 -5
- package/src/tui/app.ts +167 -71
- package/src/tui/comment-input.ts +265 -96
- package/src/tui/help.ts +4 -2
- package/src/tui/live-watcher.ts +40 -0
- package/src/tui/pager.ts +18 -8
- package/src/tui/status-bar.ts +17 -6
- package/src/tui/theme.ts +4 -4
- package/test/cli-reply.test.ts +140 -0
- package/test/cli-watch.test.ts +216 -0
- package/test/cli.test.ts +49 -40
- package/test/e2e-live.test.ts +171 -0
- package/test/live-interaction.test.ts +398 -0
- package/test/protocol/live-events.test.ts +509 -0
- package/test/protocol/live-merge.test.ts +167 -0
- package/test/protocol/merge.test.ts +5 -5
- package/test/protocol/read.test.ts +2 -2
- package/test/protocol/types.test.ts +2 -2
- package/test/protocol/write.test.ts +2 -2
- package/test/state/review-state.test.ts +88 -15
- package/test/tui/pager.test.ts +21 -46
|
@@ -23,7 +23,7 @@ describe("readReviewFile", () => {
|
|
|
23
23
|
id: "t1",
|
|
24
24
|
line: 10,
|
|
25
25
|
status: "open",
|
|
26
|
-
messages: [{ author: "
|
|
26
|
+
messages: [{ author: "reviewer", text: "Looks good?" }],
|
|
27
27
|
},
|
|
28
28
|
],
|
|
29
29
|
};
|
|
@@ -73,7 +73,7 @@ describe("readDraftFile", () => {
|
|
|
73
73
|
id: "t2",
|
|
74
74
|
line: 5,
|
|
75
75
|
status: "pending",
|
|
76
|
-
messages: [{ author: "
|
|
76
|
+
messages: [{ author: "owner", text: "Response here" }],
|
|
77
77
|
},
|
|
78
78
|
],
|
|
79
79
|
};
|
|
@@ -22,7 +22,7 @@ describe("isValidThread", () => {
|
|
|
22
22
|
id: "t1",
|
|
23
23
|
line: 5,
|
|
24
24
|
status: "open",
|
|
25
|
-
messages: [{ author: "
|
|
25
|
+
messages: [{ author: "reviewer", text: "hello" }],
|
|
26
26
|
})
|
|
27
27
|
).toBe(true);
|
|
28
28
|
});
|
|
@@ -69,7 +69,7 @@ describe("isValidReviewFile", () => {
|
|
|
69
69
|
id: "t1",
|
|
70
70
|
line: 1,
|
|
71
71
|
status: "open",
|
|
72
|
-
messages: [{ author: "
|
|
72
|
+
messages: [{ author: "reviewer", text: "comment" }],
|
|
73
73
|
},
|
|
74
74
|
],
|
|
75
75
|
})
|
|
@@ -19,7 +19,7 @@ describe("writeReviewFile", () => {
|
|
|
19
19
|
id: "t1",
|
|
20
20
|
line: 3,
|
|
21
21
|
status: "open",
|
|
22
|
-
messages: [{ author: "
|
|
22
|
+
messages: [{ author: "reviewer", text: "Is this right?" }],
|
|
23
23
|
},
|
|
24
24
|
],
|
|
25
25
|
};
|
|
@@ -60,7 +60,7 @@ describe("writeDraftFile", () => {
|
|
|
60
60
|
id: "t2",
|
|
61
61
|
line: 7,
|
|
62
62
|
status: "resolved",
|
|
63
|
-
messages: [{ author: "
|
|
63
|
+
messages: [{ author: "owner", text: "Fixed." }],
|
|
64
64
|
},
|
|
65
65
|
],
|
|
66
66
|
};
|
|
@@ -8,7 +8,7 @@ function makeThread(
|
|
|
8
8
|
id: string,
|
|
9
9
|
line: number,
|
|
10
10
|
status: Thread["status"],
|
|
11
|
-
messages: Thread["messages"] = [{ author: "
|
|
11
|
+
messages: Thread["messages"] = [{ author: "reviewer", text: "comment" }]
|
|
12
12
|
): Thread {
|
|
13
13
|
return { id, line, status, messages };
|
|
14
14
|
}
|
|
@@ -39,10 +39,9 @@ describe("ReviewState", () => {
|
|
|
39
39
|
expect(state.threads[0].line).toBe(3);
|
|
40
40
|
expect(state.threads[0].status).toBe("open");
|
|
41
41
|
expect(state.threads[0].messages).toHaveLength(1);
|
|
42
|
-
expect(state.threads[0].messages[0]).
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
});
|
|
42
|
+
expect(state.threads[0].messages[0].author).toBe("reviewer");
|
|
43
|
+
expect(state.threads[0].messages[0].text).toBe("needs clarification");
|
|
44
|
+
expect(state.threads[0].messages[0].ts).toBeGreaterThan(0);
|
|
46
45
|
});
|
|
47
46
|
|
|
48
47
|
it("assigns auto-incremented id", () => {
|
|
@@ -57,10 +56,9 @@ describe("ReviewState", () => {
|
|
|
57
56
|
const state = new ReviewState(SPEC, [makeThread("t1", 2, "pending")]);
|
|
58
57
|
state.replyToThread("t1", "my reply");
|
|
59
58
|
expect(state.threads[0].messages).toHaveLength(2);
|
|
60
|
-
expect(state.threads[0].messages[1]).
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
});
|
|
59
|
+
expect(state.threads[0].messages[1].author).toBe("reviewer");
|
|
60
|
+
expect(state.threads[0].messages[1].text).toBe("my reply");
|
|
61
|
+
expect(state.threads[0].messages[1].ts).toBeGreaterThan(0);
|
|
64
62
|
});
|
|
65
63
|
|
|
66
64
|
it("flips status to open", () => {
|
|
@@ -235,9 +233,9 @@ describe("ReviewState", () => {
|
|
|
235
233
|
expect(state.canApprove()).toBe(false);
|
|
236
234
|
});
|
|
237
235
|
|
|
238
|
-
it("returns
|
|
236
|
+
it("returns true when there are no threads (clean approval)", () => {
|
|
239
237
|
const state = new ReviewState(SPEC, []);
|
|
240
|
-
expect(state.canApprove()).toBe(
|
|
238
|
+
expect(state.canApprove()).toBe(true);
|
|
241
239
|
});
|
|
242
240
|
});
|
|
243
241
|
|
|
@@ -265,14 +263,14 @@ describe("ReviewState", () => {
|
|
|
265
263
|
line: 1,
|
|
266
264
|
status: "open",
|
|
267
265
|
messages: [
|
|
268
|
-
{ author: "
|
|
269
|
-
{ author: "
|
|
266
|
+
{ author: "owner", text: "AI response" },
|
|
267
|
+
{ author: "reviewer", text: "my draft" },
|
|
270
268
|
],
|
|
271
269
|
},
|
|
272
270
|
]);
|
|
273
271
|
state.deleteLastDraftMessage("t1");
|
|
274
272
|
expect(state.threads[0].messages).toHaveLength(1);
|
|
275
|
-
expect(state.threads[0].messages[0].author).toBe("
|
|
273
|
+
expect(state.threads[0].messages[0].author).toBe("owner");
|
|
276
274
|
});
|
|
277
275
|
|
|
278
276
|
it("removes the thread entirely when it becomes empty", () => {
|
|
@@ -281,7 +279,7 @@ describe("ReviewState", () => {
|
|
|
281
279
|
id: "t1",
|
|
282
280
|
line: 1,
|
|
283
281
|
status: "open",
|
|
284
|
-
messages: [{ author: "
|
|
282
|
+
messages: [{ author: "reviewer", text: "only message" }],
|
|
285
283
|
},
|
|
286
284
|
]);
|
|
287
285
|
state.deleteLastDraftMessage("t1");
|
|
@@ -323,4 +321,79 @@ describe("ReviewState", () => {
|
|
|
323
321
|
expect(state.activeThreadCount()).toEqual({ open: 0, pending: 0 });
|
|
324
322
|
});
|
|
325
323
|
});
|
|
324
|
+
|
|
325
|
+
describe("unread tracking", () => {
|
|
326
|
+
it("tracks unread owner replies", () => {
|
|
327
|
+
const state = new ReviewState(["line1", "line2"], []);
|
|
328
|
+
state.addComment(1, "fix this");
|
|
329
|
+
state.addOwnerReply("t1", "done", 1001);
|
|
330
|
+
expect(state.unreadCount()).toBe(1);
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
it("markRead clears unread for a thread", () => {
|
|
334
|
+
const state = new ReviewState(["line1"], []);
|
|
335
|
+
state.addComment(1, "fix");
|
|
336
|
+
state.addOwnerReply("t1", "done", 1001);
|
|
337
|
+
state.markRead("t1");
|
|
338
|
+
expect(state.unreadCount()).toBe(0);
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
it("nextUnreadThread returns line of next unread thread after cursor", () => {
|
|
342
|
+
const state = new ReviewState(["a", "b", "c", "d", "e"], []);
|
|
343
|
+
state.addComment(2, "fix");
|
|
344
|
+
state.addComment(4, "fix too");
|
|
345
|
+
state.addOwnerReply("t1", "done", 1001);
|
|
346
|
+
state.addOwnerReply("t2", "done", 1002);
|
|
347
|
+
state.cursorLine = 1;
|
|
348
|
+
expect(state.nextUnreadThread()).toBe(2);
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
it("prevUnreadThread returns line of prev unread thread before cursor", () => {
|
|
352
|
+
const state = new ReviewState(["a", "b", "c", "d", "e"], []);
|
|
353
|
+
state.addComment(2, "fix");
|
|
354
|
+
state.addComment(4, "fix too");
|
|
355
|
+
state.addOwnerReply("t1", "done", 1001);
|
|
356
|
+
state.addOwnerReply("t2", "done", 1002);
|
|
357
|
+
state.cursorLine = 5;
|
|
358
|
+
expect(state.prevUnreadThread()).toBe(4);
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
it("nextUnreadThread returns null when no unread", () => {
|
|
362
|
+
const state = new ReviewState(["a"], []);
|
|
363
|
+
expect(state.nextUnreadThread()).toBeNull();
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
it("isThreadUnread returns correct state", () => {
|
|
367
|
+
const state = new ReviewState(["a"], []);
|
|
368
|
+
state.addComment(1, "fix");
|
|
369
|
+
expect(state.isThreadUnread("t1")).toBe(false);
|
|
370
|
+
state.addOwnerReply("t1", "done", 1001);
|
|
371
|
+
expect(state.isThreadUnread("t1")).toBe(true);
|
|
372
|
+
state.markRead("t1");
|
|
373
|
+
expect(state.isThreadUnread("t1")).toBe(false);
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
it("nextUnreadThread wraps around", () => {
|
|
377
|
+
const state = new ReviewState(["a", "b", "c", "d", "e"], []);
|
|
378
|
+
state.addComment(2, "fix");
|
|
379
|
+
state.addOwnerReply("t1", "done", 1001);
|
|
380
|
+
state.cursorLine = 4;
|
|
381
|
+
expect(state.nextUnreadThread()).toBe(2); // wraps to beginning
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
it("addOwnerReply sets thread status to pending", () => {
|
|
385
|
+
const state = new ReviewState(["a"], []);
|
|
386
|
+
state.addComment(1, "fix");
|
|
387
|
+
expect(state.threads[0].status).toBe("open");
|
|
388
|
+
state.addOwnerReply("t1", "done", 1001);
|
|
389
|
+
expect(state.threads[0].status).toBe("pending");
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
it("addOwnerReply preserves timestamp on message", () => {
|
|
393
|
+
const state = new ReviewState(["a"], []);
|
|
394
|
+
state.addComment(1, "fix");
|
|
395
|
+
state.addOwnerReply("t1", "done", 1234567890);
|
|
396
|
+
expect(state.threads[0].messages[1].ts).toBe(1234567890);
|
|
397
|
+
});
|
|
398
|
+
});
|
|
326
399
|
});
|
package/test/tui/pager.test.ts
CHANGED
|
@@ -9,7 +9,7 @@ function makeThread(
|
|
|
9
9
|
id: string,
|
|
10
10
|
line: number,
|
|
11
11
|
status: Thread["status"],
|
|
12
|
-
messages: Thread["messages"] = [{ author: "
|
|
12
|
+
messages: Thread["messages"] = [{ author: "reviewer", text: "comment" }]
|
|
13
13
|
): Thread {
|
|
14
14
|
return { id, line, status, messages };
|
|
15
15
|
}
|
|
@@ -21,10 +21,8 @@ describe("buildPagerContent", () => {
|
|
|
21
21
|
const lines = content.split("\n");
|
|
22
22
|
|
|
23
23
|
expect(lines).toHaveLength(4);
|
|
24
|
-
// Line 1 is the cursor line, gets ">" prefix
|
|
25
24
|
expect(lines[0]).toContain(" 1");
|
|
26
25
|
expect(lines[0]).toContain("# Title");
|
|
27
|
-
// Other lines get " " prefix
|
|
28
26
|
expect(lines[1]).toContain(" 2");
|
|
29
27
|
expect(lines[1]).toContain("Some text");
|
|
30
28
|
expect(lines[2]).toContain(" 3");
|
|
@@ -37,45 +35,42 @@ describe("buildPagerContent", () => {
|
|
|
37
35
|
const content = buildPagerContent(state);
|
|
38
36
|
const lines = content.split("\n");
|
|
39
37
|
|
|
40
|
-
// Line 3 (index 2) should have ">" prefix
|
|
41
38
|
expect(lines[2]).toMatch(/^>/);
|
|
42
|
-
// Other lines should have " " prefix
|
|
43
39
|
expect(lines[0]).toMatch(/^ /);
|
|
44
40
|
expect(lines[1]).toMatch(/^ /);
|
|
45
41
|
expect(lines[3]).toMatch(/^ /);
|
|
46
42
|
});
|
|
47
43
|
|
|
48
|
-
it("shows
|
|
44
|
+
it("shows gutter indicator for open threads", () => {
|
|
49
45
|
const state = new ReviewState(SPEC, [makeThread("t1", 2, "open")]);
|
|
50
46
|
const content = buildPagerContent(state);
|
|
51
47
|
const lines = content.split("\n");
|
|
52
48
|
|
|
53
|
-
|
|
54
|
-
expect(lines[1]).toContain("*");
|
|
49
|
+
expect(lines[1]).toContain("\u258c");
|
|
55
50
|
});
|
|
56
51
|
|
|
57
|
-
it("shows
|
|
52
|
+
it("shows gutter indicator for pending threads", () => {
|
|
58
53
|
const state = new ReviewState(SPEC, [makeThread("t1", 2, "pending")]);
|
|
59
54
|
const content = buildPagerContent(state);
|
|
60
55
|
const lines = content.split("\n");
|
|
61
56
|
|
|
62
|
-
expect(lines[1]).toContain("
|
|
57
|
+
expect(lines[1]).toContain("\u258c");
|
|
63
58
|
});
|
|
64
59
|
|
|
65
|
-
it("shows
|
|
60
|
+
it("shows checkmark for resolved threads", () => {
|
|
66
61
|
const state = new ReviewState(SPEC, [makeThread("t1", 2, "resolved")]);
|
|
67
62
|
const content = buildPagerContent(state);
|
|
68
63
|
const lines = content.split("\n");
|
|
69
64
|
|
|
70
|
-
expect(lines[1]).toContain("\
|
|
65
|
+
expect(lines[1]).toContain("\u2713");
|
|
71
66
|
});
|
|
72
67
|
|
|
73
|
-
it("shows
|
|
68
|
+
it("shows gutter indicator for outdated threads", () => {
|
|
74
69
|
const state = new ReviewState(SPEC, [makeThread("t1", 2, "outdated")]);
|
|
75
70
|
const content = buildPagerContent(state);
|
|
76
71
|
const lines = content.split("\n");
|
|
77
72
|
|
|
78
|
-
expect(lines[1]).toContain("\
|
|
73
|
+
expect(lines[1]).toContain("\u258c");
|
|
79
74
|
});
|
|
80
75
|
|
|
81
76
|
it("shows different icons for different statuses", () => {
|
|
@@ -88,38 +83,24 @@ describe("buildPagerContent", () => {
|
|
|
88
83
|
const content = buildPagerContent(state);
|
|
89
84
|
const lines = content.split("\n");
|
|
90
85
|
|
|
91
|
-
expect(lines[0]).toContain("
|
|
92
|
-
expect(lines[1]).toContain("
|
|
93
|
-
expect(lines[2]).toContain("\
|
|
94
|
-
expect(lines[3]).toContain("\
|
|
86
|
+
expect(lines[0]).toContain("\u258c");
|
|
87
|
+
expect(lines[1]).toContain("\u258c");
|
|
88
|
+
expect(lines[2]).toContain("\u2713");
|
|
89
|
+
expect(lines[3]).toContain("\u258c");
|
|
95
90
|
});
|
|
96
91
|
|
|
97
|
-
it("
|
|
92
|
+
it("does not show inline comment preview (removed)", () => {
|
|
98
93
|
const state = new ReviewState(SPEC, [
|
|
99
94
|
makeThread("t1", 2, "open", [
|
|
100
|
-
{ author: "
|
|
101
|
-
{ author: "human", text: "My response" },
|
|
95
|
+
{ author: "reviewer", text: "My response" },
|
|
102
96
|
]),
|
|
103
97
|
]);
|
|
104
98
|
const content = buildPagerContent(state);
|
|
105
99
|
const lines = content.split("\n");
|
|
106
100
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
it("truncates long comment text to 40 chars", () => {
|
|
111
|
-
const longText =
|
|
112
|
-
"This is a very long comment that should be truncated because it exceeds the maximum hint length";
|
|
113
|
-
const state = new ReviewState(SPEC, [
|
|
114
|
-
makeThread("t1", 2, "open", [{ author: "human", text: longText }]),
|
|
115
|
-
]);
|
|
116
|
-
const content = buildPagerContent(state);
|
|
117
|
-
const lines = content.split("\n");
|
|
118
|
-
|
|
119
|
-
// Should be truncated to 39 chars + ellipsis
|
|
120
|
-
expect(lines[1]).toContain("\u2026");
|
|
121
|
-
// The full text should NOT appear
|
|
122
|
-
expect(lines[1]).not.toContain(longText);
|
|
101
|
+
// Only gutter indicator, no inline text preview
|
|
102
|
+
expect(lines[1]).toContain("\u258c");
|
|
103
|
+
expect(lines[1]).not.toContain("My response");
|
|
123
104
|
});
|
|
124
105
|
|
|
125
106
|
it("does not show status indicator for lines without threads", () => {
|
|
@@ -127,11 +108,8 @@ describe("buildPagerContent", () => {
|
|
|
127
108
|
const content = buildPagerContent(state);
|
|
128
109
|
const lines = content.split("\n");
|
|
129
110
|
|
|
130
|
-
|
|
131
|
-
expect(lines[0]).not.toContain("\
|
|
132
|
-
expect(lines[0]).not.toContain("\u{1F535}");
|
|
133
|
-
expect(lines[0]).not.toContain("\u2714");
|
|
134
|
-
expect(lines[0]).not.toContain("\u26A0");
|
|
111
|
+
expect(lines[0]).not.toContain("\u258c");
|
|
112
|
+
expect(lines[0]).not.toContain("\u2713");
|
|
135
113
|
});
|
|
136
114
|
|
|
137
115
|
it("pads line numbers to 4 characters", () => {
|
|
@@ -139,7 +117,6 @@ describe("buildPagerContent", () => {
|
|
|
139
117
|
const content = buildPagerContent(state);
|
|
140
118
|
const lines = content.split("\n");
|
|
141
119
|
|
|
142
|
-
// Line numbers are wrapped in ANSI gray codes, so check the raw number is present
|
|
143
120
|
expect(lines[0]).toContain(" 1");
|
|
144
121
|
expect(lines[0]).toContain("# Title");
|
|
145
122
|
});
|
|
@@ -155,10 +132,8 @@ describe("buildPagerContent", () => {
|
|
|
155
132
|
const content = buildPagerContent(state, "text");
|
|
156
133
|
const lines = content.split("\n");
|
|
157
134
|
|
|
158
|
-
// Lines 2 and 3 contain "text" — should be highlighted
|
|
159
135
|
expect(lines[1]).toContain("Some >>text<<");
|
|
160
136
|
expect(lines[2]).toContain("More >>text<<");
|
|
161
|
-
// Lines 1 and 4 do not contain "text"
|
|
162
137
|
expect(lines[0]).not.toContain(">>");
|
|
163
138
|
expect(lines[3]).not.toContain(">>");
|
|
164
139
|
});
|
|
@@ -180,5 +155,5 @@ describe("buildPagerContent", () => {
|
|
|
180
155
|
expect(lines[1]).toContain("Some text");
|
|
181
156
|
expect(lines[1]).not.toContain(">>");
|
|
182
157
|
});
|
|
183
|
-
});
|
|
184
158
|
|
|
159
|
+
});
|