@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
package/dist/tui/app.js
ADDED
|
@@ -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
|
+
}
|