revspec 0.3.0 → 0.4.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/docs/superpowers/plans/2026-03-15-ui-refactor.md +1025 -0
- package/package.json +1 -1
- package/src/tui/app.ts +173 -225
- package/src/tui/comment-input.ts +145 -144
- package/src/tui/confirm.ts +29 -43
- package/src/tui/help.ts +28 -52
- package/src/tui/pager.ts +24 -257
- package/src/tui/search.ts +6 -6
- package/src/tui/status-bar.ts +11 -20
- package/src/tui/thread-list.ts +28 -54
- package/src/tui/ui/dialog.ts +106 -0
- package/src/tui/ui/hint-bar.ts +20 -0
- package/src/tui/ui/keybinds.ts +104 -0
- package/src/tui/ui/markdown.ts +251 -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,7 +7,8 @@ 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
|
|
|
@@ -25,250 +26,6 @@ function threadHint(thread: Thread): string {
|
|
|
25
26
|
return text.slice(0, MAX_HINT_LENGTH - 1) + "\u2026";
|
|
26
27
|
}
|
|
27
28
|
|
|
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
29
|
// --- Plain text builder (for tests) ---
|
|
273
30
|
|
|
274
31
|
/**
|
|
@@ -334,7 +91,7 @@ export function buildPagerNodes(lineNode: TextRenderable, state: ReviewState, se
|
|
|
334
91
|
|
|
335
92
|
// Thread indicator — gutter bar on the left
|
|
336
93
|
let indicator = " ";
|
|
337
|
-
let indicatorColor = theme.
|
|
94
|
+
let indicatorColor: string = theme.textDim;
|
|
338
95
|
if (thread) {
|
|
339
96
|
const isUnread = unreadThreadIds && unreadThreadIds.has(thread.id);
|
|
340
97
|
if (isUnread) {
|
|
@@ -356,7 +113,7 @@ export function buildPagerNodes(lineNode: TextRenderable, state: ReviewState, se
|
|
|
356
113
|
|
|
357
114
|
// Top border before first table row (on its own visual line with blank gutter)
|
|
358
115
|
if (isTable && relIdx === 0) {
|
|
359
|
-
lineNode.add(TextNodeRenderable.fromString(" ", { fg: theme.
|
|
116
|
+
lineNode.add(TextNodeRenderable.fromString(" ", { fg: theme.textDim }));
|
|
360
117
|
renderTableBorder(lineNode, tableBlock.colWidths, "top");
|
|
361
118
|
lineNode.add(TextNodeRenderable.fromString("\n", {}));
|
|
362
119
|
}
|
|
@@ -364,15 +121,15 @@ export function buildPagerNodes(lineNode: TextRenderable, state: ReviewState, se
|
|
|
364
121
|
// Gutter: cursor + indicator + line number (dimmed)
|
|
365
122
|
lineNode.add(TextNodeRenderable.fromString(
|
|
366
123
|
`${prefix}`,
|
|
367
|
-
{ fg: isCursor ? theme.text : theme.
|
|
124
|
+
{ fg: isCursor ? theme.text : theme.textDim, bg: isCursor ? theme.backgroundElement : undefined }
|
|
368
125
|
));
|
|
369
126
|
lineNode.add(TextNodeRenderable.fromString(
|
|
370
127
|
indicator,
|
|
371
|
-
{ fg: indicatorColor }
|
|
128
|
+
{ fg: indicatorColor, bg: isCursor ? theme.backgroundElement : undefined }
|
|
372
129
|
));
|
|
373
130
|
lineNode.add(TextNodeRenderable.fromString(
|
|
374
131
|
`${padLineNum(lineNum)} `,
|
|
375
|
-
{ fg: theme.
|
|
132
|
+
{ fg: theme.textDim, attributes: TextAttributes.DIM, bg: isCursor ? theme.backgroundElement : undefined }
|
|
376
133
|
));
|
|
377
134
|
|
|
378
135
|
// Spec text — table or regular markdown
|
|
@@ -390,18 +147,28 @@ export function buildPagerNodes(lineNode: TextRenderable, state: ReviewState, se
|
|
|
390
147
|
// Bottom border after last row (on its own visual line with blank gutter)
|
|
391
148
|
if (relIdx === tableBlock.lines.length - 1) {
|
|
392
149
|
lineNode.add(TextNodeRenderable.fromString("\n", {}));
|
|
393
|
-
lineNode.add(TextNodeRenderable.fromString(" ", { fg: theme.
|
|
150
|
+
lineNode.add(TextNodeRenderable.fromString(" ", { fg: theme.textDim }));
|
|
394
151
|
renderTableBorder(lineNode, tableBlock.colWidths, "bottom");
|
|
395
152
|
}
|
|
396
153
|
} else if (searchQuery) {
|
|
397
|
-
// When searching, show
|
|
398
|
-
const
|
|
399
|
-
const
|
|
400
|
-
|
|
154
|
+
// When searching, show colored match segments (no markdown styling)
|
|
155
|
+
const escaped = searchQuery.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
156
|
+
const searchRegex = new RegExp(`(${escaped})`, "gi");
|
|
157
|
+
const parts = specText.split(searchRegex);
|
|
158
|
+
for (let p = 0; p < parts.length; p++) {
|
|
159
|
+
const part = parts[p];
|
|
160
|
+
if (part.length === 0) continue;
|
|
161
|
+
const isMatch = p % 2 === 1; // split with capture group: [before, match, between, match, after]
|
|
162
|
+
if (isMatch) {
|
|
163
|
+
lineNode.add(TextNodeRenderable.fromString(part, { fg: theme.base, bg: theme.yellow, attributes: TextAttributes.BOLD }));
|
|
164
|
+
} else {
|
|
165
|
+
lineNode.add(TextNodeRenderable.fromString(part, { fg: theme.text, bg: isCursor ? theme.backgroundElement : undefined }));
|
|
166
|
+
}
|
|
167
|
+
}
|
|
401
168
|
} else {
|
|
402
169
|
// Parse and render inline markdown
|
|
403
170
|
const segments = parseMarkdownLine(specText);
|
|
404
|
-
addSegments(lineNode, segments, theme.text);
|
|
171
|
+
addSegments(lineNode, segments, theme.text, isCursor ? theme.backgroundElement : undefined);
|
|
405
172
|
}
|
|
406
173
|
|
|
407
174
|
// Thread hint (dimmed, inline)
|
|
@@ -409,7 +176,7 @@ export function buildPagerNodes(lineNode: TextRenderable, state: ReviewState, se
|
|
|
409
176
|
const hint = threadHint(thread);
|
|
410
177
|
lineNode.add(TextNodeRenderable.fromString(
|
|
411
178
|
` \u00ab ${hint}`,
|
|
412
|
-
{ fg: theme.
|
|
179
|
+
{ fg: theme.textDim, attributes: TextAttributes.DIM, bg: isCursor ? theme.backgroundElement : undefined }
|
|
413
180
|
));
|
|
414
181
|
}
|
|
415
182
|
|
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 } 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,13 +55,13 @@ 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 }));
|
|
64
65
|
}
|
|
65
66
|
|
|
66
67
|
/**
|
|
@@ -80,15 +81,7 @@ export function buildBottomBar(bar: BottomBarComponents, commandBuffer: string |
|
|
|
80
81
|
{ key: "/", action: "search" },
|
|
81
82
|
{ key: "?", action: "help" },
|
|
82
83
|
];
|
|
83
|
-
t
|
|
84
|
-
for (let i = 0; i < hints.length; i++) {
|
|
85
|
-
const h = hints[i];
|
|
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
|
-
}
|
|
91
|
-
}
|
|
84
|
+
buildHints(t, hints);
|
|
92
85
|
}
|
|
93
86
|
|
|
94
87
|
/**
|
|
@@ -98,9 +91,7 @@ export function createTopBar(renderer: CliRenderer): TopBarComponents {
|
|
|
98
91
|
const box = new BoxRenderable(renderer, {
|
|
99
92
|
width: "100%",
|
|
100
93
|
height: 1,
|
|
101
|
-
backgroundColor: theme.
|
|
102
|
-
border: ["bottom"],
|
|
103
|
-
borderColor: theme.surface1,
|
|
94
|
+
backgroundColor: theme.backgroundPanel,
|
|
104
95
|
});
|
|
105
96
|
|
|
106
97
|
const text = new TextRenderable(renderer, {
|
|
@@ -123,7 +114,7 @@ export function createBottomBar(renderer: CliRenderer): BottomBarComponents {
|
|
|
123
114
|
width: "100%",
|
|
124
115
|
height: 1,
|
|
125
116
|
flexShrink: 0,
|
|
126
|
-
backgroundColor: theme.
|
|
117
|
+
backgroundColor: theme.backgroundPanel,
|
|
127
118
|
});
|
|
128
119
|
|
|
129
120
|
const text = new TextRenderable(renderer, {
|
package/src/tui/thread-list.ts
CHANGED
|
@@ -1,13 +1,12 @@
|
|
|
1
1
|
import {
|
|
2
|
-
BoxRenderable,
|
|
3
2
|
TextRenderable,
|
|
4
3
|
SelectRenderable,
|
|
5
4
|
SelectRenderableEvents,
|
|
6
5
|
type CliRenderer,
|
|
7
|
-
type KeyEvent,
|
|
8
6
|
} from "@opentui/core";
|
|
9
7
|
import type { Thread } from "../protocol/types";
|
|
10
|
-
import { theme, STATUS_ICONS } from "./theme";
|
|
8
|
+
import { theme, STATUS_ICONS } from "./ui/theme";
|
|
9
|
+
import { createDialog } from "./ui/dialog";
|
|
11
10
|
|
|
12
11
|
export interface ThreadListOptions {
|
|
13
12
|
renderer: CliRenderer;
|
|
@@ -17,7 +16,7 @@ export interface ThreadListOptions {
|
|
|
17
16
|
}
|
|
18
17
|
|
|
19
18
|
export interface ThreadListOverlay {
|
|
20
|
-
container: BoxRenderable;
|
|
19
|
+
container: import("@opentui/core").BoxRenderable;
|
|
21
20
|
cleanup: () => void;
|
|
22
21
|
}
|
|
23
22
|
|
|
@@ -44,21 +43,22 @@ export function createThreadList(opts: ThreadListOptions): ThreadListOverlay {
|
|
|
44
43
|
(t) => t.status === "open" || t.status === "pending"
|
|
45
44
|
);
|
|
46
45
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
46
|
+
const count = activeThreads.length;
|
|
47
|
+
|
|
48
|
+
const dialog = createDialog({
|
|
49
|
+
renderer,
|
|
50
|
+
title: `Threads (${count} active)`,
|
|
52
51
|
width: "70%",
|
|
53
52
|
height: "60%",
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
53
|
+
top: "15%",
|
|
54
|
+
left: "15%",
|
|
55
|
+
borderColor: theme.mauve,
|
|
56
|
+
onDismiss: onCancel,
|
|
57
|
+
hints: [
|
|
58
|
+
{ key: "j/k", action: "navigate" },
|
|
59
|
+
{ key: "Enter", action: "jump" },
|
|
60
|
+
{ key: "Esc", action: "close" },
|
|
61
|
+
],
|
|
62
62
|
});
|
|
63
63
|
|
|
64
64
|
if (activeThreads.length === 0) {
|
|
@@ -66,10 +66,10 @@ export function createThreadList(opts: ThreadListOptions): ThreadListOverlay {
|
|
|
66
66
|
content: "No active threads. Press [Esc] to close.",
|
|
67
67
|
width: "100%",
|
|
68
68
|
height: 1,
|
|
69
|
-
fg: theme.
|
|
69
|
+
fg: theme.textDim,
|
|
70
70
|
wrapMode: "none",
|
|
71
71
|
});
|
|
72
|
-
|
|
72
|
+
dialog.content.add(emptyMsg);
|
|
73
73
|
} else {
|
|
74
74
|
// Build select options from threads
|
|
75
75
|
const selectOptions = activeThreads.map((t) => {
|
|
@@ -86,19 +86,19 @@ export function createThreadList(opts: ThreadListOptions): ThreadListOverlay {
|
|
|
86
86
|
flexGrow: 1,
|
|
87
87
|
options: selectOptions,
|
|
88
88
|
selectedIndex: 0,
|
|
89
|
-
backgroundColor: theme.
|
|
89
|
+
backgroundColor: theme.backgroundPanel,
|
|
90
90
|
textColor: theme.text,
|
|
91
|
-
focusedBackgroundColor: theme.
|
|
91
|
+
focusedBackgroundColor: theme.backgroundPanel,
|
|
92
92
|
focusedTextColor: theme.text,
|
|
93
|
-
selectedBackgroundColor: theme.
|
|
93
|
+
selectedBackgroundColor: theme.backgroundElement,
|
|
94
94
|
selectedTextColor: "#f5c2e7",
|
|
95
|
-
descriptionColor: theme.
|
|
96
|
-
selectedDescriptionColor: theme.
|
|
95
|
+
descriptionColor: theme.textDim,
|
|
96
|
+
selectedDescriptionColor: theme.textMuted,
|
|
97
97
|
showDescription: true,
|
|
98
98
|
wrapSelection: true,
|
|
99
99
|
});
|
|
100
100
|
|
|
101
|
-
|
|
101
|
+
dialog.content.add(select);
|
|
102
102
|
|
|
103
103
|
// Focus the select so it handles j/k navigation
|
|
104
104
|
renderer.focusRenderable(select);
|
|
@@ -112,34 +112,8 @@ export function createThreadList(opts: ThreadListOptions): ThreadListOverlay {
|
|
|
112
112
|
});
|
|
113
113
|
}
|
|
114
114
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
width: "100%",
|
|
119
|
-
height: 1,
|
|
120
|
-
fg: theme.hintFg,
|
|
121
|
-
bg: theme.hintBg,
|
|
122
|
-
wrapMode: "none",
|
|
123
|
-
truncate: true,
|
|
124
|
-
});
|
|
125
|
-
|
|
126
|
-
container.add(hint);
|
|
127
|
-
|
|
128
|
-
// Key handler for Esc
|
|
129
|
-
const keyHandler = (key: KeyEvent) => {
|
|
130
|
-
if (key.name === "escape") {
|
|
131
|
-
key.preventDefault();
|
|
132
|
-
key.stopPropagation();
|
|
133
|
-
onCancel();
|
|
134
|
-
return;
|
|
135
|
-
}
|
|
115
|
+
return {
|
|
116
|
+
container: dialog.container,
|
|
117
|
+
cleanup: dialog.cleanup,
|
|
136
118
|
};
|
|
137
|
-
|
|
138
|
-
renderer.keyInput.on("keypress", keyHandler);
|
|
139
|
-
|
|
140
|
-
function cleanup(): void {
|
|
141
|
-
renderer.keyInput.off("keypress", keyHandler);
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
return { container, cleanup };
|
|
145
119
|
}
|