nexrall-code 0.5.106 → 0.5.108

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.
@@ -0,0 +1,36 @@
1
+ const defaultDeps = {
2
+ setTimeout: (fn, ms) => setTimeout(fn, ms),
3
+ clearTimeout: (handle) => clearTimeout(handle),
4
+ };
5
+ /**
6
+ * Fires `onSettle` once, `settleMs` after the LAST `notify()` call — i.e.
7
+ * once resize events have stopped arriving for that long. Any `notify()`
8
+ * that arrives before the window elapses restarts it, so a continuous drag
9
+ * (events firing every few milliseconds) never triggers `onSettle` until the
10
+ * user actually lets go.
11
+ *
12
+ * `deps` defaults to the real timers; tests inject fakes so the debounce
13
+ * window can be exercised deterministically without real elapsed time.
14
+ */
15
+ export function createResizeDebouncer(settleMs, onSettle, deps = defaultDeps) {
16
+ let timer = null;
17
+ let disposed = false;
18
+ const notify = () => {
19
+ if (disposed)
20
+ return;
21
+ if (timer !== null)
22
+ deps.clearTimeout(timer);
23
+ timer = deps.setTimeout(() => {
24
+ timer = null;
25
+ onSettle();
26
+ }, settleMs);
27
+ };
28
+ const dispose = () => {
29
+ disposed = true;
30
+ if (timer !== null) {
31
+ deps.clearTimeout(timer);
32
+ timer = null;
33
+ }
34
+ };
35
+ return { notify, dispose };
36
+ }
@@ -0,0 +1,482 @@
1
+ import chalk from 'chalk';
2
+ import { visibleLen, padVis } from './theme';
3
+ // ─── Session Screen Setup ──────────────────────────────────────────────────
4
+ //
5
+ // The session runs in the terminal's NORMAL screen buffer, and starts by
6
+ // clearing both the screen and the scrollback. It deliberately does NOT use
7
+ // the alternate screen buffer (\x1b[?1049h) that vim/less/htop use, which is
8
+ // what this did previously.
9
+ //
10
+ // The alternate buffer is the wrong tool for a scrolling chat transcript, for
11
+ // two reasons that show up immediately in normal use:
12
+ //
13
+ // • It has no scrollback of its own. Once the conversation grows past one
14
+ // screenful the earlier turns — and the banner — are gone for good, not
15
+ // merely off-screen. There is nothing to scroll back to.
16
+ // • Because it doesn't scroll, terminals including macOS Terminal.app route
17
+ // a mouse-wheel scroll to the MAIN buffer sitting underneath instead. So
18
+ // scrolling up during a session shows whatever was on screen before `nex`
19
+ // started — typically a stack of empty `user@host ~ %` shell prompts —
20
+ // which is precisely the reported symptom.
21
+ //
22
+ // Clearing the normal buffer instead gives the desired behaviour directly and
23
+ // with far less machinery: the session begins on a blank screen with the
24
+ // banner at the top, and as the conversation grows it scrolls naturally, so
25
+ // scrolling up reveals the banner and earlier turns and nothing else —
26
+ // because after the clear that is genuinely all the scrollback contains.
27
+ //
28
+ // It also means the session's output is still there after exit, which is
29
+ // usually what you want from a CLI (you can scroll back to what the agent
30
+ // said), whereas the alternate buffer discarded all of it on quit.
31
+ //
32
+ // \x1b[2J clears the visible screen; \x1b[3J clears the scrollback (xterm's
33
+ // XTCLEARSB — supported by Terminal.app, iTerm2, Ghostty, kitty, WezTerm,
34
+ // VS Code's terminal and Windows Terminal); \x1b[H then homes the cursor so
35
+ // output starts at row 1 rather than wherever the shell prompt left it.
36
+ // Terminals that don't implement 3J simply keep their scrollback: the session
37
+ // still renders correctly, it just doesn't get the clean-slate scrollback.
38
+ export function prepareSessionScreen() {
39
+ if (!process.stdout.isTTY)
40
+ return;
41
+ process.stdout.write('\x1b[2J\x1b[3J\x1b[H');
42
+ }
43
+ // ─── Pinning the bottom chrome to the last row ─────────────────────────────
44
+ /**
45
+ * Rows Ink's persistent bottom chrome occupies: top separator, input line,
46
+ * bottom separator, footer.
47
+ *
48
+ * This is the MINIMUM (one row each) — it is only correct while every one of
49
+ * those four rows fits on a single terminal line. Prefer bottomChromeRows()
50
+ * below, which accounts for the two ways that assumption breaks.
51
+ */
52
+ export const BOTTOM_CHROME_ROWS = 4;
53
+ /**
54
+ * Rows the bottom chrome ACTUALLY occupies, given the terminal width and the
55
+ * text currently in the input box.
56
+ *
57
+ * `BOTTOM_CHROME_ROWS` is a hardcoded 4, which silently under-counts in two
58
+ * situations that are both easy to hit — and under-counting makes padToBottom
59
+ * push the chrome PAST the last row, scrolling the top of the banner away:
60
+ *
61
+ * • A narrow terminal wraps the footer. The footer string
62
+ * ("auto mode on · /help for shortcuts · /agents for agents") is 55 cells,
63
+ * so at any width below ~56 columns it takes two rows, not one — measured
64
+ * at 40 columns: actual chrome 5, constant 4.
65
+ * • Shift+Enter puts real newlines IN the input line (a feature this same
66
+ * UI added), so the input row becomes as many rows as there are lines —
67
+ * measured: one Shift+Enter → 5, three → 7, against a constant of 4.
68
+ *
69
+ * Deriving the number from the same strings the component renders means the
70
+ * two cannot drift apart the way a hand-maintained constant did.
71
+ */
72
+ export function bottomChromeRows(opts = {
73
+ footerText: '',
74
+ }) {
75
+ const columns = opts.columns ?? process.stdout.columns ?? 80;
76
+ // Two full-width rules now bound the input box: one above (existing) and
77
+ // one below (added so the footer hints sit visually separated from the
78
+ // input, matching the separator already drawn above the box) — see
79
+ // inkTerminal.tsx's second <Box borderBottom> rule.
80
+ const separatorRows = 2;
81
+ const inputRows = rowsOccupied(' '.repeat(opts.promptWidth ?? 2) + (opts.inputText ?? ''), columns);
82
+ const footerRows = rowsOccupied(opts.footerText, columns);
83
+ return separatorRows + inputRows + footerRows;
84
+ }
85
+ /**
86
+ * Terminal rows a printed string occupies, accounting for long lines
87
+ * soft-wrapping at the terminal's width. Counting '\n' alone undercounts (and
88
+ * so under-pads) whenever a line is wider than the terminal — e.g. a long
89
+ * working-directory path on a narrow window. Measured in cells via visibleLen,
90
+ * so CJK and emoji are counted correctly.
91
+ */
92
+ export function rowsOccupied(text, columns = process.stdout.columns || 80) {
93
+ let rows = 0;
94
+ for (const line of text.split('\n')) {
95
+ const width = visibleLen(line);
96
+ rows += width === 0 ? 1 : Math.ceil(width / columns);
97
+ }
98
+ return rows;
99
+ }
100
+ /**
101
+ * Pure row-count arithmetic behind padToBottom() below — extracted so callers
102
+ * that need to RECOMPUTE the same filler amount without printing anything can
103
+ * do so. This exists for exactly one reason: inkTerminal.tsx's
104
+ * `forceFullRedraw` (resize) and `collapseStartupPadding` both raw-clear the
105
+ * screen (`\x1b[2J\x1b[H`) and remount `<Static>`, which replays only what is
106
+ * in the `items` array — any padding padToBottom printed via plain
107
+ * `console.log` is NOT in `items`, so it is wiped by that clear and never
108
+ * replayed, leaving the chrome stranded wherever the replayed items end
109
+ * instead of at the last row (this was a real, reproducible bug: verified via
110
+ * pty capture that the footer floated mid-screen, sometimes permanently, on
111
+ * both the very first resize AND the very first message of every session,
112
+ * because both paths share this exact redraw mechanism). The fix is for
113
+ * those callers to push a FILLER ITEM into `items` itself instead of calling
114
+ * `console.log` — see inkTerminal.tsx's `repadBottomChrome` — which requires
115
+ * the same arithmetic padToBottom uses, minus the actual printing.
116
+ *
117
+ * See padToBottom's own doc for why every constant below is what it is —
118
+ * this is the identical math, just factored out so both call sites can't
119
+ * drift apart the way a second hand-copied formula would.
120
+ */
121
+ export function fillerRowCount(rowsAlreadyPrinted, footerTextValue = '', columns = process.stdout.columns ?? 80, rows = process.stdout.rows) {
122
+ if (!rows)
123
+ return 0;
124
+ // Two rows of headroom, not one. The first is the cursor row Ink keeps
125
+ // below its frame. The second is a safety margin: the tally can only measure
126
+ // what went through console.log, and mis-counting by even one row overflows
127
+ // the viewport and scrolls the top of the banner away permanently. Erring
128
+ // one row short is invisible (the footer sits one line above the bottom);
129
+ // erring one row long destroys the banner. The asymmetry justifies the
130
+ // margin.
131
+ //
132
+ // The chrome height is MEASURED (bottomChromeRows) rather than assumed to be
133
+ // 3: on a terminal narrower than the footer string the footer wraps onto a
134
+ // second row, and the old constant then under-counted by one and pushed the
135
+ // banner's top row off the screen. The input box is empty at startup, which
136
+ // is when this runs, so only the footer can wrap here.
137
+ const chromeRows = bottomChromeRows({ footerText: footerTextValue, columns });
138
+ const filler = rows - rowsAlreadyPrinted - chromeRows - 2;
139
+ // Skip padding entirely unless there is a comfortable amount of slack.
140
+ //
141
+ // When startup output nearly fills the terminal there is no gap worth
142
+ // closing, but there IS a real risk: any timing jitter in Ink's frame
143
+ // commits can then push the total one row past the viewport and scroll the
144
+ // banner's top away. Measured on a 24-row terminal, where startup already
145
+ // occupies 21 rows, padding failed roughly 1 run in 8 for exactly this
146
+ // reason, while adding at most two blank rows of benefit.
147
+ //
148
+ // The padding exists to close an 8-19 row gap on a roomy terminal. Below
149
+ // this threshold the gap is already negligible, so declining to pad trades
150
+ // nothing for determinism.
151
+ const MIN_WORTHWHILE_FILLER = 3;
152
+ return filler < MIN_WORTHWHILE_FILLER ? 0 : filler;
153
+ }
154
+ /**
155
+ * Prints blank rows so the input line and footer come to rest on the LAST row
156
+ * of the terminal, instead of floating directly beneath the banner with dead
157
+ * space below them.
158
+ *
159
+ * The blanks MUST go through `console.log`, not `process.stdout.write`. Ink
160
+ * patches console.log (patchConsole) to erase its current frame, emit the
161
+ * line above it, then repaint the frame below — which is exactly the "push
162
+ * the chrome down" behaviour wanted. A raw stdout write bypasses that
163
+ * bookkeeping: the text lands wherever the cursor happens to be, Ink's idea
164
+ * of its own frame position goes stale, and the next repaint draws the
165
+ * chrome back over the padding. An earlier attempt did precisely this and
166
+ * left the footer stranded mid-screen.
167
+ *
168
+ * This runs once at startup. Once the conversation grows past one screenful
169
+ * the padding is irrelevant: content then fills the viewport and the chrome
170
+ * stays on the last row through ordinary terminal scrolling (verified by
171
+ * streaming 30 lines after padding — the footer did not move).
172
+ *
173
+ * Returns whether it actually printed filler rows. A terminal is append-only:
174
+ * these blank rows can never be "filled in" by later output, only appended
175
+ * after — so the caller (chat.ts) uses this to know whether it must later
176
+ * collapse them via inkTerminal.tsx's `collapseStartupPadding()` once real
177
+ * conversation content starts, instead of leaving them permanently wedged
178
+ * between the banner and the first turn (see that function's own doc for the
179
+ * full mechanism and the pty capture that found this).
180
+ */
181
+ export function padToBottom(rowsAlreadyPrinted, footerText = '') {
182
+ if (!process.stdout.isTTY)
183
+ return false;
184
+ const filler = fillerRowCount(rowsAlreadyPrinted, footerText);
185
+ if (filler <= 0)
186
+ return false;
187
+ // Emitted as ONE console.log, not `filler` separate calls. Ink's patched
188
+ // console.log erases its frame, writes the line, then repaints the frame
189
+ // below — so N calls mean N erase/repaint cycles in a tight loop, and on
190
+ // taller terminals the incremental erase falls behind, leaving stray
191
+ // separator fragments on screen and scrolling the banner's top row away.
192
+ // A single call is one erase/repaint for the whole block.
193
+ //
194
+ // console.log appends its own trailing newline, so `filler - 1` newlines
195
+ // produce exactly `filler` rows.
196
+ console.log('\n'.repeat(filler - 1));
197
+ return true;
198
+ }
199
+ /**
200
+ * Tallies the terminal rows consumed by everything written through
201
+ * `console.log` between `startRowCount()` and `stopRowCount()`.
202
+ *
203
+ * Startup output is conditional in several places — permission rules, loaded
204
+ * skills, connected MCP servers, resumed-session notices — so a hardcoded
205
+ * "the banner is N rows tall" constant would drift out of date the next time
206
+ * one of those notices is added or reworded, silently breaking the alignment
207
+ * again. Measuring what was actually printed cannot drift.
208
+ *
209
+ * Must be started AFTER Ink's render() has run: patchConsole replaces
210
+ * console.log at that point, and starting first would have Ink's patch
211
+ * overwrite the counting wrapper, leaving the count at zero.
212
+ */
213
+ let rowCounter = null;
214
+ export function startRowCount() {
215
+ if (rowCounter)
216
+ return;
217
+ const columns = process.stdout.columns || 80;
218
+ const originalLog = console.log;
219
+ const originalError = console.error;
220
+ const state = { rows: 0, originalLog, originalError };
221
+ rowCounter = state;
222
+ // console.log() with no arguments prints a single blank line.
223
+ const measure = (args) => args.length === 0 ? 1 : rowsOccupied(args.map(String).join(' '), columns);
224
+ console.log = (...args) => {
225
+ state.rows += measure(args);
226
+ originalLog(...args);
227
+ };
228
+ // console.error is counted too, and it is not a theoretical case: Ink patches
229
+ // BOTH (patchConsole), so an error printed during startup lands on screen and
230
+ // pushes the chrome down exactly like a console.log does — but it used to go
231
+ // untallied, so padToBottom under-measured and over-padded.
232
+ //
233
+ // Startup really does write here. A failing MCP server logs
234
+ // `[MCP] Failed to connect to server "…": …` via console.error, and it is
235
+ // long: measured at 277 characters, which is 3 rows at 100 columns, none of
236
+ // which the tally saw. Every startup error path (session-not-found, compact
237
+ // failures) has the same shape.
238
+ console.error = (...args) => {
239
+ state.rows += measure(args);
240
+ originalError(...args);
241
+ };
242
+ }
243
+ /** Stops tallying and returns the row count. Safe to call when not started. */
244
+ export function stopRowCount() {
245
+ if (!rowCounter)
246
+ return 0;
247
+ const { rows, originalLog, originalError } = rowCounter;
248
+ console.log = originalLog;
249
+ console.error = originalError;
250
+ rowCounter = null;
251
+ return rows;
252
+ }
253
+ /**
254
+ * Manually adds `text`'s row height to the running tally, for output that
255
+ * bypasses console.log/console.error entirely and so is invisible to the
256
+ * patched functions above.
257
+ *
258
+ * The interactive-path welcome banner is exactly this case: it goes through
259
+ * `getInkTerminal()!.print()` (an Ink `<Static>` list item, so it can be
260
+ * regenerated on resize — see setBannerRegenerator's doc), never through
261
+ * console.log. Before this existed, padToBottom() was blind to the banner's
262
+ * ~15-18 rows entirely: it only tallied the handful of console.log lines
263
+ * printed AFTER the banner (permission rules, skills, "Type a message…"),
264
+ * computed a filler far larger than the real gap, and pushed the input
265
+ * box/footer clean off the bottom of the viewport — scrolling the banner's
266
+ * own top rows away and leaving a large empty gap above the chrome. Callers
267
+ * must invoke this with the exact string passed to `print()`, immediately
268
+ * after printing it, so the count reflects what is really on screen.
269
+ *
270
+ * A no-op when counting hasn't started (mirrors stopRowCount's safety) so
271
+ * call sites don't need to guard on `interactive` themselves.
272
+ */
273
+ export function addRowCount(text) {
274
+ if (!rowCounter)
275
+ return;
276
+ const columns = process.stdout.columns || 80;
277
+ rowCounter.rows += rowsOccupied(text, columns);
278
+ }
279
+ // ─── Welcome Banner (box-drawn "Welcome back" card) ────────────────────────
280
+ //
281
+ // Mirrors Claude Code's startup screen: a rounded box with version header,
282
+ // centered greeting + logo on the left, "Tips" / "What's new" on the right.
283
+ // Pure chalk + Unicode box-drawing — no React/Ink dependency needed here,
284
+ // since this only runs ONCE at session start, before Ink's own render() has
285
+ // booted (see chat.ts's startChatSession: the banner call is deliberately
286
+ // ordered after startInkTerminal() so Ink's patchConsole picks it up like
287
+ // any other console.log call).
288
+ const NEX_LOGO = [
289
+ '███╗ ██╗███████╗██╗ ██╗',
290
+ '████╗ ██║██╔════╝╚██╗██╔╝',
291
+ '██╔██╗ ██║█████╗ ╚███╔╝ ',
292
+ '██║╚██╗██║██╔══╝ ██╔██╗ ',
293
+ '██║ ╚████║███████╗██╔╝ ██╗',
294
+ '╚═╝ ╚═══╝╚══════╝╚═╝ ╚═╝',
295
+ ];
296
+ /** Center `s` (already chalk-styled OK) within visible width `w`. */
297
+ function center(s, w) {
298
+ const len = visibleLen(s);
299
+ if (len >= w)
300
+ return s;
301
+ const left = Math.floor((w - len) / 2);
302
+ return ' '.repeat(left) + s;
303
+ }
304
+ // Both truncators below iterate over grapheme-ish units and measure with
305
+ // visibleLen (terminal cells) rather than slicing by String index, so a
306
+ // wide CJK character is never cut in half and a two-cell glyph is never
307
+ // counted as one cell — either mistake would leave the column a cell over
308
+ // budget and tear the frame.
309
+ const graphemesOf = (s) => [...s];
310
+ /** Trim `s` to at most `w` cells, marking the cut with a trailing ellipsis. */
311
+ function truncateEnd(s, w) {
312
+ if (visibleLen(s) <= w)
313
+ return s;
314
+ let out = '';
315
+ for (const g of graphemesOf(s)) {
316
+ if (visibleLen(out + g) > w - 1)
317
+ break;
318
+ out += g;
319
+ }
320
+ return out + '…';
321
+ }
322
+ /**
323
+ * Trim `s` to at most `w` cells from the START, marking the cut with a leading
324
+ * ellipsis — used for filesystem paths, where the tail identifies the project
325
+ * and the head is usually a long, uninformative home-directory prefix.
326
+ */
327
+ function truncateStart(s, w) {
328
+ if (visibleLen(s) <= w)
329
+ return s;
330
+ const gs = graphemesOf(s);
331
+ let out = '';
332
+ for (let i = gs.length - 1; i >= 0; i--) {
333
+ if (visibleLen(gs[i] + out) > w - 1)
334
+ break;
335
+ out = gs[i] + out;
336
+ }
337
+ return '…' + out;
338
+ }
339
+ /**
340
+ * Renders the two-column bordered welcome card. Returns the fully composed
341
+ * string (caller does the single console.log) so callers/tests can inspect
342
+ * it without capturing stdout.
343
+ */
344
+ export function renderBanner(info) {
345
+ // ─── Geometry ────────────────────────────────────────────────────────────
346
+ //
347
+ // The card spans the FULL terminal width and is split into two columns by a
348
+ // vertical rule, so the layout is derived from the terminal size rather than
349
+ // from fixed column widths:
350
+ //
351
+ // │ <left column> │ <right column> │
352
+ // ^ ^ ^ ^ ^ ^
353
+ // │ └ PAD │ └ PAD │ └ PAD (1 cell each side of a column)
354
+ // └ frame └ divider └ frame
355
+ //
356
+ // Every row is exactly `totalW` cells, so the frame, the divider and the
357
+ // horizontal rules all line up by construction instead of by three separate
358
+ // hand-maintained arithmetic expressions that can drift apart (the previous
359
+ // revision's rules were 2 cells wider than its content rows for exactly
360
+ // that reason).
361
+ const PAD = 1;
362
+ // Non-content cells in a row: the 3 frame/divider glyphs (│ … │ … │) plus
363
+ // PAD cells on each side of each of the 2 columns — i.e. 4 pads, not 6.
364
+ // Overstating this by 2 made the card render 2 cells narrower than the
365
+ // terminal instead of spanning it fully.
366
+ const CHROME = 3 + 4 * PAD;
367
+ // The card spans the full terminal width. A floor keeps it from collapsing
368
+ // below what the logo plus a usable tips column need on a very narrow
369
+ // window — in that case the card is wider than the terminal and the
370
+ // terminal soft-wraps it, which is still more legible than a crushed or
371
+ // torn layout.
372
+ const LOGO_W = Math.max(...NEX_LOGO.map(visibleLen));
373
+ const termW = info.columns ?? process.stdout.columns ?? 100;
374
+ // One cell short of the terminal, for the same reason as the input
375
+ // separator in inkTerminal.tsx: a row that fills the final column makes many
376
+ // terminals wrap immediately, turning each banner row into two and doubling
377
+ // the card's height (which then scrolls its own top off the screen).
378
+ const totalW = Math.max(LOGO_W + CHROME + 24, termW - 1);
379
+ // The left column hugs its content (the logo is the widest fixed element);
380
+ // the right column takes everything that's left, so the divider sits at a
381
+ // stable position and the tips get the larger share on wide terminals.
382
+ const headerText = `Nexrall Code v${info.version}`;
383
+ const LEFT_W = Math.max(LOGO_W, visibleLen(headerText));
384
+ const RIGHT_W = totalW - CHROME - LEFT_W;
385
+ const leftLines = [];
386
+ leftLines.push(chalk.bold.hex('#3b82f6')('Nexrall Code ') + chalk.dim(`v${info.version}`));
387
+ leftLines.push('');
388
+ leftLines.push(center(chalk.bold(`Welcome back ${info.userLabel}!`), LEFT_W));
389
+ leftLines.push('');
390
+ // Logo lines are all LOGO_W wide and LEFT_W is >= LOGO_W, so centering here
391
+ // keeps the logo optically centred over the column without ever shifting it
392
+ // off the left edge.
393
+ for (const l of NEX_LOGO)
394
+ leftLines.push(center(chalk.hex('#3b82f6')(l), LEFT_W));
395
+ leftLines.push('');
396
+ // The left column is sized to the logo, so anything longer — most often a
397
+ // deeply nested working directory — has to be truncated or it would push
398
+ // past the divider and tear the frame. Paths are truncated from the LEFT so
399
+ // the meaningful tail (the project directory) survives.
400
+ leftLines.push(chalk.dim(truncateEnd(info.modelLabel, LEFT_W)));
401
+ leftLines.push(chalk.dim(truncateStart(info.workDir, LEFT_W)));
402
+ if (info.nexrallMdLoaded)
403
+ leftLines.push(chalk.dim('nexrall.md loaded'));
404
+ const rightLines = [];
405
+ rightLines.push(chalk.bold.yellow('Tips for getting started'));
406
+ const tips = info.tips ?? [
407
+ 'Run /init to create a nexrall.md file with instructions for Nexrall Code.',
408
+ 'Use /help to see all slash commands.',
409
+ ];
410
+ for (const t of tips)
411
+ rightLines.push(...wrapText(t, RIGHT_W));
412
+ const whatsNew = info.whatsNew ?? [];
413
+ if (whatsNew.length) {
414
+ rightLines.push('');
415
+ rightLines.push(chalk.bold.yellow("What's new"));
416
+ for (const t of whatsNew)
417
+ rightLines.push(...wrapText(t, RIGHT_W));
418
+ rightLines.push(chalk.dim.italic('/release-notes for more'));
419
+ }
420
+ const rowCount = Math.max(leftLines.length, rightLines.length);
421
+ // Nexrall brand blue — deliberately NOT Claude Code's orange (#d97757):
422
+ // this UI mirrors Claude Code's LAYOUT (box banner, two columns) but must
423
+ // stay visually distinct as Nexrall's own product.
424
+ const border = chalk.hex('#3b82f6');
425
+ // Each column's full span including its padding on both sides. The
426
+ // horizontal rules are built from these same two numbers as the content
427
+ // rows, so the T-junctions (┬ ┴) always land exactly on the divider (│)
428
+ // rather than being positioned by separate arithmetic that can drift.
429
+ const leftSpan = LEFT_W + PAD * 2;
430
+ const rightSpan = RIGHT_W + PAD * 2;
431
+ const pad = ' '.repeat(PAD);
432
+ const rule = (l, mid, r) => border(l + '─'.repeat(leftSpan) + mid + '─'.repeat(rightSpan) + r);
433
+ const lines = [];
434
+ lines.push(rule('╭', '┬', '╮'));
435
+ for (let i = 0; i < rowCount; i++) {
436
+ const l = padVis(leftLines[i] ?? '', LEFT_W);
437
+ const r = padVis(rightLines[i] ?? '', RIGHT_W);
438
+ lines.push(border('│') + pad + l + pad + border('│') + pad + r + pad + border('│'));
439
+ }
440
+ lines.push(rule('╰', '┴', '╯'));
441
+ return lines.join('\n');
442
+ }
443
+ /** Naive word-wrap that respects visible (ANSI-stripped) width. */
444
+ function wrapText(text, width) {
445
+ const words = text.split(' ');
446
+ const out = [];
447
+ let cur = '';
448
+ for (const w of words) {
449
+ const candidate = cur ? cur + ' ' + w : w;
450
+ if (visibleLen(candidate) > width && cur) {
451
+ out.push(cur);
452
+ cur = w;
453
+ }
454
+ else {
455
+ cur = candidate;
456
+ }
457
+ }
458
+ if (cur)
459
+ out.push(cur);
460
+ // A single "word" longer than the column (a URL, or any run of CJK, which
461
+ // has no spaces to break on at all) can't be wrapped by the loop above and
462
+ // would otherwise overflow the column and tear the frame. Hard-break any
463
+ // such line at the column width.
464
+ const fitted = [];
465
+ for (const line of out) {
466
+ if (visibleLen(line) <= width) {
467
+ fitted.push(line);
468
+ continue;
469
+ }
470
+ let chunk = '';
471
+ for (const g of graphemesOf(line)) {
472
+ if (visibleLen(chunk + g) > width) {
473
+ fitted.push(chunk);
474
+ chunk = '';
475
+ }
476
+ chunk += g;
477
+ }
478
+ if (chunk)
479
+ fitted.push(chunk);
480
+ }
481
+ return fitted;
482
+ }