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/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
- lines.push(`${prefix}${indicator}${padLineNum(lineNum)} ${specText}`);
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.overlay;
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(" ", { fg: theme.overlay }));
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.overlay }
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
- `${padLineNum(lineNum)} `,
375
- { fg: theme.overlay, attributes: TextAttributes.DIM }
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 (isTable) {
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(" ", { fg: theme.overlay }));
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 raw text with search markers (no markdown styling)
398
- const regex = new RegExp(searchQuery.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "gi");
399
- const highlighted = specText.replace(regex, (match) => `>>${match}<<`);
400
- lineNode.add(TextNodeRenderable.fromString(highlighted, { fg: theme.text }));
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.overlay, attributes: TextAttributes.DIM }
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.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,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(" | ", { fg: theme.overlay }));
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.subtext }));
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(" | ", { fg: theme.overlay }));
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(" | ", { fg: theme.overlay }));
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(" | ", { fg: theme.overlay }));
63
- t.add(TextNodeRenderable.fromString(`L${state.cursorLine}/${state.lineCount}`, { fg: theme.subtext }));
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
- t.add(TextNodeRenderable.fromString(" ", {}));
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
- }
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.base,
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.surface0,
129
+ backgroundColor: theme.backgroundPanel,
127
130
  });
128
131
 
129
132
  const text = new TextRenderable(renderer, {