revspec 0.8.2 → 0.8.4
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 +45 -20
- package/src/tui/help.ts +2 -1
- package/src/tui/live-watcher.ts +9 -7
- package/src/tui/pager.ts +50 -28
- 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 +2 -2
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
|
|
|
@@ -101,6 +108,12 @@ export async function runTui(
|
|
|
101
108
|
rootBox.add(bottomBar.box);
|
|
102
109
|
renderer.root.add(rootBox);
|
|
103
110
|
|
|
111
|
+
// Wrap mode state
|
|
112
|
+
let wrapEnabled = false;
|
|
113
|
+
function currentWrapWidth(): number {
|
|
114
|
+
return wrapEnabled ? renderer.width : 0;
|
|
115
|
+
}
|
|
116
|
+
|
|
104
117
|
// 7. Initial render
|
|
105
118
|
function refreshPager(): void {
|
|
106
119
|
// Spec mutation guard
|
|
@@ -111,10 +124,13 @@ export async function runTui(
|
|
|
111
124
|
}
|
|
112
125
|
} catch {}
|
|
113
126
|
|
|
114
|
-
buildPagerNodes(pager.lineNode, state, searchQuery, state.unreadThreadIds);
|
|
127
|
+
buildPagerNodes(pager.lineNode, state, searchQuery, state.unreadThreadIds, currentWrapWidth());
|
|
115
128
|
buildTopBar(topBar, specFile, state, state.unreadCount(), specMtimeChanged);
|
|
116
|
-
|
|
117
|
-
|
|
129
|
+
// Don't overwrite transient messages (welcome hint, warnings) during navigation
|
|
130
|
+
if (!messageTimer) {
|
|
131
|
+
const hasThread = !!state.threadAtLine(state.cursorLine);
|
|
132
|
+
buildBottomBar(bottomBar, commandBuffer, hasThread);
|
|
133
|
+
}
|
|
118
134
|
renderer.requestRender();
|
|
119
135
|
}
|
|
120
136
|
|
|
@@ -161,7 +177,7 @@ export async function runTui(
|
|
|
161
177
|
// Map visual row back to spec line number (for H/M/L)
|
|
162
178
|
function visualRowToSpecLine(targetRow: number): number {
|
|
163
179
|
for (let i = 0; i < state.specLines.length; i++) {
|
|
164
|
-
const row = i + countExtraVisualLines(state.specLines, i);
|
|
180
|
+
const row = i + countExtraVisualLines(state.specLines, i, currentWrapWidth());
|
|
165
181
|
if (row >= targetRow) return i + 1;
|
|
166
182
|
}
|
|
167
183
|
return state.lineCount;
|
|
@@ -209,7 +225,7 @@ export async function runTui(
|
|
|
209
225
|
// Helper: scroll pager to ensure cursor line is visible
|
|
210
226
|
function ensureCursorVisible(): void {
|
|
211
227
|
// Map spec line to visual row, accounting for table border extra lines
|
|
212
|
-
const extra = countExtraVisualLines(state.specLines, state.cursorLine - 1);
|
|
228
|
+
const extra = countExtraVisualLines(state.specLines, state.cursorLine - 1, currentWrapWidth());
|
|
213
229
|
const cursorRow = state.cursorLine - 1 + extra;
|
|
214
230
|
const viewportHeight = Math.max(1, renderer.height - 2); // minus top + bottom bar
|
|
215
231
|
|
|
@@ -244,6 +260,13 @@ export async function runTui(
|
|
|
244
260
|
exitTui(resolve, "session-end");
|
|
245
261
|
return "exit";
|
|
246
262
|
}
|
|
263
|
+
// :wrap — toggle line wrapping
|
|
264
|
+
if (cmd === "wrap") {
|
|
265
|
+
wrapEnabled = !wrapEnabled;
|
|
266
|
+
refreshPager();
|
|
267
|
+
showTransient(wrapEnabled ? "Line wrap on" : "Line wrap off", "info");
|
|
268
|
+
return "stay";
|
|
269
|
+
}
|
|
247
270
|
// :{N} — jump to line number
|
|
248
271
|
const lineNum = parseInt(cmd, 10);
|
|
249
272
|
if (!isNaN(lineNum) && lineNum > 0) {
|
|
@@ -291,13 +314,24 @@ export async function runTui(
|
|
|
291
314
|
}
|
|
292
315
|
},
|
|
293
316
|
onResolve: () => {
|
|
317
|
+
let didResolve = false;
|
|
294
318
|
if (existingThread) {
|
|
295
319
|
const wasResolved = existingThread.status === "resolved";
|
|
320
|
+
didResolve = !wasResolved;
|
|
296
321
|
state.resolveThread(existingThread.id);
|
|
297
322
|
state.markRead(existingThread.id);
|
|
298
323
|
appendEvent(jsonlPath, { type: wasResolved ? "unresolve" : "resolve", threadId: existingThread.id, author: "reviewer", ts: Date.now() });
|
|
299
324
|
}
|
|
300
325
|
dismissOverlay();
|
|
326
|
+
// Auto-advance to next thread only when resolving (not reopening)
|
|
327
|
+
if (didResolve) {
|
|
328
|
+
const nextLine = state.nextThread();
|
|
329
|
+
if (nextLine !== null) {
|
|
330
|
+
state.cursorLine = nextLine;
|
|
331
|
+
ensureCursorVisible();
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
refreshPager();
|
|
301
335
|
},
|
|
302
336
|
onCancel: () => {
|
|
303
337
|
if (existingThread) state.markRead(existingThread.id);
|
|
@@ -455,8 +489,7 @@ export async function runTui(
|
|
|
455
489
|
|
|
456
490
|
refreshPager();
|
|
457
491
|
if (state.threads.length === 0) {
|
|
458
|
-
|
|
459
|
-
renderer.requestRender();
|
|
492
|
+
showTransient("Navigate to a line and press c to comment | ? for help", "info", 8000);
|
|
460
493
|
}
|
|
461
494
|
renderer.start();
|
|
462
495
|
|
|
@@ -616,7 +649,7 @@ export async function runTui(
|
|
|
616
649
|
refreshPager();
|
|
617
650
|
break;
|
|
618
651
|
case "center-cursor": {
|
|
619
|
-
const extra = countExtraVisualLines(state.specLines, state.cursorLine - 1);
|
|
652
|
+
const extra = countExtraVisualLines(state.specLines, state.cursorLine - 1, currentWrapWidth());
|
|
620
653
|
const cursorRow = state.cursorLine - 1 + extra;
|
|
621
654
|
const halfView = Math.floor(pageSize() / 2);
|
|
622
655
|
pager.scrollBox.scrollTo(Math.max(0, cursorRow - halfView));
|
|
@@ -763,6 +796,9 @@ export async function runTui(
|
|
|
763
796
|
liveWatcher.start();
|
|
764
797
|
dismissOverlay();
|
|
765
798
|
searchQuery = null;
|
|
799
|
+
jumpList.length = 0;
|
|
800
|
+
jumpList.push(1);
|
|
801
|
+
jumpIndex = 0;
|
|
766
802
|
ensureCursorVisible();
|
|
767
803
|
refreshPager();
|
|
768
804
|
showTransient("Spec rewritten \u2014 review cleared", "success", 2500);
|
|
@@ -773,18 +809,7 @@ export async function runTui(
|
|
|
773
809
|
break;
|
|
774
810
|
case "approve":
|
|
775
811
|
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);
|
|
812
|
+
exitTui(resolve, "approve");
|
|
788
813
|
});
|
|
789
814
|
break;
|
|
790
815
|
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
|
|
|
@@ -114,6 +114,7 @@ export function createHelp(opts: {
|
|
|
114
114
|
" :q/:wq Quit (warns if unresolved)",
|
|
115
115
|
" :q! Force quit",
|
|
116
116
|
" :{N} Jump to line N",
|
|
117
|
+
" :wrap Toggle line wrapping",
|
|
117
118
|
" Ctrl+C Force quit",
|
|
118
119
|
]);
|
|
119
120
|
|
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
|
/**
|
|
@@ -64,13 +53,33 @@ export function buildPagerContent(state: ReviewState, searchQuery?: string | nul
|
|
|
64
53
|
* Inline markdown is parsed and styled per line.
|
|
65
54
|
* Line numbers and thread hints are dimmed.
|
|
66
55
|
*/
|
|
67
|
-
|
|
56
|
+
/**
|
|
57
|
+
* Word-wrap a string at the given width, breaking at word boundaries.
|
|
58
|
+
*/
|
|
59
|
+
function wordWrap(text: string, width: number): string[] {
|
|
60
|
+
if (width <= 0 || text.length <= width) return [text];
|
|
61
|
+
const lines: string[] = [];
|
|
62
|
+
let remaining = text;
|
|
63
|
+
while (remaining.length > width) {
|
|
64
|
+
let breakAt = remaining.lastIndexOf(" ", width);
|
|
65
|
+
if (breakAt <= 0) breakAt = width; // no space found — hard break
|
|
66
|
+
lines.push(remaining.slice(0, breakAt));
|
|
67
|
+
remaining = remaining.slice(breakAt).replace(/^ /, ""); // trim leading space on continuation
|
|
68
|
+
}
|
|
69
|
+
if (remaining.length > 0) lines.push(remaining);
|
|
70
|
+
return lines;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function buildPagerNodes(lineNode: TextRenderable, state: ReviewState, searchQuery?: string | null, unreadThreadIds?: ReadonlySet<string>, wrapWidth?: number): void {
|
|
68
74
|
lineNode.clear();
|
|
69
75
|
|
|
70
76
|
// Calculate dynamic gutter width based on total line count
|
|
71
77
|
const numWidth = Math.max(String(state.lineCount).length, 3);
|
|
72
|
-
|
|
73
|
-
|
|
78
|
+
const gutterWidth = 2 + numWidth + 2; // prefix(1) + indicator(1) + numWidth + spaces(2)
|
|
79
|
+
// Blank gutter for table borders and wrap continuations
|
|
80
|
+
const gutterBlank = " ".repeat(gutterWidth);
|
|
81
|
+
// Available content width for wrapping
|
|
82
|
+
const contentWidth = wrapWidth && wrapWidth > gutterWidth ? wrapWidth - gutterWidth : 0;
|
|
74
83
|
|
|
75
84
|
// Pre-scan for table blocks so we can calculate column widths
|
|
76
85
|
const tableBlocks = new Map<number, TableBlock>();
|
|
@@ -127,7 +136,7 @@ export function buildPagerNodes(lineNode: TextRenderable, state: ReviewState, se
|
|
|
127
136
|
// Gutter: cursor + indicator + line number (dimmed)
|
|
128
137
|
lineNode.add(TextNodeRenderable.fromString(
|
|
129
138
|
`${prefix}`,
|
|
130
|
-
{ fg: isCursor ? theme.
|
|
139
|
+
{ fg: isCursor ? theme.mauve : theme.textDim, bg: isCursor ? theme.backgroundElement : undefined }
|
|
131
140
|
));
|
|
132
141
|
lineNode.add(TextNodeRenderable.fromString(
|
|
133
142
|
indicator,
|
|
@@ -171,7 +180,10 @@ export function buildPagerNodes(lineNode: TextRenderable, state: ReviewState, se
|
|
|
171
180
|
lineNode.add(TextNodeRenderable.fromString(gutterBlank, { fg: theme.textDim }));
|
|
172
181
|
renderTableBorder(lineNode, tableBlock.colWidths, "bottom");
|
|
173
182
|
}
|
|
174
|
-
} else if (searchQuery &&
|
|
183
|
+
} else if (searchQuery && (() => {
|
|
184
|
+
const cs = searchQuery !== searchQuery.toLowerCase();
|
|
185
|
+
return cs ? specText.includes(searchQuery) : specText.toLowerCase().includes(searchQuery.toLowerCase());
|
|
186
|
+
})()) {
|
|
175
187
|
// Line contains search match — show colored match segments (no markdown styling)
|
|
176
188
|
const escaped = searchQuery.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
177
189
|
const caseSensitive = searchQuery !== searchQuery.toLowerCase();
|
|
@@ -187,21 +199,23 @@ export function buildPagerNodes(lineNode: TextRenderable, state: ReviewState, se
|
|
|
187
199
|
lineNode.add(TextNodeRenderable.fromString(part, { fg: theme.text, bg: isCursor ? theme.backgroundElement : undefined }));
|
|
188
200
|
}
|
|
189
201
|
}
|
|
202
|
+
} else if (contentWidth > 0 && specText.length > contentWidth) {
|
|
203
|
+
// Wrap long lines — first chunk gets markdown, continuations get blank gutter + markdown
|
|
204
|
+
const chunks = wordWrap(specText, contentWidth);
|
|
205
|
+
const segments = parseMarkdownLine(chunks[0]);
|
|
206
|
+
addSegments(lineNode, segments, theme.text, isCursor ? theme.backgroundElement : undefined);
|
|
207
|
+
for (let c = 1; c < chunks.length; c++) {
|
|
208
|
+
lineNode.add(TextNodeRenderable.fromString("\n", {}));
|
|
209
|
+
lineNode.add(TextNodeRenderable.fromString(gutterBlank, { fg: theme.textDim }));
|
|
210
|
+
const contSegments = parseMarkdownLine(chunks[c]);
|
|
211
|
+
addSegments(lineNode, contSegments, theme.text, isCursor ? theme.backgroundElement : undefined);
|
|
212
|
+
}
|
|
190
213
|
} else {
|
|
191
214
|
// Parse and render inline markdown
|
|
192
215
|
const segments = parseMarkdownLine(specText);
|
|
193
216
|
addSegments(lineNode, segments, theme.text, isCursor ? theme.backgroundElement : undefined);
|
|
194
217
|
}
|
|
195
218
|
|
|
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
219
|
// Newline between lines (except last)
|
|
206
220
|
if (i < state.specLines.length - 1) {
|
|
207
221
|
lineNode.add(TextNodeRenderable.fromString("\n", {}));
|
|
@@ -212,10 +226,14 @@ export function buildPagerNodes(lineNode: TextRenderable, state: ReviewState, se
|
|
|
212
226
|
// --- Visual row offset calculation ---
|
|
213
227
|
|
|
214
228
|
/**
|
|
215
|
-
* Count extra visual lines (table borders) before a given spec line index.
|
|
229
|
+
* Count extra visual lines (table borders + word wrap) before a given spec line index.
|
|
216
230
|
* Used to map spec line numbers to actual visual rows in the rendered content.
|
|
217
231
|
*/
|
|
218
|
-
export function countExtraVisualLines(specLines: string[], cursorIndex: number): number {
|
|
232
|
+
export function countExtraVisualLines(specLines: string[], cursorIndex: number, wrapWidth?: number): number {
|
|
233
|
+
const numWidth = Math.max(String(specLines.length).length, 3);
|
|
234
|
+
const gutterWidth = 2 + numWidth + 2;
|
|
235
|
+
const contentWidth = wrapWidth && wrapWidth > gutterWidth ? wrapWidth - gutterWidth : 0;
|
|
236
|
+
|
|
219
237
|
let extra = 0;
|
|
220
238
|
let i = 0;
|
|
221
239
|
while (i < specLines.length) {
|
|
@@ -232,6 +250,10 @@ export function countExtraVisualLines(specLines: string[], cursorIndex: number):
|
|
|
232
250
|
if (cursorIndex >= tableEnd) extra++;
|
|
233
251
|
continue;
|
|
234
252
|
}
|
|
253
|
+
// Word wrap: count extra continuation lines
|
|
254
|
+
if (contentWidth > 0 && i < cursorIndex && specLines[i].length > contentWidth) {
|
|
255
|
+
extra += wordWrap(specLines[i], contentWidth).length - 1;
|
|
256
|
+
}
|
|
235
257
|
i++;
|
|
236
258
|
}
|
|
237
259
|
return extra;
|
|
@@ -260,7 +282,7 @@ export function createPager(renderer: CliRenderer): PagerComponents {
|
|
|
260
282
|
width: "100%",
|
|
261
283
|
flexGrow: 1,
|
|
262
284
|
scrollY: true,
|
|
263
|
-
scrollX:
|
|
285
|
+
scrollX: false,
|
|
264
286
|
backgroundColor: theme.base,
|
|
265
287
|
});
|
|
266
288
|
|
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) {
|
|
@@ -82,7 +82,7 @@ export function parseMarkdownLine(line: string): StyledSegment[] {
|
|
|
82
82
|
const headingMatch = line.match(/^(#{1,6})\s+(.*)/);
|
|
83
83
|
if (headingMatch) {
|
|
84
84
|
const level = headingMatch[1].length;
|
|
85
|
-
const color = level <= 2 ? theme.blue : theme.mauve;
|
|
85
|
+
const color = level <= 2 ? theme.blue : level === 3 ? theme.mauve : theme.textMuted;
|
|
86
86
|
// Parse inline markdown within heading text
|
|
87
87
|
const inner = parseInlineMarkdown(headingMatch[2]);
|
|
88
88
|
return inner.map((s) => ({
|