revspec 0.4.0 → 0.6.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/CLAUDE.md +3 -1
- package/README.md +48 -10
- package/bun.lock +3 -0
- package/package.json +6 -3
- package/src/state/review-state.ts +5 -0
- package/src/tui/app.ts +65 -34
- package/src/tui/comment-input.ts +15 -3
- package/src/tui/help.ts +123 -38
- package/src/tui/pager.ts +36 -14
- package/src/tui/search.ts +9 -4
- package/src/tui/status-bar.ts +17 -7
- 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/test/e2e/__snapshots__/snapshot.test.ts.snap +31 -0
- package/test/e2e/fixtures/spec.md +36 -0
- package/test/e2e/harness.ts +80 -0
- package/test/e2e/snapshot.test.ts +182 -0
- package/test/{cli-reply.test.ts → integration/cli-reply.test.ts} +2 -2
- package/test/{cli-watch.test.ts → integration/cli-watch.test.ts} +2 -2
- package/test/{cli.test.ts → integration/cli.test.ts} +3 -3
- package/test/{e2e-live.test.ts → integration/e2e-live.test.ts} +4 -4
- package/test/{live-interaction.test.ts → integration/live-interaction.test.ts} +4 -4
- package/test/{protocol → unit/protocol}/live-events.test.ts +1 -1
- package/test/{protocol → unit/protocol}/live-merge.test.ts +3 -3
- package/test/{protocol → unit/protocol}/merge.test.ts +2 -2
- package/test/{protocol → unit/protocol}/read.test.ts +2 -2
- package/test/{protocol → unit/protocol}/types.test.ts +1 -1
- package/test/{protocol → unit/protocol}/write.test.ts +2 -2
- package/test/{state → unit/state}/review-state.test.ts +2 -2
- package/test/{tui → unit/tui}/pager.test.ts +3 -3
- package/test/{tui → unit/tui}/ui/keybinds.test.ts +1 -1
- /package/test/{opentui-smoke.test.ts → integration/opentui-smoke.test.ts} +0 -0
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,65 +46,84 @@ 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",
|
|
59
52
|
width: "60%",
|
|
60
|
-
height: Math.min(
|
|
53
|
+
height: Math.min(34, renderer.height - 2),
|
|
61
54
|
top: "10%",
|
|
62
55
|
left: "20%",
|
|
63
56
|
borderColor: theme.info,
|
|
64
57
|
onDismiss: onClose,
|
|
65
58
|
hints: [
|
|
59
|
+
{ key: "j/k", action: "navigate" },
|
|
66
60
|
{ key: "q/?/Esc", action: "close" },
|
|
67
|
-
{ key: "j/k", action: "scroll" },
|
|
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, "Quick Start", [
|
|
75
|
+
" Navigate to a line and press c to comment.",
|
|
76
|
+
" The AI replies in real-time via the thread popup.",
|
|
77
|
+
" Press r to resolve threads, a to approve the spec.",
|
|
78
|
+
" Use :wq to save and quit when done reviewing.",
|
|
79
|
+
]);
|
|
80
|
+
|
|
81
|
+
addHelpSection(dialog.content, renderer, "Thread Popup", [
|
|
82
|
+
" Opens in INSERT mode — type and press Tab to send.",
|
|
83
|
+
" Press Esc for NORMAL mode — scroll with j/k/gg/G,",
|
|
84
|
+
" c to reply, r to resolve, q to close.",
|
|
85
|
+
]);
|
|
86
|
+
|
|
87
|
+
addHelpSection(dialog.content, renderer, "Navigation", [
|
|
88
|
+
" j/k Down/up",
|
|
89
|
+
" gg/G Top/bottom",
|
|
90
|
+
" Ctrl+d/u Half page down/up",
|
|
91
|
+
" zz Center cursor line",
|
|
92
|
+
" / Search (smartcase)",
|
|
93
|
+
" n/N Next/prev match",
|
|
94
|
+
" Esc Clear search",
|
|
95
|
+
" ]t/[t Next/prev thread",
|
|
96
|
+
" ]r/[r Next/prev unread",
|
|
97
|
+
]);
|
|
98
|
+
|
|
99
|
+
addHelpSection(dialog.content, renderer, "Review", [
|
|
100
|
+
" c Comment / view thread",
|
|
101
|
+
" r Resolve thread (toggle)",
|
|
102
|
+
" R Resolve all pending",
|
|
103
|
+
" dd Delete thread",
|
|
104
|
+
" T List threads",
|
|
105
|
+
" a Approve spec",
|
|
106
|
+
]);
|
|
107
|
+
|
|
108
|
+
addHelpSection(dialog.content, renderer, "Commands", [
|
|
109
|
+
" :w Merge to review JSON",
|
|
110
|
+
" :wq Merge and quit",
|
|
111
|
+
" :q Quit (blocks if unmerged)",
|
|
112
|
+
" :q! Quit without merging",
|
|
113
|
+
" :{N} Jump to line N",
|
|
114
|
+
" Ctrl+C Quit without merging",
|
|
115
|
+
]);
|
|
77
116
|
|
|
78
|
-
|
|
117
|
+
// Trailing blank line
|
|
118
|
+
dialog.content.add(new TextRenderable(renderer, { content: "", width: "100%", height: 1, wrapMode: "none" }));
|
|
119
|
+
|
|
120
|
+
let pendingG: ReturnType<typeof setTimeout> | null = null;
|
|
79
121
|
|
|
80
122
|
const extraKeyHandler = (key: KeyEvent) => {
|
|
81
123
|
if (key.name === "q" || key.sequence === "?") {
|
|
82
124
|
key.preventDefault();
|
|
83
125
|
key.stopPropagation();
|
|
126
|
+
if (pendingG) { clearTimeout(pendingG); pendingG = null; }
|
|
84
127
|
onClose();
|
|
85
128
|
return;
|
|
86
129
|
}
|
|
@@ -98,6 +141,47 @@ export function createHelp(opts: {
|
|
|
98
141
|
renderer.requestRender();
|
|
99
142
|
return;
|
|
100
143
|
}
|
|
144
|
+
// G = goto bottom
|
|
145
|
+
if (key.name === "g" && key.shift) {
|
|
146
|
+
key.preventDefault();
|
|
147
|
+
key.stopPropagation();
|
|
148
|
+
if (pendingG) { clearTimeout(pendingG); pendingG = null; }
|
|
149
|
+
dialog.content.scrollTo(dialog.content.scrollHeight);
|
|
150
|
+
renderer.requestRender();
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
// gg = goto top
|
|
154
|
+
if (key.name === "g" && !key.shift && !key.ctrl) {
|
|
155
|
+
key.preventDefault();
|
|
156
|
+
key.stopPropagation();
|
|
157
|
+
if (pendingG) {
|
|
158
|
+
clearTimeout(pendingG);
|
|
159
|
+
pendingG = null;
|
|
160
|
+
dialog.content.scrollTo(0);
|
|
161
|
+
renderer.requestRender();
|
|
162
|
+
} else {
|
|
163
|
+
pendingG = setTimeout(() => { pendingG = null; }, 300);
|
|
164
|
+
}
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
// Ctrl+D = half page down
|
|
168
|
+
if (key.ctrl && key.name === "d") {
|
|
169
|
+
key.preventDefault();
|
|
170
|
+
key.stopPropagation();
|
|
171
|
+
const half = Math.max(1, Math.floor((renderer.height - 4) / 2));
|
|
172
|
+
dialog.content.scrollTo(Math.min(dialog.content.scrollTop + half, dialog.content.scrollHeight));
|
|
173
|
+
renderer.requestRender();
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
// Ctrl+U = half page up
|
|
177
|
+
if (key.ctrl && key.name === "u") {
|
|
178
|
+
key.preventDefault();
|
|
179
|
+
key.stopPropagation();
|
|
180
|
+
const half = Math.max(1, Math.floor((renderer.height - 4) / 2));
|
|
181
|
+
dialog.content.scrollTo(Math.max(dialog.content.scrollTop - half, 0));
|
|
182
|
+
renderer.requestRender();
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
101
185
|
};
|
|
102
186
|
|
|
103
187
|
renderer.keyInput.on("keypress", extraKeyHandler);
|
|
@@ -105,6 +189,7 @@ export function createHelp(opts: {
|
|
|
105
189
|
return {
|
|
106
190
|
container: dialog.container,
|
|
107
191
|
cleanup() {
|
|
192
|
+
if (pendingG) clearTimeout(pendingG);
|
|
108
193
|
dialog.cleanup();
|
|
109
194
|
renderer.keyInput.off("keypress", extraKeyHandler);
|
|
110
195
|
},
|
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;
|
|
@@ -40,7 +35,8 @@ export function buildPagerContent(state: ReviewState, searchQuery?: string | nul
|
|
|
40
35
|
const prefix = isCursor ? ">" : " ";
|
|
41
36
|
let specText = state.specLines[i];
|
|
42
37
|
if (searchQuery) {
|
|
43
|
-
const
|
|
38
|
+
const csSensitive = searchQuery !== searchQuery.toLowerCase();
|
|
39
|
+
const regex = new RegExp(searchQuery.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), csSensitive ? "g" : "gi");
|
|
44
40
|
specText = specText.replace(regex, (match) => `>>${match}<<`);
|
|
45
41
|
}
|
|
46
42
|
let indicator = " ";
|
|
@@ -54,7 +50,9 @@ export function buildPagerContent(state: ReviewState, searchQuery?: string | nul
|
|
|
54
50
|
indicator = "\u258c";
|
|
55
51
|
}
|
|
56
52
|
}
|
|
57
|
-
|
|
53
|
+
const numStr = String(lineNum);
|
|
54
|
+
const padded = " ".repeat(numWidth - numStr.length) + numStr;
|
|
55
|
+
lines.push(`${prefix}${indicator}${padded} ${specText}`);
|
|
58
56
|
}
|
|
59
57
|
return lines.join("\n");
|
|
60
58
|
}
|
|
@@ -69,6 +67,11 @@ export function buildPagerContent(state: ReviewState, searchQuery?: string | nul
|
|
|
69
67
|
export function buildPagerNodes(lineNode: TextRenderable, state: ReviewState, searchQuery?: string | null, unreadThreadIds?: ReadonlySet<string>): void {
|
|
70
68
|
lineNode.clear();
|
|
71
69
|
|
|
70
|
+
// Calculate dynamic gutter width based on total line count
|
|
71
|
+
const numWidth = Math.max(String(state.lineCount).length, 3);
|
|
72
|
+
// Blank gutter for table borders: prefix(1) + indicator(1) + numWidth + spaces(2)
|
|
73
|
+
const gutterBlank = " ".repeat(2 + numWidth + 2);
|
|
74
|
+
|
|
72
75
|
// Pre-scan for table blocks so we can calculate column widths
|
|
73
76
|
const tableBlocks = new Map<number, TableBlock>();
|
|
74
77
|
for (let i = 0; i < state.specLines.length; i++) {
|
|
@@ -81,6 +84,9 @@ export function buildPagerNodes(lineNode: TextRenderable, state: ReviewState, se
|
|
|
81
84
|
}
|
|
82
85
|
}
|
|
83
86
|
|
|
87
|
+
// Track fenced code block state
|
|
88
|
+
let inCodeBlock = false;
|
|
89
|
+
|
|
84
90
|
for (let i = 0; i < state.specLines.length; i++) {
|
|
85
91
|
const lineNum = i + 1;
|
|
86
92
|
const thread = state.threadAtLine(lineNum);
|
|
@@ -113,7 +119,7 @@ export function buildPagerNodes(lineNode: TextRenderable, state: ReviewState, se
|
|
|
113
119
|
|
|
114
120
|
// Top border before first table row (on its own visual line with blank gutter)
|
|
115
121
|
if (isTable && relIdx === 0) {
|
|
116
|
-
lineNode.add(TextNodeRenderable.fromString(
|
|
122
|
+
lineNode.add(TextNodeRenderable.fromString(gutterBlank, { fg: theme.textDim }));
|
|
117
123
|
renderTableBorder(lineNode, tableBlock.colWidths, "top");
|
|
118
124
|
lineNode.add(TextNodeRenderable.fromString("\n", {}));
|
|
119
125
|
}
|
|
@@ -127,13 +133,28 @@ export function buildPagerNodes(lineNode: TextRenderable, state: ReviewState, se
|
|
|
127
133
|
indicator,
|
|
128
134
|
{ fg: indicatorColor, bg: isCursor ? theme.backgroundElement : undefined }
|
|
129
135
|
));
|
|
136
|
+
const numStr = String(lineNum);
|
|
137
|
+
const paddedNum = " ".repeat(numWidth - numStr.length) + numStr;
|
|
130
138
|
lineNode.add(TextNodeRenderable.fromString(
|
|
131
|
-
`${
|
|
139
|
+
`${paddedNum} `,
|
|
132
140
|
{ fg: theme.textDim, attributes: TextAttributes.DIM, bg: isCursor ? theme.backgroundElement : undefined }
|
|
133
141
|
));
|
|
134
142
|
|
|
135
|
-
// Spec text — table or regular markdown
|
|
136
|
-
if (
|
|
143
|
+
// Spec text — fenced code block, table, or regular markdown
|
|
144
|
+
if (specText.trimStart().startsWith("```")) {
|
|
145
|
+
inCodeBlock = !inCodeBlock;
|
|
146
|
+
// Render the fence line itself as dim
|
|
147
|
+
lineNode.add(TextNodeRenderable.fromString(specText, {
|
|
148
|
+
fg: theme.textDim,
|
|
149
|
+
bg: isCursor ? theme.backgroundElement : undefined,
|
|
150
|
+
}));
|
|
151
|
+
} else if (inCodeBlock) {
|
|
152
|
+
// Inside code block — render as green, no markdown parsing
|
|
153
|
+
lineNode.add(TextNodeRenderable.fromString(specText, {
|
|
154
|
+
fg: theme.green,
|
|
155
|
+
bg: isCursor ? theme.backgroundElement : undefined,
|
|
156
|
+
}));
|
|
157
|
+
} else if (isTable) {
|
|
137
158
|
if (relIdx === tableBlock.separatorIndex) {
|
|
138
159
|
// Separator row → box-drawing line
|
|
139
160
|
renderTableSeparator(lineNode, tableBlock.colWidths);
|
|
@@ -147,13 +168,14 @@ export function buildPagerNodes(lineNode: TextRenderable, state: ReviewState, se
|
|
|
147
168
|
// Bottom border after last row (on its own visual line with blank gutter)
|
|
148
169
|
if (relIdx === tableBlock.lines.length - 1) {
|
|
149
170
|
lineNode.add(TextNodeRenderable.fromString("\n", {}));
|
|
150
|
-
lineNode.add(TextNodeRenderable.fromString(
|
|
171
|
+
lineNode.add(TextNodeRenderable.fromString(gutterBlank, { fg: theme.textDim }));
|
|
151
172
|
renderTableBorder(lineNode, tableBlock.colWidths, "bottom");
|
|
152
173
|
}
|
|
153
174
|
} else if (searchQuery) {
|
|
154
175
|
// When searching, show colored match segments (no markdown styling)
|
|
155
176
|
const escaped = searchQuery.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
156
|
-
const
|
|
177
|
+
const caseSensitive = searchQuery !== searchQuery.toLowerCase();
|
|
178
|
+
const searchRegex = new RegExp(`(${escaped})`, caseSensitive ? "g" : "gi");
|
|
157
179
|
const parts = specText.split(searchRegex);
|
|
158
180
|
for (let p = 0; p < parts.length; p++) {
|
|
159
181
|
const part = parts[p];
|
package/src/tui/search.ts
CHANGED
|
@@ -83,18 +83,23 @@ export function createSearch(opts: SearchOptions): SearchOverlay {
|
|
|
83
83
|
if (key.name === "return") {
|
|
84
84
|
key.preventDefault();
|
|
85
85
|
key.stopPropagation();
|
|
86
|
-
const
|
|
87
|
-
if (
|
|
86
|
+
const raw = input.value.trim();
|
|
87
|
+
if (raw.length === 0) {
|
|
88
88
|
onCancel();
|
|
89
89
|
return;
|
|
90
90
|
}
|
|
91
91
|
|
|
92
|
+
// Smartcase: if query has any uppercase, case-sensitive; otherwise case-insensitive
|
|
93
|
+
const caseSensitive = raw !== raw.toLowerCase();
|
|
94
|
+
const query = caseSensitive ? raw : raw.toLowerCase();
|
|
95
|
+
|
|
92
96
|
// Search forward from cursor, wrapping around
|
|
93
97
|
const total = specLines.length;
|
|
94
98
|
for (let offset = 1; offset <= total; offset++) {
|
|
95
99
|
const i = (cursorLine - 1 + offset) % total;
|
|
96
|
-
|
|
97
|
-
|
|
100
|
+
const line = caseSensitive ? specLines[i] : specLines[i].toLowerCase();
|
|
101
|
+
if (line.includes(query)) {
|
|
102
|
+
onResult(i + 1, raw); // 1-based line number + original query (preserves case for n/N)
|
|
98
103
|
return;
|
|
99
104
|
}
|
|
100
105
|
}
|
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,33 @@ 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 = [
|
|
78
|
-
{ key: "j/k", action: "
|
|
86
|
+
const hints: Hint[] = [
|
|
87
|
+
{ key: "j/k", action: "navigate" },
|
|
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
|
+
}
|
|
93
|
+
hints.push({ key: "?", action: "help" });
|
|
84
94
|
buildHints(t, hints);
|
|
85
95
|
}
|
|
86
96
|
|
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
|
}
|