@askalf/dario 3.38.6 → 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.
@@ -0,0 +1,267 @@
1
+ /**
2
+ * Config tab — view + edit the persistent ~/.dario/config.json.
3
+ *
4
+ * Renders a flat list of editable fields. Each field has a typed
5
+ * editor: bool toggles inline, numbers / strings open an input prompt
6
+ * at the bottom of the panel.
7
+ *
8
+ * Keys:
9
+ * ↑↓ navigate
10
+ * Enter edit (bool: toggle; number/string: open input)
11
+ * Esc cancel input
12
+ * s save → write ~/.dario/config.json
13
+ * d discard local changes
14
+ * r reload from disk
15
+ *
16
+ * Coverage in v4.0: port, host, stealth, pacing/thinkTime/sessionStart
17
+ * sub-knobs, drainOnClose. Additional fields (preserveTools, mergeTools,
18
+ * etc.) can follow the same FieldDef pattern in v4.x without API
19
+ * change. The DarioConfig schema is the source of truth — adding a
20
+ * field there + a row here lights it up.
21
+ */
22
+ import { fg, dim, brand, inverse, pad } from '../render.js';
23
+ import { CONFIG_SCHEMA_VERSION, defaultConfig, loadConfig, saveConfig, } from '../../config-file.js';
24
+ /**
25
+ * The visible field registry. Order = display order. New fields just
26
+ * append; the tab grows automatically. Path → DarioConfig is dotted.
27
+ */
28
+ const FIELDS = [
29
+ { path: 'port', label: 'Port', type: 'number', hint: 'default 3456' },
30
+ { path: 'host', label: 'Host', type: 'string', hint: '127.0.0.1 (loopback only)' },
31
+ { path: 'stealth', label: 'Stealth preset', type: 'bool', hint: 'enables behavioural pacing + jitter' },
32
+ { path: 'drainOnClose', label: 'Drain on close', type: 'bool', hint: 'finish upstream SSE after client disconnects' },
33
+ { path: 'pacing.minMs', label: 'Pacing min (ms)', type: 'number', hint: 'min inter-request distance' },
34
+ { path: 'pacing.jitterMs', label: 'Pacing jitter (ms)', type: 'number', hint: 'uniform-random extra delay' },
35
+ { path: 'thinkTime.baseMs', label: 'Think-time base (ms)', type: 'number' },
36
+ { path: 'thinkTime.perTokenMs', label: 'Think-time per-token', type: 'number', hint: 'ms per output token of last response' },
37
+ { path: 'thinkTime.jitterMs', label: 'Think-time jitter', type: 'number' },
38
+ { path: 'thinkTime.maxMs', label: 'Think-time cap (ms)', type: 'number', hint: 'upper bound for the whole formula' },
39
+ { path: 'sessionStart.minMs', label: 'Session-start min', type: 'number', hint: 'first-request delay floor' },
40
+ { path: 'sessionStart.jitterMs', label: 'Session-start jitter', type: 'number' },
41
+ ];
42
+ export const ConfigTab = {
43
+ id: 'config',
44
+ label: 'Config',
45
+ hotkey: 'c',
46
+ initialState() {
47
+ const loaded = loadConfig();
48
+ return {
49
+ config: loaded.config,
50
+ snapshot: structuredClone(loaded.config),
51
+ selectedIdx: 0,
52
+ editBuffer: null,
53
+ statusMessage: null,
54
+ statusKind: null,
55
+ };
56
+ },
57
+ onKey(state, key) {
58
+ // ── Edit mode key handling ─────────────────────────────────
59
+ if (state.editBuffer !== null) {
60
+ if (key.name === 'escape') {
61
+ return { ...state, editBuffer: null, statusMessage: 'Edit cancelled.', statusKind: 'info' };
62
+ }
63
+ if (key.name === 'enter') {
64
+ return commitEdit(state);
65
+ }
66
+ if (key.name === 'backspace') {
67
+ return { ...state, editBuffer: state.editBuffer.slice(0, -1) };
68
+ }
69
+ if (key.name === 'printable' && !key.ctrl) {
70
+ return { ...state, editBuffer: state.editBuffer + key.ch };
71
+ }
72
+ return undefined;
73
+ }
74
+ // ── Normal mode ────────────────────────────────────────────
75
+ if (key.name === 'up') {
76
+ return { ...state, selectedIdx: Math.max(0, state.selectedIdx - 1), statusMessage: null, statusKind: null };
77
+ }
78
+ if (key.name === 'down') {
79
+ return { ...state, selectedIdx: Math.min(FIELDS.length - 1, state.selectedIdx + 1), statusMessage: null, statusKind: null };
80
+ }
81
+ if (key.name === 'enter') {
82
+ return startEdit(state);
83
+ }
84
+ if (key.name === 'printable' && !key.ctrl) {
85
+ if (key.ch === 's')
86
+ return doSave(state);
87
+ if (key.ch === 'd')
88
+ return doDiscard(state);
89
+ if (key.ch === 'r')
90
+ return doReload();
91
+ }
92
+ return undefined;
93
+ },
94
+ render(state, dimv) {
95
+ const lines = [];
96
+ const w = dimv.cols;
97
+ const labelW = 26;
98
+ const valueW = w - labelW - 6;
99
+ const dirty = isDirty(state);
100
+ const title = dirty
101
+ ? brand('Config') + dim(' — ') + fg('yellow', '● unsaved changes')
102
+ : brand('Config');
103
+ lines.push(' ' + title);
104
+ lines.push('');
105
+ for (let i = 0; i < FIELDS.length; i++) {
106
+ const field = FIELDS[i];
107
+ const value = getByPath(state.config, field.path);
108
+ const orig = getByPath(state.snapshot, field.path);
109
+ const changed = !Object.is(value, orig);
110
+ const valueRender = renderValue(field, value, changed);
111
+ const hint = field.hint ? ' ' + dim('— ' + field.hint) : '';
112
+ const row = ' ' + pad(field.label + ':', labelW) + pad(valueRender, valueW) + hint;
113
+ lines.push(i === state.selectedIdx ? inverse(row) : row);
114
+ }
115
+ // ── Edit prompt or status line ─────────────────────────────
116
+ lines.push('');
117
+ if (state.editBuffer !== null) {
118
+ const f = FIELDS[state.selectedIdx];
119
+ lines.push(' ' + fg('cyan', `Edit ${f.label}:`) + ' ' + state.editBuffer + fg('cyan', '_'));
120
+ lines.push(' ' + dim('Enter to confirm · Esc to cancel'));
121
+ }
122
+ else if (state.statusMessage) {
123
+ const color = state.statusKind === 'error' ? 'red'
124
+ : state.statusKind === 'success' ? 'green'
125
+ : 'cyan';
126
+ lines.push(' ' + fg(color, state.statusMessage));
127
+ }
128
+ else {
129
+ lines.push(' ' + dim('↑↓ navigate · Enter edit · s save · d discard · r reload'));
130
+ }
131
+ return lines.join('\n');
132
+ },
133
+ };
134
+ // ── Helpers ───────────────────────────────────────────────────
135
+ function getByPath(obj, path) {
136
+ return path.split('.').reduce((acc, part) => {
137
+ if (acc && typeof acc === 'object' && part in acc) {
138
+ return acc[part];
139
+ }
140
+ return undefined;
141
+ }, obj);
142
+ }
143
+ function setByPath(obj, path, value) {
144
+ // Guard against prototype-pollution paths. `path` is always sourced
145
+ // from FIELDS (the static registry at the top of this file), but
146
+ // CodeQL flags the recursive descent as risky because it can't
147
+ // prove that statically — and rightly so: if a future caller ever
148
+ // passes a user-controlled path, walking `__proto__` or
149
+ // `constructor` would mutate Object.prototype. Reject those
150
+ // segments explicitly so the seam is safe by construction.
151
+ const parts = path.split('.');
152
+ for (const part of parts) {
153
+ if (part === '__proto__' || part === 'constructor' || part === 'prototype') {
154
+ throw new Error(`refusing to set forbidden path segment: ${part}`);
155
+ }
156
+ }
157
+ const next = structuredClone(obj);
158
+ let cursor = next;
159
+ for (let i = 0; i < parts.length - 1; i++) {
160
+ const part = parts[i];
161
+ // Object.prototype.hasOwnProperty.call so we don't accidentally
162
+ // pick up inherited keys when probing for existing nested groups.
163
+ if (!Object.prototype.hasOwnProperty.call(cursor, part)
164
+ || typeof cursor[part] !== 'object'
165
+ || cursor[part] === null) {
166
+ cursor[part] = {};
167
+ }
168
+ cursor = cursor[part];
169
+ }
170
+ cursor[parts[parts.length - 1]] = value;
171
+ return next;
172
+ }
173
+ function renderValue(field, value, changed) {
174
+ let text;
175
+ if (field.type === 'bool')
176
+ text = value === true ? 'on' : 'off';
177
+ else if (value === null || value === undefined)
178
+ text = '—';
179
+ else
180
+ text = String(value);
181
+ // Yellow if changed-from-snapshot; green for bool-on; default otherwise
182
+ if (changed)
183
+ return fg('yellow', text);
184
+ if (field.type === 'bool' && value === true)
185
+ return fg('green', text);
186
+ return text;
187
+ }
188
+ function startEdit(state) {
189
+ const f = FIELDS[state.selectedIdx];
190
+ if (f.type === 'bool') {
191
+ // Toggle in place
192
+ const current = getByPath(state.config, f.path);
193
+ const next = setByPath(state.config, f.path, !current);
194
+ return { ...state, config: next, statusMessage: null, statusKind: null };
195
+ }
196
+ // String / number: open the prompt with the current value
197
+ const current = getByPath(state.config, f.path);
198
+ return { ...state, editBuffer: current === null || current === undefined ? '' : String(current) };
199
+ }
200
+ function commitEdit(state) {
201
+ if (state.editBuffer === null)
202
+ return state;
203
+ const f = FIELDS[state.selectedIdx];
204
+ let parsed;
205
+ if (f.type === 'number') {
206
+ if (state.editBuffer === '') {
207
+ // Empty number → null (clears the override)
208
+ parsed = null;
209
+ }
210
+ else {
211
+ const n = Number(state.editBuffer);
212
+ if (!Number.isFinite(n)) {
213
+ return { ...state, editBuffer: null, statusMessage: `Not a number: "${state.editBuffer}"`, statusKind: 'error' };
214
+ }
215
+ parsed = n;
216
+ }
217
+ }
218
+ else {
219
+ parsed = state.editBuffer;
220
+ }
221
+ const next = setByPath(state.config, f.path, parsed);
222
+ return { ...state, config: next, editBuffer: null, statusMessage: `Updated ${f.label}.`, statusKind: 'success' };
223
+ }
224
+ function doSave(state) {
225
+ try {
226
+ saveConfig(undefined, { ...state.config, version: CONFIG_SCHEMA_VERSION });
227
+ return {
228
+ ...state,
229
+ snapshot: structuredClone(state.config),
230
+ statusMessage: 'Saved to ~/.dario/config.json',
231
+ statusKind: 'success',
232
+ };
233
+ }
234
+ catch (err) {
235
+ return {
236
+ ...state,
237
+ statusMessage: `Save failed: ${err.message}`,
238
+ statusKind: 'error',
239
+ };
240
+ }
241
+ }
242
+ function doDiscard(state) {
243
+ return {
244
+ ...state,
245
+ config: structuredClone(state.snapshot),
246
+ statusMessage: 'Local changes discarded.',
247
+ statusKind: 'info',
248
+ };
249
+ }
250
+ function doReload() {
251
+ const loaded = loadConfig();
252
+ return {
253
+ config: loaded.config,
254
+ snapshot: structuredClone(loaded.config),
255
+ selectedIdx: 0,
256
+ editBuffer: null,
257
+ statusMessage: loaded.source === 'file' ? 'Reloaded from disk.'
258
+ : loaded.source === 'missing' ? 'No file on disk — showing defaults.'
259
+ : `Invalid file: ${loaded.error}`,
260
+ statusKind: loaded.source === 'invalid' ? 'error' : 'info',
261
+ };
262
+ }
263
+ function isDirty(state) {
264
+ return JSON.stringify(state.config) !== JSON.stringify(state.snapshot);
265
+ }
266
+ // Avoid "unused" lint
267
+ void defaultConfig;
@@ -0,0 +1,34 @@
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 type { Tab } from '../tab.js';
27
+ import type { RequestRecord } from '../../analytics.js';
28
+ export interface HitsState {
29
+ buffer: RequestRecord[];
30
+ selectedIdx: number;
31
+ subscribed: boolean;
32
+ connectionError: string | null;
33
+ }
34
+ export declare const HitsTab: Tab<HitsState>;
@@ -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>;