@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.
@@ -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.38.6",
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": {