revspec 0.4.0 → 0.5.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/README.md +13 -0
- package/package.json +1 -1
- package/src/state/review-state.ts +5 -0
- package/src/tui/app.ts +35 -28
- package/src/tui/comment-input.ts +1 -0
- package/src/tui/help.ts +61 -36
- package/src/tui/pager.ts +32 -12
- package/src/tui/status-bar.ts +18 -6
- package/src/tui/thread-list.ts +1 -1
- package/src/tui/ui/keybinds.ts +4 -2
- package/src/tui/ui/markdown.ts +50 -9
package/README.md
CHANGED
|
@@ -29,6 +29,19 @@ revspec spec.md
|
|
|
29
29
|
|
|
30
30
|
Opens a TUI in line mode with vim-style navigation. Press `c` on any line to open a thread and start commenting.
|
|
31
31
|
|
|
32
|
+
### Markdown rendering
|
|
33
|
+
|
|
34
|
+
Revspec renders markdown in-place (toggle with `m`):
|
|
35
|
+
|
|
36
|
+
- **Headings** — colored and bold, `#`–`######`
|
|
37
|
+
- **Inline** — bold (`**`/`__`), italic (`*`/`_`), bold-italic (`***`), strikethrough (`~~`), `code`, [links](url)
|
|
38
|
+
- **Fenced code blocks** — fence markers dimmed, body in green
|
|
39
|
+
- **Tables** — box-drawing borders, header row bolded, auto-column-widths
|
|
40
|
+
- **Lists** — unordered (`•`), ordered, task lists (`☐`/`☑`)
|
|
41
|
+
- **Blockquotes** — bar gutter, italicized text
|
|
42
|
+
- **Cursor line** highlighting across all elements
|
|
43
|
+
- **Search highlights** — colored match segments
|
|
44
|
+
|
|
32
45
|
### Keybindings
|
|
33
46
|
|
|
34
47
|
| Key | Action |
|
package/package.json
CHANGED
|
@@ -136,6 +136,11 @@ export class ReviewState {
|
|
|
136
136
|
}
|
|
137
137
|
}
|
|
138
138
|
|
|
139
|
+
deleteThread(threadId: string): void {
|
|
140
|
+
this.threads = this.threads.filter((t) => t.id !== threadId);
|
|
141
|
+
this._unreadThreadIds.delete(threadId);
|
|
142
|
+
}
|
|
143
|
+
|
|
139
144
|
addOwnerReply(threadId: string, text: string, ts?: number): void {
|
|
140
145
|
const thread = this.threads.find((t) => t.id === threadId);
|
|
141
146
|
if (!thread) return;
|
package/src/tui/app.ts
CHANGED
|
@@ -17,6 +17,7 @@ import { buildPagerNodes, createPager, countExtraVisualLines, type PagerComponen
|
|
|
17
17
|
import {
|
|
18
18
|
buildTopBar,
|
|
19
19
|
buildBottomBar,
|
|
20
|
+
setBottomBarMessage,
|
|
20
21
|
createTopBar,
|
|
21
22
|
createBottomBar,
|
|
22
23
|
type TopBarComponents,
|
|
@@ -129,7 +130,8 @@ export async function runTui(
|
|
|
129
130
|
|
|
130
131
|
buildPagerNodes(pager.lineNode, state, searchQuery, state.unreadThreadIds);
|
|
131
132
|
buildTopBar(topBar, specFile, state, state.unreadCount(), specMtimeChanged);
|
|
132
|
-
|
|
133
|
+
const hasThread = !!state.threadAtLine(state.cursorLine);
|
|
134
|
+
buildBottomBar(bottomBar, commandBuffer, hasThread);
|
|
133
135
|
renderer.requestRender();
|
|
134
136
|
}
|
|
135
137
|
|
|
@@ -221,12 +223,12 @@ export async function runTui(
|
|
|
221
223
|
if (cmd === "w") {
|
|
222
224
|
// Merge JSONL -> JSON, stay open
|
|
223
225
|
doMerge();
|
|
224
|
-
bottomBar
|
|
226
|
+
setBottomBarMessage(bottomBar, " \u2714 Merged to review JSON");
|
|
225
227
|
renderer.requestRender();
|
|
226
228
|
setTimeout(() => { refreshPager(); }, 1200);
|
|
227
229
|
return "stay";
|
|
228
230
|
}
|
|
229
|
-
if (cmd === "wq") {
|
|
231
|
+
if (cmd === "wq" || cmd === "qw") {
|
|
230
232
|
// Merge and exit
|
|
231
233
|
mergeAndExit(resolve);
|
|
232
234
|
return "merged";
|
|
@@ -234,7 +236,7 @@ export async function runTui(
|
|
|
234
236
|
if (cmd === "q") {
|
|
235
237
|
// Exit only if merged (no pending changes)
|
|
236
238
|
if (hasPendingChanges()) {
|
|
237
|
-
bottomBar
|
|
239
|
+
setBottomBarMessage(bottomBar, " Unmerged changes. Use :w to save or :q! to discard");
|
|
238
240
|
renderer.requestRender();
|
|
239
241
|
setTimeout(() => { refreshPager(); }, 2000);
|
|
240
242
|
return "stay";
|
|
@@ -480,7 +482,7 @@ export async function runTui(
|
|
|
480
482
|
if (!action) {
|
|
481
483
|
const p = keybinds.pending();
|
|
482
484
|
if (p) {
|
|
483
|
-
bottomBar
|
|
485
|
+
setBottomBarMessage(bottomBar, ` ${p}`);
|
|
484
486
|
renderer.requestRender();
|
|
485
487
|
}
|
|
486
488
|
return;
|
|
@@ -533,7 +535,7 @@ export async function runTui(
|
|
|
533
535
|
ensureCursorVisible();
|
|
534
536
|
}
|
|
535
537
|
} else {
|
|
536
|
-
bottomBar
|
|
538
|
+
setBottomBarMessage(bottomBar, " No active search \u2014 use / to search");
|
|
537
539
|
renderer.requestRender();
|
|
538
540
|
setTimeout(() => { refreshPager(); }, 1500);
|
|
539
541
|
}
|
|
@@ -547,7 +549,7 @@ export async function runTui(
|
|
|
547
549
|
ensureCursorVisible();
|
|
548
550
|
}
|
|
549
551
|
} else {
|
|
550
|
-
bottomBar
|
|
552
|
+
setBottomBarMessage(bottomBar, " No active search \u2014 use / to search");
|
|
551
553
|
renderer.requestRender();
|
|
552
554
|
setTimeout(() => { refreshPager(); }, 1500);
|
|
553
555
|
}
|
|
@@ -570,7 +572,7 @@ export async function runTui(
|
|
|
570
572
|
const msg = wasResolved
|
|
571
573
|
? ` \u21a9 Reopened thread #${thread.id}`
|
|
572
574
|
: ` \u2714 Resolved thread #${thread.id}`;
|
|
573
|
-
bottomBar
|
|
575
|
+
setBottomBarMessage(bottomBar, msg);
|
|
574
576
|
renderer.requestRender();
|
|
575
577
|
setTimeout(() => { refreshPager(); }, 1500);
|
|
576
578
|
}
|
|
@@ -584,7 +586,7 @@ export async function runTui(
|
|
|
584
586
|
appendEvent(jsonlPath, { type: "resolve", threadId: t.id, author: "reviewer", ts: Date.now() });
|
|
585
587
|
}
|
|
586
588
|
refreshPager();
|
|
587
|
-
bottomBar
|
|
589
|
+
setBottomBarMessage(bottomBar, ` \u2714 Resolved ${pending} pending thread(s)`);
|
|
588
590
|
renderer.requestRender();
|
|
589
591
|
setTimeout(() => { refreshPager(); }, 1500);
|
|
590
592
|
break;
|
|
@@ -592,26 +594,31 @@ export async function runTui(
|
|
|
592
594
|
case "delete-draft": {
|
|
593
595
|
const thread = state.threadAtLine(state.cursorLine);
|
|
594
596
|
if (!thread) break;
|
|
595
|
-
const
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
597
|
+
const deleteOverlay = createConfirm({
|
|
598
|
+
renderer,
|
|
599
|
+
title: "Delete Thread",
|
|
600
|
+
message: `Delete thread #${thread.id} on line ${thread.line}?`,
|
|
601
|
+
onConfirm: () => {
|
|
602
|
+
dismissOverlay();
|
|
603
|
+
state.deleteThread(thread.id);
|
|
604
|
+
appendEvent(jsonlPath, { type: "delete", threadId: thread.id, author: "reviewer", ts: Date.now() });
|
|
605
|
+
refreshPager();
|
|
606
|
+
setBottomBarMessage(bottomBar, ` \u2714 Deleted thread #${thread.id}`);
|
|
607
|
+
renderer.requestRender();
|
|
608
|
+
setTimeout(() => { refreshPager(); }, 1500);
|
|
609
|
+
},
|
|
610
|
+
onCancel: () => {
|
|
611
|
+
dismissOverlay();
|
|
612
|
+
},
|
|
613
|
+
});
|
|
614
|
+
showOverlay(deleteOverlay);
|
|
608
615
|
break;
|
|
609
616
|
}
|
|
610
617
|
case "approve":
|
|
611
618
|
if (state.canApprove()) {
|
|
612
619
|
const confirmOverlay = createConfirm({
|
|
613
620
|
renderer,
|
|
614
|
-
message: "Approve spec and proceed to implementation?
|
|
621
|
+
message: "Approve spec and proceed to implementation?",
|
|
615
622
|
onConfirm: () => {
|
|
616
623
|
dismissOverlay();
|
|
617
624
|
appendEvent(jsonlPath, { type: "approve", author: "reviewer", ts: Date.now() });
|
|
@@ -628,7 +635,7 @@ export async function runTui(
|
|
|
628
635
|
const msg = total === 0
|
|
629
636
|
? "No threads to approve"
|
|
630
637
|
: `${total} thread${total !== 1 ? "s" : ""} still open/pending`;
|
|
631
|
-
bottomBar
|
|
638
|
+
setBottomBarMessage(bottomBar, ` \u26a0 ${msg}`);
|
|
632
639
|
renderer.requestRender();
|
|
633
640
|
setTimeout(() => { refreshPager(); }, 2000);
|
|
634
641
|
}
|
|
@@ -638,8 +645,8 @@ export async function runTui(
|
|
|
638
645
|
if (next !== null) {
|
|
639
646
|
state.cursorLine = next;
|
|
640
647
|
ensureCursorVisible();
|
|
641
|
-
refreshPager();
|
|
642
648
|
}
|
|
649
|
+
refreshPager();
|
|
643
650
|
break;
|
|
644
651
|
}
|
|
645
652
|
case "prev-thread": {
|
|
@@ -647,8 +654,8 @@ export async function runTui(
|
|
|
647
654
|
if (prev !== null) {
|
|
648
655
|
state.cursorLine = prev;
|
|
649
656
|
ensureCursorVisible();
|
|
650
|
-
refreshPager();
|
|
651
657
|
}
|
|
658
|
+
refreshPager();
|
|
652
659
|
break;
|
|
653
660
|
}
|
|
654
661
|
case "next-unread": {
|
|
@@ -656,8 +663,8 @@ export async function runTui(
|
|
|
656
663
|
if (nextLine !== null) {
|
|
657
664
|
state.cursorLine = nextLine;
|
|
658
665
|
ensureCursorVisible();
|
|
659
|
-
refreshPager();
|
|
660
666
|
}
|
|
667
|
+
refreshPager();
|
|
661
668
|
break;
|
|
662
669
|
}
|
|
663
670
|
case "prev-unread": {
|
|
@@ -665,8 +672,8 @@ export async function runTui(
|
|
|
665
672
|
if (prevLine !== null) {
|
|
666
673
|
state.cursorLine = prevLine;
|
|
667
674
|
ensureCursorVisible();
|
|
668
|
-
refreshPager();
|
|
669
675
|
}
|
|
676
|
+
refreshPager();
|
|
670
677
|
break;
|
|
671
678
|
}
|
|
672
679
|
case "help":
|
package/src/tui/comment-input.ts
CHANGED
package/src/tui/help.ts
CHANGED
|
@@ -2,6 +2,7 @@ import {
|
|
|
2
2
|
TextRenderable,
|
|
3
3
|
type CliRenderer,
|
|
4
4
|
type KeyEvent,
|
|
5
|
+
type ScrollBoxRenderable,
|
|
5
6
|
} from "@opentui/core";
|
|
6
7
|
import { theme } from "./ui/theme";
|
|
7
8
|
import { createDialog } from "./ui/dialog";
|
|
@@ -11,6 +12,29 @@ export interface HelpOverlay {
|
|
|
11
12
|
cleanup: () => void;
|
|
12
13
|
}
|
|
13
14
|
|
|
15
|
+
function addHelpSection(container: ScrollBoxRenderable, renderer: CliRenderer, title: string, lines: string[]): void {
|
|
16
|
+
// Blank line before section
|
|
17
|
+
container.add(new TextRenderable(renderer, { content: "", width: "100%", height: 1, wrapMode: "none" }));
|
|
18
|
+
// Section header in blue
|
|
19
|
+
container.add(new TextRenderable(renderer, {
|
|
20
|
+
content: ` ${title}`,
|
|
21
|
+
width: "100%",
|
|
22
|
+
height: 1,
|
|
23
|
+
fg: theme.blue,
|
|
24
|
+
wrapMode: "none",
|
|
25
|
+
}));
|
|
26
|
+
// Content lines
|
|
27
|
+
for (const line of lines) {
|
|
28
|
+
container.add(new TextRenderable(renderer, {
|
|
29
|
+
content: line,
|
|
30
|
+
width: "100%",
|
|
31
|
+
height: 1,
|
|
32
|
+
fg: theme.text,
|
|
33
|
+
wrapMode: "none",
|
|
34
|
+
}));
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
14
38
|
/**
|
|
15
39
|
* Create a help overlay popup showing all keybindings.
|
|
16
40
|
* Dismissable with `?`, `q`, or `Esc`.
|
|
@@ -22,37 +46,6 @@ export function createHelp(opts: {
|
|
|
22
46
|
}): HelpOverlay {
|
|
23
47
|
const { renderer, version, onClose } = opts;
|
|
24
48
|
|
|
25
|
-
const helpText = [
|
|
26
|
-
"",
|
|
27
|
-
` revspec v${version}`,
|
|
28
|
-
"",
|
|
29
|
-
" Navigation",
|
|
30
|
-
" j/k Down/up",
|
|
31
|
-
" gg Go to first line / scroll to top",
|
|
32
|
-
" G Go to last line / scroll to bottom",
|
|
33
|
-
" Ctrl+d/u Half page down/up",
|
|
34
|
-
" / Search",
|
|
35
|
-
" n/N Next/prev search match",
|
|
36
|
-
" Esc Clear search highlights",
|
|
37
|
-
" ]t/[t Next/prev thread",
|
|
38
|
-
" ]r/[r Next/prev unread thread",
|
|
39
|
-
"",
|
|
40
|
-
" Review",
|
|
41
|
-
" c Comment / view thread / reply",
|
|
42
|
-
" r Resolve thread",
|
|
43
|
-
" R Resolve all pending",
|
|
44
|
-
" dd Delete draft comment (double-tap)",
|
|
45
|
-
" l List threads",
|
|
46
|
-
" a Approve spec",
|
|
47
|
-
"",
|
|
48
|
-
" Commands",
|
|
49
|
-
" :w Show save status",
|
|
50
|
-
" :q Save and quit",
|
|
51
|
-
" :wq Save and quit",
|
|
52
|
-
" :q! Quit without saving",
|
|
53
|
-
"",
|
|
54
|
-
].join("\n");
|
|
55
|
-
|
|
56
49
|
const dialog = createDialog({
|
|
57
50
|
renderer,
|
|
58
51
|
title: "Help",
|
|
@@ -68,14 +61,46 @@ export function createHelp(opts: {
|
|
|
68
61
|
],
|
|
69
62
|
});
|
|
70
63
|
|
|
71
|
-
|
|
72
|
-
|
|
64
|
+
// Version header
|
|
65
|
+
dialog.content.add(new TextRenderable(renderer, { content: "", width: "100%", height: 1, wrapMode: "none" }));
|
|
66
|
+
dialog.content.add(new TextRenderable(renderer, {
|
|
67
|
+
content: ` revspec v${version}`,
|
|
73
68
|
width: "100%",
|
|
74
|
-
|
|
69
|
+
height: 1,
|
|
70
|
+
fg: theme.textMuted,
|
|
75
71
|
wrapMode: "none",
|
|
76
|
-
});
|
|
72
|
+
}));
|
|
73
|
+
|
|
74
|
+
addHelpSection(dialog.content, renderer, "Navigation", [
|
|
75
|
+
" j/k Down/up",
|
|
76
|
+
" gg Go to first line / scroll to top",
|
|
77
|
+
" G Go to last line / scroll to bottom",
|
|
78
|
+
" Ctrl+d/u Half page down/up",
|
|
79
|
+
" / Search",
|
|
80
|
+
" n/N Next/prev search match",
|
|
81
|
+
" Esc Clear search highlights",
|
|
82
|
+
" ]t/[t Next/prev thread",
|
|
83
|
+
" ]r/[r Next/prev unread thread",
|
|
84
|
+
]);
|
|
85
|
+
|
|
86
|
+
addHelpSection(dialog.content, renderer, "Review", [
|
|
87
|
+
" c Comment / view thread / reply",
|
|
88
|
+
" r Resolve thread",
|
|
89
|
+
" R Resolve all pending",
|
|
90
|
+
" dd Delete thread (with confirm)",
|
|
91
|
+
" l List threads",
|
|
92
|
+
" a Approve spec",
|
|
93
|
+
]);
|
|
94
|
+
|
|
95
|
+
addHelpSection(dialog.content, renderer, "Commands", [
|
|
96
|
+
" :w Show save status",
|
|
97
|
+
" :q Quit (blocks if unsaved)",
|
|
98
|
+
" :wq Save and quit",
|
|
99
|
+
" :q! Quit without saving",
|
|
100
|
+
]);
|
|
77
101
|
|
|
78
|
-
|
|
102
|
+
// Trailing blank line
|
|
103
|
+
dialog.content.add(new TextRenderable(renderer, { content: "", width: "100%", height: 1, wrapMode: "none" }));
|
|
79
104
|
|
|
80
105
|
const extraKeyHandler = (key: KeyEvent) => {
|
|
81
106
|
if (key.name === "q" || key.sequence === "?") {
|
package/src/tui/pager.ts
CHANGED
|
@@ -12,12 +12,6 @@ import { parseMarkdownLine, addSegments, collectTable, renderTableBorder, render
|
|
|
12
12
|
|
|
13
13
|
const MAX_HINT_LENGTH = 40;
|
|
14
14
|
|
|
15
|
-
function padLineNum(n: number): string {
|
|
16
|
-
const s = String(n);
|
|
17
|
-
if (s.length >= 4) return s;
|
|
18
|
-
return " ".repeat(4 - s.length) + s;
|
|
19
|
-
}
|
|
20
|
-
|
|
21
15
|
function threadHint(thread: Thread): string {
|
|
22
16
|
if (thread.messages.length === 0) return "";
|
|
23
17
|
const last = thread.messages[thread.messages.length - 1];
|
|
@@ -32,6 +26,7 @@ function threadHint(thread: Thread): string {
|
|
|
32
26
|
* Build plain text line-mode content (for testing / plain fallback).
|
|
33
27
|
*/
|
|
34
28
|
export function buildPagerContent(state: ReviewState, searchQuery?: string | null, unreadThreadIds?: ReadonlySet<string>): string {
|
|
29
|
+
const numWidth = Math.max(String(state.lineCount).length, 3);
|
|
35
30
|
const lines: string[] = [];
|
|
36
31
|
for (let i = 0; i < state.specLines.length; i++) {
|
|
37
32
|
const lineNum = i + 1;
|
|
@@ -54,7 +49,9 @@ export function buildPagerContent(state: ReviewState, searchQuery?: string | nul
|
|
|
54
49
|
indicator = "\u258c";
|
|
55
50
|
}
|
|
56
51
|
}
|
|
57
|
-
|
|
52
|
+
const numStr = String(lineNum);
|
|
53
|
+
const padded = " ".repeat(numWidth - numStr.length) + numStr;
|
|
54
|
+
lines.push(`${prefix}${indicator}${padded} ${specText}`);
|
|
58
55
|
}
|
|
59
56
|
return lines.join("\n");
|
|
60
57
|
}
|
|
@@ -69,6 +66,11 @@ export function buildPagerContent(state: ReviewState, searchQuery?: string | nul
|
|
|
69
66
|
export function buildPagerNodes(lineNode: TextRenderable, state: ReviewState, searchQuery?: string | null, unreadThreadIds?: ReadonlySet<string>): void {
|
|
70
67
|
lineNode.clear();
|
|
71
68
|
|
|
69
|
+
// Calculate dynamic gutter width based on total line count
|
|
70
|
+
const numWidth = Math.max(String(state.lineCount).length, 3);
|
|
71
|
+
// Blank gutter for table borders: prefix(1) + indicator(1) + numWidth + spaces(2)
|
|
72
|
+
const gutterBlank = " ".repeat(2 + numWidth + 2);
|
|
73
|
+
|
|
72
74
|
// Pre-scan for table blocks so we can calculate column widths
|
|
73
75
|
const tableBlocks = new Map<number, TableBlock>();
|
|
74
76
|
for (let i = 0; i < state.specLines.length; i++) {
|
|
@@ -81,6 +83,9 @@ export function buildPagerNodes(lineNode: TextRenderable, state: ReviewState, se
|
|
|
81
83
|
}
|
|
82
84
|
}
|
|
83
85
|
|
|
86
|
+
// Track fenced code block state
|
|
87
|
+
let inCodeBlock = false;
|
|
88
|
+
|
|
84
89
|
for (let i = 0; i < state.specLines.length; i++) {
|
|
85
90
|
const lineNum = i + 1;
|
|
86
91
|
const thread = state.threadAtLine(lineNum);
|
|
@@ -113,7 +118,7 @@ export function buildPagerNodes(lineNode: TextRenderable, state: ReviewState, se
|
|
|
113
118
|
|
|
114
119
|
// Top border before first table row (on its own visual line with blank gutter)
|
|
115
120
|
if (isTable && relIdx === 0) {
|
|
116
|
-
lineNode.add(TextNodeRenderable.fromString(
|
|
121
|
+
lineNode.add(TextNodeRenderable.fromString(gutterBlank, { fg: theme.textDim }));
|
|
117
122
|
renderTableBorder(lineNode, tableBlock.colWidths, "top");
|
|
118
123
|
lineNode.add(TextNodeRenderable.fromString("\n", {}));
|
|
119
124
|
}
|
|
@@ -127,13 +132,28 @@ export function buildPagerNodes(lineNode: TextRenderable, state: ReviewState, se
|
|
|
127
132
|
indicator,
|
|
128
133
|
{ fg: indicatorColor, bg: isCursor ? theme.backgroundElement : undefined }
|
|
129
134
|
));
|
|
135
|
+
const numStr = String(lineNum);
|
|
136
|
+
const paddedNum = " ".repeat(numWidth - numStr.length) + numStr;
|
|
130
137
|
lineNode.add(TextNodeRenderable.fromString(
|
|
131
|
-
`${
|
|
138
|
+
`${paddedNum} `,
|
|
132
139
|
{ fg: theme.textDim, attributes: TextAttributes.DIM, bg: isCursor ? theme.backgroundElement : undefined }
|
|
133
140
|
));
|
|
134
141
|
|
|
135
|
-
// Spec text — table or regular markdown
|
|
136
|
-
if (
|
|
142
|
+
// Spec text — fenced code block, table, or regular markdown
|
|
143
|
+
if (specText.trimStart().startsWith("```")) {
|
|
144
|
+
inCodeBlock = !inCodeBlock;
|
|
145
|
+
// Render the fence line itself as dim
|
|
146
|
+
lineNode.add(TextNodeRenderable.fromString(specText, {
|
|
147
|
+
fg: theme.textDim,
|
|
148
|
+
bg: isCursor ? theme.backgroundElement : undefined,
|
|
149
|
+
}));
|
|
150
|
+
} else if (inCodeBlock) {
|
|
151
|
+
// Inside code block — render as green, no markdown parsing
|
|
152
|
+
lineNode.add(TextNodeRenderable.fromString(specText, {
|
|
153
|
+
fg: theme.green,
|
|
154
|
+
bg: isCursor ? theme.backgroundElement : undefined,
|
|
155
|
+
}));
|
|
156
|
+
} else if (isTable) {
|
|
137
157
|
if (relIdx === tableBlock.separatorIndex) {
|
|
138
158
|
// Separator row → box-drawing line
|
|
139
159
|
renderTableSeparator(lineNode, tableBlock.colWidths);
|
|
@@ -147,7 +167,7 @@ export function buildPagerNodes(lineNode: TextRenderable, state: ReviewState, se
|
|
|
147
167
|
// Bottom border after last row (on its own visual line with blank gutter)
|
|
148
168
|
if (relIdx === tableBlock.lines.length - 1) {
|
|
149
169
|
lineNode.add(TextNodeRenderable.fromString("\n", {}));
|
|
150
|
-
lineNode.add(TextNodeRenderable.fromString(
|
|
170
|
+
lineNode.add(TextNodeRenderable.fromString(gutterBlank, { fg: theme.textDim }));
|
|
151
171
|
renderTableBorder(lineNode, tableBlock.colWidths, "bottom");
|
|
152
172
|
}
|
|
153
173
|
} else if (searchQuery) {
|
package/src/tui/status-bar.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { BoxRenderable, TextRenderable, TextNodeRenderable, TextAttributes, type
|
|
|
2
2
|
import type { ReviewState } from "../state/review-state";
|
|
3
3
|
import { basename } from "path";
|
|
4
4
|
import { theme } from "./ui/theme";
|
|
5
|
-
import { buildHints } from "./ui/hint-bar";
|
|
5
|
+
import { buildHints, type Hint } from "./ui/hint-bar";
|
|
6
6
|
|
|
7
7
|
export interface TopBarComponents {
|
|
8
8
|
box: BoxRenderable;
|
|
@@ -64,23 +64,35 @@ export function buildTopBar(
|
|
|
64
64
|
t.add(TextNodeRenderable.fromString(`L${state.cursorLine}/${state.lineCount}`, { fg: theme.textMuted }));
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
+
/**
|
|
68
|
+
* Set a transient message on the bottom bar (using TextNodes, not .content).
|
|
69
|
+
*/
|
|
70
|
+
export function setBottomBarMessage(bar: BottomBarComponents, message: string, fg?: string): void {
|
|
71
|
+
const t = bar.text;
|
|
72
|
+
t.clear();
|
|
73
|
+
t.add(TextNodeRenderable.fromString(message, { fg: fg ?? theme.text }));
|
|
74
|
+
}
|
|
75
|
+
|
|
67
76
|
/**
|
|
68
77
|
* Build the bottom bar with styled TextNodes.
|
|
69
78
|
*/
|
|
70
|
-
export function buildBottomBar(bar: BottomBarComponents, commandBuffer: string | null): void {
|
|
79
|
+
export function buildBottomBar(bar: BottomBarComponents, commandBuffer: string | null, hasThread?: boolean): void {
|
|
71
80
|
const t = bar.text;
|
|
72
81
|
t.clear();
|
|
73
82
|
if (commandBuffer !== null) {
|
|
74
83
|
t.add(TextNodeRenderable.fromString(` :${commandBuffer}`, { fg: theme.text }));
|
|
75
84
|
return;
|
|
76
85
|
}
|
|
77
|
-
const hints = [
|
|
86
|
+
const hints: Hint[] = [
|
|
78
87
|
{ key: "j/k", action: "move" },
|
|
79
88
|
{ key: "c", action: "comment" },
|
|
80
|
-
{ key: "r", action: "resolve" },
|
|
81
|
-
{ key: "/", action: "search" },
|
|
82
|
-
{ key: "?", action: "help" },
|
|
83
89
|
];
|
|
90
|
+
if (hasThread) {
|
|
91
|
+
hints.push({ key: "r", action: "resolve" });
|
|
92
|
+
hints.push({ key: "dd", action: "delete thread" });
|
|
93
|
+
}
|
|
94
|
+
hints.push({ key: "/", action: "search" });
|
|
95
|
+
hints.push({ key: "?", action: "help" });
|
|
84
96
|
buildHints(t, hints);
|
|
85
97
|
}
|
|
86
98
|
|
package/src/tui/thread-list.ts
CHANGED
|
@@ -24,7 +24,7 @@ const MAX_PREVIEW_LENGTH = 50;
|
|
|
24
24
|
|
|
25
25
|
function previewText(thread: Thread): string {
|
|
26
26
|
if (thread.messages.length === 0) return "(empty)";
|
|
27
|
-
const last = thread.messages[
|
|
27
|
+
const last = thread.messages[0];
|
|
28
28
|
const text = last.text.replace(/\n/g, " ");
|
|
29
29
|
if (text.length <= MAX_PREVIEW_LENGTH) return text;
|
|
30
30
|
return text.slice(0, MAX_PREVIEW_LENGTH - 1) + "\u2026";
|
package/src/tui/ui/keybinds.ts
CHANGED
|
@@ -53,6 +53,7 @@ export function createKeybindRegistry(bindings: KeyBinding[], timeout = 500): Ke
|
|
|
53
53
|
|
|
54
54
|
function match(key: KeyEvent): string | null {
|
|
55
55
|
const keyStr = keyToString(key);
|
|
56
|
+
let skipSequenceCheck = false;
|
|
56
57
|
|
|
57
58
|
if (sequence) {
|
|
58
59
|
const seq = sequence.first + keyStr;
|
|
@@ -61,6 +62,7 @@ export function createKeybindRegistry(bindings: KeyBinding[], timeout = 500): Ke
|
|
|
61
62
|
|
|
62
63
|
const action = sequenceBindings.get(seq);
|
|
63
64
|
if (action) return action;
|
|
65
|
+
skipSequenceCheck = true; // Don't start a new sequence with the failed second key
|
|
64
66
|
}
|
|
65
67
|
|
|
66
68
|
// Check ctrl variants first
|
|
@@ -69,8 +71,8 @@ export function createKeybindRegistry(bindings: KeyBinding[], timeout = 500): Ke
|
|
|
69
71
|
if (action) return action;
|
|
70
72
|
}
|
|
71
73
|
|
|
72
|
-
// Check if this starts a sequence (but not if ctrl is held)
|
|
73
|
-
if (!key.ctrl && sequenceStarters.has(keyStr)) {
|
|
74
|
+
// Check if this starts a sequence (but not if ctrl is held, and not if from failed sequence)
|
|
75
|
+
if (!key.ctrl && !skipSequenceCheck && sequenceStarters.has(keyStr)) {
|
|
74
76
|
sequence = {
|
|
75
77
|
first: keyStr,
|
|
76
78
|
timer: setTimeout(() => { sequence = null; }, timeout),
|
package/src/tui/ui/markdown.ts
CHANGED
|
@@ -14,13 +14,22 @@ export interface StyledSegment {
|
|
|
14
14
|
}
|
|
15
15
|
|
|
16
16
|
/**
|
|
17
|
-
* Parse inline markdown (bold, italic, code) into styled segments.
|
|
17
|
+
* Parse inline markdown (bold italic, bold, italic, code, links, strikethrough) into styled segments.
|
|
18
18
|
* Strips syntax markers and returns display text with style info.
|
|
19
|
+
* Order matters: longer patterns first (***bold italic*** before **bold** before *italic*).
|
|
19
20
|
*/
|
|
20
21
|
export function parseInlineMarkdown(text: string): StyledSegment[] {
|
|
21
22
|
const segments: StyledSegment[] = [];
|
|
22
|
-
//
|
|
23
|
-
|
|
23
|
+
// Groups:
|
|
24
|
+
// 2: ***bold italic***
|
|
25
|
+
// 3: **bold**
|
|
26
|
+
// 4: *italic*
|
|
27
|
+
// 5: __bold__
|
|
28
|
+
// 6: _italic_
|
|
29
|
+
// 7: ~~strikethrough~~
|
|
30
|
+
// 8: [link text](url) — display text only
|
|
31
|
+
// 9: `code`
|
|
32
|
+
const regex = /(\*\*\*(.+?)\*\*\*|\*\*(.+?)\*\*|\*(.+?)\*|__(.+?)__|_(.+?)_|~~(.+?)~~|\[([^\]]+)\]\([^)]+\)|`([^`]+)`)/g;
|
|
24
33
|
let pos = 0;
|
|
25
34
|
let match;
|
|
26
35
|
while ((match = regex.exec(text)) !== null) {
|
|
@@ -28,14 +37,29 @@ export function parseInlineMarkdown(text: string): StyledSegment[] {
|
|
|
28
37
|
segments.push({ text: text.slice(pos, match.index) });
|
|
29
38
|
}
|
|
30
39
|
if (match[2] !== undefined) {
|
|
31
|
-
//
|
|
32
|
-
segments.push({ text: match[2], attributes: TextAttributes.BOLD });
|
|
40
|
+
// ***bold italic***
|
|
41
|
+
segments.push({ text: match[2], attributes: TextAttributes.BOLD | TextAttributes.ITALIC });
|
|
33
42
|
} else if (match[3] !== undefined) {
|
|
34
|
-
//
|
|
35
|
-
segments.push({ text: match[3], attributes: TextAttributes.
|
|
43
|
+
// **bold**
|
|
44
|
+
segments.push({ text: match[3], attributes: TextAttributes.BOLD });
|
|
36
45
|
} else if (match[4] !== undefined) {
|
|
46
|
+
// *italic*
|
|
47
|
+
segments.push({ text: match[4], attributes: TextAttributes.ITALIC });
|
|
48
|
+
} else if (match[5] !== undefined) {
|
|
49
|
+
// __bold__
|
|
50
|
+
segments.push({ text: match[5], attributes: TextAttributes.BOLD });
|
|
51
|
+
} else if (match[6] !== undefined) {
|
|
52
|
+
// _italic_
|
|
53
|
+
segments.push({ text: match[6], attributes: TextAttributes.ITALIC });
|
|
54
|
+
} else if (match[7] !== undefined) {
|
|
55
|
+
// ~~strikethrough~~ — use actual terminal strikethrough + dim
|
|
56
|
+
segments.push({ text: match[7], fg: theme.textDim, attributes: TextAttributes.STRIKETHROUGH });
|
|
57
|
+
} else if (match[8] !== undefined) {
|
|
58
|
+
// [link text](url) — show text in blue + underline
|
|
59
|
+
segments.push({ text: match[8], fg: theme.blue, attributes: TextAttributes.UNDERLINE });
|
|
60
|
+
} else if (match[9] !== undefined) {
|
|
37
61
|
// `code`
|
|
38
|
-
segments.push({ text: match[
|
|
62
|
+
segments.push({ text: match[9], fg: theme.mauve });
|
|
39
63
|
}
|
|
40
64
|
pos = match.index + match[0].length;
|
|
41
65
|
}
|
|
@@ -86,6 +110,18 @@ export function parseMarkdownLine(line: string): StyledSegment[] {
|
|
|
86
110
|
];
|
|
87
111
|
}
|
|
88
112
|
|
|
113
|
+
// Task list: - [ ] or - [x]
|
|
114
|
+
const taskMatch = line.match(/^(\s*)[-*+]\s+\[([ xX])\]\s+(.*)/);
|
|
115
|
+
if (taskMatch) {
|
|
116
|
+
const checked = taskMatch[2].toLowerCase() === "x";
|
|
117
|
+
const checkbox = checked ? "\u2611 " : "\u2610 "; // ☑ or ☐
|
|
118
|
+
const color = checked ? theme.green : theme.textDim;
|
|
119
|
+
return [
|
|
120
|
+
{ text: taskMatch[1] + checkbox, fg: color },
|
|
121
|
+
...parseInlineMarkdown(taskMatch[3]),
|
|
122
|
+
];
|
|
123
|
+
}
|
|
124
|
+
|
|
89
125
|
// Unordered list: - item, * item, + item
|
|
90
126
|
const ulMatch = line.match(/^(\s*)([-*+])\s+(.*)/);
|
|
91
127
|
if (ulMatch) {
|
|
@@ -137,10 +173,15 @@ export function parseTableCells(line: string): string[] {
|
|
|
137
173
|
|
|
138
174
|
/** Compute the display width of a string (strips inline markdown markers). */
|
|
139
175
|
export function displayWidth(text: string): number {
|
|
140
|
-
// Remove
|
|
176
|
+
// Remove all inline markdown markers to get display length
|
|
141
177
|
return text
|
|
178
|
+
.replace(/\*\*\*(.+?)\*\*\*/g, "$1")
|
|
142
179
|
.replace(/\*\*(.+?)\*\*/g, "$1")
|
|
143
180
|
.replace(/\*(.+?)\*/g, "$1")
|
|
181
|
+
.replace(/__(.+?)__/g, "$1")
|
|
182
|
+
.replace(/_(.+?)_/g, "$1")
|
|
183
|
+
.replace(/~~(.+?)~~/g, "$1")
|
|
184
|
+
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
|
|
144
185
|
.replace(/`([^`]+)`/g, "$1")
|
|
145
186
|
.length;
|
|
146
187
|
}
|