@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,223 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hits tab — live request stream with per-record detail drill-down.
|
|
3
|
+
*
|
|
4
|
+
* Subscribes to /analytics/stream on mount. Each incoming RequestRecord
|
|
5
|
+
* is prepended to the buffer (newest at the top of the visible list).
|
|
6
|
+
* Up/Down navigate the selection; the lower pane shows the selected
|
|
7
|
+
* record's full field set.
|
|
8
|
+
*
|
|
9
|
+
* Layout:
|
|
10
|
+
*
|
|
11
|
+
* ┌─ Hits ────────────────────────[ ↑↓ select · r refresh ]
|
|
12
|
+
* │ HH:MM:SS METHOD MODEL IN OUT LAT ST
|
|
13
|
+
* │ 18:42:01 POST opus-4-7 842 216 1.2s 200 ←
|
|
14
|
+
* │ 18:42:03 POST sonnet-4-6 1.2k 480 0.8s 200
|
|
15
|
+
* │ …
|
|
16
|
+
* ├─────────────────────────────────────────────────────────────
|
|
17
|
+
* │ selected: 18:42:01 req_011…NvMn
|
|
18
|
+
* │ account: sprayberryit (single)
|
|
19
|
+
* │ model: claude-opus-4-7
|
|
20
|
+
* │ bucket: subscription
|
|
21
|
+
* │ tokens: in 842 / out 216 / cache-read 6.2k / thinking 84
|
|
22
|
+
* │ latency: 1.18s stream: yes status: 200
|
|
23
|
+
* │ 5h util: 18% 7d util: 8%
|
|
24
|
+
* └─────────────────────────────────────────────────────────────
|
|
25
|
+
*/
|
|
26
|
+
import { fg, dim, brand, inverse, BOX, pad, truncate } from '../render.js';
|
|
27
|
+
import { renderKvRow } from '../layout.js';
|
|
28
|
+
import { billingBucketFromClaim } from '../../analytics.js';
|
|
29
|
+
const MAX_BUFFER = 5000;
|
|
30
|
+
export const HitsTab = {
|
|
31
|
+
id: 'hits',
|
|
32
|
+
label: 'Hits',
|
|
33
|
+
hotkey: 'h',
|
|
34
|
+
initialState() {
|
|
35
|
+
return { buffer: [], selectedIdx: -1, subscribed: false, connectionError: null };
|
|
36
|
+
},
|
|
37
|
+
onMount(_state, ctx) {
|
|
38
|
+
// Subscribe to the live stream. Each record is prepended-conceptually
|
|
39
|
+
// (we push to the array and render in reverse, which keeps the
|
|
40
|
+
// buffer's mutation simple — Array.push is O(1) while unshift is O(n)).
|
|
41
|
+
const close = ctx.client.subscribeAnalyticsStream((record) => {
|
|
42
|
+
ctx.setState((s) => {
|
|
43
|
+
const next = {
|
|
44
|
+
...s,
|
|
45
|
+
buffer: [...s.buffer, record].slice(-MAX_BUFFER),
|
|
46
|
+
subscribed: true,
|
|
47
|
+
connectionError: null,
|
|
48
|
+
};
|
|
49
|
+
// If user was at top (newest), keep them there. -1 means "no
|
|
50
|
+
// selection yet"; auto-select newest on first record.
|
|
51
|
+
if (s.selectedIdx === -1 || s.selectedIdx === 0) {
|
|
52
|
+
next.selectedIdx = 0;
|
|
53
|
+
}
|
|
54
|
+
return next;
|
|
55
|
+
});
|
|
56
|
+
}, (err) => {
|
|
57
|
+
ctx.setState({ subscribed: false, connectionError: err.message });
|
|
58
|
+
});
|
|
59
|
+
ctx.registerCleanup(close);
|
|
60
|
+
return undefined;
|
|
61
|
+
},
|
|
62
|
+
onKey(state, key) {
|
|
63
|
+
if (state.buffer.length === 0)
|
|
64
|
+
return undefined;
|
|
65
|
+
// ↑ — go to OLDER (toward higher index in our reversed display)
|
|
66
|
+
if (key.name === 'up') {
|
|
67
|
+
const max = state.buffer.length - 1;
|
|
68
|
+
return { ...state, selectedIdx: Math.min(state.selectedIdx + 1, max) };
|
|
69
|
+
}
|
|
70
|
+
// ↓ — go to NEWER
|
|
71
|
+
if (key.name === 'down') {
|
|
72
|
+
return { ...state, selectedIdx: Math.max(state.selectedIdx - 1, 0) };
|
|
73
|
+
}
|
|
74
|
+
// PgUp / PgDn — step by 10
|
|
75
|
+
if (key.name === 'pageup') {
|
|
76
|
+
const max = state.buffer.length - 1;
|
|
77
|
+
return { ...state, selectedIdx: Math.min(state.selectedIdx + 10, max) };
|
|
78
|
+
}
|
|
79
|
+
if (key.name === 'pagedown') {
|
|
80
|
+
return { ...state, selectedIdx: Math.max(state.selectedIdx - 10, 0) };
|
|
81
|
+
}
|
|
82
|
+
// Home — jump to newest
|
|
83
|
+
if (key.name === 'home') {
|
|
84
|
+
return { ...state, selectedIdx: 0 };
|
|
85
|
+
}
|
|
86
|
+
// End — jump to oldest
|
|
87
|
+
if (key.name === 'end') {
|
|
88
|
+
return { ...state, selectedIdx: state.buffer.length - 1 };
|
|
89
|
+
}
|
|
90
|
+
return undefined;
|
|
91
|
+
},
|
|
92
|
+
render(state, dimv) {
|
|
93
|
+
const lines = [];
|
|
94
|
+
const w = dimv.cols;
|
|
95
|
+
const totalRows = dimv.rows;
|
|
96
|
+
// Split the body roughly 60/40 between list and detail.
|
|
97
|
+
const detailRows = 9;
|
|
98
|
+
const listRows = Math.max(3, totalRows - detailRows - 2);
|
|
99
|
+
if (state.buffer.length === 0) {
|
|
100
|
+
lines.push(' ' + brand('Hits') + dim(' — live request stream'));
|
|
101
|
+
lines.push('');
|
|
102
|
+
if (state.connectionError) {
|
|
103
|
+
lines.push(' ' + fg('red', `SSE error: ${state.connectionError}`));
|
|
104
|
+
lines.push(' ' + dim('Is `dario proxy` running? The stream reconnects automatically on the next mount.'));
|
|
105
|
+
}
|
|
106
|
+
else if (!state.subscribed) {
|
|
107
|
+
lines.push(' ' + dim('Connecting to /analytics/stream …'));
|
|
108
|
+
}
|
|
109
|
+
else {
|
|
110
|
+
lines.push(' ' + dim('Waiting for requests. Send one through dario to see it land here.'));
|
|
111
|
+
}
|
|
112
|
+
return lines.join('\n');
|
|
113
|
+
}
|
|
114
|
+
// Render newest-first: the LAST element of the buffer renders at
|
|
115
|
+
// the TOP of the list.
|
|
116
|
+
const newestFirst = [...state.buffer].reverse();
|
|
117
|
+
const startIdx = clampVisibleStart(state.selectedIdx, listRows, newestFirst.length);
|
|
118
|
+
const endIdx = Math.min(startIdx + listRows, newestFirst.length);
|
|
119
|
+
// Column layout — fixed widths to keep alignment stable across
|
|
120
|
+
// varied content. Fall back to truncation when columns overflow.
|
|
121
|
+
const colTime = 9;
|
|
122
|
+
const colModel = 18;
|
|
123
|
+
const colIn = 8, colOut = 7, colLat = 7, colStatus = 5;
|
|
124
|
+
lines.push(' ' + brand('Hits') +
|
|
125
|
+
dim(` ${state.buffer.length} buffered · ${state.subscribed ? fg('green', 'live') : fg('yellow', 'disconnected')}`));
|
|
126
|
+
lines.push('');
|
|
127
|
+
// Header row (aligned with data rows)
|
|
128
|
+
lines.push(' ' + dim(pad('time', colTime) +
|
|
129
|
+
pad('model', colModel) +
|
|
130
|
+
pad('in', colIn) +
|
|
131
|
+
pad('out', colOut) +
|
|
132
|
+
pad('lat', colLat) +
|
|
133
|
+
pad('st', colStatus)));
|
|
134
|
+
for (let i = startIdx; i < endIdx; i++) {
|
|
135
|
+
const r = newestFirst[i];
|
|
136
|
+
const marker = i === state.selectedIdx ? fg('cyan', '▎') : ' ';
|
|
137
|
+
const row = marker + ' ' +
|
|
138
|
+
pad(formatTime(r.timestamp), colTime) +
|
|
139
|
+
pad(shortenModel(r.model), colModel) +
|
|
140
|
+
pad(formatTokens(r.inputTokens), colIn) +
|
|
141
|
+
pad(formatTokens(r.outputTokens), colOut) +
|
|
142
|
+
pad(formatLatency(r.latencyMs), colLat) +
|
|
143
|
+
pad(formatStatus(r.status), colStatus);
|
|
144
|
+
lines.push(i === state.selectedIdx ? inverse(truncate(row, w - 2)) : truncate(row, w - 2));
|
|
145
|
+
}
|
|
146
|
+
// Scroll hint
|
|
147
|
+
if (newestFirst.length > listRows) {
|
|
148
|
+
lines.push(' ' + dim(`${state.selectedIdx + 1} / ${newestFirst.length} ` +
|
|
149
|
+
(startIdx > 0 ? '↑ more ' : '') +
|
|
150
|
+
(endIdx < newestFirst.length ? '↓ more' : '')));
|
|
151
|
+
}
|
|
152
|
+
// Separator
|
|
153
|
+
lines.push(' ' + dim(BOX.horizontal.repeat(w - 2)));
|
|
154
|
+
// Detail pane
|
|
155
|
+
if (state.selectedIdx >= 0 && state.selectedIdx < newestFirst.length) {
|
|
156
|
+
const r = newestFirst[state.selectedIdx];
|
|
157
|
+
lines.push(' ' + brand('Selected') + dim(` ${formatTime(r.timestamp)}`));
|
|
158
|
+
lines.push(' ' + renderKvRow('Account', r.account, w - 4));
|
|
159
|
+
lines.push(' ' + renderKvRow('Model', r.model, w - 4));
|
|
160
|
+
lines.push(' ' + renderKvRow('Billing bucket', billingBucketFromClaim(r.claim), w - 4));
|
|
161
|
+
lines.push(' ' + renderKvRow('Tokens', tokenBreakdown(r), w - 4));
|
|
162
|
+
lines.push(' ' + renderKvRow('Latency', `${formatLatency(r.latencyMs)} ${dim(r.isStream ? '(streaming)' : '(buffered)')}`, w - 4));
|
|
163
|
+
lines.push(' ' + renderKvRow('Util at request', `5h ${(r.util5h * 100).toFixed(0)}% 7d ${(r.util7d * 100).toFixed(0)}%`, w - 4));
|
|
164
|
+
lines.push(' ' + renderKvRow('Status', formatStatus(r.status), w - 4));
|
|
165
|
+
}
|
|
166
|
+
else {
|
|
167
|
+
lines.push('');
|
|
168
|
+
lines.push(' ' + dim('Use ↑↓ to select a request for details.'));
|
|
169
|
+
}
|
|
170
|
+
return lines.join('\n');
|
|
171
|
+
},
|
|
172
|
+
};
|
|
173
|
+
/**
|
|
174
|
+
* Decide what range of the (newest-first) buffer to show given the
|
|
175
|
+
* current selection. Keeps the selection visible: if selected drifts
|
|
176
|
+
* off the bottom we scroll down; off the top we scroll up.
|
|
177
|
+
*/
|
|
178
|
+
function clampVisibleStart(selectedIdx, listRows, total) {
|
|
179
|
+
if (selectedIdx < 0)
|
|
180
|
+
return 0;
|
|
181
|
+
// Try to keep selection roughly centered when scrolling
|
|
182
|
+
const desired = selectedIdx - Math.floor(listRows / 3);
|
|
183
|
+
return Math.max(0, Math.min(desired, Math.max(0, total - listRows)));
|
|
184
|
+
}
|
|
185
|
+
function formatTime(ts) {
|
|
186
|
+
const d = new Date(ts);
|
|
187
|
+
return `${pad2(d.getHours())}:${pad2(d.getMinutes())}:${pad2(d.getSeconds())}`;
|
|
188
|
+
}
|
|
189
|
+
function pad2(n) { return n < 10 ? '0' + n : String(n); }
|
|
190
|
+
function shortenModel(model) {
|
|
191
|
+
return model.replace(/^claude-/, '');
|
|
192
|
+
}
|
|
193
|
+
function formatTokens(n) {
|
|
194
|
+
if (n >= 1_000_000)
|
|
195
|
+
return (n / 1_000_000).toFixed(1) + 'M';
|
|
196
|
+
if (n >= 1000)
|
|
197
|
+
return (n / 1000).toFixed(1) + 'k';
|
|
198
|
+
return String(n);
|
|
199
|
+
}
|
|
200
|
+
function formatLatency(ms) {
|
|
201
|
+
if (ms >= 1000)
|
|
202
|
+
return (ms / 1000).toFixed(1) + 's';
|
|
203
|
+
return ms + 'ms';
|
|
204
|
+
}
|
|
205
|
+
function formatStatus(code) {
|
|
206
|
+
if (code >= 200 && code < 300)
|
|
207
|
+
return fg('green', String(code));
|
|
208
|
+
if (code >= 400 && code < 500)
|
|
209
|
+
return fg('yellow', String(code));
|
|
210
|
+
if (code >= 500)
|
|
211
|
+
return fg('red', String(code));
|
|
212
|
+
return String(code);
|
|
213
|
+
}
|
|
214
|
+
function tokenBreakdown(r) {
|
|
215
|
+
const parts = [`in ${r.inputTokens}`, `out ${r.outputTokens}`];
|
|
216
|
+
if (r.cacheReadTokens > 0)
|
|
217
|
+
parts.push(`cache-read ${formatTokens(r.cacheReadTokens)}`);
|
|
218
|
+
if (r.cacheCreateTokens > 0)
|
|
219
|
+
parts.push(`cache-create ${formatTokens(r.cacheCreateTokens)}`);
|
|
220
|
+
if (r.thinkingTokens > 0)
|
|
221
|
+
parts.push(`thinking ${r.thinkingTokens}`);
|
|
222
|
+
return parts.join(' / ');
|
|
223
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Status tab — at-a-glance proxy + auth + config-source view.
|
|
3
|
+
*
|
|
4
|
+
* Read-mostly. On mount: probe /health for proxy reachability; load
|
|
5
|
+
* config-file metadata locally. On any key, return undefined (no
|
|
6
|
+
* mutations from this tab).
|
|
7
|
+
*
|
|
8
|
+
* Layout:
|
|
9
|
+
*
|
|
10
|
+
* ┌─ Proxy ─────────────────────────────────────────┐
|
|
11
|
+
* │ status: running │
|
|
12
|
+
* │ port: 3456 │
|
|
13
|
+
* │ oauth: healthy (expires in 7h 41m) │
|
|
14
|
+
* │ requests: 247 │
|
|
15
|
+
* └─────────────────────────────────────────────────┘
|
|
16
|
+
* ┌─ Config ────────────────────────────────────────┐
|
|
17
|
+
* │ source: ~/.dario/config.json │
|
|
18
|
+
* │ schema: v1 │
|
|
19
|
+
* │ …per-knob effective values (read-only) │
|
|
20
|
+
* └─────────────────────────────────────────────────┘
|
|
21
|
+
*/
|
|
22
|
+
import type { Tab, TabContext } from '../tab.js';
|
|
23
|
+
export interface StatusState {
|
|
24
|
+
loading: boolean;
|
|
25
|
+
/** Proxy /health response, or null if unreachable. */
|
|
26
|
+
health: {
|
|
27
|
+
status: string;
|
|
28
|
+
oauth: string;
|
|
29
|
+
expiresIn?: string;
|
|
30
|
+
requests?: number;
|
|
31
|
+
} | null;
|
|
32
|
+
/** Config-file load source: file | missing | invalid. */
|
|
33
|
+
configSource: 'file' | 'missing' | 'invalid' | null;
|
|
34
|
+
/** Last refresh timestamp (ms). */
|
|
35
|
+
lastRefreshAt: number;
|
|
36
|
+
/** Error from the last refresh attempt, if any. */
|
|
37
|
+
error: string | null;
|
|
38
|
+
}
|
|
39
|
+
export declare const StatusTab: Tab<StatusState>;
|
|
40
|
+
/**
|
|
41
|
+
* Refresh the Status tab's data — probe /health, load config file
|
|
42
|
+
* metadata. Exported separately so the parent can re-invoke on key
|
|
43
|
+
* 'r' without re-running the full onMount flow.
|
|
44
|
+
*/
|
|
45
|
+
export declare function refreshStatus(ctx: TabContext): Promise<StatusState>;
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Status tab — at-a-glance proxy + auth + config-source view.
|
|
3
|
+
*
|
|
4
|
+
* Read-mostly. On mount: probe /health for proxy reachability; load
|
|
5
|
+
* config-file metadata locally. On any key, return undefined (no
|
|
6
|
+
* mutations from this tab).
|
|
7
|
+
*
|
|
8
|
+
* Layout:
|
|
9
|
+
*
|
|
10
|
+
* ┌─ Proxy ─────────────────────────────────────────┐
|
|
11
|
+
* │ status: running │
|
|
12
|
+
* │ port: 3456 │
|
|
13
|
+
* │ oauth: healthy (expires in 7h 41m) │
|
|
14
|
+
* │ requests: 247 │
|
|
15
|
+
* └─────────────────────────────────────────────────┘
|
|
16
|
+
* ┌─ Config ────────────────────────────────────────┐
|
|
17
|
+
* │ source: ~/.dario/config.json │
|
|
18
|
+
* │ schema: v1 │
|
|
19
|
+
* │ …per-knob effective values (read-only) │
|
|
20
|
+
* └─────────────────────────────────────────────────┘
|
|
21
|
+
*/
|
|
22
|
+
import { fg, dim, brand } from '../render.js';
|
|
23
|
+
import { renderKvRow } from '../layout.js';
|
|
24
|
+
export const StatusTab = {
|
|
25
|
+
id: 'status',
|
|
26
|
+
label: 'Status',
|
|
27
|
+
hotkey: 's',
|
|
28
|
+
initialState() {
|
|
29
|
+
return {
|
|
30
|
+
loading: true,
|
|
31
|
+
health: null,
|
|
32
|
+
configSource: null,
|
|
33
|
+
lastRefreshAt: 0,
|
|
34
|
+
error: null,
|
|
35
|
+
};
|
|
36
|
+
},
|
|
37
|
+
async onMount(_state, ctx) {
|
|
38
|
+
return refreshStatus(ctx);
|
|
39
|
+
},
|
|
40
|
+
onKey(state, key) {
|
|
41
|
+
// `r` triggers a manual refresh by signaling the parent to call
|
|
42
|
+
// onMount again. The parent watches for a sentinel state.
|
|
43
|
+
if (key.name === 'printable' && key.ch === 'r' && !key.ctrl) {
|
|
44
|
+
return { ...state, loading: true };
|
|
45
|
+
}
|
|
46
|
+
return undefined;
|
|
47
|
+
},
|
|
48
|
+
render(state, dim_) {
|
|
49
|
+
const lines = [];
|
|
50
|
+
const w = dim_.cols;
|
|
51
|
+
if (state.loading && !state.health) {
|
|
52
|
+
lines.push('');
|
|
53
|
+
lines.push(' ' + dim('Loading status…'));
|
|
54
|
+
return lines.join('\n');
|
|
55
|
+
}
|
|
56
|
+
// ── Proxy section ──────────────────────────────────────────
|
|
57
|
+
lines.push(' ' + brand('Proxy'));
|
|
58
|
+
if (state.health) {
|
|
59
|
+
lines.push(' ' + renderKvRow('Status', fg('green', state.health.status), w - 4));
|
|
60
|
+
lines.push(' ' + renderKvRow('OAuth', formatOauth(state.health.oauth, state.health.expiresIn), w - 4));
|
|
61
|
+
lines.push(' ' + renderKvRow('Requests', String(state.health.requests ?? 0), w - 4));
|
|
62
|
+
}
|
|
63
|
+
else {
|
|
64
|
+
lines.push(' ' + renderKvRow('Status', fg('red', 'unreachable — is `dario proxy` running?'), w - 4));
|
|
65
|
+
if (state.error) {
|
|
66
|
+
lines.push(' ' + renderKvRow('Error', dim(state.error), w - 4));
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
lines.push('');
|
|
70
|
+
// ── Config section ─────────────────────────────────────────
|
|
71
|
+
lines.push(' ' + brand('Config'));
|
|
72
|
+
const sourceLabel = state.configSource === 'file' ? '~/.dario/config.json'
|
|
73
|
+
: state.configSource === 'missing' ? dim('(no file — using defaults)')
|
|
74
|
+
: state.configSource === 'invalid' ? fg('yellow', '(file present but invalid — using defaults)')
|
|
75
|
+
: dim('not loaded');
|
|
76
|
+
lines.push(' ' + renderKvRow('Source', sourceLabel, w - 4));
|
|
77
|
+
lines.push('');
|
|
78
|
+
// ── Footer hint ────────────────────────────────────────────
|
|
79
|
+
lines.push('');
|
|
80
|
+
lines.push(' ' + dim(`Last refresh: ${formatAgo(state.lastRefreshAt)}. Press ${fg('cyan', 'r')} to refresh.`));
|
|
81
|
+
return lines.join('\n');
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
/**
|
|
85
|
+
* Refresh the Status tab's data — probe /health, load config file
|
|
86
|
+
* metadata. Exported separately so the parent can re-invoke on key
|
|
87
|
+
* 'r' without re-running the full onMount flow.
|
|
88
|
+
*/
|
|
89
|
+
export async function refreshStatus(ctx) {
|
|
90
|
+
const { loadConfig } = await import('../../config-file.js');
|
|
91
|
+
const fileResult = loadConfig();
|
|
92
|
+
let health = null;
|
|
93
|
+
let error = null;
|
|
94
|
+
try {
|
|
95
|
+
const h = await ctx.client.health();
|
|
96
|
+
health = h;
|
|
97
|
+
}
|
|
98
|
+
catch (e) {
|
|
99
|
+
error = e.message;
|
|
100
|
+
}
|
|
101
|
+
return {
|
|
102
|
+
loading: false,
|
|
103
|
+
health,
|
|
104
|
+
configSource: fileResult.source,
|
|
105
|
+
lastRefreshAt: Date.now(),
|
|
106
|
+
error,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
function formatOauth(label, expiresIn) {
|
|
110
|
+
if (label === 'healthy') {
|
|
111
|
+
return fg('green', expiresIn ? `healthy (expires in ${expiresIn})` : 'healthy');
|
|
112
|
+
}
|
|
113
|
+
if (label === 'expired')
|
|
114
|
+
return fg('yellow', 'expired (refresh on next request)');
|
|
115
|
+
if (label === 'broken')
|
|
116
|
+
return fg('red', 'broken — run `dario login`');
|
|
117
|
+
if (label === 'none')
|
|
118
|
+
return dim('no credentials');
|
|
119
|
+
return label;
|
|
120
|
+
}
|
|
121
|
+
function formatAgo(ts) {
|
|
122
|
+
if (ts === 0)
|
|
123
|
+
return 'never';
|
|
124
|
+
const secs = Math.floor((Date.now() - ts) / 1000);
|
|
125
|
+
if (secs < 1)
|
|
126
|
+
return 'just now';
|
|
127
|
+
if (secs < 60)
|
|
128
|
+
return `${secs}s ago`;
|
|
129
|
+
if (secs < 3600)
|
|
130
|
+
return `${Math.floor(secs / 60)}m ago`;
|
|
131
|
+
return `${Math.floor(secs / 3600)}h ago`;
|
|
132
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Top-level TuiApp — composes the six tabs into a single App<S>.
|
|
3
|
+
*
|
|
4
|
+
* Responsibilities:
|
|
5
|
+
* - Render the header + tab strip + active tab body + footer
|
|
6
|
+
* - Route keys: Tab cycles, q quits, hotkeys jump, else delegate
|
|
7
|
+
* - Build per-tab TabContext (client + setState + registerCleanup)
|
|
8
|
+
* - Drive an onTick heartbeat (every 250ms) for tabs that poll
|
|
9
|
+
* - Run per-tab cleanups on tab-switch or App shutdown
|
|
10
|
+
*/
|
|
11
|
+
import { type StatusState } from './tabs/status.js';
|
|
12
|
+
import { type ConfigState } from './tabs/config.js';
|
|
13
|
+
import { type AnalyticsState } from './tabs/analytics.js';
|
|
14
|
+
import { type HitsState } from './tabs/hits.js';
|
|
15
|
+
import { type AccountsState } from './tabs/accounts.js';
|
|
16
|
+
import { type BackendsState } from './tabs/backends.js';
|
|
17
|
+
/**
|
|
18
|
+
* Composite state shape — one slice per tab plus the activeTab index.
|
|
19
|
+
* Each slice is held with its own static type so tabs don't lose
|
|
20
|
+
* type-safety; the parent dispatcher does the type-erasure when
|
|
21
|
+
* routing setState calls.
|
|
22
|
+
*/
|
|
23
|
+
export interface TuiState {
|
|
24
|
+
activeTab: number;
|
|
25
|
+
exiting: boolean;
|
|
26
|
+
status: StatusState;
|
|
27
|
+
config: ConfigState;
|
|
28
|
+
analytics: AnalyticsState;
|
|
29
|
+
hits: HitsState;
|
|
30
|
+
accounts: AccountsState;
|
|
31
|
+
backends: BackendsState;
|
|
32
|
+
}
|
|
33
|
+
export interface TuiAppOpts {
|
|
34
|
+
/** Base URL of the running proxy. Defaults to http://127.0.0.1:3456. */
|
|
35
|
+
proxyUrl?: string;
|
|
36
|
+
/** API key for the proxy (if DARIO_API_KEY is set). */
|
|
37
|
+
apiKey?: string;
|
|
38
|
+
/** dario package version, displayed in the header. */
|
|
39
|
+
version: string;
|
|
40
|
+
}
|
|
41
|
+
export declare function startTuiApp(opts: TuiAppOpts): Promise<void>;
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Top-level TuiApp — composes the six tabs into a single App<S>.
|
|
3
|
+
*
|
|
4
|
+
* Responsibilities:
|
|
5
|
+
* - Render the header + tab strip + active tab body + footer
|
|
6
|
+
* - Route keys: Tab cycles, q quits, hotkeys jump, else delegate
|
|
7
|
+
* - Build per-tab TabContext (client + setState + registerCleanup)
|
|
8
|
+
* - Drive an onTick heartbeat (every 250ms) for tabs that poll
|
|
9
|
+
* - Run per-tab cleanups on tab-switch or App shutdown
|
|
10
|
+
*/
|
|
11
|
+
import { App } from './app.js';
|
|
12
|
+
import { ProxyClient } from './proxy-client.js';
|
|
13
|
+
import { fg, dim } from './render.js';
|
|
14
|
+
import { renderFooter, renderHeader, renderTabStrip } from './layout.js';
|
|
15
|
+
import { StatusTab } from './tabs/status.js';
|
|
16
|
+
import { ConfigTab } from './tabs/config.js';
|
|
17
|
+
import { AnalyticsTab } from './tabs/analytics.js';
|
|
18
|
+
import { HitsTab } from './tabs/hits.js';
|
|
19
|
+
import { AccountsTab } from './tabs/accounts.js';
|
|
20
|
+
import { BackendsTab } from './tabs/backends.js';
|
|
21
|
+
const TICK_MS = 250;
|
|
22
|
+
const TABS = [
|
|
23
|
+
StatusTab,
|
|
24
|
+
ConfigTab,
|
|
25
|
+
AnalyticsTab,
|
|
26
|
+
HitsTab,
|
|
27
|
+
AccountsTab,
|
|
28
|
+
BackendsTab,
|
|
29
|
+
];
|
|
30
|
+
export function startTuiApp(opts) {
|
|
31
|
+
const proxyUrl = opts.proxyUrl ?? 'http://127.0.0.1:3456';
|
|
32
|
+
const client = new ProxyClient({ baseUrl: proxyUrl, apiKey: opts.apiKey });
|
|
33
|
+
// Per-tab cleanup queues. Each entry is the cleanup fns the tab
|
|
34
|
+
// registered during its current mount. When the user switches tabs,
|
|
35
|
+
// we run + clear the OLD tab's cleanups before mounting the new one.
|
|
36
|
+
const cleanupsByTab = new Map();
|
|
37
|
+
for (let i = 0; i < TABS.length; i++)
|
|
38
|
+
cleanupsByTab.set(i, []);
|
|
39
|
+
const initialState = {
|
|
40
|
+
activeTab: 0,
|
|
41
|
+
exiting: false,
|
|
42
|
+
status: StatusTab.initialState(),
|
|
43
|
+
config: ConfigTab.initialState(),
|
|
44
|
+
analytics: AnalyticsTab.initialState(),
|
|
45
|
+
hits: HitsTab.initialState(),
|
|
46
|
+
accounts: AccountsTab.initialState(),
|
|
47
|
+
backends: BackendsTab.initialState(),
|
|
48
|
+
};
|
|
49
|
+
// Forward-declared App ref so the onKey closure can reference it
|
|
50
|
+
// before construction. TS needs the explicit annotation since
|
|
51
|
+
// strict mode disallows referencing a let-binding inside its
|
|
52
|
+
// own initializer expression.
|
|
53
|
+
let app;
|
|
54
|
+
app = new App({
|
|
55
|
+
initialState,
|
|
56
|
+
render: (state, dim_) => renderTui(state, dim_, opts.version, proxyUrl),
|
|
57
|
+
onKey: (state, key) => onKey(state, key, app, client, cleanupsByTab),
|
|
58
|
+
afterFrame: () => { },
|
|
59
|
+
});
|
|
60
|
+
// Mount the initial tab. The first tab's onMount fires before the
|
|
61
|
+
// first redraw — that means the loading state shows briefly until
|
|
62
|
+
// the async data arrives. Acceptable for v4.0; could be optimized
|
|
63
|
+
// by pre-fetching before app.start().
|
|
64
|
+
void mountTab(app, client, cleanupsByTab, initialState.activeTab);
|
|
65
|
+
// Tick heartbeat. Calls the active tab's onTick (if any) every
|
|
66
|
+
// TICK_MS. Each tab decides whether to act.
|
|
67
|
+
const tickInterval = setInterval(() => {
|
|
68
|
+
const s = app.getState();
|
|
69
|
+
const tab = TABS[s.activeTab];
|
|
70
|
+
if (tab.onTick) {
|
|
71
|
+
const ctx = makeContext(app, client, cleanupsByTab, s.activeTab);
|
|
72
|
+
tab.onTick(stateOf(s, s.activeTab), ctx);
|
|
73
|
+
}
|
|
74
|
+
}, TICK_MS);
|
|
75
|
+
// Global cleanup — fires when app.start()'s returned promise resolves
|
|
76
|
+
// (i.e. when App.stop() runs).
|
|
77
|
+
return app.start().finally(() => {
|
|
78
|
+
clearInterval(tickInterval);
|
|
79
|
+
// Run all per-tab cleanups
|
|
80
|
+
for (const fns of cleanupsByTab.values()) {
|
|
81
|
+
for (const fn of fns) {
|
|
82
|
+
try {
|
|
83
|
+
fn();
|
|
84
|
+
}
|
|
85
|
+
catch { /* ignore */ }
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
// ── Wiring ─────────────────────────────────────────────────────────
|
|
91
|
+
function onKey(state, key, app, client, cleanupsByTab) {
|
|
92
|
+
// ── Global keys ────────────────────────────────────────────
|
|
93
|
+
// q quits — but only when NOT inside an edit field (the Config
|
|
94
|
+
// tab's editor uses 'q' as a literal character).
|
|
95
|
+
const tab = TABS[state.activeTab];
|
|
96
|
+
const inEdit = state.activeTab === 1 /* config */ && state.config.editBuffer !== null;
|
|
97
|
+
if (!inEdit) {
|
|
98
|
+
if (key.name === 'printable' && key.ch === 'q' && !key.ctrl) {
|
|
99
|
+
app.stop();
|
|
100
|
+
return { ...state, exiting: true };
|
|
101
|
+
}
|
|
102
|
+
// Tab cycles forward; Shift+Tab cycles back. Some terminals send
|
|
103
|
+
// Shift+Tab as ESC[Z (BackTab) — parseKeys reports as 'unknown'
|
|
104
|
+
// currently. Listed for v4.x.
|
|
105
|
+
if (key.name === 'tab') {
|
|
106
|
+
return switchTab(state, (state.activeTab + 1) % TABS.length, app, client, cleanupsByTab);
|
|
107
|
+
}
|
|
108
|
+
// Hotkey jump
|
|
109
|
+
if (key.name === 'printable' && !key.ctrl) {
|
|
110
|
+
for (let i = 0; i < TABS.length; i++) {
|
|
111
|
+
if (TABS[i].hotkey === key.ch) {
|
|
112
|
+
return switchTab(state, i, app, client, cleanupsByTab);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
// Delegate to the active tab's onKey
|
|
118
|
+
const tabSlice = stateOf(state, state.activeTab);
|
|
119
|
+
const nextSlice = tab.onKey?.(tabSlice, key);
|
|
120
|
+
if (nextSlice !== undefined && nextSlice !== tabSlice) {
|
|
121
|
+
return withTabState(state, state.activeTab, nextSlice);
|
|
122
|
+
}
|
|
123
|
+
return undefined;
|
|
124
|
+
}
|
|
125
|
+
function switchTab(state, newIdx, app, client, cleanupsByTab) {
|
|
126
|
+
if (newIdx === state.activeTab)
|
|
127
|
+
return state;
|
|
128
|
+
// Run OLD tab's cleanup queue + clear it
|
|
129
|
+
const oldCleanups = cleanupsByTab.get(state.activeTab) ?? [];
|
|
130
|
+
for (const fn of oldCleanups) {
|
|
131
|
+
try {
|
|
132
|
+
fn();
|
|
133
|
+
}
|
|
134
|
+
catch { /* ignore */ }
|
|
135
|
+
}
|
|
136
|
+
cleanupsByTab.set(state.activeTab, []);
|
|
137
|
+
// Call the old tab's onUnmount (synchronous-only)
|
|
138
|
+
const oldTab = TABS[state.activeTab];
|
|
139
|
+
oldTab.onUnmount?.(stateOf(state, state.activeTab));
|
|
140
|
+
// Mount the new tab (fire-and-forget; async state updates via setState)
|
|
141
|
+
void mountTab(app, client, cleanupsByTab, newIdx);
|
|
142
|
+
return { ...state, activeTab: newIdx };
|
|
143
|
+
}
|
|
144
|
+
async function mountTab(app, client, cleanupsByTab, idx) {
|
|
145
|
+
const tab = TABS[idx];
|
|
146
|
+
if (!tab.onMount)
|
|
147
|
+
return;
|
|
148
|
+
const ctx = makeContext(app, client, cleanupsByTab, idx);
|
|
149
|
+
const sliceBefore = stateOf(app.getState(), idx);
|
|
150
|
+
const result = await tab.onMount(sliceBefore, ctx);
|
|
151
|
+
if (result !== undefined) {
|
|
152
|
+
app.setState((s) => withTabState(s, idx, result));
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
function makeContext(app, client, cleanupsByTab, idx) {
|
|
156
|
+
return {
|
|
157
|
+
client,
|
|
158
|
+
setState: (updater) => {
|
|
159
|
+
app.setState((s) => {
|
|
160
|
+
const currentSlice = stateOf(s, idx);
|
|
161
|
+
const nextSlice = typeof updater === 'function'
|
|
162
|
+
? updater(currentSlice)
|
|
163
|
+
: { ...currentSlice, ...updater };
|
|
164
|
+
return withTabState(s, idx, nextSlice);
|
|
165
|
+
});
|
|
166
|
+
},
|
|
167
|
+
registerCleanup: (fn) => {
|
|
168
|
+
cleanupsByTab.get(idx)?.push(fn);
|
|
169
|
+
},
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
/** Read the state slice for tab `idx`. */
|
|
173
|
+
function stateOf(s, idx) {
|
|
174
|
+
const key = TABS[idx].id;
|
|
175
|
+
return s[key];
|
|
176
|
+
}
|
|
177
|
+
/** Return a new TuiState with the slice for tab `idx` replaced. */
|
|
178
|
+
function withTabState(s, idx, sliceVal) {
|
|
179
|
+
const key = TABS[idx].id;
|
|
180
|
+
return { ...s, [key]: sliceVal };
|
|
181
|
+
}
|
|
182
|
+
// ── Rendering ───────────────────────────────────────────────────
|
|
183
|
+
function renderTui(state, dim_, version, proxyUrl) {
|
|
184
|
+
const cols = dim_.cols;
|
|
185
|
+
const rows = dim_.rows;
|
|
186
|
+
const out = [];
|
|
187
|
+
// Row 1: header
|
|
188
|
+
out.push(renderHeader(cols, { version, status: proxyUrl }));
|
|
189
|
+
// Row 2: tab strip
|
|
190
|
+
const tabLabels = TABS.map(t => t.label);
|
|
191
|
+
out.push(renderTabStrip(cols, tabLabels, state.activeTab));
|
|
192
|
+
out.push(dim('─'.repeat(cols)));
|
|
193
|
+
// Body — passed (cols, rows-5) so the tab knows it has rows 4..rows-2
|
|
194
|
+
const bodyRows = rows - 5;
|
|
195
|
+
const tab = TABS[state.activeTab];
|
|
196
|
+
const slice = stateOf(state, state.activeTab);
|
|
197
|
+
const body = tab.render(slice, { cols, rows: bodyRows });
|
|
198
|
+
out.push(body);
|
|
199
|
+
// Footer — fixed key hints (tab-cycling stays universal; per-tab
|
|
200
|
+
// hints are inside each tab body to keep the global footer stable).
|
|
201
|
+
const footerHints = [
|
|
202
|
+
{ key: 'Tab', label: 'next tab' },
|
|
203
|
+
{ key: 'q', label: 'quit' },
|
|
204
|
+
{ key: 'r', label: 'refresh' },
|
|
205
|
+
];
|
|
206
|
+
// Pad body to fill rows before the footer so the footer's row stays
|
|
207
|
+
// at the bottom (close to it — slight underflow OK; row count
|
|
208
|
+
// depends on each tab's content).
|
|
209
|
+
const bodyLines = body.split('\n').length;
|
|
210
|
+
if (bodyLines < bodyRows) {
|
|
211
|
+
out.push(''.padEnd(bodyRows - bodyLines, '\n'));
|
|
212
|
+
}
|
|
213
|
+
out.push(renderFooter(cols, footerHints));
|
|
214
|
+
// Connect with newlines
|
|
215
|
+
void fg; // silence unused if neither tab uses it through this module
|
|
216
|
+
return out.join('\n');
|
|
217
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@askalf/dario",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "4.0.1",
|
|
4
4
|
"description": "Use your Claude Pro/Max subscription in any tool — Cursor, Cline, Aider, the Agent SDK, your scripts — at subscription pricing, not per-token API bills. One local Anthropic + OpenAI-compatible endpoint.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|