revspec 0.8.2 → 0.8.3
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/README.md +1 -1
- package/package.json +1 -1
- package/src/tui/app.ts +28 -16
- package/src/tui/help.ts +1 -1
- package/src/tui/live-watcher.ts +9 -7
- package/src/tui/pager.ts +5 -22
- package/src/tui/spinner.ts +1 -1
- package/src/tui/status-bar.ts +21 -11
- package/src/tui/thread-list.ts +5 -1
- package/src/tui/ui/markdown.ts +1 -1
package/README.md
CHANGED
|
@@ -94,7 +94,7 @@ Opens a TUI with vim-style navigation. Press `c` on any line to open a thread an
|
|
|
94
94
|
The thread popup has two vim-style modes, indicated by border color and label:
|
|
95
95
|
|
|
96
96
|
- **Insert mode** (green border) — type your comment, `Tab` sends, `Esc` switches to normal mode
|
|
97
|
-
- **Normal mode** (blue border) — `j/k` and `Ctrl+D/U` scroll the conversation, `gg/G` top/bottom, `c` to reply, `r` to resolve, `q/Esc` to close
|
|
97
|
+
- **Normal mode** (blue border) — `j/k` and `Ctrl+D/U` scroll the conversation, `gg/G` top/bottom, `i/c` to reply, `r` to resolve, `q/Esc` to close
|
|
98
98
|
|
|
99
99
|
### Markdown rendering
|
|
100
100
|
|
package/package.json
CHANGED
package/src/tui/app.ts
CHANGED
|
@@ -61,9 +61,12 @@ export async function runTui(
|
|
|
61
61
|
|
|
62
62
|
// Create and start the live watcher
|
|
63
63
|
const liveWatcher: LiveWatcher = createLiveWatcher(jsonlPath, (ownerEvents) => {
|
|
64
|
+
let lastReplyThread: { id: string; line: number } | null = null;
|
|
64
65
|
for (const event of ownerEvents) {
|
|
65
66
|
if (event.type === "reply" && event.threadId && event.text) {
|
|
66
67
|
state.addOwnerReply(event.threadId, event.text, event.ts);
|
|
68
|
+
const thread = state.threads.find((t) => t.id === event.threadId);
|
|
69
|
+
if (thread) lastReplyThread = { id: thread.id, line: thread.line };
|
|
67
70
|
// If the thread popup is open for this thread, push the message in
|
|
68
71
|
if (activeOverlay?.addMessage && activeOverlay?.threadId === event.threadId) {
|
|
69
72
|
activeOverlay.addMessage({ author: "owner", text: event.text, ts: event.ts });
|
|
@@ -71,6 +74,10 @@ export async function runTui(
|
|
|
71
74
|
}
|
|
72
75
|
}
|
|
73
76
|
refreshPager();
|
|
77
|
+
// Flash notification for AI replies when not viewing that thread
|
|
78
|
+
if (lastReplyThread && activeOverlay?.threadId !== lastReplyThread.id) {
|
|
79
|
+
showTransient(`AI replied on line ${lastReplyThread.line}`, "info");
|
|
80
|
+
}
|
|
74
81
|
});
|
|
75
82
|
liveWatcher.start();
|
|
76
83
|
|
|
@@ -113,8 +120,11 @@ export async function runTui(
|
|
|
113
120
|
|
|
114
121
|
buildPagerNodes(pager.lineNode, state, searchQuery, state.unreadThreadIds);
|
|
115
122
|
buildTopBar(topBar, specFile, state, state.unreadCount(), specMtimeChanged);
|
|
116
|
-
|
|
117
|
-
|
|
123
|
+
// Don't overwrite transient messages (welcome hint, warnings) during navigation
|
|
124
|
+
if (!messageTimer) {
|
|
125
|
+
const hasThread = !!state.threadAtLine(state.cursorLine);
|
|
126
|
+
buildBottomBar(bottomBar, commandBuffer, hasThread);
|
|
127
|
+
}
|
|
118
128
|
renderer.requestRender();
|
|
119
129
|
}
|
|
120
130
|
|
|
@@ -291,13 +301,24 @@ export async function runTui(
|
|
|
291
301
|
}
|
|
292
302
|
},
|
|
293
303
|
onResolve: () => {
|
|
304
|
+
let didResolve = false;
|
|
294
305
|
if (existingThread) {
|
|
295
306
|
const wasResolved = existingThread.status === "resolved";
|
|
307
|
+
didResolve = !wasResolved;
|
|
296
308
|
state.resolveThread(existingThread.id);
|
|
297
309
|
state.markRead(existingThread.id);
|
|
298
310
|
appendEvent(jsonlPath, { type: wasResolved ? "unresolve" : "resolve", threadId: existingThread.id, author: "reviewer", ts: Date.now() });
|
|
299
311
|
}
|
|
300
312
|
dismissOverlay();
|
|
313
|
+
// Auto-advance to next thread only when resolving (not reopening)
|
|
314
|
+
if (didResolve) {
|
|
315
|
+
const nextLine = state.nextThread();
|
|
316
|
+
if (nextLine !== null) {
|
|
317
|
+
state.cursorLine = nextLine;
|
|
318
|
+
ensureCursorVisible();
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
refreshPager();
|
|
301
322
|
},
|
|
302
323
|
onCancel: () => {
|
|
303
324
|
if (existingThread) state.markRead(existingThread.id);
|
|
@@ -455,8 +476,7 @@ export async function runTui(
|
|
|
455
476
|
|
|
456
477
|
refreshPager();
|
|
457
478
|
if (state.threads.length === 0) {
|
|
458
|
-
|
|
459
|
-
renderer.requestRender();
|
|
479
|
+
showTransient("Navigate to a line and press c to comment | ? for help", "info", 8000);
|
|
460
480
|
}
|
|
461
481
|
renderer.start();
|
|
462
482
|
|
|
@@ -763,6 +783,9 @@ export async function runTui(
|
|
|
763
783
|
liveWatcher.start();
|
|
764
784
|
dismissOverlay();
|
|
765
785
|
searchQuery = null;
|
|
786
|
+
jumpList.length = 0;
|
|
787
|
+
jumpList.push(1);
|
|
788
|
+
jumpIndex = 0;
|
|
766
789
|
ensureCursorVisible();
|
|
767
790
|
refreshPager();
|
|
768
791
|
showTransient("Spec rewritten \u2014 review cleared", "success", 2500);
|
|
@@ -773,18 +796,7 @@ export async function runTui(
|
|
|
773
796
|
break;
|
|
774
797
|
case "approve":
|
|
775
798
|
unresolvedGate(() => {
|
|
776
|
-
|
|
777
|
-
renderer,
|
|
778
|
-
message: "Approve spec and proceed to implementation?",
|
|
779
|
-
onConfirm: () => {
|
|
780
|
-
dismissOverlay();
|
|
781
|
-
exitTui(resolve, "approve");
|
|
782
|
-
},
|
|
783
|
-
onCancel: () => {
|
|
784
|
-
dismissOverlay();
|
|
785
|
-
},
|
|
786
|
-
});
|
|
787
|
-
showOverlay(confirmOverlay);
|
|
799
|
+
exitTui(resolve, "approve");
|
|
788
800
|
});
|
|
789
801
|
break;
|
|
790
802
|
case "next-thread": {
|
package/src/tui/help.ts
CHANGED
|
@@ -78,7 +78,7 @@ export function createHelp(opts: {
|
|
|
78
78
|
|
|
79
79
|
addHelpSection(dialog.content, renderer, "Thread Popup", [
|
|
80
80
|
" New thread: INSERT mode (green border) — type and Tab to send.",
|
|
81
|
-
" Existing thread: NORMAL mode (blue border) —
|
|
81
|
+
" Existing thread: NORMAL mode (blue border) — scroll conversation,",
|
|
82
82
|
" c to reply, r to resolve, q/Esc to close.",
|
|
83
83
|
]);
|
|
84
84
|
|
package/src/tui/live-watcher.ts
CHANGED
|
@@ -15,14 +15,16 @@ export function createLiveWatcher(
|
|
|
15
15
|
let pollTimer: ReturnType<typeof setInterval> | null = null
|
|
16
16
|
|
|
17
17
|
function check() {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
18
|
+
try {
|
|
19
|
+
const { events, newOffset } = readEventsFromOffset(jsonlPath, offset)
|
|
20
|
+
if (events.length > 0) {
|
|
21
|
+
offset = newOffset
|
|
22
|
+
const ownerEvents = events.filter((e) => e.author === "owner")
|
|
23
|
+
if (ownerEvents.length > 0) {
|
|
24
|
+
onOwnerEvents(ownerEvents)
|
|
25
|
+
}
|
|
24
26
|
}
|
|
25
|
-
}
|
|
27
|
+
} catch {}
|
|
26
28
|
}
|
|
27
29
|
|
|
28
30
|
return {
|
package/src/tui/pager.ts
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import type { ReviewState } from "../state/review-state";
|
|
2
|
-
import type { Thread } from "../protocol/types";
|
|
3
2
|
import {
|
|
4
3
|
ScrollBoxRenderable,
|
|
5
4
|
TextRenderable,
|
|
@@ -10,16 +9,6 @@ import {
|
|
|
10
9
|
import { theme } from "./ui/theme";
|
|
11
10
|
import { parseMarkdownLine, addSegments, collectTable, renderTableBorder, renderTableSeparator, renderTableRow, parseTableCells, type TableBlock } from "./ui/markdown";
|
|
12
11
|
|
|
13
|
-
const MAX_HINT_LENGTH = 40;
|
|
14
|
-
|
|
15
|
-
function threadHint(thread: Thread): string {
|
|
16
|
-
if (thread.messages.length === 0) return "";
|
|
17
|
-
const last = thread.messages[thread.messages.length - 1];
|
|
18
|
-
const text = last.text.replace(/\n/g, " ");
|
|
19
|
-
if (text.length <= MAX_HINT_LENGTH) return text;
|
|
20
|
-
return text.slice(0, MAX_HINT_LENGTH - 1) + "\u2026";
|
|
21
|
-
}
|
|
22
|
-
|
|
23
12
|
// --- Plain text builder (for tests) ---
|
|
24
13
|
|
|
25
14
|
/**
|
|
@@ -127,7 +116,7 @@ export function buildPagerNodes(lineNode: TextRenderable, state: ReviewState, se
|
|
|
127
116
|
// Gutter: cursor + indicator + line number (dimmed)
|
|
128
117
|
lineNode.add(TextNodeRenderable.fromString(
|
|
129
118
|
`${prefix}`,
|
|
130
|
-
{ fg: isCursor ? theme.
|
|
119
|
+
{ fg: isCursor ? theme.mauve : theme.textDim, bg: isCursor ? theme.backgroundElement : undefined }
|
|
131
120
|
));
|
|
132
121
|
lineNode.add(TextNodeRenderable.fromString(
|
|
133
122
|
indicator,
|
|
@@ -171,7 +160,10 @@ export function buildPagerNodes(lineNode: TextRenderable, state: ReviewState, se
|
|
|
171
160
|
lineNode.add(TextNodeRenderable.fromString(gutterBlank, { fg: theme.textDim }));
|
|
172
161
|
renderTableBorder(lineNode, tableBlock.colWidths, "bottom");
|
|
173
162
|
}
|
|
174
|
-
} else if (searchQuery &&
|
|
163
|
+
} else if (searchQuery && (() => {
|
|
164
|
+
const cs = searchQuery !== searchQuery.toLowerCase();
|
|
165
|
+
return cs ? specText.includes(searchQuery) : specText.toLowerCase().includes(searchQuery.toLowerCase());
|
|
166
|
+
})()) {
|
|
175
167
|
// Line contains search match — show colored match segments (no markdown styling)
|
|
176
168
|
const escaped = searchQuery.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
177
169
|
const caseSensitive = searchQuery !== searchQuery.toLowerCase();
|
|
@@ -193,15 +185,6 @@ export function buildPagerNodes(lineNode: TextRenderable, state: ReviewState, se
|
|
|
193
185
|
addSegments(lineNode, segments, theme.text, isCursor ? theme.backgroundElement : undefined);
|
|
194
186
|
}
|
|
195
187
|
|
|
196
|
-
// Thread hint (dimmed, inline)
|
|
197
|
-
if (thread && thread.messages.length > 0) {
|
|
198
|
-
const hint = threadHint(thread);
|
|
199
|
-
lineNode.add(TextNodeRenderable.fromString(
|
|
200
|
-
` \u00ab ${hint}`,
|
|
201
|
-
{ fg: theme.textDim, attributes: TextAttributes.DIM, bg: isCursor ? theme.backgroundElement : undefined }
|
|
202
|
-
));
|
|
203
|
-
}
|
|
204
|
-
|
|
205
188
|
// Newline between lines (except last)
|
|
206
189
|
if (i < state.specLines.length - 1) {
|
|
207
190
|
lineNode.add(TextNodeRenderable.fromString("\n", {}));
|
package/src/tui/spinner.ts
CHANGED
|
@@ -6,7 +6,7 @@ import {
|
|
|
6
6
|
} from "@opentui/core";
|
|
7
7
|
import { theme } from "./ui/theme";
|
|
8
8
|
|
|
9
|
-
const SPINNER_FRAMES = ["
|
|
9
|
+
const SPINNER_FRAMES = ["|", "/", "-", "\\"];
|
|
10
10
|
|
|
11
11
|
export interface SpinnerOverlay {
|
|
12
12
|
container: BoxRenderable;
|
package/src/tui/status-bar.ts
CHANGED
|
@@ -33,16 +33,16 @@ export function buildTopBar(
|
|
|
33
33
|
// Filename — bold
|
|
34
34
|
t.add(TextNodeRenderable.fromString(` ${name}`, { fg: theme.text, attributes: TextAttributes.BOLD }));
|
|
35
35
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
if (
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
36
|
+
// Thread progress — shown when threads exist
|
|
37
|
+
if (state.threads.length > 0) {
|
|
38
|
+
const resolved = state.threads.filter((t) => t.status === "resolved").length;
|
|
39
|
+
const total = state.threads.length;
|
|
40
|
+
t.add(TextNodeRenderable.fromString(" \u00b7 ", { fg: theme.textDim }));
|
|
41
|
+
if (resolved === total) {
|
|
42
|
+
t.add(TextNodeRenderable.fromString(`${resolved}/${total} resolved`, { fg: theme.green }));
|
|
43
|
+
} else {
|
|
44
|
+
t.add(TextNodeRenderable.fromString(`${resolved}/${total} resolved`, { fg: theme.yellow }));
|
|
45
|
+
}
|
|
46
46
|
}
|
|
47
47
|
|
|
48
48
|
// Unread replies
|
|
@@ -50,7 +50,7 @@ export function buildTopBar(
|
|
|
50
50
|
t.add(TextNodeRenderable.fromString(" \u00b7 ", { fg: theme.textDim }));
|
|
51
51
|
t.add(TextNodeRenderable.fromString(
|
|
52
52
|
`${unreadCount} new repl${unreadCount === 1 ? "y" : "ies"}`,
|
|
53
|
-
{ fg: theme.
|
|
53
|
+
{ fg: theme.yellow, attributes: TextAttributes.BOLD }
|
|
54
54
|
));
|
|
55
55
|
}
|
|
56
56
|
|
|
@@ -66,6 +66,16 @@ export function buildTopBar(
|
|
|
66
66
|
: `${Math.round(((state.cursorLine - 1) / (state.lineCount - 1)) * 100)}%`;
|
|
67
67
|
t.add(TextNodeRenderable.fromString(" \u00b7 ", { fg: theme.textDim }));
|
|
68
68
|
t.add(TextNodeRenderable.fromString(`L${state.cursorLine}/${state.lineCount} ${posLabel}`, { fg: theme.textMuted }));
|
|
69
|
+
|
|
70
|
+
// Current section breadcrumb — nearest heading above cursor
|
|
71
|
+
for (let i = state.cursorLine - 1; i >= 0; i--) {
|
|
72
|
+
const match = state.specLines[i].match(/^(#{1,3})\s+(.+)/);
|
|
73
|
+
if (match) {
|
|
74
|
+
t.add(TextNodeRenderable.fromString(" \u00b7 ", { fg: theme.textDim }));
|
|
75
|
+
t.add(TextNodeRenderable.fromString(match[2].trim(), { fg: theme.textDim, attributes: TextAttributes.ITALIC }));
|
|
76
|
+
break;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
69
79
|
}
|
|
70
80
|
|
|
71
81
|
export type MessageIcon = "warn" | "success" | "info";
|
package/src/tui/thread-list.ts
CHANGED
|
@@ -57,7 +57,11 @@ function buildTitle(threads: Thread[], mode: FilterMode): string {
|
|
|
57
57
|
}
|
|
58
58
|
|
|
59
59
|
function threadsToOptions(threads: Thread[]) {
|
|
60
|
-
|
|
60
|
+
const STATUS_ORDER: Record<string, number> = { open: 0, pending: 1, resolved: 2 };
|
|
61
|
+
const sorted = [...threads].sort((a, b) =>
|
|
62
|
+
(STATUS_ORDER[a.status] ?? 3) - (STATUS_ORDER[b.status] ?? 3) || a.line - b.line
|
|
63
|
+
);
|
|
64
|
+
return sorted.map((t) => {
|
|
61
65
|
const icon = STATUS_ICONS[t.status];
|
|
62
66
|
return {
|
|
63
67
|
name: `${icon} #${t.id} line ${t.line}: ${previewText(t)}`,
|
package/src/tui/ui/markdown.ts
CHANGED
|
@@ -29,7 +29,7 @@ export function parseInlineMarkdown(text: string): StyledSegment[] {
|
|
|
29
29
|
// 7: ~~strikethrough~~
|
|
30
30
|
// 8: [link text](url) — display text only
|
|
31
31
|
// 9: `code`
|
|
32
|
-
const regex = /(\*\*\*(.+?)\*\*\*|\*\*(.+?)\*\*|\*(.+?)\*|__(.+?)__|_(.+?)_|~~(.+?)~~|\[([^\]]+)\]\([^)]+\)|`([^`]+)`)/g;
|
|
32
|
+
const regex = /(\*\*\*(.+?)\*\*\*|\*\*(.+?)\*\*|\*(.+?)\*|(?<!\w)__(.+?)__(?!\w)|(?<!\w)_(.+?)_(?!\w)|~~(.+?)~~|\[([^\]]+)\]\([^)]+\)|`([^`]+)`)/g;
|
|
33
33
|
let pos = 0;
|
|
34
34
|
let match;
|
|
35
35
|
while ((match = regex.exec(text)) !== null) {
|