@askalf/dario 3.38.6 → 4.0.1
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 +136 -25
- package/dist/analytics.d.ts +32 -3
- package/dist/analytics.js +48 -3
- package/dist/cc-template-data.json +36 -5
- package/dist/cli.js +111 -25
- package/dist/config-file.d.ts +157 -0
- package/dist/config-file.js +326 -0
- package/dist/live-fingerprint.d.ts +1 -1
- package/dist/live-fingerprint.js +1 -1
- 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,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
|
+
}
|
|
@@ -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
|
+
}
|