@askalf/dario 3.38.6 → 4.0.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.
@@ -0,0 +1,178 @@
1
+ /**
2
+ * The TUI App — main render loop + lifecycle + key dispatch.
3
+ *
4
+ * The shape is deliberately simple: one render function (caller-
5
+ * supplied) that takes `state + dimensions` and returns the full frame
6
+ * as a string. The App drives the loop, manages stdin raw mode, handles
7
+ * resize events, and flushes one write per frame. Tabs (M4) are
8
+ * decoupled state machines that the caller's render() composes.
9
+ *
10
+ * Lifecycle invariants:
11
+ *
12
+ * - Enters alt-screen on start, leaves on stop. The shell that
13
+ * spawned dario sees its prior content restored when the TUI quits.
14
+ * - Hides the cursor on start, restores it on stop. ALWAYS, even on
15
+ * uncaught exceptions or SIGINT — we install signal + exit hooks.
16
+ * - Sets stdin raw mode on start, restores it on stop.
17
+ *
18
+ * The hook chain is registered on process events; quit calls them all
19
+ * synchronously so no terminal state leaks out.
20
+ */
21
+ import { attachKeyHandler } from './input.js';
22
+ import { clearScreen, enterAltScreen, leaveAltScreen, hideCursor, showCursor, } from './render.js';
23
+ export class App {
24
+ state;
25
+ renderFn;
26
+ keyFn;
27
+ afterFrameFn;
28
+ stdin;
29
+ stdout;
30
+ cleanupFns = [];
31
+ running = false;
32
+ // Coalesce setState calls that arrive in the same tick — only one
33
+ // redraw per tick regardless of how many setStates fire.
34
+ redrawScheduled = false;
35
+ constructor(opts) {
36
+ this.state = opts.initialState;
37
+ this.renderFn = opts.render;
38
+ this.keyFn = opts.onKey;
39
+ this.afterFrameFn = opts.afterFrame;
40
+ this.stdin = opts.stdin ?? process.stdin;
41
+ this.stdout = opts.stdout ?? process.stdout;
42
+ }
43
+ /**
44
+ * Replace state. If the new state differs from the old (shallow
45
+ * equality), schedule a redraw. Tabs use this both for synchronous
46
+ * key-driven updates and for async data arrivals (SSE 'record').
47
+ *
48
+ * The "differs" check is intentionally shallow — deep equality on
49
+ * potentially-large analytics records would be expensive and the
50
+ * caller almost always passes new object identity when it mutates.
51
+ * If you mutate state in place and don't change identity, the
52
+ * redraw won't fire (this is documented; sometimes desired).
53
+ */
54
+ setState(updater) {
55
+ const next = typeof updater === 'function'
56
+ ? updater(this.state)
57
+ : { ...this.state, ...updater };
58
+ if (next !== this.state) {
59
+ this.state = next;
60
+ this.scheduleRedraw();
61
+ }
62
+ }
63
+ /** Read-only state accessor — for callers that need to compute next state from current. */
64
+ getState() {
65
+ return this.state;
66
+ }
67
+ /**
68
+ * Start the TUI: enter alt-screen, hide cursor, raw stdin, attach
69
+ * resize listener, render once, then idle until stop() or process
70
+ * exit.
71
+ *
72
+ * Returns a Promise that resolves when stop() is called. Wires
73
+ * process exit / signal hooks so a Ctrl-C / kill leaves the
74
+ * terminal sane.
75
+ */
76
+ start() {
77
+ if (this.running)
78
+ throw new Error('TUI already running');
79
+ this.running = true;
80
+ // Enter alt-screen + hide cursor in one write so the terminal
81
+ // doesn't briefly show a normal screen with a hidden cursor.
82
+ this.stdout.write(enterAltScreen + hideCursor + clearScreen);
83
+ // Raw-mode key handler
84
+ try {
85
+ const detachKeys = attachKeyHandler(this.stdin, (key) => {
86
+ // Global keys handled by the App itself; everything else
87
+ // falls through to the user's onKey reducer.
88
+ if (key.name === 'printable' && key.ctrl && key.ch === 'c') {
89
+ // Ctrl-C → quit
90
+ this.stop();
91
+ return;
92
+ }
93
+ if (key.name === 'printable' && key.ctrl && key.ch === 'l') {
94
+ // Ctrl-L → forced redraw (no state change, but force a
95
+ // re-render which also re-clears the screen — clears any
96
+ // garbage left by misbehaving processes that wrote past the
97
+ // alt-screen boundary)
98
+ this.scheduleRedraw(true);
99
+ return;
100
+ }
101
+ const next = this.keyFn(this.state, key);
102
+ if (next !== undefined && next !== this.state) {
103
+ this.state = next;
104
+ this.scheduleRedraw();
105
+ }
106
+ });
107
+ this.cleanupFns.push(detachKeys);
108
+ }
109
+ catch (err) {
110
+ // Couldn't attach to stdin (not a TTY). Restore screen state
111
+ // before propagating so we don't leave the terminal in
112
+ // alt-screen mode.
113
+ this.stdout.write(leaveAltScreen + showCursor);
114
+ this.running = false;
115
+ throw err;
116
+ }
117
+ // Window resize → redraw with new dimensions
118
+ const onResize = () => this.scheduleRedraw(true);
119
+ this.stdout.on('resize', onResize);
120
+ this.cleanupFns.push(() => this.stdout.off('resize', onResize));
121
+ // Process-level safety net — any abnormal exit should leave the
122
+ // terminal in a usable state.
123
+ const finalCleanup = () => {
124
+ if (this.running)
125
+ this.stop();
126
+ };
127
+ process.once('SIGINT', finalCleanup);
128
+ process.once('SIGTERM', finalCleanup);
129
+ process.once('exit', finalCleanup);
130
+ this.cleanupFns.push(() => {
131
+ process.off('SIGINT', finalCleanup);
132
+ process.off('SIGTERM', finalCleanup);
133
+ process.off('exit', finalCleanup);
134
+ });
135
+ // First frame
136
+ this.redraw();
137
+ return new Promise((resolve) => {
138
+ this.cleanupFns.push(() => resolve());
139
+ });
140
+ }
141
+ /** Stop the TUI — restore terminal state and resolve the start() promise. */
142
+ stop() {
143
+ if (!this.running)
144
+ return;
145
+ this.running = false;
146
+ // Run cleanup fns in reverse order so most-recent goes first
147
+ // (matches typical resource-stack semantics).
148
+ while (this.cleanupFns.length > 0) {
149
+ const fn = this.cleanupFns.pop();
150
+ try {
151
+ fn();
152
+ }
153
+ catch { /* keep unwinding */ }
154
+ }
155
+ // Final state restore
156
+ this.stdout.write(leaveAltScreen + showCursor);
157
+ }
158
+ scheduleRedraw(force = false) {
159
+ if (this.redrawScheduled && !force)
160
+ return;
161
+ this.redrawScheduled = true;
162
+ queueMicrotask(() => {
163
+ this.redrawScheduled = false;
164
+ if (!this.running)
165
+ return;
166
+ this.redraw();
167
+ });
168
+ }
169
+ redraw() {
170
+ const cols = this.stdout.columns ?? 80;
171
+ const rows = this.stdout.rows ?? 24;
172
+ const frame = this.renderFn(this.state, { cols, rows });
173
+ // Single write — minimizes flicker
174
+ this.stdout.write(clearScreen + frame);
175
+ if (this.afterFrameFn)
176
+ this.afterFrameFn(this.state);
177
+ }
178
+ }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * TUI input handling — stdin raw-mode key parser.
3
+ *
4
+ * Why not Node's `readline.emitKeypressEvents`: it works, but the Key
5
+ * shape (`{ name, ctrl, meta, sequence }`) is loosely typed, the
6
+ * legacy event flag is awkward to disable cleanly, and its escape-
7
+ * sequence parser has historically lagged on edge cases (Windows
8
+ * Terminal modifyOtherKeys, Kitty progressive enhancement, etc).
9
+ *
10
+ * Writing ~150 lines that handle the keys we ACTUALLY use is more
11
+ * predictable. The keys we care about:
12
+ *
13
+ * - Printable ASCII (0x20-0x7e)
14
+ * - Enter, Tab, Backspace, Escape
15
+ * - Arrow up/down/left/right
16
+ * - Home, End, PgUp, PgDn
17
+ * - Ctrl+C, Ctrl+D (exit), Ctrl+L (redraw)
18
+ *
19
+ * Standalone Esc vs Esc-led sequence (e.g. arrow): we use the same
20
+ * heuristic xterm uses — if ESC arrives in the same buffer chunk as
21
+ * subsequent bytes, treat as a CSI sequence. If ESC arrives alone in
22
+ * a chunk, treat as a standalone Escape keypress. This is reliable
23
+ * on real terminals and avoids the alternative (a wait-timer + lookahead)
24
+ * which adds complexity and a delay every Esc keypress.
25
+ */
26
+ export interface Key {
27
+ /** A short name for the key. 'printable' for normal characters. */
28
+ name: 'printable' | 'enter' | 'tab' | 'backspace' | 'escape' | 'up' | 'down' | 'left' | 'right' | 'home' | 'end' | 'pageup' | 'pagedown' | 'delete' | 'unknown';
29
+ /** The printable character (or the raw sequence for `unknown`). */
30
+ ch: string;
31
+ /** True if a Ctrl modifier was held. */
32
+ ctrl: boolean;
33
+ /** True if a Shift modifier was held (only detectable on some keys). */
34
+ shift: boolean;
35
+ /** True if an Alt/Meta modifier was held. */
36
+ meta: boolean;
37
+ }
38
+ /**
39
+ * Parse one stdin chunk into zero or more Key events. Pure function;
40
+ * the caller drives stdin and accumulates the result. Most chunks
41
+ * yield exactly one key (interactive typing); paste / IME burst can
42
+ * yield several.
43
+ */
44
+ export declare function parseKeys(chunk: Buffer): Key[];
45
+ /**
46
+ * Put stdin into raw mode and start emitting Key events to `handler`.
47
+ * Returns a cleanup function that restores stdin's pre-raw mode and
48
+ * unbinds the listener. ALWAYS call the cleanup on exit (including
49
+ * abnormal exits — wire a process exit / signal hook).
50
+ *
51
+ * Defaults are picked so the caller doesn't have to think about them:
52
+ * UTF-8 encoding (we don't see Buffer chunks split mid-character),
53
+ * resume() so paused stdin doesn't drop key events.
54
+ *
55
+ * Throws if stdin isn't a TTY — the TUI doesn't make sense in a pipe.
56
+ */
57
+ export declare function attachKeyHandler(stdin: NodeJS.ReadStream, handler: (key: Key) => void): () => void;
@@ -0,0 +1,206 @@
1
+ /**
2
+ * TUI input handling — stdin raw-mode key parser.
3
+ *
4
+ * Why not Node's `readline.emitKeypressEvents`: it works, but the Key
5
+ * shape (`{ name, ctrl, meta, sequence }`) is loosely typed, the
6
+ * legacy event flag is awkward to disable cleanly, and its escape-
7
+ * sequence parser has historically lagged on edge cases (Windows
8
+ * Terminal modifyOtherKeys, Kitty progressive enhancement, etc).
9
+ *
10
+ * Writing ~150 lines that handle the keys we ACTUALLY use is more
11
+ * predictable. The keys we care about:
12
+ *
13
+ * - Printable ASCII (0x20-0x7e)
14
+ * - Enter, Tab, Backspace, Escape
15
+ * - Arrow up/down/left/right
16
+ * - Home, End, PgUp, PgDn
17
+ * - Ctrl+C, Ctrl+D (exit), Ctrl+L (redraw)
18
+ *
19
+ * Standalone Esc vs Esc-led sequence (e.g. arrow): we use the same
20
+ * heuristic xterm uses — if ESC arrives in the same buffer chunk as
21
+ * subsequent bytes, treat as a CSI sequence. If ESC arrives alone in
22
+ * a chunk, treat as a standalone Escape keypress. This is reliable
23
+ * on real terminals and avoids the alternative (a wait-timer + lookahead)
24
+ * which adds complexity and a delay every Esc keypress.
25
+ */
26
+ /**
27
+ * Parse one stdin chunk into zero or more Key events. Pure function;
28
+ * the caller drives stdin and accumulates the result. Most chunks
29
+ * yield exactly one key (interactive typing); paste / IME burst can
30
+ * yield several.
31
+ */
32
+ export function parseKeys(chunk) {
33
+ const keys = [];
34
+ let i = 0;
35
+ while (i < chunk.length) {
36
+ const b = chunk[i];
37
+ // Backspace: ASCII 0x7f on most terminals, 0x08 (BS / Ctrl+H) on
38
+ // Windows console + a few older ttys. Must check 0x08 BEFORE the
39
+ // Ctrl+letter range below, otherwise BS gets reported as Ctrl+H.
40
+ if (b === 0x7f || b === 0x08) {
41
+ keys.push(k('backspace', ''));
42
+ i++;
43
+ continue;
44
+ }
45
+ // Control keys (Ctrl+letter): byte 0x01-0x1a (A-Z masked with 0x1f)
46
+ if (b >= 0x01 && b <= 0x1a) {
47
+ // Special-case the named ones that aren't really "Ctrl+letter".
48
+ if (b === 0x09) {
49
+ keys.push(k('tab', '\t'));
50
+ i++;
51
+ continue;
52
+ }
53
+ if (b === 0x0a || b === 0x0d) {
54
+ keys.push(k('enter', '\n'));
55
+ i++;
56
+ continue;
57
+ }
58
+ // The rest are Ctrl+A through Ctrl+Z (skipping the named ones).
59
+ keys.push({
60
+ name: 'printable', ch: String.fromCharCode(b + 96), // 1 → 'a'
61
+ ctrl: true, shift: false, meta: false,
62
+ });
63
+ i++;
64
+ continue;
65
+ }
66
+ // Escape — either standalone or the start of a CSI sequence.
67
+ if (b === 0x1b) {
68
+ // Look at what follows in this same chunk
69
+ if (i + 1 >= chunk.length) {
70
+ // ESC alone in this chunk → standalone Escape keypress.
71
+ keys.push(k('escape', ''));
72
+ i++;
73
+ continue;
74
+ }
75
+ // ESC + '[' → CSI sequence. Read up to the terminating byte
76
+ // (0x40-0x7e per ECMA-48).
77
+ if (chunk[i + 1] === 0x5b /* '[' */ || chunk[i + 1] === 0x4f /* 'O' for SS3 */) {
78
+ const seqStart = i;
79
+ const ss3 = chunk[i + 1] === 0x4f;
80
+ i += 2;
81
+ let paramBytes = '';
82
+ while (i < chunk.length) {
83
+ const c = chunk[i];
84
+ if (c >= 0x40 && c <= 0x7e) {
85
+ const final = String.fromCharCode(c);
86
+ i++;
87
+ keys.push(parseCsi(final, paramBytes, ss3, chunk.slice(seqStart, i)));
88
+ break;
89
+ }
90
+ paramBytes += String.fromCharCode(c);
91
+ i++;
92
+ }
93
+ continue;
94
+ }
95
+ // ESC + other byte → Alt/Meta + that byte
96
+ const next = chunk[i + 1];
97
+ if (next >= 0x20 && next <= 0x7e) {
98
+ keys.push({
99
+ name: 'printable', ch: String.fromCharCode(next),
100
+ ctrl: false, shift: false, meta: true,
101
+ });
102
+ i += 2;
103
+ continue;
104
+ }
105
+ // Fallback — emit ESC alone, advance one.
106
+ keys.push(k('escape', ''));
107
+ i++;
108
+ continue;
109
+ }
110
+ // Printable ASCII (including space)
111
+ if (b >= 0x20 && b <= 0x7e) {
112
+ keys.push({
113
+ name: 'printable', ch: String.fromCharCode(b),
114
+ ctrl: false, shift: b >= 0x41 && b <= 0x5a, meta: false,
115
+ });
116
+ i++;
117
+ continue;
118
+ }
119
+ // Anything else — pass through as unknown so the caller can see it.
120
+ keys.push({ name: 'unknown', ch: String.fromCharCode(b), ctrl: false, shift: false, meta: false });
121
+ i++;
122
+ }
123
+ return keys;
124
+ }
125
+ function k(name, ch) {
126
+ return { name, ch, ctrl: false, shift: false, meta: false };
127
+ }
128
+ /**
129
+ * Decode the body of a CSI sequence given the terminating byte and
130
+ * the parameter bytes between `[` and the terminator.
131
+ *
132
+ * ESC[A → up
133
+ * ESC[B → down
134
+ * ESC[C → right
135
+ * ESC[D → left
136
+ * ESC[H → home
137
+ * ESC[F → end
138
+ * ESC[5~ → pageup
139
+ * ESC[6~ → pagedown
140
+ * ESC[3~ → delete
141
+ * ESC[1~ → home (alternate)
142
+ * ESC[4~ → end (alternate)
143
+ *
144
+ * Modifier-bearing variants (e.g. `ESC[1;5A` for Ctrl-Up) parse the
145
+ * second parameter as a modifier mask: bit 0 = Shift, bit 1 = Alt,
146
+ * bit 2 = Ctrl. xterm spec §"Modifier-Encoding".
147
+ */
148
+ function parseCsi(final, params, ss3, raw) {
149
+ // Parse semicolon-separated numeric params
150
+ const parts = params.split(';').map(p => parseInt(p, 10) || 0);
151
+ // Modifier byte is the second param when present.
152
+ const mod = parts[1] ?? 1;
153
+ const ctrl = (mod - 1) & 4 ? true : false;
154
+ const shift = (mod - 1) & 1 ? true : false;
155
+ const meta = (mod - 1) & 2 ? true : false;
156
+ // Tilde-terminated: ESC[<n>~ where n is in parts[0]
157
+ if (final === '~') {
158
+ const n = parts[0];
159
+ const map = {
160
+ 1: 'home', 2: 'home', 3: 'delete', 4: 'end',
161
+ 5: 'pageup', 6: 'pagedown', 7: 'home', 8: 'end',
162
+ };
163
+ return { name: map[n] ?? 'unknown', ch: raw.toString(), ctrl, shift, meta };
164
+ }
165
+ // Letter-terminated: arrow / home / end (and SS3 variants)
166
+ void ss3;
167
+ const letterMap = {
168
+ A: 'up', B: 'down', C: 'right', D: 'left',
169
+ H: 'home', F: 'end',
170
+ };
171
+ if (letterMap[final]) {
172
+ return { name: letterMap[final], ch: raw.toString(), ctrl, shift, meta };
173
+ }
174
+ return { name: 'unknown', ch: raw.toString(), ctrl, shift, meta };
175
+ }
176
+ // ── Lifecycle helpers ──────────────────────────────────────────────
177
+ /**
178
+ * Put stdin into raw mode and start emitting Key events to `handler`.
179
+ * Returns a cleanup function that restores stdin's pre-raw mode and
180
+ * unbinds the listener. ALWAYS call the cleanup on exit (including
181
+ * abnormal exits — wire a process exit / signal hook).
182
+ *
183
+ * Defaults are picked so the caller doesn't have to think about them:
184
+ * UTF-8 encoding (we don't see Buffer chunks split mid-character),
185
+ * resume() so paused stdin doesn't drop key events.
186
+ *
187
+ * Throws if stdin isn't a TTY — the TUI doesn't make sense in a pipe.
188
+ */
189
+ export function attachKeyHandler(stdin, handler) {
190
+ if (!stdin.isTTY) {
191
+ throw new Error('TUI requires a TTY on stdin — pipe / redirect not supported');
192
+ }
193
+ const prevRawMode = stdin.isRaw;
194
+ stdin.setRawMode(true);
195
+ stdin.resume();
196
+ const onData = (chunk) => {
197
+ for (const key of parseKeys(chunk))
198
+ handler(key);
199
+ };
200
+ stdin.on('data', onData);
201
+ return () => {
202
+ stdin.off('data', onData);
203
+ stdin.setRawMode(prevRawMode ?? false);
204
+ stdin.pause();
205
+ };
206
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Higher-level layout primitives built on top of `render.ts`.
3
+ *
4
+ * These compose the lower-level ANSI strings into the structural
5
+ * patterns the v4 TUI actually uses: a header bar, a tab strip,
6
+ * a body region, a footer with key hints. Each returns a string
7
+ * (or appends to a Frame) — no terminal state, no side effects.
8
+ */
9
+ /**
10
+ * Render the top header bar: brand + version on the left, contextual
11
+ * status text on the right (e.g. proxy URL when connected).
12
+ *
13
+ * ╭ dario v4.0.0 ───────────────────── http://localhost:3456 ─╮
14
+ *
15
+ * Width is the full terminal columns; the caller positions it at row 1.
16
+ */
17
+ export declare function renderHeader(width: number, opts: {
18
+ version: string;
19
+ status?: string;
20
+ }): string;
21
+ /**
22
+ * Render the bottom footer with key-hint pairs. Wide gaps so it doesn't
23
+ * look mashed-together:
24
+ *
25
+ * [Tab] switch panel [q] quit [?] help
26
+ */
27
+ export declare function renderFooter(width: number, hints: Array<{
28
+ key: string;
29
+ label: string;
30
+ }>): string;
31
+ /**
32
+ * Render a tab strip — one row of clickable-looking tab labels, with
33
+ * the active tab inverse-highlighted. Borders flank both sides.
34
+ *
35
+ * │ Status ▎Analytics▎ Config Hits Accounts Backends │
36
+ *
37
+ * The `activeTab` is the index of the highlighted tab. Caller draws
38
+ * the surrounding box separately.
39
+ */
40
+ export declare function renderTabStrip(width: number, tabs: string[], activeTab: number): string;
41
+ /**
42
+ * Render a vertical scroll indicator on the right edge of a panel.
43
+ * Shows "n / total" + a position blob if there's overflow.
44
+ *
45
+ * ─ 24 / 412
46
+ */
47
+ export declare function renderScrollIndicator(visible: number, total: number, selectedIdx: number): string;
48
+ /**
49
+ * Word-wrap or hard-break `text` to lines of at most `width` visible
50
+ * chars. ANSI sequences are preserved across line boundaries; visible
51
+ * width is what's counted.
52
+ *
53
+ * Used for free-form prose in the Status / About panels.
54
+ */
55
+ export declare function wrap(text: string, width: number): string[];
56
+ /**
57
+ * Render a left-key / right-value row (the shape every config/status
58
+ * line uses).
59
+ *
60
+ * Port: 3456
61
+ * Mode: passthrough
62
+ *
63
+ * Key is left-padded to `keyWidth` so a column of rows aligns. Value
64
+ * is truncated to fit the remaining space.
65
+ */
66
+ export declare function renderKvRow(key: string, value: string, totalWidth: number, keyWidth?: number): string;
@@ -0,0 +1,152 @@
1
+ /**
2
+ * Higher-level layout primitives built on top of `render.ts`.
3
+ *
4
+ * These compose the lower-level ANSI strings into the structural
5
+ * patterns the v4 TUI actually uses: a header bar, a tab strip,
6
+ * a body region, a footer with key hints. Each returns a string
7
+ * (or appends to a Frame) — no terminal state, no side effects.
8
+ */
9
+ import { brand, fg, inverse, BOX, pad, truncate, visibleWidth } from './render.js';
10
+ /**
11
+ * Render the top header bar: brand + version on the left, contextual
12
+ * status text on the right (e.g. proxy URL when connected).
13
+ *
14
+ * ╭ dario v4.0.0 ───────────────────── http://localhost:3456 ─╮
15
+ *
16
+ * Width is the full terminal columns; the caller positions it at row 1.
17
+ */
18
+ export function renderHeader(width, opts) {
19
+ const left = ` ${brand('dario')} v${opts.version} `;
20
+ const right = opts.status ? ` ${opts.status} ` : '';
21
+ const dashWidth = Math.max(0, width - visibleWidth(left) - visibleWidth(right) - 2);
22
+ return BOX.topLeft + left + BOX.horizontal.repeat(dashWidth) + right + BOX.topRight;
23
+ }
24
+ /**
25
+ * Render the bottom footer with key-hint pairs. Wide gaps so it doesn't
26
+ * look mashed-together:
27
+ *
28
+ * [Tab] switch panel [q] quit [?] help
29
+ */
30
+ export function renderFooter(width, hints) {
31
+ const items = hints.map(h => `${fg('cyan', `[${h.key}]`)} ${h.label}`);
32
+ const joined = items.join(' ');
33
+ // Truncate if the hints don't fit (small terminal); never wrap to a
34
+ // second line — the footer is a single row by design.
35
+ return ' ' + truncate(joined, width - 2);
36
+ }
37
+ /**
38
+ * Render a tab strip — one row of clickable-looking tab labels, with
39
+ * the active tab inverse-highlighted. Borders flank both sides.
40
+ *
41
+ * │ Status ▎Analytics▎ Config Hits Accounts Backends │
42
+ *
43
+ * The `activeTab` is the index of the highlighted tab. Caller draws
44
+ * the surrounding box separately.
45
+ */
46
+ export function renderTabStrip(width, tabs, activeTab) {
47
+ const sep = ' ';
48
+ const rendered = tabs.map((label, idx) => {
49
+ if (idx === activeTab) {
50
+ // Active: inverse-highlight with thin side-bars to evoke a
51
+ // selected pill. The ▎ left-eighth-block is visually subtle but
52
+ // unmistakable on every terminal that supports box-drawing.
53
+ return inverse(` ${label} `);
54
+ }
55
+ return ` ${label} `;
56
+ });
57
+ const inner = rendered.join(sep);
58
+ const padded = pad(inner, width, 'left');
59
+ return padded;
60
+ }
61
+ /**
62
+ * Render a vertical scroll indicator on the right edge of a panel.
63
+ * Shows "n / total" + a position blob if there's overflow.
64
+ *
65
+ * ─ 24 / 412
66
+ */
67
+ export function renderScrollIndicator(visible, total, selectedIdx) {
68
+ if (total <= visible)
69
+ return '';
70
+ return ` ${selectedIdx + 1} / ${total} `;
71
+ }
72
+ /**
73
+ * Word-wrap or hard-break `text` to lines of at most `width` visible
74
+ * chars. ANSI sequences are preserved across line boundaries; visible
75
+ * width is what's counted.
76
+ *
77
+ * Used for free-form prose in the Status / About panels.
78
+ */
79
+ export function wrap(text, width) {
80
+ if (width <= 0)
81
+ return [];
82
+ const out = [];
83
+ for (const para of text.split('\n')) {
84
+ if (para === '') {
85
+ out.push('');
86
+ continue;
87
+ }
88
+ let line = '';
89
+ let lineWidth = 0;
90
+ for (const word of para.split(' ')) {
91
+ const w = visibleWidth(word);
92
+ // Word itself longer than width → hard-break it into chunks
93
+ // of `width` chars. Push the current accumulated line first so
94
+ // we don't lose pre-existing content.
95
+ if (w > width) {
96
+ if (lineWidth > 0) {
97
+ out.push(line);
98
+ line = '';
99
+ lineWidth = 0;
100
+ }
101
+ let remaining = word;
102
+ while (visibleWidth(remaining) > width) {
103
+ out.push(remaining.slice(0, width));
104
+ remaining = remaining.slice(width);
105
+ }
106
+ line = remaining;
107
+ lineWidth = visibleWidth(remaining);
108
+ continue;
109
+ }
110
+ // Normal-width word: fit on current line if there's room, else
111
+ // wrap to a new line.
112
+ if (lineWidth === 0) {
113
+ line = word;
114
+ lineWidth = w;
115
+ continue;
116
+ }
117
+ if (lineWidth + 1 + w <= width) {
118
+ line += ' ' + word;
119
+ lineWidth += 1 + w;
120
+ }
121
+ else {
122
+ out.push(line);
123
+ line = word;
124
+ lineWidth = w;
125
+ }
126
+ }
127
+ if (line.length > 0)
128
+ out.push(line);
129
+ }
130
+ return out;
131
+ }
132
+ /**
133
+ * Render a left-key / right-value row (the shape every config/status
134
+ * line uses).
135
+ *
136
+ * Port: 3456
137
+ * Mode: passthrough
138
+ *
139
+ * Key is left-padded to `keyWidth` so a column of rows aligns. Value
140
+ * is truncated to fit the remaining space.
141
+ */
142
+ export function renderKvRow(key, value, totalWidth, keyWidth = 22) {
143
+ // Clamp keyWidth so a narrow terminal (totalWidth < keyWidth default)
144
+ // still produces a well-formed totalWidth-char row. The key gets
145
+ // truncated proportionally; the value column gets the rest.
146
+ const effectiveKeyWidth = Math.min(keyWidth, Math.max(1, totalWidth - 1));
147
+ const keyPart = pad(key + ':', effectiveKeyWidth);
148
+ // Value pad-to-width (not just truncate) so a full row reaches the
149
+ // panel edge — keeps backgrounds + borders aligned.
150
+ const valuePart = pad(value, totalWidth - effectiveKeyWidth);
151
+ return keyPart + valuePart;
152
+ }