revspec 0.2.2 → 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/CLAUDE.md +10 -2
- package/README.md +86 -29
- package/bin/revspec.ts +2 -1
- package/docs/superpowers/plans/2026-03-15-ui-refactor.md +1025 -0
- package/package.json +1 -1
- package/scripts/install-skill.sh +20 -0
- package/scripts/release.sh +5 -6
- package/skills/revspec/SKILL.md +137 -0
- package/src/protocol/live-events.ts +3 -2
- package/src/tui/app.ts +198 -310
- package/src/tui/comment-input.ts +145 -144
- package/src/tui/confirm.ts +29 -43
- package/src/tui/help.ts +33 -57
- package/src/tui/pager.ts +162 -82
- package/src/tui/search.ts +6 -6
- package/src/tui/status-bar.ts +77 -34
- 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
|
@@ -3,12 +3,12 @@ import type { Thread } from "../protocol/types";
|
|
|
3
3
|
import {
|
|
4
4
|
ScrollBoxRenderable,
|
|
5
5
|
TextRenderable,
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
parseColor,
|
|
6
|
+
TextNodeRenderable,
|
|
7
|
+
TextAttributes,
|
|
9
8
|
type CliRenderer,
|
|
10
9
|
} from "@opentui/core";
|
|
11
|
-
import { theme } from "./theme";
|
|
10
|
+
import { theme } from "./ui/theme";
|
|
11
|
+
import { parseMarkdownLine, addSegments, collectTable, renderTableBorder, renderTableSeparator, renderTableRow, parseTableCells, type TableBlock } from "./ui/markdown";
|
|
12
12
|
|
|
13
13
|
const MAX_HINT_LENGTH = 40;
|
|
14
14
|
|
|
@@ -26,90 +26,206 @@ function threadHint(thread: Thread): string {
|
|
|
26
26
|
return text.slice(0, MAX_HINT_LENGTH - 1) + "\u2026";
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
+
// --- Plain text builder (for tests) ---
|
|
30
|
+
|
|
29
31
|
/**
|
|
30
|
-
* Build plain text line-mode content (for
|
|
31
|
-
* Each line: cursor marker + lineNum + content + thread indicator + hint.
|
|
32
|
+
* Build plain text line-mode content (for testing / plain fallback).
|
|
32
33
|
*/
|
|
33
34
|
export function buildPagerContent(state: ReviewState, searchQuery?: string | null, unreadThreadIds?: ReadonlySet<string>): string {
|
|
34
35
|
const lines: string[] = [];
|
|
35
|
-
|
|
36
36
|
for (let i = 0; i < state.specLines.length; i++) {
|
|
37
37
|
const lineNum = i + 1;
|
|
38
38
|
const thread = state.threadAtLine(lineNum);
|
|
39
39
|
const isCursor = lineNum === state.cursorLine;
|
|
40
|
-
|
|
41
40
|
const prefix = isCursor ? ">" : " ";
|
|
42
41
|
let specText = state.specLines[i];
|
|
43
|
-
|
|
44
42
|
if (searchQuery) {
|
|
45
43
|
const regex = new RegExp(searchQuery.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "gi");
|
|
46
44
|
specText = specText.replace(regex, (match) => `>>${match}<<`);
|
|
47
45
|
}
|
|
46
|
+
let indicator = " ";
|
|
47
|
+
if (thread) {
|
|
48
|
+
const isUnread = unreadThreadIds && unreadThreadIds.has(thread.id);
|
|
49
|
+
if (isUnread) {
|
|
50
|
+
indicator = "\u2588";
|
|
51
|
+
} else if (thread.status === "resolved") {
|
|
52
|
+
indicator = "\u2713";
|
|
53
|
+
} else {
|
|
54
|
+
indicator = "\u258c";
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
lines.push(`${prefix}${indicator}${padLineNum(lineNum)} ${specText}`);
|
|
58
|
+
}
|
|
59
|
+
return lines.join("\n");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// --- Styled node builder ---
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Build styled line-mode content using TextNodeRenderable.
|
|
66
|
+
* Inline markdown is parsed and styled per line.
|
|
67
|
+
* Line numbers and thread hints are dimmed.
|
|
68
|
+
*/
|
|
69
|
+
export function buildPagerNodes(lineNode: TextRenderable, state: ReviewState, searchQuery?: string | null, unreadThreadIds?: ReadonlySet<string>): void {
|
|
70
|
+
lineNode.clear();
|
|
71
|
+
|
|
72
|
+
// Pre-scan for table blocks so we can calculate column widths
|
|
73
|
+
const tableBlocks = new Map<number, TableBlock>();
|
|
74
|
+
for (let i = 0; i < state.specLines.length; i++) {
|
|
75
|
+
if (state.specLines[i].trimStart().startsWith("|") && !tableBlocks.has(i)) {
|
|
76
|
+
const block = collectTable(state.specLines, i);
|
|
77
|
+
// Mark all lines in this block
|
|
78
|
+
for (let j = 0; j < block.lines.length; j++) {
|
|
79
|
+
tableBlocks.set(i + j, block);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
for (let i = 0; i < state.specLines.length; i++) {
|
|
85
|
+
const lineNum = i + 1;
|
|
86
|
+
const thread = state.threadAtLine(lineNum);
|
|
87
|
+
const isCursor = lineNum === state.cursorLine;
|
|
88
|
+
|
|
89
|
+
const prefix = isCursor ? ">" : " ";
|
|
90
|
+
const specText = state.specLines[i];
|
|
48
91
|
|
|
49
92
|
// Thread indicator — gutter bar on the left
|
|
50
93
|
let indicator = " ";
|
|
94
|
+
let indicatorColor: string = theme.textDim;
|
|
51
95
|
if (thread) {
|
|
52
96
|
const isUnread = unreadThreadIds && unreadThreadIds.has(thread.id);
|
|
53
97
|
if (isUnread) {
|
|
54
98
|
indicator = "\u2588"; // █ full block — unread reply
|
|
99
|
+
indicatorColor = theme.yellow;
|
|
55
100
|
} else if (thread.status === "resolved") {
|
|
56
101
|
indicator = "\u2713"; // ✓ resolved
|
|
102
|
+
indicatorColor = theme.green;
|
|
57
103
|
} else {
|
|
58
104
|
indicator = "\u258c"; // ▌ half block — has thread
|
|
105
|
+
indicatorColor = theme.blue;
|
|
59
106
|
}
|
|
60
107
|
}
|
|
61
108
|
|
|
62
|
-
|
|
109
|
+
// Check for table context before rendering gutter
|
|
110
|
+
const tableBlock = tableBlocks.get(i);
|
|
111
|
+
const isTable = tableBlock && !searchQuery;
|
|
112
|
+
const relIdx = isTable ? i - tableBlock.startIndex : -1;
|
|
63
113
|
|
|
64
|
-
//
|
|
65
|
-
|
|
114
|
+
// Top border before first table row (on its own visual line with blank gutter)
|
|
115
|
+
if (isTable && relIdx === 0) {
|
|
116
|
+
lineNode.add(TextNodeRenderable.fromString(" ", { fg: theme.textDim }));
|
|
117
|
+
renderTableBorder(lineNode, tableBlock.colWidths, "top");
|
|
118
|
+
lineNode.add(TextNodeRenderable.fromString("\n", {}));
|
|
119
|
+
}
|
|
66
120
|
|
|
67
|
-
|
|
68
|
-
|
|
121
|
+
// Gutter: cursor + indicator + line number (dimmed)
|
|
122
|
+
lineNode.add(TextNodeRenderable.fromString(
|
|
123
|
+
`${prefix}`,
|
|
124
|
+
{ fg: isCursor ? theme.text : theme.textDim, bg: isCursor ? theme.backgroundElement : undefined }
|
|
125
|
+
));
|
|
126
|
+
lineNode.add(TextNodeRenderable.fromString(
|
|
127
|
+
indicator,
|
|
128
|
+
{ fg: indicatorColor, bg: isCursor ? theme.backgroundElement : undefined }
|
|
129
|
+
));
|
|
130
|
+
lineNode.add(TextNodeRenderable.fromString(
|
|
131
|
+
`${padLineNum(lineNum)} `,
|
|
132
|
+
{ fg: theme.textDim, attributes: TextAttributes.DIM, bg: isCursor ? theme.backgroundElement : undefined }
|
|
133
|
+
));
|
|
134
|
+
|
|
135
|
+
// Spec text — table or regular markdown
|
|
136
|
+
if (isTable) {
|
|
137
|
+
if (relIdx === tableBlock.separatorIndex) {
|
|
138
|
+
// Separator row → box-drawing line
|
|
139
|
+
renderTableSeparator(lineNode, tableBlock.colWidths);
|
|
140
|
+
} else {
|
|
141
|
+
// Header (before separator) or data (after separator)
|
|
142
|
+
const isHeader = tableBlock.separatorIndex >= 0 && relIdx < tableBlock.separatorIndex;
|
|
143
|
+
const cells = parseTableCells(specText);
|
|
144
|
+
renderTableRow(lineNode, cells, tableBlock.colWidths, isHeader);
|
|
145
|
+
}
|
|
69
146
|
|
|
70
|
-
|
|
147
|
+
// Bottom border after last row (on its own visual line with blank gutter)
|
|
148
|
+
if (relIdx === tableBlock.lines.length - 1) {
|
|
149
|
+
lineNode.add(TextNodeRenderable.fromString("\n", {}));
|
|
150
|
+
lineNode.add(TextNodeRenderable.fromString(" ", { fg: theme.textDim }));
|
|
151
|
+
renderTableBorder(lineNode, tableBlock.colWidths, "bottom");
|
|
152
|
+
}
|
|
153
|
+
} else if (searchQuery) {
|
|
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
|
+
}
|
|
168
|
+
} else {
|
|
169
|
+
// Parse and render inline markdown
|
|
170
|
+
const segments = parseMarkdownLine(specText);
|
|
171
|
+
addSegments(lineNode, segments, theme.text, isCursor ? theme.backgroundElement : undefined);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Thread hint (dimmed, inline)
|
|
175
|
+
if (thread && thread.messages.length > 0) {
|
|
176
|
+
const hint = threadHint(thread);
|
|
177
|
+
lineNode.add(TextNodeRenderable.fromString(
|
|
178
|
+
` \u00ab ${hint}`,
|
|
179
|
+
{ fg: theme.textDim, attributes: TextAttributes.DIM, bg: isCursor ? theme.backgroundElement : undefined }
|
|
180
|
+
));
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Newline between lines (except last)
|
|
184
|
+
if (i < state.specLines.length - 1) {
|
|
185
|
+
lineNode.add(TextNodeRenderable.fromString("\n", {}));
|
|
186
|
+
}
|
|
187
|
+
}
|
|
71
188
|
}
|
|
72
189
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
190
|
+
// --- Visual row offset calculation ---
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Count extra visual lines (table borders) before a given spec line index.
|
|
194
|
+
* Used to map spec line numbers to actual visual rows in the rendered content.
|
|
195
|
+
*/
|
|
196
|
+
export function countExtraVisualLines(specLines: string[], cursorIndex: number): number {
|
|
197
|
+
let extra = 0;
|
|
198
|
+
let i = 0;
|
|
199
|
+
while (i < specLines.length) {
|
|
200
|
+
if (specLines[i].trimStart().startsWith("|")) {
|
|
201
|
+
const tableStart = i;
|
|
202
|
+
while (i < specLines.length && specLines[i].trimStart().startsWith("|")) {
|
|
203
|
+
i++;
|
|
204
|
+
}
|
|
205
|
+
const tableEnd = i; // first line AFTER the table
|
|
206
|
+
|
|
207
|
+
// Top border: rendered before first table row
|
|
208
|
+
if (cursorIndex >= tableStart) extra++;
|
|
209
|
+
// Bottom border: rendered after last table row
|
|
210
|
+
if (cursorIndex >= tableEnd) extra++;
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
i++;
|
|
214
|
+
}
|
|
215
|
+
return extra;
|
|
95
216
|
}
|
|
96
217
|
|
|
218
|
+
// --- Pager components ---
|
|
219
|
+
|
|
97
220
|
export interface PagerComponents {
|
|
98
221
|
scrollBox: ScrollBoxRenderable;
|
|
99
|
-
/** Plain text node for line mode */
|
|
100
222
|
lineNode: TextRenderable;
|
|
101
|
-
/** Rendered markdown node for reading mode */
|
|
102
|
-
markdownNode: MarkdownRenderable;
|
|
103
|
-
/** Current mode */
|
|
104
|
-
mode: "markdown" | "line";
|
|
105
223
|
}
|
|
106
224
|
|
|
107
225
|
/**
|
|
108
|
-
* Create the pager
|
|
109
|
-
* Only one is visible at a time. Toggle with `m`.
|
|
226
|
+
* Create the pager — single styled line-mode view with inline markdown.
|
|
110
227
|
*/
|
|
111
228
|
export function createPager(renderer: CliRenderer): PagerComponents {
|
|
112
|
-
// Line mode (default) — plain text with line numbers, cursor, thread indicators
|
|
113
229
|
const lineNode = new TextRenderable(renderer, {
|
|
114
230
|
content: "",
|
|
115
231
|
width: "100%",
|
|
@@ -118,51 +234,15 @@ export function createPager(renderer: CliRenderer): PagerComponents {
|
|
|
118
234
|
bg: theme.base,
|
|
119
235
|
});
|
|
120
236
|
|
|
121
|
-
// Markdown mode — rendered markdown, full-width, beautiful reading
|
|
122
|
-
const markdownNode = new MarkdownRenderable(renderer, {
|
|
123
|
-
content: "",
|
|
124
|
-
width: "100%",
|
|
125
|
-
syntaxStyle: createMarkdownStyle(),
|
|
126
|
-
conceal: true,
|
|
127
|
-
visible: false, // hidden by default — line mode is default
|
|
128
|
-
});
|
|
129
|
-
|
|
130
|
-
// Scrollable container
|
|
131
237
|
const scrollBox = new ScrollBoxRenderable(renderer, {
|
|
132
238
|
width: "100%",
|
|
133
239
|
flexGrow: 1,
|
|
134
|
-
flexShrink: 1,
|
|
135
240
|
scrollY: true,
|
|
136
241
|
scrollX: true,
|
|
137
242
|
backgroundColor: theme.base,
|
|
138
243
|
});
|
|
139
244
|
|
|
140
|
-
scrollBox.add(markdownNode);
|
|
141
245
|
scrollBox.add(lineNode);
|
|
142
246
|
|
|
143
|
-
return { scrollBox, lineNode
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
/**
|
|
147
|
-
* Toggle between markdown and line mode.
|
|
148
|
-
*/
|
|
149
|
-
export function togglePagerMode(pager: PagerComponents): void {
|
|
150
|
-
if (pager.mode === "markdown") {
|
|
151
|
-
pager.mode = "line";
|
|
152
|
-
pager.markdownNode.visible = false;
|
|
153
|
-
pager.lineNode.visible = true;
|
|
154
|
-
} else {
|
|
155
|
-
pager.mode = "markdown";
|
|
156
|
-
pager.lineNode.visible = false;
|
|
157
|
-
pager.markdownNode.visible = true;
|
|
158
|
-
}
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
/**
|
|
162
|
-
* Switch to line mode (for commenting).
|
|
163
|
-
*/
|
|
164
|
-
export function ensureLineMode(pager: PagerComponents): void {
|
|
165
|
-
if (pager.mode !== "line") {
|
|
166
|
-
togglePagerMode(pager);
|
|
167
|
-
}
|
|
247
|
+
return { scrollBox, lineNode };
|
|
168
248
|
}
|
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,87 +1,130 @@
|
|
|
1
|
-
import { TextRenderable, type CliRenderer } from "@opentui/core";
|
|
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;
|
|
9
|
+
text: TextRenderable;
|
|
8
10
|
}
|
|
9
11
|
|
|
10
12
|
export interface BottomBarComponents {
|
|
11
|
-
|
|
13
|
+
box: BoxRenderable;
|
|
14
|
+
text: TextRenderable;
|
|
12
15
|
}
|
|
13
16
|
|
|
14
17
|
/**
|
|
15
|
-
* Build the top bar
|
|
18
|
+
* Build the top bar with styled TextNodes.
|
|
16
19
|
*/
|
|
17
|
-
export function
|
|
20
|
+
export function buildTopBar(
|
|
21
|
+
bar: TopBarComponents,
|
|
18
22
|
specFile: string,
|
|
19
23
|
state: ReviewState,
|
|
20
24
|
unreadCount?: number,
|
|
21
25
|
specChanged?: boolean,
|
|
22
|
-
|
|
23
|
-
|
|
26
|
+
): void {
|
|
27
|
+
const t = bar.text;
|
|
28
|
+
t.clear();
|
|
24
29
|
const name = basename(specFile);
|
|
25
|
-
const modeLabel = mode === "markdown" ? "[md]" : mode === "line" ? "[line]" : "";
|
|
26
30
|
const { open, pending } = state.activeThreadCount();
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
31
|
+
|
|
32
|
+
// Filename — bold
|
|
33
|
+
t.add(TextNodeRenderable.fromString(` ${name}`, { fg: theme.text, attributes: TextAttributes.BOLD }));
|
|
34
|
+
|
|
35
|
+
t.add(TextNodeRenderable.fromString(" \u00b7 ", { fg: theme.textDim }));
|
|
36
|
+
|
|
37
|
+
// Thread summary
|
|
38
|
+
if (open > 0 || pending > 0) {
|
|
39
|
+
const parts: string[] = [];
|
|
40
|
+
if (open > 0) parts.push(`${open} open`);
|
|
41
|
+
if (pending > 0) parts.push(`${pending} pending`);
|
|
42
|
+
t.add(TextNodeRenderable.fromString(parts.join(", "), { fg: theme.yellow }));
|
|
43
|
+
} else {
|
|
44
|
+
t.add(TextNodeRenderable.fromString("No active threads", { fg: theme.textMuted }));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Unread replies
|
|
33
48
|
if (unreadCount && unreadCount > 0) {
|
|
34
|
-
|
|
49
|
+
t.add(TextNodeRenderable.fromString(" \u00b7 ", { fg: theme.textDim }));
|
|
50
|
+
t.add(TextNodeRenderable.fromString(
|
|
51
|
+
`${unreadCount} new repl${unreadCount === 1 ? "y" : "ies"}`,
|
|
52
|
+
{ fg: theme.green, attributes: TextAttributes.BOLD }
|
|
53
|
+
));
|
|
35
54
|
}
|
|
55
|
+
|
|
56
|
+
// Spec changed warning
|
|
36
57
|
if (specChanged) {
|
|
37
|
-
|
|
58
|
+
t.add(TextNodeRenderable.fromString(" \u00b7 ", { fg: theme.textDim }));
|
|
59
|
+
t.add(TextNodeRenderable.fromString("!! Spec changed externally", { fg: theme.red, attributes: TextAttributes.BOLD }));
|
|
38
60
|
}
|
|
39
|
-
|
|
40
|
-
|
|
61
|
+
|
|
62
|
+
// Cursor position
|
|
63
|
+
t.add(TextNodeRenderable.fromString(" \u00b7 ", { fg: theme.textDim }));
|
|
64
|
+
t.add(TextNodeRenderable.fromString(`L${state.cursorLine}/${state.lineCount}`, { fg: theme.textMuted }));
|
|
41
65
|
}
|
|
42
66
|
|
|
43
67
|
/**
|
|
44
|
-
* Build the bottom bar
|
|
45
|
-
* Contextually shows command buffer when in command mode.
|
|
46
|
-
* Prepends mode indicator when provided.
|
|
68
|
+
* Build the bottom bar with styled TextNodes.
|
|
47
69
|
*/
|
|
48
|
-
export function
|
|
70
|
+
export function buildBottomBar(bar: BottomBarComponents, commandBuffer: string | null): void {
|
|
71
|
+
const t = bar.text;
|
|
72
|
+
t.clear();
|
|
49
73
|
if (commandBuffer !== null) {
|
|
50
|
-
|
|
74
|
+
t.add(TextNodeRenderable.fromString(` :${commandBuffer}`, { fg: theme.text }));
|
|
75
|
+
return;
|
|
51
76
|
}
|
|
52
|
-
|
|
77
|
+
const hints = [
|
|
78
|
+
{ key: "j/k", action: "move" },
|
|
79
|
+
{ key: "c", action: "comment" },
|
|
80
|
+
{ key: "r", action: "resolve" },
|
|
81
|
+
{ key: "/", action: "search" },
|
|
82
|
+
{ key: "?", action: "help" },
|
|
83
|
+
];
|
|
84
|
+
buildHints(t, hints);
|
|
53
85
|
}
|
|
54
86
|
|
|
55
87
|
/**
|
|
56
|
-
* Create the top status bar.
|
|
88
|
+
* Create the top status bar (BoxRenderable with backgroundColor for full-width fill).
|
|
57
89
|
*/
|
|
58
90
|
export function createTopBar(renderer: CliRenderer): TopBarComponents {
|
|
59
|
-
const
|
|
60
|
-
content: "",
|
|
91
|
+
const box = new BoxRenderable(renderer, {
|
|
61
92
|
width: "100%",
|
|
62
93
|
height: 1,
|
|
63
|
-
|
|
94
|
+
backgroundColor: theme.backgroundPanel,
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
const text = new TextRenderable(renderer, {
|
|
98
|
+
content: "",
|
|
99
|
+
width: "100%",
|
|
64
100
|
fg: theme.text,
|
|
65
101
|
wrapMode: "none",
|
|
66
102
|
truncate: true,
|
|
67
103
|
});
|
|
68
104
|
|
|
69
|
-
|
|
105
|
+
box.add(text);
|
|
106
|
+
return { box, text };
|
|
70
107
|
}
|
|
71
108
|
|
|
72
109
|
/**
|
|
73
110
|
* Create the bottom status bar.
|
|
74
111
|
*/
|
|
75
112
|
export function createBottomBar(renderer: CliRenderer): BottomBarComponents {
|
|
76
|
-
const
|
|
77
|
-
content: "",
|
|
113
|
+
const box = new BoxRenderable(renderer, {
|
|
78
114
|
width: "100%",
|
|
79
115
|
height: 1,
|
|
80
|
-
|
|
116
|
+
flexShrink: 0,
|
|
117
|
+
backgroundColor: theme.backgroundPanel,
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
const text = new TextRenderable(renderer, {
|
|
121
|
+
content: "",
|
|
122
|
+
width: "100%",
|
|
81
123
|
fg: theme.text,
|
|
82
124
|
wrapMode: "none",
|
|
83
125
|
truncate: true,
|
|
84
126
|
});
|
|
85
127
|
|
|
86
|
-
|
|
128
|
+
box.add(text);
|
|
129
|
+
return { box, text };
|
|
87
130
|
}
|
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
|
}
|