@pellux/goodvibes-tui 1.20.1 → 1.21.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/CHANGELOG.md +13 -1
- package/README.md +1 -1
- package/docs/foundation-artifacts/operator-contract.json +106 -22
- package/package.json +7 -7
- package/src/cli/doctor.ts +7 -1
- package/src/cli/package-verification.ts +92 -7
- package/src/cli/status.ts +16 -0
- package/src/config/read-versioned.ts +115 -2
- package/src/core/conversation-event-navigation.ts +42 -0
- package/src/core/conversation-search-expansion.ts +67 -0
- package/src/core/conversation-splash-state.ts +58 -0
- package/src/core/conversation.ts +23 -23
- package/src/core/model-routing-chip.ts +38 -18
- package/src/core/rewind-turn-anchors.ts +155 -3
- package/src/core/stream-event-wiring.ts +141 -20
- package/src/core/transcript-journal.ts +145 -7
- package/src/daemon/handlers/inbox/cursor-store.ts +159 -3
- package/src/daemon/handlers/inbox/index.ts +14 -1
- package/src/daemon/handlers/sqlite-store.ts +160 -6
- package/src/input/command-registry.ts +8 -0
- package/src/input/commands/local-runtime.ts +6 -1
- package/src/input/commands/schedule-runtime.ts +15 -2
- package/src/input/commands/shell-core.ts +87 -38
- package/src/input/composer-capture.ts +47 -0
- package/src/input/handler-command-route.ts +4 -0
- package/src/input/handler-content-actions.ts +9 -0
- package/src/input/handler-feed-routes.ts +1 -0
- package/src/input/handler-modal-routes.ts +12 -1
- package/src/input/handler-picker-routes.ts +43 -5
- package/src/input/handler-shortcuts.ts +1 -1
- package/src/input/handler-ui-state.ts +8 -3
- package/src/input/model-picker-items.ts +17 -2
- package/src/input/model-picker.ts +19 -2
- package/src/input/search.ts +183 -89
- package/src/main.ts +12 -20
- package/src/providers/reasoning-effort-surface.ts +399 -0
- package/src/renderer/compositor.ts +68 -18
- package/src/renderer/markdown-inline.ts +143 -0
- package/src/renderer/markdown-table.ts +459 -0
- package/src/renderer/markdown.ts +6 -335
- package/src/renderer/model-workspace.ts +48 -5
- package/src/runtime/bootstrap-command-parts.ts +36 -4
- package/src/runtime/bootstrap-core.ts +1 -1
- package/src/runtime/durability-housekeeping.ts +183 -0
- package/src/runtime/durability-services.ts +68 -11
- package/src/runtime/legacy-daemon-reconcile.ts +16 -7
- package/src/runtime/provider-fallback.ts +16 -2
- package/src/runtime/recovery-decisions.ts +180 -11
- package/src/runtime/recovery-prompt.ts +11 -1
- package/src/runtime/services.ts +9 -4
- package/src/runtime/session-liveness-marker.ts +142 -5
- package/src/runtime/workstream-draft-store.ts +198 -25
- package/src/runtime/workstream-services.ts +17 -2
- package/src/shell/ui-openers.ts +1 -1
- package/src/version.ts +1 -1
- package/src/work-plans/work-plan-store.ts +334 -16
|
@@ -0,0 +1,459 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* markdown-table.ts — box-drawn markdown tables whose cells WRAP.
|
|
3
|
+
*
|
|
4
|
+
* The previous implementation (inside markdown.ts) allocated each column a
|
|
5
|
+
* proportional slice of the terminal width and then ellipsized any cell that
|
|
6
|
+
* did not fit: a comparison table rendered "Files, code, and…" in its label
|
|
7
|
+
* column and "citation coverage, checkpoin…" in its content columns, and the
|
|
8
|
+
* cut text was unrecoverable — it was never written to the buffer at all, so
|
|
9
|
+
* no scroll, copy, or expand could bring it back.
|
|
10
|
+
*
|
|
11
|
+
* This renderer never ellipsizes. A cell that exceeds its column width wraps
|
|
12
|
+
* onto as many physical lines as it needs, the row grows to the tallest cell,
|
|
13
|
+
* and shorter cells are blank-padded so the borders stay aligned. A single
|
|
14
|
+
* word longer than its column is force-broken across lines rather than cut.
|
|
15
|
+
*
|
|
16
|
+
* Column allocation gives every column a workable minimum before any column
|
|
17
|
+
* gets its full natural width, so a narrow label column is never starved down
|
|
18
|
+
* to one word plus an ellipsis while a wide prose column takes the rest:
|
|
19
|
+
*
|
|
20
|
+
* 1. Each column asks for `desiredMin` — enough to hold its longest single
|
|
21
|
+
* word (capped at WORD_MIN_CAP so one long URL cannot starve the table),
|
|
22
|
+
* but never more than the column's natural width and never less than
|
|
23
|
+
* MIN_COL when the budget allows it.
|
|
24
|
+
* 2. If those minimums fit, the leftover budget is shared out in proportion
|
|
25
|
+
* to each column's remaining demand (natural width minus its minimum),
|
|
26
|
+
* then any rounding remainder is handed round-robin to columns that are
|
|
27
|
+
* still short of natural.
|
|
28
|
+
* 3. If the minimums do NOT fit (a genuinely narrow terminal), they are
|
|
29
|
+
* scaled down toward HARD_MIN_COL and the cells simply wrap harder.
|
|
30
|
+
*
|
|
31
|
+
* When even HARD_MIN_COL per column plus the borders exceeds the terminal
|
|
32
|
+
* width, no allocation helps: the box drawing itself does not fit. That case
|
|
33
|
+
* used to write the overflowing columns past the end of the line buffer, where
|
|
34
|
+
* they were discarded — an eight-column table at width 40 lost its last
|
|
35
|
+
* columns, headers included, with nothing on screen to say so. The renderer now
|
|
36
|
+
* abandons the box at that point and stacks each row as `Header: value` records
|
|
37
|
+
* wrapped to the available width, so every column header and every cell is
|
|
38
|
+
* still emitted in full.
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
import { type Line, type Cell, createStyledCell } from '../types/grid.ts';
|
|
42
|
+
import { UIFactory } from './ui-factory.ts';
|
|
43
|
+
import { getDisplayWidth } from '../utils/terminal-width.ts';
|
|
44
|
+
import { activeTheme } from './theme.ts';
|
|
45
|
+
import { renderInlineMarkdown } from './markdown-inline.ts';
|
|
46
|
+
|
|
47
|
+
/** Width a column is given when the budget allows, even if its content is narrower. */
|
|
48
|
+
const MIN_COL = 6;
|
|
49
|
+
/** Absolute floor per column on a terminal too narrow for the real minimums. */
|
|
50
|
+
const HARD_MIN_COL = 3;
|
|
51
|
+
/** Most a single long word may claim as its column's minimum before the word
|
|
52
|
+
* is simply force-broken instead. Keeps one long URL from starving the rest. */
|
|
53
|
+
const WORD_MIN_CAP = 24;
|
|
54
|
+
|
|
55
|
+
function splitTableCells(row: string): string[] {
|
|
56
|
+
const cells = row.trim().split('|').map((c) => c.trim());
|
|
57
|
+
if (cells.length > 0 && cells[0] === '') cells.shift();
|
|
58
|
+
if (cells.length > 0 && cells[cells.length - 1] === '') cells.pop();
|
|
59
|
+
return cells;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function isLikelyTableSeparatorRow(row: string): boolean {
|
|
63
|
+
if (!row.includes('|')) return false;
|
|
64
|
+
const cells = splitTableCells(row);
|
|
65
|
+
if (cells.length === 0) return false;
|
|
66
|
+
return cells.every((cell) => /:?-{3,}:?/.test(cell));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function isLikelyTableHeaderRow(row: string): boolean {
|
|
70
|
+
return splitTableCells(row).length >= 2;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Strip markdown formatting from text for width measurement.
|
|
75
|
+
* Removes **, *, `, ~~ markers but keeps the inner text.
|
|
76
|
+
*/
|
|
77
|
+
function stripMarkdown(text: string): string {
|
|
78
|
+
return text
|
|
79
|
+
.replace(/\*\*(.+?)\*\*/g, '$1') // bold
|
|
80
|
+
.replace(/\*(.+?)\*/g, '$1') // italic
|
|
81
|
+
.replace(/~~(.+?)~~/g, '$1') // strikethrough
|
|
82
|
+
.replace(/`(.+?)`/g, '$1'); // inline code
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** One display character with the style it inherited from its inline token. */
|
|
86
|
+
type StyledChar = { char: string; style: Partial<Cell> };
|
|
87
|
+
|
|
88
|
+
/** Display width of a character, floored at 1 so a zero-width oddity can never
|
|
89
|
+
* make a wrap loop spin without consuming input. */
|
|
90
|
+
function charWidth(ch: string): number {
|
|
91
|
+
return getDisplayWidth(ch) || 1;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Widest single whitespace-delimited word in a cell, after markdown stripping. */
|
|
95
|
+
function longestWordWidth(raw: string): number {
|
|
96
|
+
let widest = 0;
|
|
97
|
+
for (const word of stripMarkdown(raw).split(/\s+/)) {
|
|
98
|
+
if (word.length === 0) continue;
|
|
99
|
+
widest = Math.max(widest, getDisplayWidth(word));
|
|
100
|
+
}
|
|
101
|
+
return widest;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Flatten a cell's inline-markdown tokens into per-character styles, applying
|
|
106
|
+
* the same header/body styling the single-line renderer used.
|
|
107
|
+
*/
|
|
108
|
+
function flattenCellChars(raw: string, isHdr: boolean): StyledChar[] {
|
|
109
|
+
const T = activeTheme();
|
|
110
|
+
const out: StyledChar[] = [];
|
|
111
|
+
for (const token of renderInlineMarkdown(raw)) {
|
|
112
|
+
let style: Partial<Cell>;
|
|
113
|
+
if (token.type === 'code') {
|
|
114
|
+
style = { fg: T.inlineCodeFg, bold: true };
|
|
115
|
+
} else if (token.type === 'link') {
|
|
116
|
+
style = { fg: T.link, underline: true };
|
|
117
|
+
} else {
|
|
118
|
+
style = { ...token.style };
|
|
119
|
+
}
|
|
120
|
+
if (isHdr) {
|
|
121
|
+
style.fg = style.fg || T.heading1;
|
|
122
|
+
style.bold = true;
|
|
123
|
+
} else {
|
|
124
|
+
style.fg = style.fg || '252';
|
|
125
|
+
}
|
|
126
|
+
for (const ch of token.text) out.push({ char: ch, style });
|
|
127
|
+
}
|
|
128
|
+
return out;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Word-aware wrap of a styled character run into lines no wider than `maxW`
|
|
133
|
+
* display columns. A word wider than `maxW` is broken across lines character
|
|
134
|
+
* by character — the text is always emitted in full, never cut.
|
|
135
|
+
*/
|
|
136
|
+
function wrapStyledChars(chars: readonly StyledChar[], maxW: number): StyledChar[][] {
|
|
137
|
+
if (maxW <= 0) return [[]];
|
|
138
|
+
const lines: StyledChar[][] = [];
|
|
139
|
+
let cur: StyledChar[] = [];
|
|
140
|
+
let curW = 0;
|
|
141
|
+
let word: StyledChar[] = [];
|
|
142
|
+
let wordW = 0;
|
|
143
|
+
|
|
144
|
+
const pushLine = (): void => {
|
|
145
|
+
lines.push(cur);
|
|
146
|
+
cur = [];
|
|
147
|
+
curW = 0;
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
const flushWord = (): void => {
|
|
151
|
+
if (word.length === 0) return;
|
|
152
|
+
if (wordW > maxW) {
|
|
153
|
+
// Longer than the whole column: force-break it rather than lose any of it.
|
|
154
|
+
for (const sc of word) {
|
|
155
|
+
const cw = charWidth(sc.char);
|
|
156
|
+
if (curW + cw > maxW && curW > 0) pushLine();
|
|
157
|
+
cur.push(sc);
|
|
158
|
+
curW += cw;
|
|
159
|
+
}
|
|
160
|
+
} else {
|
|
161
|
+
if (curW + wordW > maxW && curW > 0) pushLine();
|
|
162
|
+
cur.push(...word);
|
|
163
|
+
curW += wordW;
|
|
164
|
+
}
|
|
165
|
+
word = [];
|
|
166
|
+
wordW = 0;
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
for (const sc of chars) {
|
|
170
|
+
if (sc.char === ' ') {
|
|
171
|
+
flushWord();
|
|
172
|
+
// A space never starts a line; at a line boundary it becomes the break.
|
|
173
|
+
if (curW > 0 && curW + 1 <= maxW) {
|
|
174
|
+
cur.push(sc);
|
|
175
|
+
curW += 1;
|
|
176
|
+
} else if (curW > 0) {
|
|
177
|
+
pushLine();
|
|
178
|
+
}
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
word.push(sc);
|
|
182
|
+
wordW += charWidth(sc.char);
|
|
183
|
+
}
|
|
184
|
+
flushWord();
|
|
185
|
+
if (cur.length > 0 || lines.length === 0) lines.push(cur);
|
|
186
|
+
return lines;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Turn one wrapped line of styled characters into exactly `maxW` display
|
|
191
|
+
* columns of Cells, inserting wide-glyph placeholders and blank-padding the
|
|
192
|
+
* remainder so every column of every row is the same width.
|
|
193
|
+
*/
|
|
194
|
+
function padLineToCells(lineChars: readonly StyledChar[], maxW: number, isHdr: boolean): Cell[] {
|
|
195
|
+
const T = activeTheme();
|
|
196
|
+
const cells: Cell[] = [];
|
|
197
|
+
let w = 0;
|
|
198
|
+
for (const sc of lineChars) {
|
|
199
|
+
const cw = charWidth(sc.char);
|
|
200
|
+
if (w + cw > maxW) break; // wrapping already guaranteed the fit; defensive only
|
|
201
|
+
cells.push(createStyledCell(sc.char, sc.style));
|
|
202
|
+
if (cw === 2) cells.push(createStyledCell('', sc.style)); // wide char placeholder
|
|
203
|
+
w += cw;
|
|
204
|
+
}
|
|
205
|
+
while (w < maxW) {
|
|
206
|
+
cells.push(createStyledCell(' ', isHdr ? { fg: T.heading1 } : { fg: '252' }));
|
|
207
|
+
w++;
|
|
208
|
+
}
|
|
209
|
+
return cells;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Allocate a display width to each column. See the module header for the
|
|
214
|
+
* three-stage policy; the contract callers rely on is only that every returned
|
|
215
|
+
* width is >= 1 and that no cell is ever asked to truncate.
|
|
216
|
+
*/
|
|
217
|
+
function allocateColumnWidths(
|
|
218
|
+
naturalWidths: readonly number[],
|
|
219
|
+
minWordWidths: readonly number[],
|
|
220
|
+
contentBudget: number,
|
|
221
|
+
): number[] {
|
|
222
|
+
const colCount = naturalWidths.length;
|
|
223
|
+
const totalNatural = naturalWidths.reduce((a, b) => a + b, 0);
|
|
224
|
+
if (totalNatural <= contentBudget) return [...naturalWidths];
|
|
225
|
+
|
|
226
|
+
const widths = naturalWidths.map((natural, c) =>
|
|
227
|
+
Math.min(natural, Math.max(MIN_COL, Math.min(minWordWidths[c], WORD_MIN_CAP))),
|
|
228
|
+
);
|
|
229
|
+
let used = widths.reduce((a, b) => a + b, 0);
|
|
230
|
+
|
|
231
|
+
if (used > contentBudget) {
|
|
232
|
+
// Narrow terminal: scale the minimums down, then shave the widest column
|
|
233
|
+
// repeatedly until the row fits or every column is at the hard floor.
|
|
234
|
+
const scale = contentBudget / used;
|
|
235
|
+
for (let c = 0; c < colCount; c++) {
|
|
236
|
+
widths[c] = Math.max(HARD_MIN_COL, Math.floor(widths[c] * scale));
|
|
237
|
+
}
|
|
238
|
+
used = widths.reduce((a, b) => a + b, 0);
|
|
239
|
+
while (used > contentBudget) {
|
|
240
|
+
let widest = -1;
|
|
241
|
+
let widestW = HARD_MIN_COL;
|
|
242
|
+
for (let c = 0; c < colCount; c++) {
|
|
243
|
+
if (widths[c] > widestW) {
|
|
244
|
+
widestW = widths[c];
|
|
245
|
+
widest = c;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
if (widest === -1) break; // everything is at the floor; cannot shrink further
|
|
249
|
+
widths[widest]--;
|
|
250
|
+
used--;
|
|
251
|
+
}
|
|
252
|
+
return widths;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Share the leftover budget in proportion to each column's unmet demand.
|
|
256
|
+
const demand = naturalWidths.map((natural, c) => Math.max(0, natural - widths[c]));
|
|
257
|
+
const totalDemand = demand.reduce((a, b) => a + b, 0);
|
|
258
|
+
if (totalDemand === 0) return widths;
|
|
259
|
+
|
|
260
|
+
const leftover = contentBudget - used;
|
|
261
|
+
for (let c = 0; c < colCount; c++) {
|
|
262
|
+
const share = Math.min(demand[c], Math.floor((leftover * demand[c]) / totalDemand));
|
|
263
|
+
widths[c] += share;
|
|
264
|
+
used += share;
|
|
265
|
+
}
|
|
266
|
+
// Rounding remainder: round-robin to whoever is still short of natural.
|
|
267
|
+
let cursor = 0;
|
|
268
|
+
let guard = contentBudget * 2 + colCount;
|
|
269
|
+
while (used < contentBudget && guard-- > 0) {
|
|
270
|
+
if (widths[cursor] < naturalWidths[cursor]) {
|
|
271
|
+
widths[cursor]++;
|
|
272
|
+
used++;
|
|
273
|
+
}
|
|
274
|
+
cursor = (cursor + 1) % colCount;
|
|
275
|
+
}
|
|
276
|
+
return widths;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** Lay one wrapped run of styled characters into a full-width Line at `indent`. */
|
|
280
|
+
function emitStyledLines(
|
|
281
|
+
chars: readonly StyledChar[],
|
|
282
|
+
width: number,
|
|
283
|
+
indent: number,
|
|
284
|
+
maxW: number,
|
|
285
|
+
): Line[] {
|
|
286
|
+
const out: Line[] = [];
|
|
287
|
+
for (const lineChars of wrapStyledChars(chars, maxW)) {
|
|
288
|
+
const line = new Array(width).fill(null).map(() => createStyledCell(' ')) as Cell[];
|
|
289
|
+
let x = indent;
|
|
290
|
+
for (const sc of lineChars) {
|
|
291
|
+
if (x < width) line[x] = createStyledCell(sc.char, sc.style);
|
|
292
|
+
x++;
|
|
293
|
+
if (charWidth(sc.char) === 2) {
|
|
294
|
+
if (x < width) line[x] = createStyledCell('', sc.style);
|
|
295
|
+
x++;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
out.push(line);
|
|
299
|
+
}
|
|
300
|
+
return out;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* The no-box fallback for a terminal too narrow to draw the table at all.
|
|
305
|
+
*
|
|
306
|
+
* Each data row becomes a block of `Header: value` lines wrapped to the
|
|
307
|
+
* available width, separated by a rule. Header text is styled as a header and
|
|
308
|
+
* force-broken rather than cut if it is somehow wider than the terminal, so the
|
|
309
|
+
* guarantee the box layout makes — every column present, nothing truncated —
|
|
310
|
+
* holds at any width.
|
|
311
|
+
*/
|
|
312
|
+
function renderStackedTable(
|
|
313
|
+
parsedRows: readonly string[][],
|
|
314
|
+
colCount: number,
|
|
315
|
+
hasSeparator: boolean,
|
|
316
|
+
width: number,
|
|
317
|
+
indent: number,
|
|
318
|
+
): Line[] {
|
|
319
|
+
const lines: Line[] = [];
|
|
320
|
+
const availW = Math.max(1, width - indent);
|
|
321
|
+
const headerRow = hasSeparator ? parsedRows[0] : undefined;
|
|
322
|
+
const bodyRows = hasSeparator ? parsedRows.slice(1) : [...parsedRows];
|
|
323
|
+
// A header-only table still has to show its headers, so it renders as one
|
|
324
|
+
// record with empty values rather than as nothing at all.
|
|
325
|
+
const records: readonly string[][] = bodyRows.length > 0 ? bodyRows : [[]];
|
|
326
|
+
const separatorStyle: Partial<Cell> = { fg: '240' };
|
|
327
|
+
const rule = UIFactory.stringToLine(' '.repeat(indent) + '─'.repeat(availW), width, { fg: '240' });
|
|
328
|
+
|
|
329
|
+
for (let r = 0; r < records.length; r++) {
|
|
330
|
+
if (r > 0) lines.push(rule);
|
|
331
|
+
const row = records[r];
|
|
332
|
+
for (let c = 0; c < colCount; c++) {
|
|
333
|
+
const label = headerRow ? (headerRow[c] ?? '') : `Column ${c + 1}`;
|
|
334
|
+
const value = c < row.length ? row[c] : '';
|
|
335
|
+
const chars: StyledChar[] = [
|
|
336
|
+
...flattenCellChars(label, true),
|
|
337
|
+
{ char: ':', style: separatorStyle },
|
|
338
|
+
{ char: ' ', style: separatorStyle },
|
|
339
|
+
...flattenCellChars(value, false),
|
|
340
|
+
];
|
|
341
|
+
lines.push(...emitStyledLines(chars, width, indent, availW));
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
return lines;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Render a markdown table with box-drawing borders. Cells wrap to as many
|
|
349
|
+
* physical lines as their content needs; nothing is ever truncated.
|
|
350
|
+
*/
|
|
351
|
+
export function renderTable(rows: string[], width: number, indent: number): Line[] {
|
|
352
|
+
const lines: Line[] = [];
|
|
353
|
+
|
|
354
|
+
// Parse rows into cells, skip separator
|
|
355
|
+
const parsedRows: string[][] = [];
|
|
356
|
+
let hasSeparator = false;
|
|
357
|
+
for (const row of rows) {
|
|
358
|
+
const trimmed = row.trim();
|
|
359
|
+
if (isLikelyTableSeparatorRow(trimmed)) {
|
|
360
|
+
hasSeparator = true;
|
|
361
|
+
continue;
|
|
362
|
+
}
|
|
363
|
+
const cells = splitTableCells(trimmed);
|
|
364
|
+
if (cells.length > 0) parsedRows.push(cells);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
if (parsedRows.length === 0) return lines;
|
|
368
|
+
|
|
369
|
+
const colCount = Math.max(...parsedRows.map((r) => r.length));
|
|
370
|
+
const availW = width - indent;
|
|
371
|
+
|
|
372
|
+
// Measure using stripped text (no markdown markers), both the natural width
|
|
373
|
+
// and the widest single word — the latter is what a column needs before it
|
|
374
|
+
// has to start breaking words apart.
|
|
375
|
+
const naturalWidths: number[] = new Array(colCount).fill(0);
|
|
376
|
+
const minWordWidths: number[] = new Array(colCount).fill(0);
|
|
377
|
+
for (const row of parsedRows) {
|
|
378
|
+
for (let c = 0; c < row.length; c++) {
|
|
379
|
+
naturalWidths[c] = Math.max(naturalWidths[c], getDisplayWidth(stripMarkdown(row[c])));
|
|
380
|
+
minWordWidths[c] = Math.max(minWordWidths[c], longestWordWidth(row[c]));
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// Budget: availW minus borders (colCount+1) minus padding (2 per col)
|
|
385
|
+
const overhead = (colCount + 1) + (colCount * 2);
|
|
386
|
+
const contentBudget = Math.max(colCount * HARD_MIN_COL, availW - overhead);
|
|
387
|
+
const colWidths = allocateColumnWidths(naturalWidths, minWordWidths, contentBudget);
|
|
388
|
+
|
|
389
|
+
// Left border + per column (leading pad, content, trailing pad, border).
|
|
390
|
+
// When that does not fit, the columns past the edge would be written beyond
|
|
391
|
+
// the end of the line buffer and silently dropped, so the box is abandoned
|
|
392
|
+
// for the stacked layout instead.
|
|
393
|
+
const boxWidth = indent + 1 + colWidths.reduce((sum, w) => sum + w + 3, 0);
|
|
394
|
+
if (boxWidth > width) {
|
|
395
|
+
return renderStackedTable(parsedRows, colCount, hasSeparator, width, indent);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
const bc = '240'; // border color
|
|
399
|
+
|
|
400
|
+
const makeBorder = (left: string, mid: string, right: string, horiz: string): Line => {
|
|
401
|
+
let s = ' '.repeat(indent) + left;
|
|
402
|
+
for (let c = 0; c < colCount; c++) {
|
|
403
|
+
s += horiz.repeat(colWidths[c] + 2) + (c < colCount - 1 ? mid : right);
|
|
404
|
+
}
|
|
405
|
+
return UIFactory.stringToLine(s, width, { fg: bc });
|
|
406
|
+
};
|
|
407
|
+
|
|
408
|
+
lines.push(makeBorder('┌', '┬', '┐', '─'));
|
|
409
|
+
|
|
410
|
+
for (let r = 0; r < parsedRows.length; r++) {
|
|
411
|
+
const row = parsedRows[r];
|
|
412
|
+
const isHeader = hasSeparator && r === 0;
|
|
413
|
+
|
|
414
|
+
// Wrap every cell first; the row is as tall as its tallest cell.
|
|
415
|
+
const wrappedCells: StyledChar[][][] = [];
|
|
416
|
+
for (let c = 0; c < colCount; c++) {
|
|
417
|
+
const raw = c < row.length ? row[c] : '';
|
|
418
|
+
wrappedCells.push(wrapStyledChars(flattenCellChars(raw, isHeader), colWidths[c]));
|
|
419
|
+
}
|
|
420
|
+
const rowHeight = Math.max(1, ...wrappedCells.map((cell) => cell.length));
|
|
421
|
+
|
|
422
|
+
for (let physical = 0; physical < rowHeight; physical++) {
|
|
423
|
+
const line = new Array(width).fill(null).map(() => createStyledCell(' ')) as Cell[];
|
|
424
|
+
let x = indent;
|
|
425
|
+
|
|
426
|
+
if (x < width) line[x] = createStyledCell('│', { fg: bc });
|
|
427
|
+
x++;
|
|
428
|
+
|
|
429
|
+
for (let c = 0; c < colCount; c++) {
|
|
430
|
+
if (x < width) line[x] = createStyledCell(' ');
|
|
431
|
+
x++;
|
|
432
|
+
|
|
433
|
+
// Past the end of this cell's wrapped lines => blank padding, so the
|
|
434
|
+
// borders of a tall row stay aligned.
|
|
435
|
+
const cellLine = wrappedCells[c][physical] ?? [];
|
|
436
|
+
for (const cell of padLineToCells(cellLine, colWidths[c], isHeader)) {
|
|
437
|
+
if (x < width) line[x] = cell;
|
|
438
|
+
x++;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
if (x < width) line[x] = createStyledCell(' ');
|
|
442
|
+
x++;
|
|
443
|
+
|
|
444
|
+
if (x < width) line[x] = createStyledCell('│', { fg: bc });
|
|
445
|
+
x++;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
lines.push(line);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
if (isHeader) {
|
|
452
|
+
lines.push(makeBorder('├', '┼', '┤', '─'));
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
lines.push(makeBorder('└', '┴', '┘', '─'));
|
|
457
|
+
|
|
458
|
+
return lines;
|
|
459
|
+
}
|