revspec 0.3.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/docs/superpowers/plans/2026-03-15-ui-refactor.md +1025 -0
- package/package.json +1 -1
- package/src/state/review-state.ts +5 -0
- package/src/tui/app.ts +189 -234
- package/src/tui/comment-input.ts +146 -144
- package/src/tui/confirm.ts +29 -43
- package/src/tui/help.ts +77 -76
- package/src/tui/pager.ts +54 -267
- package/src/tui/search.ts +6 -6
- package/src/tui/status-bar.ts +27 -24
- package/src/tui/thread-list.ts +29 -55
- package/src/tui/ui/dialog.ts +106 -0
- package/src/tui/ui/hint-bar.ts +20 -0
- package/src/tui/ui/keybinds.ts +106 -0
- package/src/tui/ui/markdown.ts +292 -0
- package/src/tui/ui/theme.ts +49 -0
- package/test/tui/ui/keybinds.test.ts +71 -0
- package/src/tui/theme.ts +0 -34
package/src/tui/pager.ts
CHANGED
|
@@ -7,16 +7,11 @@ import {
|
|
|
7
7
|
TextAttributes,
|
|
8
8
|
type CliRenderer,
|
|
9
9
|
} from "@opentui/core";
|
|
10
|
-
import { theme } from "./theme";
|
|
10
|
+
import { theme } from "./ui/theme";
|
|
11
|
+
import { parseMarkdownLine, addSegments, collectTable, renderTableBorder, renderTableSeparator, renderTableRow, parseTableCells, type TableBlock } from "./ui/markdown";
|
|
11
12
|
|
|
12
13
|
const MAX_HINT_LENGTH = 40;
|
|
13
14
|
|
|
14
|
-
function padLineNum(n: number): string {
|
|
15
|
-
const s = String(n);
|
|
16
|
-
if (s.length >= 4) return s;
|
|
17
|
-
return " ".repeat(4 - s.length) + s;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
15
|
function threadHint(thread: Thread): string {
|
|
21
16
|
if (thread.messages.length === 0) return "";
|
|
22
17
|
const last = thread.messages[thread.messages.length - 1];
|
|
@@ -25,256 +20,13 @@ function threadHint(thread: Thread): string {
|
|
|
25
20
|
return text.slice(0, MAX_HINT_LENGTH - 1) + "\u2026";
|
|
26
21
|
}
|
|
27
22
|
|
|
28
|
-
// --- Inline markdown parser ---
|
|
29
|
-
|
|
30
|
-
interface StyledSegment {
|
|
31
|
-
text: string;
|
|
32
|
-
fg?: string;
|
|
33
|
-
attributes?: number;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
/**
|
|
37
|
-
* Parse inline markdown (bold, italic, code) into styled segments.
|
|
38
|
-
* Strips syntax markers and returns display text with style info.
|
|
39
|
-
*/
|
|
40
|
-
function parseInlineMarkdown(text: string): StyledSegment[] {
|
|
41
|
-
const segments: StyledSegment[] = [];
|
|
42
|
-
// Order matters: **bold** before *italic*
|
|
43
|
-
const regex = /(\*\*(.+?)\*\*|\*(.+?)\*|`([^`]+)`)/g;
|
|
44
|
-
let pos = 0;
|
|
45
|
-
let match;
|
|
46
|
-
while ((match = regex.exec(text)) !== null) {
|
|
47
|
-
if (match.index > pos) {
|
|
48
|
-
segments.push({ text: text.slice(pos, match.index) });
|
|
49
|
-
}
|
|
50
|
-
if (match[2] !== undefined) {
|
|
51
|
-
// **bold**
|
|
52
|
-
segments.push({ text: match[2], attributes: TextAttributes.BOLD });
|
|
53
|
-
} else if (match[3] !== undefined) {
|
|
54
|
-
// *italic*
|
|
55
|
-
segments.push({ text: match[3], attributes: TextAttributes.ITALIC });
|
|
56
|
-
} else if (match[4] !== undefined) {
|
|
57
|
-
// `code`
|
|
58
|
-
segments.push({ text: match[4], fg: theme.green });
|
|
59
|
-
}
|
|
60
|
-
pos = match.index + match[0].length;
|
|
61
|
-
}
|
|
62
|
-
if (pos < text.length) {
|
|
63
|
-
segments.push({ text: text.slice(pos) });
|
|
64
|
-
}
|
|
65
|
-
if (segments.length === 0) {
|
|
66
|
-
segments.push({ text });
|
|
67
|
-
}
|
|
68
|
-
return segments;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
/**
|
|
72
|
-
* Parse a full line of markdown into styled segments.
|
|
73
|
-
* Handles block-level syntax (headings, lists, blockquotes, hr)
|
|
74
|
-
* and delegates inline content to parseInlineMarkdown.
|
|
75
|
-
*/
|
|
76
|
-
function parseMarkdownLine(line: string): StyledSegment[] {
|
|
77
|
-
// Heading: # ... ###### (strip markers, bold + colored)
|
|
78
|
-
const headingMatch = line.match(/^(#{1,6})\s+(.*)/);
|
|
79
|
-
if (headingMatch) {
|
|
80
|
-
const level = headingMatch[1].length;
|
|
81
|
-
const color = level <= 2 ? theme.blue : theme.mauve;
|
|
82
|
-
// Parse inline markdown within heading text
|
|
83
|
-
const inner = parseInlineMarkdown(headingMatch[2]);
|
|
84
|
-
return inner.map((s) => ({
|
|
85
|
-
...s,
|
|
86
|
-
fg: s.fg ?? color,
|
|
87
|
-
attributes: (s.attributes ?? 0) | TextAttributes.BOLD,
|
|
88
|
-
}));
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
// Horizontal rule: --- or *** or ___
|
|
92
|
-
if (/^(\s*[-*_]\s*){3,}$/.test(line)) {
|
|
93
|
-
return [{ text: "\u2500".repeat(40), fg: theme.overlay, attributes: TextAttributes.DIM }];
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
// Blockquote: > text
|
|
97
|
-
if (line.startsWith("> ")) {
|
|
98
|
-
const inner = parseInlineMarkdown(line.slice(2));
|
|
99
|
-
return [
|
|
100
|
-
{ text: "\u2502 ", fg: theme.overlay },
|
|
101
|
-
...inner.map((s) => ({
|
|
102
|
-
...s,
|
|
103
|
-
fg: s.fg ?? theme.overlay,
|
|
104
|
-
attributes: (s.attributes ?? 0) | TextAttributes.ITALIC,
|
|
105
|
-
})),
|
|
106
|
-
];
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
// Unordered list: - item, * item, + item
|
|
110
|
-
const ulMatch = line.match(/^(\s*)([-*+])\s+(.*)/);
|
|
111
|
-
if (ulMatch) {
|
|
112
|
-
return [
|
|
113
|
-
{ text: ulMatch[1] + "\u2022 ", fg: theme.yellow },
|
|
114
|
-
...parseInlineMarkdown(ulMatch[3]),
|
|
115
|
-
];
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
// Ordered list: 1. item
|
|
119
|
-
const olMatch = line.match(/^(\s*)(\d+)\.\s+(.*)/);
|
|
120
|
-
if (olMatch) {
|
|
121
|
-
return [
|
|
122
|
-
{ text: `${olMatch[1]}${olMatch[2]}. `, fg: theme.yellow },
|
|
123
|
-
...parseInlineMarkdown(olMatch[3]),
|
|
124
|
-
];
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
// Regular line — parse inline markdown only
|
|
128
|
-
return parseInlineMarkdown(line);
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
/**
|
|
132
|
-
* Add styled segments as TextNodeRenderable children to a parent.
|
|
133
|
-
*/
|
|
134
|
-
function addSegments(parent: TextRenderable, segments: StyledSegment[], defaultFg: string): void {
|
|
135
|
-
for (const seg of segments) {
|
|
136
|
-
const node = TextNodeRenderable.fromString(seg.text, {
|
|
137
|
-
fg: seg.fg ?? defaultFg,
|
|
138
|
-
attributes: seg.attributes,
|
|
139
|
-
});
|
|
140
|
-
parent.add(node);
|
|
141
|
-
}
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
// --- Table rendering ---
|
|
145
|
-
|
|
146
|
-
const SEPARATOR_RE = /^\|[\s:]*-+[\s:]*(\|[\s:]*-+[\s:]*)*\|?\s*$/;
|
|
147
|
-
|
|
148
|
-
/** Split a table row into trimmed cell values (strips outer pipes). */
|
|
149
|
-
function parseTableCells(line: string): string[] {
|
|
150
|
-
const trimmed = line.trim();
|
|
151
|
-
// Remove leading/trailing pipes and split
|
|
152
|
-
const inner = trimmed.startsWith("|") ? trimmed.slice(1) : trimmed;
|
|
153
|
-
const withoutTrailing = inner.endsWith("|") ? inner.slice(0, -1) : inner;
|
|
154
|
-
return withoutTrailing.split("|").map((c) => c.trim());
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
/** Compute the display width of a string (strips inline markdown markers). */
|
|
158
|
-
function displayWidth(text: string): number {
|
|
159
|
-
// Remove **bold**, *italic*, `code` markers to get display length
|
|
160
|
-
return text
|
|
161
|
-
.replace(/\*\*(.+?)\*\*/g, "$1")
|
|
162
|
-
.replace(/\*(.+?)\*/g, "$1")
|
|
163
|
-
.replace(/`([^`]+)`/g, "$1")
|
|
164
|
-
.length;
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
interface TableBlock {
|
|
168
|
-
startIndex: number;
|
|
169
|
-
lines: string[];
|
|
170
|
-
separatorIndex: number; // relative to startIndex, -1 if none
|
|
171
|
-
colWidths: number[];
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
/** Scan ahead from a starting `|` line and collect the full table block. */
|
|
175
|
-
function collectTable(specLines: string[], start: number): TableBlock {
|
|
176
|
-
const lines: string[] = [];
|
|
177
|
-
let i = start;
|
|
178
|
-
while (i < specLines.length && specLines[i].trimStart().startsWith("|")) {
|
|
179
|
-
lines.push(specLines[i]);
|
|
180
|
-
i++;
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
// Find separator row
|
|
184
|
-
let separatorIndex = -1;
|
|
185
|
-
for (let j = 0; j < lines.length; j++) {
|
|
186
|
-
if (SEPARATOR_RE.test(lines[j])) {
|
|
187
|
-
separatorIndex = j;
|
|
188
|
-
break;
|
|
189
|
-
}
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
// Calculate column widths from all non-separator rows
|
|
193
|
-
const allCells = lines
|
|
194
|
-
.filter((_, j) => j !== separatorIndex)
|
|
195
|
-
.map(parseTableCells);
|
|
196
|
-
const maxCols = Math.max(...allCells.map((r) => r.length), 0);
|
|
197
|
-
const colWidths: number[] = new Array(maxCols).fill(0);
|
|
198
|
-
for (const row of allCells) {
|
|
199
|
-
for (let c = 0; c < row.length; c++) {
|
|
200
|
-
colWidths[c] = Math.max(colWidths[c], displayWidth(row[c]));
|
|
201
|
-
}
|
|
202
|
-
}
|
|
203
|
-
// Minimum width of 3 per column
|
|
204
|
-
for (let c = 0; c < colWidths.length; c++) {
|
|
205
|
-
colWidths[c] = Math.max(colWidths[c], 3);
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
return { startIndex: start, lines, separatorIndex, colWidths };
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
/** Render a table separator row with box-drawing characters. */
|
|
212
|
-
function renderTableSeparator(parent: TextRenderable, colWidths: number[]): void {
|
|
213
|
-
const parts = colWidths.map((w) => "\u2500".repeat(w + 2));
|
|
214
|
-
const line = "\u251c" + parts.join("\u253c") + "\u2524";
|
|
215
|
-
parent.add(TextNodeRenderable.fromString(line, { fg: theme.overlay }));
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
/** Render a table data/header row with padded, styled cells. */
|
|
219
|
-
function renderTableRow(
|
|
220
|
-
parent: TextRenderable,
|
|
221
|
-
cells: string[],
|
|
222
|
-
colWidths: number[],
|
|
223
|
-
isHeader: boolean,
|
|
224
|
-
): void {
|
|
225
|
-
for (let c = 0; c < colWidths.length; c++) {
|
|
226
|
-
const cellText = c < cells.length ? cells[c] : "";
|
|
227
|
-
const dw = displayWidth(cellText);
|
|
228
|
-
const padding = Math.max(0, colWidths[c] - dw);
|
|
229
|
-
|
|
230
|
-
// Left border
|
|
231
|
-
parent.add(TextNodeRenderable.fromString(
|
|
232
|
-
c === 0 ? "\u2502 " : " \u2502 ",
|
|
233
|
-
{ fg: theme.overlay }
|
|
234
|
-
));
|
|
235
|
-
|
|
236
|
-
// Cell content — parse inline markdown, apply header bold
|
|
237
|
-
const segments = parseInlineMarkdown(cellText);
|
|
238
|
-
for (const seg of segments) {
|
|
239
|
-
const attrs = isHeader
|
|
240
|
-
? (seg.attributes ?? 0) | TextAttributes.BOLD
|
|
241
|
-
: seg.attributes;
|
|
242
|
-
parent.add(TextNodeRenderable.fromString(seg.text, {
|
|
243
|
-
fg: seg.fg ?? theme.text,
|
|
244
|
-
attributes: attrs,
|
|
245
|
-
}));
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
// Padding
|
|
249
|
-
if (padding > 0) {
|
|
250
|
-
parent.add(TextNodeRenderable.fromString(" ".repeat(padding), {}));
|
|
251
|
-
}
|
|
252
|
-
}
|
|
253
|
-
// Right border
|
|
254
|
-
parent.add(TextNodeRenderable.fromString(
|
|
255
|
-
" \u2502",
|
|
256
|
-
{ fg: theme.overlay }
|
|
257
|
-
));
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
/** Render a top or bottom border for the table. */
|
|
261
|
-
function renderTableBorder(parent: TextRenderable, colWidths: number[], position: "top" | "bottom"): void {
|
|
262
|
-
const [left, mid, right] = position === "top"
|
|
263
|
-
? ["\u250c", "\u252c", "\u2510"]
|
|
264
|
-
: ["\u2514", "\u2534", "\u2518"];
|
|
265
|
-
const parts = colWidths.map((w) => "\u2500".repeat(w + 2));
|
|
266
|
-
parent.add(TextNodeRenderable.fromString(
|
|
267
|
-
left + parts.join(mid) + right,
|
|
268
|
-
{ fg: theme.overlay }
|
|
269
|
-
));
|
|
270
|
-
}
|
|
271
|
-
|
|
272
23
|
// --- Plain text builder (for tests) ---
|
|
273
24
|
|
|
274
25
|
/**
|
|
275
26
|
* Build plain text line-mode content (for testing / plain fallback).
|
|
276
27
|
*/
|
|
277
28
|
export function buildPagerContent(state: ReviewState, searchQuery?: string | null, unreadThreadIds?: ReadonlySet<string>): string {
|
|
29
|
+
const numWidth = Math.max(String(state.lineCount).length, 3);
|
|
278
30
|
const lines: string[] = [];
|
|
279
31
|
for (let i = 0; i < state.specLines.length; i++) {
|
|
280
32
|
const lineNum = i + 1;
|
|
@@ -297,7 +49,9 @@ export function buildPagerContent(state: ReviewState, searchQuery?: string | nul
|
|
|
297
49
|
indicator = "\u258c";
|
|
298
50
|
}
|
|
299
51
|
}
|
|
300
|
-
|
|
52
|
+
const numStr = String(lineNum);
|
|
53
|
+
const padded = " ".repeat(numWidth - numStr.length) + numStr;
|
|
54
|
+
lines.push(`${prefix}${indicator}${padded} ${specText}`);
|
|
301
55
|
}
|
|
302
56
|
return lines.join("\n");
|
|
303
57
|
}
|
|
@@ -312,6 +66,11 @@ export function buildPagerContent(state: ReviewState, searchQuery?: string | nul
|
|
|
312
66
|
export function buildPagerNodes(lineNode: TextRenderable, state: ReviewState, searchQuery?: string | null, unreadThreadIds?: ReadonlySet<string>): void {
|
|
313
67
|
lineNode.clear();
|
|
314
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
|
+
|
|
315
74
|
// Pre-scan for table blocks so we can calculate column widths
|
|
316
75
|
const tableBlocks = new Map<number, TableBlock>();
|
|
317
76
|
for (let i = 0; i < state.specLines.length; i++) {
|
|
@@ -324,6 +83,9 @@ export function buildPagerNodes(lineNode: TextRenderable, state: ReviewState, se
|
|
|
324
83
|
}
|
|
325
84
|
}
|
|
326
85
|
|
|
86
|
+
// Track fenced code block state
|
|
87
|
+
let inCodeBlock = false;
|
|
88
|
+
|
|
327
89
|
for (let i = 0; i < state.specLines.length; i++) {
|
|
328
90
|
const lineNum = i + 1;
|
|
329
91
|
const thread = state.threadAtLine(lineNum);
|
|
@@ -334,7 +96,7 @@ export function buildPagerNodes(lineNode: TextRenderable, state: ReviewState, se
|
|
|
334
96
|
|
|
335
97
|
// Thread indicator — gutter bar on the left
|
|
336
98
|
let indicator = " ";
|
|
337
|
-
let indicatorColor = theme.
|
|
99
|
+
let indicatorColor: string = theme.textDim;
|
|
338
100
|
if (thread) {
|
|
339
101
|
const isUnread = unreadThreadIds && unreadThreadIds.has(thread.id);
|
|
340
102
|
if (isUnread) {
|
|
@@ -356,7 +118,7 @@ export function buildPagerNodes(lineNode: TextRenderable, state: ReviewState, se
|
|
|
356
118
|
|
|
357
119
|
// Top border before first table row (on its own visual line with blank gutter)
|
|
358
120
|
if (isTable && relIdx === 0) {
|
|
359
|
-
lineNode.add(TextNodeRenderable.fromString(
|
|
121
|
+
lineNode.add(TextNodeRenderable.fromString(gutterBlank, { fg: theme.textDim }));
|
|
360
122
|
renderTableBorder(lineNode, tableBlock.colWidths, "top");
|
|
361
123
|
lineNode.add(TextNodeRenderable.fromString("\n", {}));
|
|
362
124
|
}
|
|
@@ -364,19 +126,34 @@ export function buildPagerNodes(lineNode: TextRenderable, state: ReviewState, se
|
|
|
364
126
|
// Gutter: cursor + indicator + line number (dimmed)
|
|
365
127
|
lineNode.add(TextNodeRenderable.fromString(
|
|
366
128
|
`${prefix}`,
|
|
367
|
-
{ fg: isCursor ? theme.text : theme.
|
|
129
|
+
{ fg: isCursor ? theme.text : theme.textDim, bg: isCursor ? theme.backgroundElement : undefined }
|
|
368
130
|
));
|
|
369
131
|
lineNode.add(TextNodeRenderable.fromString(
|
|
370
132
|
indicator,
|
|
371
|
-
{ fg: indicatorColor }
|
|
133
|
+
{ fg: indicatorColor, bg: isCursor ? theme.backgroundElement : undefined }
|
|
372
134
|
));
|
|
135
|
+
const numStr = String(lineNum);
|
|
136
|
+
const paddedNum = " ".repeat(numWidth - numStr.length) + numStr;
|
|
373
137
|
lineNode.add(TextNodeRenderable.fromString(
|
|
374
|
-
`${
|
|
375
|
-
{ fg: theme.
|
|
138
|
+
`${paddedNum} `,
|
|
139
|
+
{ fg: theme.textDim, attributes: TextAttributes.DIM, bg: isCursor ? theme.backgroundElement : undefined }
|
|
376
140
|
));
|
|
377
141
|
|
|
378
|
-
// Spec text — table or regular markdown
|
|
379
|
-
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) {
|
|
380
157
|
if (relIdx === tableBlock.separatorIndex) {
|
|
381
158
|
// Separator row → box-drawing line
|
|
382
159
|
renderTableSeparator(lineNode, tableBlock.colWidths);
|
|
@@ -390,18 +167,28 @@ export function buildPagerNodes(lineNode: TextRenderable, state: ReviewState, se
|
|
|
390
167
|
// Bottom border after last row (on its own visual line with blank gutter)
|
|
391
168
|
if (relIdx === tableBlock.lines.length - 1) {
|
|
392
169
|
lineNode.add(TextNodeRenderable.fromString("\n", {}));
|
|
393
|
-
lineNode.add(TextNodeRenderable.fromString(
|
|
170
|
+
lineNode.add(TextNodeRenderable.fromString(gutterBlank, { fg: theme.textDim }));
|
|
394
171
|
renderTableBorder(lineNode, tableBlock.colWidths, "bottom");
|
|
395
172
|
}
|
|
396
173
|
} else if (searchQuery) {
|
|
397
|
-
// When searching, show
|
|
398
|
-
const
|
|
399
|
-
const
|
|
400
|
-
|
|
174
|
+
// When searching, show colored match segments (no markdown styling)
|
|
175
|
+
const escaped = searchQuery.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
176
|
+
const searchRegex = new RegExp(`(${escaped})`, "gi");
|
|
177
|
+
const parts = specText.split(searchRegex);
|
|
178
|
+
for (let p = 0; p < parts.length; p++) {
|
|
179
|
+
const part = parts[p];
|
|
180
|
+
if (part.length === 0) continue;
|
|
181
|
+
const isMatch = p % 2 === 1; // split with capture group: [before, match, between, match, after]
|
|
182
|
+
if (isMatch) {
|
|
183
|
+
lineNode.add(TextNodeRenderable.fromString(part, { fg: theme.base, bg: theme.yellow, attributes: TextAttributes.BOLD }));
|
|
184
|
+
} else {
|
|
185
|
+
lineNode.add(TextNodeRenderable.fromString(part, { fg: theme.text, bg: isCursor ? theme.backgroundElement : undefined }));
|
|
186
|
+
}
|
|
187
|
+
}
|
|
401
188
|
} else {
|
|
402
189
|
// Parse and render inline markdown
|
|
403
190
|
const segments = parseMarkdownLine(specText);
|
|
404
|
-
addSegments(lineNode, segments, theme.text);
|
|
191
|
+
addSegments(lineNode, segments, theme.text, isCursor ? theme.backgroundElement : undefined);
|
|
405
192
|
}
|
|
406
193
|
|
|
407
194
|
// Thread hint (dimmed, inline)
|
|
@@ -409,7 +196,7 @@ export function buildPagerNodes(lineNode: TextRenderable, state: ReviewState, se
|
|
|
409
196
|
const hint = threadHint(thread);
|
|
410
197
|
lineNode.add(TextNodeRenderable.fromString(
|
|
411
198
|
` \u00ab ${hint}`,
|
|
412
|
-
{ fg: theme.
|
|
199
|
+
{ fg: theme.textDim, attributes: TextAttributes.DIM, bg: isCursor ? theme.backgroundElement : undefined }
|
|
413
200
|
));
|
|
414
201
|
}
|
|
415
202
|
|
package/src/tui/search.ts
CHANGED
|
@@ -5,7 +5,7 @@ import {
|
|
|
5
5
|
type CliRenderer,
|
|
6
6
|
type KeyEvent,
|
|
7
7
|
} from "@opentui/core";
|
|
8
|
-
import { theme } from "./theme";
|
|
8
|
+
import { theme } from "./ui/theme";
|
|
9
9
|
|
|
10
10
|
export interface SearchOptions {
|
|
11
11
|
renderer: CliRenderer;
|
|
@@ -36,7 +36,7 @@ export function createSearch(opts: SearchOptions): SearchOverlay {
|
|
|
36
36
|
width: "100%",
|
|
37
37
|
height: 1,
|
|
38
38
|
zIndex: 100,
|
|
39
|
-
backgroundColor: theme.
|
|
39
|
+
backgroundColor: theme.backgroundPanel,
|
|
40
40
|
flexDirection: "row",
|
|
41
41
|
alignItems: "center",
|
|
42
42
|
});
|
|
@@ -47,7 +47,7 @@ export function createSearch(opts: SearchOptions): SearchOverlay {
|
|
|
47
47
|
width: 3,
|
|
48
48
|
height: 1,
|
|
49
49
|
fg: theme.yellow,
|
|
50
|
-
bg: theme.
|
|
50
|
+
bg: theme.backgroundPanel,
|
|
51
51
|
wrapMode: "none",
|
|
52
52
|
});
|
|
53
53
|
|
|
@@ -55,12 +55,12 @@ export function createSearch(opts: SearchOptions): SearchOverlay {
|
|
|
55
55
|
const input = new InputRenderable(renderer, {
|
|
56
56
|
width: "100%",
|
|
57
57
|
flexGrow: 1,
|
|
58
|
-
backgroundColor: theme.
|
|
58
|
+
backgroundColor: theme.backgroundPanel,
|
|
59
59
|
textColor: theme.text,
|
|
60
|
-
focusedBackgroundColor: theme.
|
|
60
|
+
focusedBackgroundColor: theme.backgroundElement,
|
|
61
61
|
focusedTextColor: theme.text,
|
|
62
62
|
placeholder: "Search...",
|
|
63
|
-
placeholderColor: theme.
|
|
63
|
+
placeholderColor: theme.textDim,
|
|
64
64
|
});
|
|
65
65
|
|
|
66
66
|
container.add(label);
|
package/src/tui/status-bar.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { BoxRenderable, TextRenderable, TextNodeRenderable, TextAttributes, type CliRenderer } from "@opentui/core";
|
|
2
2
|
import type { ReviewState } from "../state/review-state";
|
|
3
3
|
import { basename } from "path";
|
|
4
|
-
import { theme } from "./theme";
|
|
4
|
+
import { theme } from "./ui/theme";
|
|
5
|
+
import { buildHints, type Hint } from "./ui/hint-bar";
|
|
5
6
|
|
|
6
7
|
export interface TopBarComponents {
|
|
7
8
|
box: BoxRenderable;
|
|
@@ -31,7 +32,7 @@ export function buildTopBar(
|
|
|
31
32
|
// Filename — bold
|
|
32
33
|
t.add(TextNodeRenderable.fromString(` ${name}`, { fg: theme.text, attributes: TextAttributes.BOLD }));
|
|
33
34
|
|
|
34
|
-
t.add(TextNodeRenderable.fromString("
|
|
35
|
+
t.add(TextNodeRenderable.fromString(" \u00b7 ", { fg: theme.textDim }));
|
|
35
36
|
|
|
36
37
|
// Thread summary
|
|
37
38
|
if (open > 0 || pending > 0) {
|
|
@@ -40,12 +41,12 @@ export function buildTopBar(
|
|
|
40
41
|
if (pending > 0) parts.push(`${pending} pending`);
|
|
41
42
|
t.add(TextNodeRenderable.fromString(parts.join(", "), { fg: theme.yellow }));
|
|
42
43
|
} else {
|
|
43
|
-
t.add(TextNodeRenderable.fromString("No active threads", { fg: theme.
|
|
44
|
+
t.add(TextNodeRenderable.fromString("No active threads", { fg: theme.textMuted }));
|
|
44
45
|
}
|
|
45
46
|
|
|
46
47
|
// Unread replies
|
|
47
48
|
if (unreadCount && unreadCount > 0) {
|
|
48
|
-
t.add(TextNodeRenderable.fromString("
|
|
49
|
+
t.add(TextNodeRenderable.fromString(" \u00b7 ", { fg: theme.textDim }));
|
|
49
50
|
t.add(TextNodeRenderable.fromString(
|
|
50
51
|
`${unreadCount} new repl${unreadCount === 1 ? "y" : "ies"}`,
|
|
51
52
|
{ fg: theme.green, attributes: TextAttributes.BOLD }
|
|
@@ -54,41 +55,45 @@ export function buildTopBar(
|
|
|
54
55
|
|
|
55
56
|
// Spec changed warning
|
|
56
57
|
if (specChanged) {
|
|
57
|
-
t.add(TextNodeRenderable.fromString("
|
|
58
|
+
t.add(TextNodeRenderable.fromString(" \u00b7 ", { fg: theme.textDim }));
|
|
58
59
|
t.add(TextNodeRenderable.fromString("!! Spec changed externally", { fg: theme.red, attributes: TextAttributes.BOLD }));
|
|
59
60
|
}
|
|
60
61
|
|
|
61
62
|
// Cursor position
|
|
62
|
-
t.add(TextNodeRenderable.fromString("
|
|
63
|
-
t.add(TextNodeRenderable.fromString(`L${state.cursorLine}/${state.lineCount}`, { fg: theme.
|
|
63
|
+
t.add(TextNodeRenderable.fromString(" \u00b7 ", { fg: theme.textDim }));
|
|
64
|
+
t.add(TextNodeRenderable.fromString(`L${state.cursorLine}/${state.lineCount}`, { fg: theme.textMuted }));
|
|
65
|
+
}
|
|
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 }));
|
|
64
74
|
}
|
|
65
75
|
|
|
66
76
|
/**
|
|
67
77
|
* Build the bottom bar with styled TextNodes.
|
|
68
78
|
*/
|
|
69
|
-
export function buildBottomBar(bar: BottomBarComponents, commandBuffer: string | null): void {
|
|
79
|
+
export function buildBottomBar(bar: BottomBarComponents, commandBuffer: string | null, hasThread?: boolean): void {
|
|
70
80
|
const t = bar.text;
|
|
71
81
|
t.clear();
|
|
72
82
|
if (commandBuffer !== null) {
|
|
73
83
|
t.add(TextNodeRenderable.fromString(` :${commandBuffer}`, { fg: theme.text }));
|
|
74
84
|
return;
|
|
75
85
|
}
|
|
76
|
-
const hints = [
|
|
86
|
+
const hints: Hint[] = [
|
|
77
87
|
{ key: "j/k", action: "move" },
|
|
78
88
|
{ key: "c", action: "comment" },
|
|
79
|
-
{ key: "r", action: "resolve" },
|
|
80
|
-
{ key: "/", action: "search" },
|
|
81
|
-
{ key: "?", action: "help" },
|
|
82
89
|
];
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
t.add(TextNodeRenderable.fromString(`[${h.key}]`, { fg: theme.blue }));
|
|
87
|
-
t.add(TextNodeRenderable.fromString(` ${h.action}`, { fg: theme.subtext }));
|
|
88
|
-
if (i < hints.length - 1) {
|
|
89
|
-
t.add(TextNodeRenderable.fromString(" ", {}));
|
|
90
|
-
}
|
|
90
|
+
if (hasThread) {
|
|
91
|
+
hints.push({ key: "r", action: "resolve" });
|
|
92
|
+
hints.push({ key: "dd", action: "delete thread" });
|
|
91
93
|
}
|
|
94
|
+
hints.push({ key: "/", action: "search" });
|
|
95
|
+
hints.push({ key: "?", action: "help" });
|
|
96
|
+
buildHints(t, hints);
|
|
92
97
|
}
|
|
93
98
|
|
|
94
99
|
/**
|
|
@@ -98,9 +103,7 @@ export function createTopBar(renderer: CliRenderer): TopBarComponents {
|
|
|
98
103
|
const box = new BoxRenderable(renderer, {
|
|
99
104
|
width: "100%",
|
|
100
105
|
height: 1,
|
|
101
|
-
backgroundColor: theme.
|
|
102
|
-
border: ["bottom"],
|
|
103
|
-
borderColor: theme.surface1,
|
|
106
|
+
backgroundColor: theme.backgroundPanel,
|
|
104
107
|
});
|
|
105
108
|
|
|
106
109
|
const text = new TextRenderable(renderer, {
|
|
@@ -123,7 +126,7 @@ export function createBottomBar(renderer: CliRenderer): BottomBarComponents {
|
|
|
123
126
|
width: "100%",
|
|
124
127
|
height: 1,
|
|
125
128
|
flexShrink: 0,
|
|
126
|
-
backgroundColor: theme.
|
|
129
|
+
backgroundColor: theme.backgroundPanel,
|
|
127
130
|
});
|
|
128
131
|
|
|
129
132
|
const text = new TextRenderable(renderer, {
|