@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
package/dist/live-fingerprint.js
CHANGED
|
@@ -777,7 +777,7 @@ export function _resetInstalledVersionProbeForTest() {
|
|
|
777
777
|
*/
|
|
778
778
|
export const SUPPORTED_CC_RANGE = {
|
|
779
779
|
min: '1.0.0',
|
|
780
|
-
maxTested: '2.1.
|
|
780
|
+
maxTested: '2.1.143',
|
|
781
781
|
};
|
|
782
782
|
/**
|
|
783
783
|
* Compare two dotted-numeric version strings. Returns negative if `a<b`,
|
package/dist/proxy.js
CHANGED
|
@@ -567,7 +567,12 @@ export async function startProxy(opts = {}) {
|
|
|
567
567
|
// eventual `7d_opus`) doesn't slip past unnoticed. Pure observability —
|
|
568
568
|
// routing already handles unknown families generically.
|
|
569
569
|
const seenPerModelBuckets = new Set();
|
|
570
|
-
|
|
570
|
+
// v4 promotion: analytics is always-on so the TUI's Analytics + Hits
|
|
571
|
+
// tabs work in both pool and single-account mode. Pre-v4 this was
|
|
572
|
+
// `pool ? new Analytics() : null` — that gated the /analytics
|
|
573
|
+
// endpoint, but burn-rate / per-request visibility is useful for
|
|
574
|
+
// single-account users too.
|
|
575
|
+
const analytics = new Analytics();
|
|
571
576
|
let status;
|
|
572
577
|
if (pool) {
|
|
573
578
|
for (const acc of accountsList) {
|
|
@@ -912,17 +917,70 @@ export async function startProxy(opts = {}) {
|
|
|
912
917
|
}));
|
|
913
918
|
return;
|
|
914
919
|
}
|
|
915
|
-
// Analytics endpoint —
|
|
920
|
+
// Analytics endpoint — rolling-window summary + burn-rate snapshot.
|
|
921
|
+
// Always-on as of v4 (pre-v4 this was gated to pool mode).
|
|
916
922
|
if (urlPath === '/analytics' && req.method === 'GET') {
|
|
917
|
-
if (!analytics) {
|
|
918
|
-
res.writeHead(200, JSON_HEADERS);
|
|
919
|
-
res.end(JSON.stringify({ mode: 'single-account', note: 'Analytics are only collected in pool mode.' }));
|
|
920
|
-
return;
|
|
921
|
-
}
|
|
922
923
|
res.writeHead(200, JSON_HEADERS);
|
|
923
924
|
res.end(JSON.stringify(analytics.summary()));
|
|
924
925
|
return;
|
|
925
926
|
}
|
|
927
|
+
// Analytics live stream — SSE of new RequestRecord JSON, one event
|
|
928
|
+
// per record as it lands. Drives the v4 TUI's Hits tab. Sends a
|
|
929
|
+
// backlog of the most-recent 50 records on connect so a freshly-
|
|
930
|
+
// attached subscriber sees state immediately, then live-tails.
|
|
931
|
+
//
|
|
932
|
+
// Auth: same as /analytics — no auth in single-account default mode;
|
|
933
|
+
// the proxy listens on loopback by default. DARIO_API_KEY users
|
|
934
|
+
// get rejected by the earlier auth gate up the handler chain.
|
|
935
|
+
//
|
|
936
|
+
// Disconnect handling: the 'close' event on `req` removes our
|
|
937
|
+
// listener from the Analytics EventEmitter so we don't leak.
|
|
938
|
+
if (urlPath === '/analytics/stream' && req.method === 'GET') {
|
|
939
|
+
// SECURITY_HEADERS sets Cache-Control: no-store; SSE wants
|
|
940
|
+
// no-cache, no-transform. Spread SECURITY_HEADERS first then
|
|
941
|
+
// override the cache directive — order matters since spread
|
|
942
|
+
// overlap is last-wins in JS.
|
|
943
|
+
const sseHeaders = {
|
|
944
|
+
...SECURITY_HEADERS,
|
|
945
|
+
'Content-Type': 'text/event-stream',
|
|
946
|
+
'Cache-Control': 'no-cache, no-transform',
|
|
947
|
+
'Connection': 'keep-alive',
|
|
948
|
+
'X-Accel-Buffering': 'no', // disable any proxy buffering
|
|
949
|
+
'Access-Control-Allow-Origin': corsOrigin,
|
|
950
|
+
};
|
|
951
|
+
res.writeHead(200, sseHeaders);
|
|
952
|
+
// Backlog: replay recent records so a TUI attaching mid-session
|
|
953
|
+
// sees something. 50 is a soft default; lots of room to send more
|
|
954
|
+
// since this is one-time on connect.
|
|
955
|
+
for (const past of analytics.recent(50)) {
|
|
956
|
+
res.write(`data: ${JSON.stringify(past)}\n\n`);
|
|
957
|
+
}
|
|
958
|
+
// Live tail
|
|
959
|
+
const onRecord = (r) => {
|
|
960
|
+
// Use try/catch so a broken socket (peer hung up between events)
|
|
961
|
+
// doesn't crash the request hot-path — Analytics already wraps
|
|
962
|
+
// its emit in try/catch but the .write itself can also throw.
|
|
963
|
+
try {
|
|
964
|
+
res.write(`data: ${JSON.stringify(r)}\n\n`);
|
|
965
|
+
}
|
|
966
|
+
catch { /* ignored */ }
|
|
967
|
+
};
|
|
968
|
+
analytics.on('record', onRecord);
|
|
969
|
+
// Heartbeat every 25s — SSE comments are ignored by clients but
|
|
970
|
+
// keep middle-boxes (CDNs, dev-proxies) from closing the pipe.
|
|
971
|
+
const heartbeat = setInterval(() => {
|
|
972
|
+
try {
|
|
973
|
+
res.write(`: heartbeat ${Date.now()}\n\n`);
|
|
974
|
+
}
|
|
975
|
+
catch { /* ignored */ }
|
|
976
|
+
}, 25_000);
|
|
977
|
+
heartbeat.unref?.();
|
|
978
|
+
req.on('close', () => {
|
|
979
|
+
analytics.off('record', onRecord);
|
|
980
|
+
clearInterval(heartbeat);
|
|
981
|
+
});
|
|
982
|
+
return;
|
|
983
|
+
}
|
|
926
984
|
if (urlPath === '/v1/models' && req.method === 'GET') {
|
|
927
985
|
requestCount++;
|
|
928
986
|
res.writeHead(200, { ...JSON_HEADERS, 'Access-Control-Allow-Origin': corsOrigin });
|
|
@@ -1585,12 +1643,19 @@ export async function startProxy(opts = {}) {
|
|
|
1585
1643
|
}
|
|
1586
1644
|
}
|
|
1587
1645
|
requestCount++;
|
|
1588
|
-
|
|
1646
|
+
// v4: analytics is always-on. Pool mode supplies the rate-limit
|
|
1647
|
+
// snapshot from `poolAccount.rateLimit` (already authoritative);
|
|
1648
|
+
// single-account mode parses it from the upstream response
|
|
1649
|
+
// headers on the spot so the TUI's Hits feed shows the same
|
|
1650
|
+
// bucket / utilization fields in both modes.
|
|
1651
|
+
{
|
|
1652
|
+
const rl = poolAccount?.rateLimit ?? parseRateLimits(upstream.headers);
|
|
1589
1653
|
analytics.record({
|
|
1590
|
-
timestamp: Date.now(),
|
|
1654
|
+
timestamp: Date.now(),
|
|
1655
|
+
account: poolAccount?.alias ?? ACCOUNT_KEY_SINGLE,
|
|
1656
|
+
model: requestModel,
|
|
1591
1657
|
inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheCreateTokens: 0, thinkingTokens: 0,
|
|
1592
|
-
claim:
|
|
1593
|
-
util7d: poolAccount.rateLimit.util7d, overageUtil: poolAccount.rateLimit.overageUtil,
|
|
1658
|
+
claim: rl.claim, util5h: rl.util5h, util7d: rl.util7d, overageUtil: rl.overageUtil,
|
|
1594
1659
|
latencyMs: Date.now() - startTime, status: 429, isStream: false, isOpenAI,
|
|
1595
1660
|
});
|
|
1596
1661
|
}
|
|
@@ -1669,12 +1734,14 @@ export async function startProxy(opts = {}) {
|
|
|
1669
1734
|
}
|
|
1670
1735
|
}
|
|
1671
1736
|
requestCount++;
|
|
1672
|
-
|
|
1737
|
+
{
|
|
1738
|
+
const rl = poolAccount?.rateLimit ?? parseRateLimits(upstream.headers);
|
|
1673
1739
|
analytics.record({
|
|
1674
|
-
timestamp: Date.now(),
|
|
1740
|
+
timestamp: Date.now(),
|
|
1741
|
+
account: poolAccount?.alias ?? ACCOUNT_KEY_SINGLE,
|
|
1742
|
+
model: requestModel,
|
|
1675
1743
|
inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheCreateTokens: 0, thinkingTokens: 0,
|
|
1676
|
-
claim:
|
|
1677
|
-
util7d: poolAccount.rateLimit.util7d, overageUtil: poolAccount.rateLimit.overageUtil,
|
|
1744
|
+
claim: rl.claim, util5h: rl.util5h, util7d: rl.util7d, overageUtil: rl.overageUtil,
|
|
1678
1745
|
latencyMs: Date.now() - startTime, status: 429, isStream: false, isOpenAI,
|
|
1679
1746
|
});
|
|
1680
1747
|
}
|
|
@@ -1880,14 +1947,16 @@ export async function startProxy(opts = {}) {
|
|
|
1880
1947
|
lastResponseTime = Date.now();
|
|
1881
1948
|
lastResponseTokens = streamOutputTokens;
|
|
1882
1949
|
}
|
|
1883
|
-
|
|
1950
|
+
{
|
|
1951
|
+
const rl = poolAccount?.rateLimit ?? parseRateLimits(upstream.headers);
|
|
1884
1952
|
analytics.record({
|
|
1885
|
-
timestamp: Date.now(),
|
|
1953
|
+
timestamp: Date.now(),
|
|
1954
|
+
account: poolAccount?.alias ?? ACCOUNT_KEY_SINGLE,
|
|
1955
|
+
model: requestModel,
|
|
1886
1956
|
inputTokens: streamInputTokens, outputTokens: streamOutputTokens,
|
|
1887
1957
|
cacheReadTokens: streamCacheReadTokens, cacheCreateTokens: streamCacheCreateTokens,
|
|
1888
1958
|
thinkingTokens: 0,
|
|
1889
|
-
claim:
|
|
1890
|
-
util7d: poolAccount.rateLimit.util7d, overageUtil: poolAccount.rateLimit.overageUtil,
|
|
1959
|
+
claim: rl.claim, util5h: rl.util5h, util7d: rl.util7d, overageUtil: rl.overageUtil,
|
|
1891
1960
|
latencyMs: Date.now() - startTime, status: upstream.status, isStream: true, isOpenAI,
|
|
1892
1961
|
});
|
|
1893
1962
|
}
|
|
@@ -1938,16 +2007,17 @@ export async function startProxy(opts = {}) {
|
|
|
1938
2007
|
lastResponseTime = Date.now();
|
|
1939
2008
|
lastResponseTokens = bufferedUsage?.outputTokens ?? 0;
|
|
1940
2009
|
}
|
|
1941
|
-
if (
|
|
2010
|
+
if (bufferedUsage) {
|
|
1942
2011
|
try {
|
|
2012
|
+
const rl = poolAccount?.rateLimit ?? parseRateLimits(upstream.headers);
|
|
1943
2013
|
analytics.record({
|
|
1944
|
-
timestamp: Date.now(),
|
|
2014
|
+
timestamp: Date.now(),
|
|
2015
|
+
account: poolAccount?.alias ?? ACCOUNT_KEY_SINGLE,
|
|
1945
2016
|
model: bufferedUsage.model || requestModel,
|
|
1946
2017
|
inputTokens: bufferedUsage.inputTokens, outputTokens: bufferedUsage.outputTokens,
|
|
1947
2018
|
cacheReadTokens: bufferedUsage.cacheReadTokens, cacheCreateTokens: bufferedUsage.cacheCreateTokens,
|
|
1948
2019
|
thinkingTokens: bufferedUsage.thinkingTokens,
|
|
1949
|
-
claim:
|
|
1950
|
-
util7d: poolAccount.rateLimit.util7d, overageUtil: poolAccount.rateLimit.overageUtil,
|
|
2020
|
+
claim: rl.claim, util5h: rl.util5h, util7d: rl.util7d, overageUtil: rl.overageUtil,
|
|
1951
2021
|
latencyMs: Date.now() - startTime, status: upstream.status, isStream: false, isOpenAI,
|
|
1952
2022
|
});
|
|
1953
2023
|
}
|
|
@@ -0,0 +1,96 @@
|
|
|
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 { type Key } from './input.js';
|
|
22
|
+
export interface AppOptions<S> {
|
|
23
|
+
/**
|
|
24
|
+
* Initial state. The App holds a reference and re-renders when
|
|
25
|
+
* `setState` is called.
|
|
26
|
+
*/
|
|
27
|
+
initialState: S;
|
|
28
|
+
/**
|
|
29
|
+
* Pure function: state + dimensions → full screen content.
|
|
30
|
+
* The App calls this on every redraw + flushes the returned string
|
|
31
|
+
* to stdout in a single write.
|
|
32
|
+
*/
|
|
33
|
+
render: (state: S, dim: {
|
|
34
|
+
cols: number;
|
|
35
|
+
rows: number;
|
|
36
|
+
}) => string;
|
|
37
|
+
/**
|
|
38
|
+
* Key dispatch. Receives every parsed key from stdin (after the
|
|
39
|
+
* App has already handled global keys like Ctrl-C). Return a new
|
|
40
|
+
* state (or undefined for no change) — same shape as React's
|
|
41
|
+
* setState reducer.
|
|
42
|
+
*/
|
|
43
|
+
onKey: (state: S, key: Key) => S | undefined;
|
|
44
|
+
/**
|
|
45
|
+
* Optional: called on every redraw after the new frame has been
|
|
46
|
+
* written. Used by tabs that have async data (e.g. SSE-fed Hits
|
|
47
|
+
* tab) to schedule periodic refreshes.
|
|
48
|
+
*/
|
|
49
|
+
afterFrame?: (state: S) => void;
|
|
50
|
+
/**
|
|
51
|
+
* stdin / stdout — overridable for tests. Defaults to process.stdin
|
|
52
|
+
* and process.stdout.
|
|
53
|
+
*/
|
|
54
|
+
stdin?: NodeJS.ReadStream;
|
|
55
|
+
stdout?: NodeJS.WriteStream;
|
|
56
|
+
}
|
|
57
|
+
export declare class App<S> {
|
|
58
|
+
private state;
|
|
59
|
+
private renderFn;
|
|
60
|
+
private keyFn;
|
|
61
|
+
private afterFrameFn?;
|
|
62
|
+
private stdin;
|
|
63
|
+
private stdout;
|
|
64
|
+
private cleanupFns;
|
|
65
|
+
private running;
|
|
66
|
+
private redrawScheduled;
|
|
67
|
+
constructor(opts: AppOptions<S>);
|
|
68
|
+
/**
|
|
69
|
+
* Replace state. If the new state differs from the old (shallow
|
|
70
|
+
* equality), schedule a redraw. Tabs use this both for synchronous
|
|
71
|
+
* key-driven updates and for async data arrivals (SSE 'record').
|
|
72
|
+
*
|
|
73
|
+
* The "differs" check is intentionally shallow — deep equality on
|
|
74
|
+
* potentially-large analytics records would be expensive and the
|
|
75
|
+
* caller almost always passes new object identity when it mutates.
|
|
76
|
+
* If you mutate state in place and don't change identity, the
|
|
77
|
+
* redraw won't fire (this is documented; sometimes desired).
|
|
78
|
+
*/
|
|
79
|
+
setState(updater: Partial<S> | ((s: S) => S)): void;
|
|
80
|
+
/** Read-only state accessor — for callers that need to compute next state from current. */
|
|
81
|
+
getState(): S;
|
|
82
|
+
/**
|
|
83
|
+
* Start the TUI: enter alt-screen, hide cursor, raw stdin, attach
|
|
84
|
+
* resize listener, render once, then idle until stop() or process
|
|
85
|
+
* exit.
|
|
86
|
+
*
|
|
87
|
+
* Returns a Promise that resolves when stop() is called. Wires
|
|
88
|
+
* process exit / signal hooks so a Ctrl-C / kill leaves the
|
|
89
|
+
* terminal sane.
|
|
90
|
+
*/
|
|
91
|
+
start(): Promise<void>;
|
|
92
|
+
/** Stop the TUI — restore terminal state and resolve the start() promise. */
|
|
93
|
+
stop(): void;
|
|
94
|
+
private scheduleRedraw;
|
|
95
|
+
private redraw;
|
|
96
|
+
}
|
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;
|