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/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
- MarkdownRenderable,
7
- SyntaxStyle,
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 commenting).
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
- let line = `${prefix}${indicator}${padLineNum(lineNum)} ${specText}`;
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
- // No inline preview the gutter indicator (▌/█/✓) is enough.
65
- // Press c to open the thread and see the full conversation.
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
- lines.push(line);
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
- return lines.join("\n");
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
- function createMarkdownStyle(): SyntaxStyle {
74
- return SyntaxStyle.fromStyles({
75
- default: { fg: parseColor(theme.text) },
76
- "markup.heading": { fg: parseColor(theme.blue), bold: true },
77
- "markup.heading.1": { fg: parseColor(theme.blue), bold: true },
78
- "markup.heading.2": { fg: parseColor(theme.blue), bold: true },
79
- "markup.heading.3": { fg: parseColor(theme.mauve), bold: true },
80
- "markup.heading.4": { fg: parseColor(theme.mauve) },
81
- "markup.heading.5": { fg: parseColor(theme.mauve) },
82
- "markup.heading.6": { fg: parseColor(theme.mauve) },
83
- "markup.bold": { fg: parseColor(theme.text), bold: true },
84
- "markup.strong": { fg: parseColor(theme.text), bold: true },
85
- "markup.italic": { fg: parseColor(theme.text), italic: true },
86
- "markup.link": { fg: parseColor(theme.blue) },
87
- "markup.link.url": { fg: parseColor(theme.blue) },
88
- "markup.list": { fg: parseColor(theme.yellow) },
89
- "markup.raw": { fg: parseColor(theme.green) },
90
- "markup.raw.inline": { fg: parseColor(theme.green) },
91
- "string": { fg: parseColor(theme.green) },
92
- "comment": { fg: parseColor(theme.overlay) },
93
- "punctuation.special": { fg: parseColor(theme.overlay) },
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 with both a markdown view and a line-mode view.
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, markdownNode, mode: "line" };
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.surface0,
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.surface0,
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.surface0,
58
+ backgroundColor: theme.backgroundPanel,
59
59
  textColor: theme.text,
60
- focusedBackgroundColor: theme.surface1,
60
+ focusedBackgroundColor: theme.backgroundElement,
61
61
  focusedTextColor: theme.text,
62
62
  placeholder: "Search...",
63
- placeholderColor: theme.overlay,
63
+ placeholderColor: theme.textDim,
64
64
  });
65
65
 
66
66
  container.add(label);
@@ -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
- bar: TextRenderable;
8
+ box: BoxRenderable;
9
+ text: TextRenderable;
8
10
  }
9
11
 
10
12
  export interface BottomBarComponents {
11
- bar: TextRenderable;
13
+ box: BoxRenderable;
14
+ text: TextRenderable;
12
15
  }
13
16
 
14
17
  /**
15
- * Build the top bar text: filename + thread summary.
18
+ * Build the top bar with styled TextNodes.
16
19
  */
17
- export function buildTopBarText(
20
+ export function buildTopBar(
21
+ bar: TopBarComponents,
18
22
  specFile: string,
19
23
  state: ReviewState,
20
24
  unreadCount?: number,
21
25
  specChanged?: boolean,
22
- mode?: "markdown" | "line"
23
- ): string {
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
- const parts: string[] = [];
28
- if (open > 0) parts.push(`${open} open`);
29
- if (pending > 0) parts.push(`${pending} pending`);
30
- const threadSummary =
31
- parts.length > 0 ? `Threads: ${parts.join(", ")}` : "No active threads";
32
- let result = ` ${name} ${modeLabel} | ${threadSummary}`;
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
- result += ` | ${unreadCount} new repl${unreadCount === 1 ? "y" : "ies"}`;
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
- result += ` | !! Spec changed externally`;
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
- result += ` | L${state.cursorLine}/${state.lineCount}`;
40
- return result;
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 text: keybinding hints.
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 buildBottomBarText(commandBuffer: string | null): string {
70
+ export function buildBottomBar(bar: BottomBarComponents, commandBuffer: string | null): void {
71
+ const t = bar.text;
72
+ t.clear();
49
73
  if (commandBuffer !== null) {
50
- return ` :${commandBuffer}`;
74
+ t.add(TextNodeRenderable.fromString(` :${commandBuffer}`, { fg: theme.text }));
75
+ return;
51
76
  }
52
- return ` [j/k] move [c] comment [r] resolve [/] search [?] help`;
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 bar = new TextRenderable(renderer, {
60
- content: "",
91
+ const box = new BoxRenderable(renderer, {
61
92
  width: "100%",
62
93
  height: 1,
63
- bg: theme.surface0,
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
- return { bar };
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 bar = new TextRenderable(renderer, {
77
- content: "",
113
+ const box = new BoxRenderable(renderer, {
78
114
  width: "100%",
79
115
  height: 1,
80
- bg: theme.surface0,
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
- return { bar };
128
+ box.add(text);
129
+ return { box, text };
87
130
  }
@@ -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
- // Overlay container
48
- const container = new BoxRenderable(renderer, {
49
- position: "absolute",
50
- top: "15%",
51
- left: "15%",
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
- zIndex: 100,
55
- backgroundColor: theme.base,
56
- border: true,
57
- borderStyle: "single",
58
- borderColor: theme.borderList,
59
- title: ` Threads (${activeThreads.length} active) `,
60
- flexDirection: "column",
61
- padding: 1,
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.overlay,
69
+ fg: theme.textDim,
70
70
  wrapMode: "none",
71
71
  });
72
- container.add(emptyMsg);
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.base,
89
+ backgroundColor: theme.backgroundPanel,
90
90
  textColor: theme.text,
91
- focusedBackgroundColor: theme.base,
91
+ focusedBackgroundColor: theme.backgroundPanel,
92
92
  focusedTextColor: theme.text,
93
- selectedBackgroundColor: theme.surface1,
93
+ selectedBackgroundColor: theme.backgroundElement,
94
94
  selectedTextColor: "#f5c2e7",
95
- descriptionColor: theme.overlay,
96
- selectedDescriptionColor: theme.subtext,
95
+ descriptionColor: theme.textDim,
96
+ selectedDescriptionColor: theme.textMuted,
97
97
  showDescription: true,
98
98
  wrapSelection: true,
99
99
  });
100
100
 
101
- container.add(select);
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
- // Hint bar
116
- const hint = new TextRenderable(renderer, {
117
- content: " [j/k] navigate [Enter] jump [Esc] close",
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
  }