@askalf/dario 3.38.5 → 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.
- package/README.md +23 -1
- package/dist/analytics.d.ts +32 -3
- package/dist/analytics.js +48 -3
- package/dist/cc-template.js +18 -10
- package/dist/cli.js +111 -25
- package/dist/config-file.d.ts +157 -0
- package/dist/config-file.js +326 -0
- package/dist/proxy.js +93 -23
- package/dist/tui/app.d.ts +96 -0
- package/dist/tui/app.js +178 -0
- package/dist/tui/input.d.ts +57 -0
- package/dist/tui/input.js +206 -0
- package/dist/tui/layout.d.ts +66 -0
- package/dist/tui/layout.js +152 -0
- package/dist/tui/proxy-client.d.ts +60 -0
- package/dist/tui/proxy-client.js +166 -0
- package/dist/tui/render.d.ts +178 -0
- package/dist/tui/render.js +246 -0
- package/dist/tui/tab.d.ts +89 -0
- package/dist/tui/tab.js +19 -0
- package/dist/tui/tabs/accounts.d.ts +32 -0
- package/dist/tui/tabs/accounts.js +110 -0
- package/dist/tui/tabs/analytics.d.ts +53 -0
- package/dist/tui/tabs/analytics.js +161 -0
- package/dist/tui/tabs/backends.d.ts +19 -0
- package/dist/tui/tabs/backends.js +77 -0
- package/dist/tui/tabs/config.d.ts +35 -0
- package/dist/tui/tabs/config.js +267 -0
- package/dist/tui/tabs/hits.d.ts +34 -0
- package/dist/tui/tabs/hits.js +223 -0
- package/dist/tui/tabs/status.d.ts +45 -0
- package/dist/tui/tabs/status.js +132 -0
- package/dist/tui/tui-app.d.ts +41 -0
- package/dist/tui/tui-app.js +217 -0
- package/package.json +1 -1
|
@@ -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
|
+
}
|