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.
@@ -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
 
@@ -25,7 +24,7 @@ const MAX_PREVIEW_LENGTH = 50;
25
24
 
26
25
  function previewText(thread: Thread): string {
27
26
  if (thread.messages.length === 0) return "(empty)";
28
- const last = thread.messages[thread.messages.length - 1];
27
+ const last = thread.messages[0];
29
28
  const text = last.text.replace(/\n/g, " ");
30
29
  if (text.length <= MAX_PREVIEW_LENGTH) return text;
31
30
  return text.slice(0, MAX_PREVIEW_LENGTH - 1) + "\u2026";
@@ -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
  }
@@ -0,0 +1,106 @@
1
+ import {
2
+ BoxRenderable,
3
+ ScrollBoxRenderable,
4
+ TextRenderable,
5
+ type CliRenderer,
6
+ type KeyEvent,
7
+ } from "@opentui/core";
8
+ import { theme } from "./theme";
9
+ import { buildHints, type Hint } from "./hint-bar";
10
+
11
+ export interface DialogOptions {
12
+ renderer: CliRenderer;
13
+ title: string;
14
+ width?: string | number;
15
+ height?: string | number;
16
+ top?: string | number;
17
+ left?: string | number;
18
+ borderColor?: string;
19
+ onDismiss: () => void;
20
+ hints?: Hint[];
21
+ }
22
+
23
+ export interface DialogComponents {
24
+ container: BoxRenderable;
25
+ content: ScrollBoxRenderable;
26
+ hintText: TextRenderable;
27
+ setHints: (hints: Hint[]) => void;
28
+ cleanup: () => void;
29
+ }
30
+
31
+ export function createDialog(opts: DialogOptions): DialogComponents {
32
+ const {
33
+ renderer, title, onDismiss,
34
+ width = "80%", height = "85%",
35
+ top = "5%", left = "10%",
36
+ borderColor = theme.border,
37
+ hints = [],
38
+ } = opts;
39
+
40
+ const container = new BoxRenderable(renderer, {
41
+ position: "absolute",
42
+ top,
43
+ left,
44
+ width,
45
+ height,
46
+ zIndex: 100,
47
+ backgroundColor: theme.backgroundPanel,
48
+ border: true,
49
+ borderStyle: "single",
50
+ borderColor,
51
+ title: ` ${title} `,
52
+ flexDirection: "column",
53
+ paddingLeft: 1,
54
+ paddingRight: 1,
55
+ paddingTop: 1,
56
+ });
57
+
58
+ const content = new ScrollBoxRenderable(renderer, {
59
+ width: "100%",
60
+ flexGrow: 1,
61
+ flexShrink: 1,
62
+ scrollY: true,
63
+ scrollX: false,
64
+ });
65
+ container.add(content);
66
+
67
+ const hintBox = new BoxRenderable(renderer, {
68
+ width: "100%",
69
+ height: 1,
70
+ flexShrink: 0,
71
+ backgroundColor: theme.backgroundElement,
72
+ });
73
+ const hintText = new TextRenderable(renderer, {
74
+ content: "",
75
+ width: "100%",
76
+ fg: theme.textMuted,
77
+ wrapMode: "none",
78
+ truncate: true,
79
+ });
80
+ hintBox.add(hintText);
81
+ container.add(hintBox);
82
+
83
+ if (hints.length > 0) {
84
+ buildHints(hintText, hints);
85
+ }
86
+
87
+ function setHints(newHints: Hint[]): void {
88
+ buildHints(hintText, newHints);
89
+ renderer.requestRender();
90
+ }
91
+
92
+ const keyHandler = (key: KeyEvent) => {
93
+ if (key.name === "escape" || (key.ctrl && key.name === "c")) {
94
+ key.preventDefault();
95
+ key.stopPropagation();
96
+ onDismiss();
97
+ }
98
+ };
99
+ renderer.keyInput.on("keypress", keyHandler);
100
+
101
+ function cleanup(): void {
102
+ renderer.keyInput.off("keypress", keyHandler);
103
+ }
104
+
105
+ return { container, content, hintText, setHints, cleanup };
106
+ }
@@ -0,0 +1,20 @@
1
+ import { TextRenderable, TextNodeRenderable } from "@opentui/core";
2
+ import { theme } from "./theme";
3
+
4
+ export interface Hint {
5
+ key: string;
6
+ action: string;
7
+ }
8
+
9
+ export function buildHints(text: TextRenderable, hints: Hint[]): void {
10
+ text.clear();
11
+ text.add(TextNodeRenderable.fromString(" ", {}));
12
+ for (let i = 0; i < hints.length; i++) {
13
+ const h = hints[i];
14
+ text.add(TextNodeRenderable.fromString(`[${h.key}]`, { fg: theme.blue }));
15
+ text.add(TextNodeRenderable.fromString(` ${h.action}`, { fg: theme.textMuted }));
16
+ if (i < hints.length - 1) {
17
+ text.add(TextNodeRenderable.fromString(" ", {}));
18
+ }
19
+ }
20
+ }
@@ -0,0 +1,106 @@
1
+ import type { KeyEvent } from "@opentui/core";
2
+
3
+ export interface KeyBinding {
4
+ key: string;
5
+ action: string;
6
+ }
7
+
8
+ interface SequenceState {
9
+ first: string;
10
+ timer: ReturnType<typeof setTimeout>;
11
+ }
12
+
13
+ export interface KeybindRegistry {
14
+ match: (key: KeyEvent) => string | null;
15
+ pending: () => string | null;
16
+ destroy: () => void;
17
+ }
18
+
19
+ export function createKeybindRegistry(bindings: KeyBinding[], timeout = 500): KeybindRegistry {
20
+ let sequence: SequenceState | null = null;
21
+
22
+ const singleBindings = new Map<string, string>();
23
+ const sequenceBindings = new Map<string, string>();
24
+
25
+ // Sequence keys: exactly 2 chars, each is a single printable keystroke.
26
+ // Named keys like "up", "down" are NOT sequences even though length === 2.
27
+ // Sequences: "gg", "dd", "]t", "[r" — char + char combos.
28
+ // We detect named keys by checking: if both chars are lowercase letters AND
29
+ // the combo is different from char+char (i.e., it's a word), it's a named key.
30
+ // Simple heuristic: sequences always have either repeated chars or non-alpha first char.
31
+ const NAMED_KEYS = new Set(["up", "fn"]);
32
+
33
+ for (const b of bindings) {
34
+ if (b.key.length === 2 && !b.key.startsWith("C-") && !NAMED_KEYS.has(b.key)) {
35
+ sequenceBindings.set(b.key, b.action);
36
+ } else {
37
+ singleBindings.set(b.key, b.action);
38
+ }
39
+ }
40
+
41
+ const sequenceStarters = new Set<string>();
42
+ for (const key of sequenceBindings.keys()) {
43
+ sequenceStarters.add(key[0]);
44
+ }
45
+
46
+ function keyToString(key: KeyEvent): string {
47
+ if (key.ctrl && key.name) return `C-${key.name}`;
48
+ // For shifted keys, prefer sequence (gives "?", ":", etc.) over name.toUpperCase()
49
+ if (key.shift && key.sequence) return key.sequence;
50
+ if (key.shift && key.name) return key.name.toUpperCase();
51
+ return key.sequence || key.name || "";
52
+ }
53
+
54
+ function match(key: KeyEvent): string | null {
55
+ const keyStr = keyToString(key);
56
+ let skipSequenceCheck = false;
57
+
58
+ if (sequence) {
59
+ const seq = sequence.first + keyStr;
60
+ clearTimeout(sequence.timer);
61
+ sequence = null;
62
+
63
+ const action = sequenceBindings.get(seq);
64
+ if (action) return action;
65
+ skipSequenceCheck = true; // Don't start a new sequence with the failed second key
66
+ }
67
+
68
+ // Check ctrl variants first
69
+ if (key.ctrl && key.name) {
70
+ const action = singleBindings.get(`C-${key.name}`);
71
+ if (action) return action;
72
+ }
73
+
74
+ // Check if this starts a sequence (but not if ctrl is held, and not if from failed sequence)
75
+ if (!key.ctrl && !skipSequenceCheck && sequenceStarters.has(keyStr)) {
76
+ sequence = {
77
+ first: keyStr,
78
+ timer: setTimeout(() => { sequence = null; }, timeout),
79
+ };
80
+ return null;
81
+ }
82
+
83
+ // Shift variants
84
+ if (key.shift && key.name) {
85
+ const upper = key.name.toUpperCase();
86
+ const action = singleBindings.get(upper);
87
+ if (action) return action;
88
+ }
89
+
90
+ return singleBindings.get(keyStr) ?? null;
91
+ }
92
+
93
+ function pendingStr(): string | null {
94
+ if (!sequence) return null;
95
+ return `${sequence.first}...`;
96
+ }
97
+
98
+ function destroy(): void {
99
+ if (sequence) {
100
+ clearTimeout(sequence.timer);
101
+ sequence = null;
102
+ }
103
+ }
104
+
105
+ return { match, pending: pendingStr, destroy };
106
+ }
@@ -0,0 +1,292 @@
1
+ import {
2
+ TextRenderable,
3
+ TextNodeRenderable,
4
+ TextAttributes,
5
+ } from "@opentui/core";
6
+ import { theme } from "./theme";
7
+
8
+ // --- Inline markdown parser ---
9
+
10
+ export interface StyledSegment {
11
+ text: string;
12
+ fg?: string;
13
+ attributes?: number;
14
+ }
15
+
16
+ /**
17
+ * Parse inline markdown (bold italic, bold, italic, code, links, strikethrough) into styled segments.
18
+ * Strips syntax markers and returns display text with style info.
19
+ * Order matters: longer patterns first (***bold italic*** before **bold** before *italic*).
20
+ */
21
+ export function parseInlineMarkdown(text: string): StyledSegment[] {
22
+ const segments: StyledSegment[] = [];
23
+ // Groups:
24
+ // 2: ***bold italic***
25
+ // 3: **bold**
26
+ // 4: *italic*
27
+ // 5: __bold__
28
+ // 6: _italic_
29
+ // 7: ~~strikethrough~~
30
+ // 8: [link text](url) — display text only
31
+ // 9: `code`
32
+ const regex = /(\*\*\*(.+?)\*\*\*|\*\*(.+?)\*\*|\*(.+?)\*|__(.+?)__|_(.+?)_|~~(.+?)~~|\[([^\]]+)\]\([^)]+\)|`([^`]+)`)/g;
33
+ let pos = 0;
34
+ let match;
35
+ while ((match = regex.exec(text)) !== null) {
36
+ if (match.index > pos) {
37
+ segments.push({ text: text.slice(pos, match.index) });
38
+ }
39
+ if (match[2] !== undefined) {
40
+ // ***bold italic***
41
+ segments.push({ text: match[2], attributes: TextAttributes.BOLD | TextAttributes.ITALIC });
42
+ } else if (match[3] !== undefined) {
43
+ // **bold**
44
+ segments.push({ text: match[3], attributes: TextAttributes.BOLD });
45
+ } else if (match[4] !== undefined) {
46
+ // *italic*
47
+ segments.push({ text: match[4], attributes: TextAttributes.ITALIC });
48
+ } else if (match[5] !== undefined) {
49
+ // __bold__
50
+ segments.push({ text: match[5], attributes: TextAttributes.BOLD });
51
+ } else if (match[6] !== undefined) {
52
+ // _italic_
53
+ segments.push({ text: match[6], attributes: TextAttributes.ITALIC });
54
+ } else if (match[7] !== undefined) {
55
+ // ~~strikethrough~~ — use actual terminal strikethrough + dim
56
+ segments.push({ text: match[7], fg: theme.textDim, attributes: TextAttributes.STRIKETHROUGH });
57
+ } else if (match[8] !== undefined) {
58
+ // [link text](url) — show text in blue + underline
59
+ segments.push({ text: match[8], fg: theme.blue, attributes: TextAttributes.UNDERLINE });
60
+ } else if (match[9] !== undefined) {
61
+ // `code`
62
+ segments.push({ text: match[9], fg: theme.mauve });
63
+ }
64
+ pos = match.index + match[0].length;
65
+ }
66
+ if (pos < text.length) {
67
+ segments.push({ text: text.slice(pos) });
68
+ }
69
+ if (segments.length === 0) {
70
+ segments.push({ text });
71
+ }
72
+ return segments;
73
+ }
74
+
75
+ /**
76
+ * Parse a full line of markdown into styled segments.
77
+ * Handles block-level syntax (headings, lists, blockquotes, hr)
78
+ * and delegates inline content to parseInlineMarkdown.
79
+ */
80
+ export function parseMarkdownLine(line: string): StyledSegment[] {
81
+ // Heading: # ... ###### (strip markers, bold + colored)
82
+ const headingMatch = line.match(/^(#{1,6})\s+(.*)/);
83
+ if (headingMatch) {
84
+ const level = headingMatch[1].length;
85
+ const color = level <= 2 ? theme.blue : theme.mauve;
86
+ // Parse inline markdown within heading text
87
+ const inner = parseInlineMarkdown(headingMatch[2]);
88
+ return inner.map((s) => ({
89
+ ...s,
90
+ fg: s.fg ?? color,
91
+ attributes: (s.attributes ?? 0) | TextAttributes.BOLD,
92
+ }));
93
+ }
94
+
95
+ // Horizontal rule: --- or *** or ___
96
+ if (/^(\s*[-*_]\s*){3,}$/.test(line)) {
97
+ return [{ text: "\u2500".repeat(40), fg: theme.textDim, attributes: TextAttributes.DIM }];
98
+ }
99
+
100
+ // Blockquote: > text
101
+ if (line.startsWith("> ")) {
102
+ const inner = parseInlineMarkdown(line.slice(2));
103
+ return [
104
+ { text: "\u2502 ", fg: theme.mauve },
105
+ ...inner.map((s) => ({
106
+ ...s,
107
+ fg: s.fg ?? theme.textDim,
108
+ attributes: (s.attributes ?? 0) | TextAttributes.ITALIC,
109
+ })),
110
+ ];
111
+ }
112
+
113
+ // Task list: - [ ] or - [x]
114
+ const taskMatch = line.match(/^(\s*)[-*+]\s+\[([ xX])\]\s+(.*)/);
115
+ if (taskMatch) {
116
+ const checked = taskMatch[2].toLowerCase() === "x";
117
+ const checkbox = checked ? "\u2611 " : "\u2610 "; // ☑ or ☐
118
+ const color = checked ? theme.green : theme.textDim;
119
+ return [
120
+ { text: taskMatch[1] + checkbox, fg: color },
121
+ ...parseInlineMarkdown(taskMatch[3]),
122
+ ];
123
+ }
124
+
125
+ // Unordered list: - item, * item, + item
126
+ const ulMatch = line.match(/^(\s*)([-*+])\s+(.*)/);
127
+ if (ulMatch) {
128
+ return [
129
+ { text: ulMatch[1] + "\u2022 ", fg: theme.yellow },
130
+ ...parseInlineMarkdown(ulMatch[3]),
131
+ ];
132
+ }
133
+
134
+ // Ordered list: 1. item
135
+ const olMatch = line.match(/^(\s*)(\d+)\.\s+(.*)/);
136
+ if (olMatch) {
137
+ return [
138
+ { text: `${olMatch[1]}${olMatch[2]}. `, fg: theme.yellow },
139
+ ...parseInlineMarkdown(olMatch[3]),
140
+ ];
141
+ }
142
+
143
+ // Regular line — parse inline markdown only
144
+ return parseInlineMarkdown(line);
145
+ }
146
+
147
+ /**
148
+ * Add styled segments as TextNodeRenderable children to a parent.
149
+ */
150
+ export function addSegments(parent: TextRenderable, segments: StyledSegment[], defaultFg: string, bg?: string): void {
151
+ for (const seg of segments) {
152
+ const node = TextNodeRenderable.fromString(seg.text, {
153
+ fg: seg.fg ?? defaultFg,
154
+ attributes: seg.attributes,
155
+ bg,
156
+ });
157
+ parent.add(node);
158
+ }
159
+ }
160
+
161
+ // --- Table rendering ---
162
+
163
+ export const SEPARATOR_RE = /^\|[\s:]*-+[\s:]*(\|[\s:]*-+[\s:]*)*\|?\s*$/;
164
+
165
+ /** Split a table row into trimmed cell values (strips outer pipes). */
166
+ export function parseTableCells(line: string): string[] {
167
+ const trimmed = line.trim();
168
+ // Remove leading/trailing pipes and split
169
+ const inner = trimmed.startsWith("|") ? trimmed.slice(1) : trimmed;
170
+ const withoutTrailing = inner.endsWith("|") ? inner.slice(0, -1) : inner;
171
+ return withoutTrailing.split("|").map((c) => c.trim());
172
+ }
173
+
174
+ /** Compute the display width of a string (strips inline markdown markers). */
175
+ export function displayWidth(text: string): number {
176
+ // Remove all inline markdown markers to get display length
177
+ return text
178
+ .replace(/\*\*\*(.+?)\*\*\*/g, "$1")
179
+ .replace(/\*\*(.+?)\*\*/g, "$1")
180
+ .replace(/\*(.+?)\*/g, "$1")
181
+ .replace(/__(.+?)__/g, "$1")
182
+ .replace(/_(.+?)_/g, "$1")
183
+ .replace(/~~(.+?)~~/g, "$1")
184
+ .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
185
+ .replace(/`([^`]+)`/g, "$1")
186
+ .length;
187
+ }
188
+
189
+ export interface TableBlock {
190
+ startIndex: number;
191
+ lines: string[];
192
+ separatorIndex: number; // relative to startIndex, -1 if none
193
+ colWidths: number[];
194
+ }
195
+
196
+ /** Scan ahead from a starting `|` line and collect the full table block. */
197
+ export function collectTable(specLines: string[], start: number): TableBlock {
198
+ const lines: string[] = [];
199
+ let i = start;
200
+ while (i < specLines.length && specLines[i].trimStart().startsWith("|")) {
201
+ lines.push(specLines[i]);
202
+ i++;
203
+ }
204
+
205
+ // Find separator row
206
+ let separatorIndex = -1;
207
+ for (let j = 0; j < lines.length; j++) {
208
+ if (SEPARATOR_RE.test(lines[j])) {
209
+ separatorIndex = j;
210
+ break;
211
+ }
212
+ }
213
+
214
+ // Calculate column widths from all non-separator rows
215
+ const allCells = lines
216
+ .filter((_, j) => j !== separatorIndex)
217
+ .map(parseTableCells);
218
+ const maxCols = Math.max(...allCells.map((r) => r.length), 0);
219
+ const colWidths: number[] = new Array(maxCols).fill(0);
220
+ for (const row of allCells) {
221
+ for (let c = 0; c < row.length; c++) {
222
+ colWidths[c] = Math.max(colWidths[c], displayWidth(row[c]));
223
+ }
224
+ }
225
+ // Minimum width of 3 per column
226
+ for (let c = 0; c < colWidths.length; c++) {
227
+ colWidths[c] = Math.max(colWidths[c], 3);
228
+ }
229
+
230
+ return { startIndex: start, lines, separatorIndex, colWidths };
231
+ }
232
+
233
+ /** Render a table separator row with box-drawing characters. */
234
+ export function renderTableSeparator(parent: TextRenderable, colWidths: number[]): void {
235
+ const parts = colWidths.map((w) => "\u2500".repeat(w + 2));
236
+ const line = "\u251c" + parts.join("\u253c") + "\u2524";
237
+ parent.add(TextNodeRenderable.fromString(line, { fg: theme.textDim }));
238
+ }
239
+
240
+ /** Render a table data/header row with padded, styled cells. */
241
+ export function renderTableRow(
242
+ parent: TextRenderable,
243
+ cells: string[],
244
+ colWidths: number[],
245
+ isHeader: boolean,
246
+ ): void {
247
+ for (let c = 0; c < colWidths.length; c++) {
248
+ const cellText = c < cells.length ? cells[c] : "";
249
+ const dw = displayWidth(cellText);
250
+ const padding = Math.max(0, colWidths[c] - dw);
251
+
252
+ // Left border
253
+ parent.add(TextNodeRenderable.fromString(
254
+ c === 0 ? "\u2502 " : " \u2502 ",
255
+ { fg: theme.textDim }
256
+ ));
257
+
258
+ // Cell content — parse inline markdown, apply header bold
259
+ const segments = parseInlineMarkdown(cellText);
260
+ for (const seg of segments) {
261
+ const attrs = isHeader
262
+ ? (seg.attributes ?? 0) | TextAttributes.BOLD
263
+ : seg.attributes;
264
+ parent.add(TextNodeRenderable.fromString(seg.text, {
265
+ fg: seg.fg ?? theme.text,
266
+ attributes: attrs,
267
+ }));
268
+ }
269
+
270
+ // Padding
271
+ if (padding > 0) {
272
+ parent.add(TextNodeRenderable.fromString(" ".repeat(padding), {}));
273
+ }
274
+ }
275
+ // Right border
276
+ parent.add(TextNodeRenderable.fromString(
277
+ " \u2502",
278
+ { fg: theme.textDim }
279
+ ));
280
+ }
281
+
282
+ /** Render a top or bottom border for the table. */
283
+ export function renderTableBorder(parent: TextRenderable, colWidths: number[], position: "top" | "bottom"): void {
284
+ const [left, mid, right] = position === "top"
285
+ ? ["\u250c", "\u252c", "\u2510"]
286
+ : ["\u2514", "\u2534", "\u2518"];
287
+ const parts = colWidths.map((w) => "\u2500".repeat(w + 2));
288
+ parent.add(TextNodeRenderable.fromString(
289
+ left + parts.join(mid) + right,
290
+ { fg: theme.textDim }
291
+ ));
292
+ }