@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,60 @@
1
+ /**
2
+ * Proxy HTTP client for the TUI.
3
+ *
4
+ * The TUI runs as its own process (`dario` with no args). It talks to
5
+ * a separately-running `dario proxy` over HTTP on localhost. This
6
+ * module is the thin client layer: JSON fetch + Server-Sent Events
7
+ * subscription + a `/health` reachability probe.
8
+ *
9
+ * Why not use fetch(): Node 22's global fetch works fine for one-shot
10
+ * JSON, but SSE streaming through it is awkward (the response.body
11
+ * is a WHATWG ReadableStream that needs an extra Node-stream adapter
12
+ * to handle line splitting cleanly across chunks). Using `node:http`
13
+ * directly keeps the SSE flow simple and matches the streaming hot-
14
+ * path the proxy itself uses.
15
+ *
16
+ * Zero deps as ever.
17
+ */
18
+ export interface ProxyClientOpts {
19
+ /** Base URL of the running proxy, e.g. `http://127.0.0.1:3456`. */
20
+ baseUrl: string;
21
+ /** Optional API key — sent as `x-api-key` header. */
22
+ apiKey?: string;
23
+ /** Timeout for one-shot requests (ms). SSE subscriptions ignore this. */
24
+ timeoutMs?: number;
25
+ }
26
+ export declare class ProxyClient {
27
+ private baseUrl;
28
+ private apiKey?;
29
+ private timeoutMs;
30
+ constructor(opts: ProxyClientOpts);
31
+ /**
32
+ * GET a JSON endpoint. Rejects on non-2xx, network failure, JSON
33
+ * parse error, or timeout.
34
+ */
35
+ getJson<T = unknown>(path: string): Promise<T>;
36
+ /**
37
+ * Reachability probe — GET /health, returns parsed payload on
38
+ * success or null on any failure. Never throws; callers use the
39
+ * null as "proxy not running / unreachable".
40
+ */
41
+ health(): Promise<HealthResponse | null>;
42
+ /**
43
+ * Subscribe to /analytics/stream SSE. Calls `onMessage` for each
44
+ * data frame (parsed as JSON). Returns a `close()` function that
45
+ * unsubscribes — the caller MUST call this on unmount or the
46
+ * underlying socket leaks.
47
+ *
48
+ * Auto-reconnect is intentionally NOT included. The Hits tab decides
49
+ * when to retry (and how often) — pushing that policy into here would
50
+ * couple the client to UI semantics.
51
+ */
52
+ subscribeAnalyticsStream<T = unknown>(onMessage: (msg: T) => void, onError?: (err: Error) => void): () => void;
53
+ private headers;
54
+ }
55
+ export interface HealthResponse {
56
+ status: string;
57
+ oauth: string;
58
+ expiresIn?: string;
59
+ requests?: number;
60
+ }
@@ -0,0 +1,166 @@
1
+ /**
2
+ * Proxy HTTP client for the TUI.
3
+ *
4
+ * The TUI runs as its own process (`dario` with no args). It talks to
5
+ * a separately-running `dario proxy` over HTTP on localhost. This
6
+ * module is the thin client layer: JSON fetch + Server-Sent Events
7
+ * subscription + a `/health` reachability probe.
8
+ *
9
+ * Why not use fetch(): Node 22's global fetch works fine for one-shot
10
+ * JSON, but SSE streaming through it is awkward (the response.body
11
+ * is a WHATWG ReadableStream that needs an extra Node-stream adapter
12
+ * to handle line splitting cleanly across chunks). Using `node:http`
13
+ * directly keeps the SSE flow simple and matches the streaming hot-
14
+ * path the proxy itself uses.
15
+ *
16
+ * Zero deps as ever.
17
+ */
18
+ import { request as httpRequest } from 'node:http';
19
+ import { URL } from 'node:url';
20
+ export class ProxyClient {
21
+ baseUrl;
22
+ apiKey;
23
+ timeoutMs;
24
+ constructor(opts) {
25
+ this.baseUrl = opts.baseUrl.replace(/\/$/, '');
26
+ this.apiKey = opts.apiKey;
27
+ this.timeoutMs = opts.timeoutMs ?? 3000;
28
+ }
29
+ /**
30
+ * GET a JSON endpoint. Rejects on non-2xx, network failure, JSON
31
+ * parse error, or timeout.
32
+ */
33
+ async getJson(path) {
34
+ const url = new URL(this.baseUrl + path);
35
+ return new Promise((resolve, reject) => {
36
+ const req = httpRequest({
37
+ hostname: url.hostname,
38
+ port: url.port || 80,
39
+ path: url.pathname + url.search,
40
+ method: 'GET',
41
+ headers: this.headers(),
42
+ }, (res) => {
43
+ const chunks = [];
44
+ res.on('data', (c) => chunks.push(c));
45
+ res.on('end', () => {
46
+ const body = Buffer.concat(chunks).toString('utf-8');
47
+ if (!res.statusCode || res.statusCode < 200 || res.statusCode >= 300) {
48
+ reject(new Error(`HTTP ${res.statusCode}: ${body.slice(0, 200)}`));
49
+ return;
50
+ }
51
+ try {
52
+ resolve(JSON.parse(body));
53
+ }
54
+ catch (e) {
55
+ reject(new Error(`JSON parse: ${e.message}`));
56
+ }
57
+ });
58
+ res.on('error', reject);
59
+ });
60
+ req.on('error', reject);
61
+ req.setTimeout(this.timeoutMs, () => {
62
+ req.destroy(new Error(`timeout after ${this.timeoutMs}ms`));
63
+ });
64
+ req.end();
65
+ });
66
+ }
67
+ /**
68
+ * Reachability probe — GET /health, returns parsed payload on
69
+ * success or null on any failure. Never throws; callers use the
70
+ * null as "proxy not running / unreachable".
71
+ */
72
+ async health() {
73
+ try {
74
+ return await this.getJson('/health');
75
+ }
76
+ catch {
77
+ return null;
78
+ }
79
+ }
80
+ /**
81
+ * Subscribe to /analytics/stream SSE. Calls `onMessage` for each
82
+ * data frame (parsed as JSON). Returns a `close()` function that
83
+ * unsubscribes — the caller MUST call this on unmount or the
84
+ * underlying socket leaks.
85
+ *
86
+ * Auto-reconnect is intentionally NOT included. The Hits tab decides
87
+ * when to retry (and how often) — pushing that policy into here would
88
+ * couple the client to UI semantics.
89
+ */
90
+ subscribeAnalyticsStream(onMessage, onError) {
91
+ const url = new URL(this.baseUrl + '/analytics/stream');
92
+ let closed = false;
93
+ let res = null;
94
+ const req = httpRequest({
95
+ hostname: url.hostname,
96
+ port: url.port || 80,
97
+ path: url.pathname + url.search,
98
+ method: 'GET',
99
+ headers: { ...this.headers(), 'Accept': 'text/event-stream' },
100
+ }, (response) => {
101
+ if (closed) {
102
+ response.destroy();
103
+ return;
104
+ }
105
+ res = response;
106
+ if (!response.statusCode || response.statusCode < 200 || response.statusCode >= 300) {
107
+ onError?.(new Error(`HTTP ${response.statusCode}`));
108
+ response.destroy();
109
+ return;
110
+ }
111
+ response.setEncoding('utf-8');
112
+ let buf = '';
113
+ response.on('data', (chunk) => {
114
+ if (closed)
115
+ return;
116
+ buf += chunk;
117
+ // SSE frames are separated by a blank line. Each frame can
118
+ // have a `data:` field (possibly across multiple `data:` lines
119
+ // — we concatenate them with \n per the SSE spec).
120
+ let idx;
121
+ while ((idx = buf.indexOf('\n\n')) >= 0) {
122
+ const frame = buf.slice(0, idx);
123
+ buf = buf.slice(idx + 2);
124
+ const dataLines = frame.split('\n')
125
+ .filter(l => l.startsWith('data:'))
126
+ .map(l => l.slice(5).replace(/^ /, ''));
127
+ if (dataLines.length === 0)
128
+ continue;
129
+ const payload = dataLines.join('\n');
130
+ try {
131
+ const parsed = JSON.parse(payload);
132
+ onMessage(parsed);
133
+ }
134
+ catch (e) {
135
+ onError?.(new Error(`SSE parse: ${e.message}`));
136
+ }
137
+ }
138
+ });
139
+ response.on('error', (e) => { if (!closed)
140
+ onError?.(e); });
141
+ response.on('end', () => { if (!closed)
142
+ onError?.(new Error('stream ended')); });
143
+ });
144
+ req.on('error', (e) => { if (!closed)
145
+ onError?.(e); });
146
+ // No timeout on SSE — the heartbeat keeps the connection alive.
147
+ req.end();
148
+ return () => {
149
+ closed = true;
150
+ try {
151
+ req.destroy();
152
+ }
153
+ catch { /* ignored */ }
154
+ try {
155
+ res?.destroy();
156
+ }
157
+ catch { /* ignored */ }
158
+ };
159
+ }
160
+ headers() {
161
+ const h = {};
162
+ if (this.apiKey)
163
+ h['x-api-key'] = this.apiKey;
164
+ return h;
165
+ }
166
+ }
@@ -0,0 +1,178 @@
1
+ /**
2
+ * TUI rendering primitives — pure ANSI escape sequence helpers.
3
+ *
4
+ * Every function in this module is a pure string-returning helper. No
5
+ * side effects, no console writes. The App's render() loop accumulates
6
+ * these strings into a single buffer, then flushes once to stdout. This
7
+ * keeps render testable (assert string equality against fixtures) and
8
+ * keeps flicker minimal (single write call per frame).
9
+ *
10
+ * Color set: a deliberate 16-color subset of the ANSI palette plus a
11
+ * handful of 256-color brand accents. Reasoning: 16-color is universal,
12
+ * the brand greens (#00ff88-ish) are used sparingly for the dario
13
+ * accent and degrade gracefully on terminals that can't render them.
14
+ *
15
+ * What this module deliberately does NOT do:
16
+ * - Track cursor position (callers pass row/col explicitly)
17
+ * - Diff frames (full-screen redraw per frame is fast enough at
18
+ * ~3000 cells; complexity not worth it)
19
+ * - Handle terminal capabilities probing (use ANSI + assume modern)
20
+ */
21
+ /**
22
+ * Move the cursor to (row, col). 1-indexed, matching the ANSI spec —
23
+ * row=1 is the top line, col=1 is the leftmost column.
24
+ */
25
+ export declare function moveTo(row: number, col: number): string;
26
+ /** Hide the blinking cursor (TUI doesn't need it). */
27
+ export declare const hideCursor = "\u001B[?25l";
28
+ /** Restore the cursor. ALWAYS run on exit to leave the terminal sane. */
29
+ export declare const showCursor = "\u001B[?25h";
30
+ /** Enter the alternate screen buffer — TUI lives here so quit restores prior shell content. */
31
+ export declare const enterAltScreen = "\u001B[?1049h";
32
+ /** Leave the alternate screen buffer. Pair with enterAltScreen on exit. */
33
+ export declare const leaveAltScreen = "\u001B[?1049l";
34
+ /** Clear the entire screen and move cursor to home. */
35
+ export declare const clearScreen = "\u001B[2J\u001B[H";
36
+ /** Clear from cursor to end of line. */
37
+ export declare const clearLineRight = "\u001B[K";
38
+ /** Reset all SGR (color + style) attributes. */
39
+ export declare const reset = "\u001B[0m";
40
+ /**
41
+ * Foreground color names mapped to ANSI codes. `default` resets to
42
+ * the terminal's default foreground.
43
+ */
44
+ export declare const FG: {
45
+ readonly default: 39;
46
+ readonly black: 30;
47
+ readonly red: 31;
48
+ readonly green: 32;
49
+ readonly yellow: 33;
50
+ readonly blue: 34;
51
+ readonly magenta: 35;
52
+ readonly cyan: 36;
53
+ readonly white: 37;
54
+ readonly brightBlack: 90;
55
+ readonly brightRed: 91;
56
+ readonly brightGreen: 92;
57
+ readonly brightYellow: 93;
58
+ readonly brightBlue: 94;
59
+ readonly brightMagenta: 95;
60
+ readonly brightCyan: 96;
61
+ readonly brightWhite: 97;
62
+ };
63
+ /** Background color names mapped to ANSI codes. */
64
+ export declare const BG: {
65
+ readonly default: 49;
66
+ readonly black: 40;
67
+ readonly red: 41;
68
+ readonly green: 42;
69
+ readonly yellow: 43;
70
+ readonly blue: 44;
71
+ readonly magenta: 45;
72
+ readonly cyan: 46;
73
+ readonly white: 47;
74
+ readonly brightBlack: 100;
75
+ readonly brightRed: 101;
76
+ readonly brightGreen: 102;
77
+ readonly brightYellow: 103;
78
+ readonly brightBlue: 104;
79
+ readonly brightMagenta: 105;
80
+ readonly brightCyan: 106;
81
+ readonly brightWhite: 107;
82
+ };
83
+ export type FgColor = keyof typeof FG;
84
+ export type BgColor = keyof typeof BG;
85
+ /**
86
+ * Wrap text in foreground-color SGR codes. Resets to default fg at
87
+ * the end so subsequent uncolored text isn't accidentally colored.
88
+ *
89
+ * Example: `fg('green', 'OK')` → `\x1b[32mOK\x1b[39m`
90
+ */
91
+ export declare function fg(color: FgColor, text: string): string;
92
+ export declare function bg(color: BgColor, text: string): string;
93
+ export declare function bold(text: string): string;
94
+ export declare function dim(text: string): string;
95
+ export declare function inverse(text: string): string;
96
+ export declare function underline(text: string): string;
97
+ /**
98
+ * Brand accent — the askalf green (#00ff88-ish) via the 256-color
99
+ * palette index 48. Falls back gracefully on terminals that don't
100
+ * render 256-color (they show as bright green).
101
+ */
102
+ export declare function brand(text: string): string;
103
+ /**
104
+ * Visible-width of a string. ANSI escape sequences and zero-width
105
+ * sequences contribute 0. Tabs count as 1 (terminals vary; better
106
+ * to under- than over-estimate). Multi-byte characters (CJK, emoji)
107
+ * are NOT special-cased in v4.0 — the TUI's content is ASCII-dominant
108
+ * (model names, numbers, labels). A future revision can add a
109
+ * `string-width`-style lookup if non-ASCII becomes common.
110
+ */
111
+ export declare function visibleWidth(s: string): number;
112
+ /**
113
+ * Truncate `text` to at most `maxWidth` visible chars, appending `…`
114
+ * if anything was clipped. ANSI sequences within the truncated portion
115
+ * are preserved verbatim; truncation only counts visible characters.
116
+ *
117
+ * Example: truncate('hello world', 8) → 'hello w…'
118
+ */
119
+ export declare function truncate(text: string, maxWidth: number, ellipsis?: string): string;
120
+ /** Pad `text` (right-aligned by default 'left' fills right side) to `width`. */
121
+ export declare function pad(text: string, width: number, align?: 'left' | 'right' | 'center'): string;
122
+ /**
123
+ * Render a horizontal progress bar.
124
+ *
125
+ * value: 0..1 (clamped)
126
+ * width: total cell count of the bar
127
+ *
128
+ * Uses the full-block ▓ / shade ░ characters; passes through as plain
129
+ * ASCII on terminals that don't render the box-drawing set (they look
130
+ * like `?` but the layout still works).
131
+ */
132
+ export declare function progressBar(value: number, width: number, opts?: {
133
+ filled?: string;
134
+ empty?: string;
135
+ }): string;
136
+ /**
137
+ * Box-drawing characters for borders. Set picked to render well on
138
+ * most terminals (Unicode box-drawing). Override via `customChars` if
139
+ * a terminal renders these badly — but the default targets the
140
+ * 99%-of-users case.
141
+ */
142
+ export declare const BOX: {
143
+ readonly topLeft: "┌";
144
+ readonly topRight: "┐";
145
+ readonly bottomLeft: "└";
146
+ readonly bottomRight: "┘";
147
+ readonly horizontal: "─";
148
+ readonly vertical: "│";
149
+ readonly cross: "┼";
150
+ readonly tLeft: "├";
151
+ readonly tRight: "┤";
152
+ readonly tTop: "┬";
153
+ readonly tBottom: "┴";
154
+ };
155
+ /**
156
+ * Render a box at (row, col) with the given width and height. Returns
157
+ * the full ANSI string (positioned writes + box characters). The
158
+ * interior is NOT cleared — callers paint inside the box separately.
159
+ */
160
+ export declare function drawBox(row: number, col: number, width: number, height: number): string;
161
+ /**
162
+ * Frame builder. The App's render() collects strings into one of these
163
+ * then calls `flush(stdout)` for a single write. Single-write rendering
164
+ * eliminates flicker on most terminals.
165
+ */
166
+ export declare class Frame {
167
+ private chunks;
168
+ /** Append a string. No positioning — caller is responsible. */
169
+ write(s: string): void;
170
+ /** Append a positioned write at (row, col). */
171
+ writeAt(row: number, col: number, s: string): void;
172
+ /** Return the accumulated frame as a single string. */
173
+ toString(): string;
174
+ /** Number of accumulated chunks (debugging aid). */
175
+ get length(): number;
176
+ /** Drop everything. Reuses the array allocation. */
177
+ clear(): void;
178
+ }
@@ -0,0 +1,246 @@
1
+ /**
2
+ * TUI rendering primitives — pure ANSI escape sequence helpers.
3
+ *
4
+ * Every function in this module is a pure string-returning helper. No
5
+ * side effects, no console writes. The App's render() loop accumulates
6
+ * these strings into a single buffer, then flushes once to stdout. This
7
+ * keeps render testable (assert string equality against fixtures) and
8
+ * keeps flicker minimal (single write call per frame).
9
+ *
10
+ * Color set: a deliberate 16-color subset of the ANSI palette plus a
11
+ * handful of 256-color brand accents. Reasoning: 16-color is universal,
12
+ * the brand greens (#00ff88-ish) are used sparingly for the dario
13
+ * accent and degrade gracefully on terminals that can't render them.
14
+ *
15
+ * What this module deliberately does NOT do:
16
+ * - Track cursor position (callers pass row/col explicitly)
17
+ * - Diff frames (full-screen redraw per frame is fast enough at
18
+ * ~3000 cells; complexity not worth it)
19
+ * - Handle terminal capabilities probing (use ANSI + assume modern)
20
+ */
21
+ // ── Escape sequences ────────────────────────────────────────────────
22
+ /** Control Sequence Introducer. */
23
+ const ESC = '\x1b[';
24
+ /**
25
+ * Move the cursor to (row, col). 1-indexed, matching the ANSI spec —
26
+ * row=1 is the top line, col=1 is the leftmost column.
27
+ */
28
+ export function moveTo(row, col) {
29
+ return `${ESC}${row};${col}H`;
30
+ }
31
+ /** Hide the blinking cursor (TUI doesn't need it). */
32
+ export const hideCursor = `${ESC}?25l`;
33
+ /** Restore the cursor. ALWAYS run on exit to leave the terminal sane. */
34
+ export const showCursor = `${ESC}?25h`;
35
+ /** Enter the alternate screen buffer — TUI lives here so quit restores prior shell content. */
36
+ export const enterAltScreen = `${ESC}?1049h`;
37
+ /** Leave the alternate screen buffer. Pair with enterAltScreen on exit. */
38
+ export const leaveAltScreen = `${ESC}?1049l`;
39
+ /** Clear the entire screen and move cursor to home. */
40
+ export const clearScreen = `${ESC}2J${ESC}H`;
41
+ /** Clear from cursor to end of line. */
42
+ export const clearLineRight = `${ESC}K`;
43
+ /** Reset all SGR (color + style) attributes. */
44
+ export const reset = `${ESC}0m`;
45
+ // ── Colors (16-color ANSI + brand accent) ──────────────────────────
46
+ /**
47
+ * Foreground color names mapped to ANSI codes. `default` resets to
48
+ * the terminal's default foreground.
49
+ */
50
+ export const FG = {
51
+ default: 39,
52
+ black: 30, red: 31, green: 32, yellow: 33,
53
+ blue: 34, magenta: 35, cyan: 36, white: 37,
54
+ brightBlack: 90, brightRed: 91, brightGreen: 92, brightYellow: 93,
55
+ brightBlue: 94, brightMagenta: 95, brightCyan: 96, brightWhite: 97,
56
+ };
57
+ /** Background color names mapped to ANSI codes. */
58
+ export const BG = {
59
+ default: 49,
60
+ black: 40, red: 41, green: 42, yellow: 43,
61
+ blue: 44, magenta: 45, cyan: 46, white: 47,
62
+ brightBlack: 100, brightRed: 101, brightGreen: 102, brightYellow: 103,
63
+ brightBlue: 104, brightMagenta: 105, brightCyan: 106, brightWhite: 107,
64
+ };
65
+ /**
66
+ * Wrap text in foreground-color SGR codes. Resets to default fg at
67
+ * the end so subsequent uncolored text isn't accidentally colored.
68
+ *
69
+ * Example: `fg('green', 'OK')` → `\x1b[32mOK\x1b[39m`
70
+ */
71
+ export function fg(color, text) {
72
+ return `${ESC}${FG[color]}m${text}${ESC}${FG.default}m`;
73
+ }
74
+ export function bg(color, text) {
75
+ return `${ESC}${BG[color]}m${text}${ESC}${BG.default}m`;
76
+ }
77
+ export function bold(text) {
78
+ return `${ESC}1m${text}${ESC}22m`;
79
+ }
80
+ export function dim(text) {
81
+ return `${ESC}2m${text}${ESC}22m`;
82
+ }
83
+ export function inverse(text) {
84
+ return `${ESC}7m${text}${ESC}27m`;
85
+ }
86
+ export function underline(text) {
87
+ return `${ESC}4m${text}${ESC}24m`;
88
+ }
89
+ /**
90
+ * Brand accent — the askalf green (#00ff88-ish) via the 256-color
91
+ * palette index 48. Falls back gracefully on terminals that don't
92
+ * render 256-color (they show as bright green).
93
+ */
94
+ export function brand(text) {
95
+ return `${ESC}38;5;48m${text}${ESC}${FG.default}m`;
96
+ }
97
+ // ── String helpers ─────────────────────────────────────────────────
98
+ /**
99
+ * Visible-width of a string. ANSI escape sequences and zero-width
100
+ * sequences contribute 0. Tabs count as 1 (terminals vary; better
101
+ * to under- than over-estimate). Multi-byte characters (CJK, emoji)
102
+ * are NOT special-cased in v4.0 — the TUI's content is ASCII-dominant
103
+ * (model names, numbers, labels). A future revision can add a
104
+ * `string-width`-style lookup if non-ASCII becomes common.
105
+ */
106
+ export function visibleWidth(s) {
107
+ // Strip ANSI escape sequences (CSI + everything up to terminating byte).
108
+ const stripped = s.replace(/\x1b\[[\x30-\x3f]*[\x20-\x2f]*[\x40-\x7e]/g, '');
109
+ return stripped.length;
110
+ }
111
+ /**
112
+ * Truncate `text` to at most `maxWidth` visible chars, appending `…`
113
+ * if anything was clipped. ANSI sequences within the truncated portion
114
+ * are preserved verbatim; truncation only counts visible characters.
115
+ *
116
+ * Example: truncate('hello world', 8) → 'hello w…'
117
+ */
118
+ export function truncate(text, maxWidth, ellipsis = '…') {
119
+ if (maxWidth <= 0)
120
+ return '';
121
+ if (visibleWidth(text) <= maxWidth)
122
+ return text;
123
+ const ellipsisWidth = visibleWidth(ellipsis);
124
+ if (maxWidth <= ellipsisWidth)
125
+ return ellipsis.slice(0, maxWidth);
126
+ // Walk the string, counting visible chars, stop when we'd exceed
127
+ // maxWidth - ellipsisWidth so we have room to append.
128
+ const target = maxWidth - ellipsisWidth;
129
+ let out = '';
130
+ let visible = 0;
131
+ let i = 0;
132
+ while (i < text.length && visible < target) {
133
+ if (text[i] === '\x1b' && text[i + 1] === '[') {
134
+ // Copy the full escape sequence
135
+ const m = text.slice(i).match(/^\x1b\[[\x30-\x3f]*[\x20-\x2f]*[\x40-\x7e]/);
136
+ if (m) {
137
+ out += m[0];
138
+ i += m[0].length;
139
+ continue;
140
+ }
141
+ }
142
+ out += text[i];
143
+ visible++;
144
+ i++;
145
+ }
146
+ return out + ellipsis;
147
+ }
148
+ /** Pad `text` (right-aligned by default 'left' fills right side) to `width`. */
149
+ export function pad(text, width, align = 'left') {
150
+ const w = visibleWidth(text);
151
+ if (w >= width)
152
+ return truncate(text, width);
153
+ const gap = width - w;
154
+ if (align === 'right')
155
+ return ' '.repeat(gap) + text;
156
+ if (align === 'center') {
157
+ const left = Math.floor(gap / 2);
158
+ const right = gap - left;
159
+ return ' '.repeat(left) + text + ' '.repeat(right);
160
+ }
161
+ return text + ' '.repeat(gap);
162
+ }
163
+ /**
164
+ * Render a horizontal progress bar.
165
+ *
166
+ * value: 0..1 (clamped)
167
+ * width: total cell count of the bar
168
+ *
169
+ * Uses the full-block ▓ / shade ░ characters; passes through as plain
170
+ * ASCII on terminals that don't render the box-drawing set (they look
171
+ * like `?` but the layout still works).
172
+ */
173
+ export function progressBar(value, width, opts = {}) {
174
+ const filled = opts.filled ?? '█';
175
+ const empty = opts.empty ?? '░';
176
+ const clamped = Math.max(0, Math.min(1, value));
177
+ const cells = Math.round(clamped * width);
178
+ return filled.repeat(cells) + empty.repeat(width - cells);
179
+ }
180
+ /**
181
+ * Box-drawing characters for borders. Set picked to render well on
182
+ * most terminals (Unicode box-drawing). Override via `customChars` if
183
+ * a terminal renders these badly — but the default targets the
184
+ * 99%-of-users case.
185
+ */
186
+ export const BOX = {
187
+ topLeft: '┌', topRight: '┐',
188
+ bottomLeft: '└', bottomRight: '┘',
189
+ horizontal: '─', vertical: '│',
190
+ cross: '┼', tLeft: '├', tRight: '┤',
191
+ tTop: '┬', tBottom: '┴',
192
+ };
193
+ /**
194
+ * Render a box at (row, col) with the given width and height. Returns
195
+ * the full ANSI string (positioned writes + box characters). The
196
+ * interior is NOT cleared — callers paint inside the box separately.
197
+ */
198
+ export function drawBox(row, col, width, height) {
199
+ if (width < 2 || height < 2)
200
+ return '';
201
+ const out = [];
202
+ // Top border
203
+ out.push(moveTo(row, col));
204
+ out.push(BOX.topLeft + BOX.horizontal.repeat(width - 2) + BOX.topRight);
205
+ // Sides
206
+ for (let r = 1; r < height - 1; r++) {
207
+ out.push(moveTo(row + r, col));
208
+ out.push(BOX.vertical);
209
+ out.push(moveTo(row + r, col + width - 1));
210
+ out.push(BOX.vertical);
211
+ }
212
+ // Bottom border
213
+ out.push(moveTo(row + height - 1, col));
214
+ out.push(BOX.bottomLeft + BOX.horizontal.repeat(width - 2) + BOX.bottomRight);
215
+ return out.join('');
216
+ }
217
+ // ── Frame buffer ───────────────────────────────────────────────────
218
+ /**
219
+ * Frame builder. The App's render() collects strings into one of these
220
+ * then calls `flush(stdout)` for a single write. Single-write rendering
221
+ * eliminates flicker on most terminals.
222
+ */
223
+ export class Frame {
224
+ chunks = [];
225
+ /** Append a string. No positioning — caller is responsible. */
226
+ write(s) {
227
+ this.chunks.push(s);
228
+ }
229
+ /** Append a positioned write at (row, col). */
230
+ writeAt(row, col, s) {
231
+ this.chunks.push(moveTo(row, col));
232
+ this.chunks.push(s);
233
+ }
234
+ /** Return the accumulated frame as a single string. */
235
+ toString() {
236
+ return this.chunks.join('');
237
+ }
238
+ /** Number of accumulated chunks (debugging aid). */
239
+ get length() {
240
+ return this.chunks.length;
241
+ }
242
+ /** Drop everything. Reuses the array allocation. */
243
+ clear() {
244
+ this.chunks.length = 0;
245
+ }
246
+ }