@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,53 @@
1
+ /**
2
+ * Analytics tab — rolling-window summary + per-model + rate-limit bars.
3
+ *
4
+ * Polls /analytics on the running proxy every 2s. Renders:
5
+ *
6
+ * - Top-line counters (requests, tokens in/out, cache hit, cost saved)
7
+ * - Per-model bars (request share by model)
8
+ * - Rate-limit bars (5h / 7d utilization)
9
+ * - Billing-bucket breakdown (subscription vs extra-usage vs api)
10
+ *
11
+ * State machine is straightforward — fetch + cache; no key interaction
12
+ * beyond 'r' for forced refresh.
13
+ */
14
+ import type { Tab } from '../tab.js';
15
+ /** Subset of AnalyticsSummary the Analytics tab actually renders. */
16
+ interface SummaryShape {
17
+ window: {
18
+ minutes: number;
19
+ requests: number;
20
+ totalInputTokens: number;
21
+ totalOutputTokens: number;
22
+ totalThinkingTokens: number;
23
+ estimatedCost: number;
24
+ avgLatencyMs: number;
25
+ subscriptionPercent: number;
26
+ billingBucketBreakdown: Record<string, number>;
27
+ };
28
+ allTime: {
29
+ requests: number;
30
+ };
31
+ perModel: Record<string, {
32
+ requests: number;
33
+ totalInputTokens: number;
34
+ totalOutputTokens: number;
35
+ }>;
36
+ utilization: {
37
+ lastUtil5h: number;
38
+ lastUtil7d: number;
39
+ };
40
+ }
41
+ export interface AnalyticsState {
42
+ summary: SummaryShape | null;
43
+ loading: boolean;
44
+ error: string | null;
45
+ lastFetchAt: number;
46
+ /**
47
+ * If true, ignore the polling cadence and refetch on the next tick.
48
+ * Set by the 'r' key handler.
49
+ */
50
+ forceRefresh: boolean;
51
+ }
52
+ export declare const AnalyticsTab: Tab<AnalyticsState>;
53
+ export {};
@@ -0,0 +1,161 @@
1
+ /**
2
+ * Analytics tab — rolling-window summary + per-model + rate-limit bars.
3
+ *
4
+ * Polls /analytics on the running proxy every 2s. Renders:
5
+ *
6
+ * - Top-line counters (requests, tokens in/out, cache hit, cost saved)
7
+ * - Per-model bars (request share by model)
8
+ * - Rate-limit bars (5h / 7d utilization)
9
+ * - Billing-bucket breakdown (subscription vs extra-usage vs api)
10
+ *
11
+ * State machine is straightforward — fetch + cache; no key interaction
12
+ * beyond 'r' for forced refresh.
13
+ */
14
+ import { fg, dim, brand, progressBar, pad } from '../render.js';
15
+ import { renderKvRow } from '../layout.js';
16
+ const POLL_INTERVAL_MS = 2000;
17
+ export const AnalyticsTab = {
18
+ id: 'analytics',
19
+ label: 'Analytics',
20
+ hotkey: 'A', // capital A to avoid colliding with Accounts (a)
21
+ initialState() {
22
+ return {
23
+ summary: null,
24
+ loading: true,
25
+ error: null,
26
+ lastFetchAt: 0,
27
+ forceRefresh: false,
28
+ };
29
+ },
30
+ onMount(_state, ctx) {
31
+ void fetchSummary(ctx);
32
+ return undefined;
33
+ },
34
+ onTick(state, ctx) {
35
+ const now = Date.now();
36
+ if (state.forceRefresh) {
37
+ ctx.setState({ forceRefresh: false });
38
+ void fetchSummary(ctx);
39
+ return;
40
+ }
41
+ if (now - state.lastFetchAt >= POLL_INTERVAL_MS && !state.loading) {
42
+ void fetchSummary(ctx);
43
+ }
44
+ },
45
+ onKey(state, key) {
46
+ if (key.name === 'printable' && key.ch === 'r' && !key.ctrl) {
47
+ return { ...state, forceRefresh: true };
48
+ }
49
+ return undefined;
50
+ },
51
+ render(state, dimv) {
52
+ const lines = [];
53
+ const w = dimv.cols;
54
+ const barWidth = Math.min(36, w - 32);
55
+ lines.push(' ' + brand('Analytics') + dim(` — last ${state.summary?.window.minutes ?? 60} min`));
56
+ if (!state.summary && state.loading) {
57
+ lines.push('');
58
+ lines.push(' ' + dim('Loading…'));
59
+ return lines.join('\n');
60
+ }
61
+ if (!state.summary && state.error) {
62
+ lines.push('');
63
+ lines.push(' ' + fg('red', `Cannot reach proxy: ${state.error}`));
64
+ lines.push(' ' + dim('Start the proxy with `dario proxy`, then this view refreshes automatically.'));
65
+ return lines.join('\n');
66
+ }
67
+ if (!state.summary) {
68
+ lines.push('');
69
+ lines.push(' ' + dim('(no data yet)'));
70
+ return lines.join('\n');
71
+ }
72
+ const s = state.summary;
73
+ // ── Counters ───────────────────────────────────────────────
74
+ const rpm = s.window.requests / Math.max(1, s.window.minutes);
75
+ lines.push('');
76
+ lines.push(' ' + renderKvRow('Requests', `${s.window.requests} ${dim(`(${rpm.toFixed(1)}/min)`)}`, w - 4));
77
+ lines.push(' ' + renderKvRow('Tokens in', formatNumber(s.window.totalInputTokens), w - 4));
78
+ lines.push(' ' + renderKvRow('Tokens out', formatNumber(s.window.totalOutputTokens), w - 4));
79
+ lines.push(' ' + renderKvRow('Thinking tokens', formatNumber(s.window.totalThinkingTokens), w - 4));
80
+ lines.push(' ' + renderKvRow('Avg latency', `${Math.round(s.window.avgLatencyMs)}ms`, w - 4));
81
+ lines.push(' ' + renderKvRow('Subscription %', `${(s.window.subscriptionPercent * 100).toFixed(0)}%`, w - 4));
82
+ // ── Per-model bars ─────────────────────────────────────────
83
+ const models = Object.entries(s.perModel).sort((a, b) => b[1].requests - a[1].requests);
84
+ if (models.length > 0) {
85
+ lines.push('');
86
+ lines.push(' ' + brand('Per-model'));
87
+ const totalReq = Math.max(1, models.reduce((sum, [, m]) => sum + m.requests, 0));
88
+ for (const [name, m] of models) {
89
+ const share = m.requests / totalReq;
90
+ const sharePct = `${(share * 100).toFixed(0)}%`.padStart(4);
91
+ lines.push(' ' + pad(shortenModelName(name), 18) +
92
+ fg('green', progressBar(share, barWidth)) +
93
+ ' ' + dim(`${sharePct} (${m.requests})`));
94
+ }
95
+ }
96
+ // ── Rate-limit ────────────────────────────────────────────
97
+ lines.push('');
98
+ lines.push(' ' + brand('Rate-limit'));
99
+ lines.push(' ' + pad('5h', 6) +
100
+ fg('cyan', progressBar(s.utilization.lastUtil5h, barWidth)) +
101
+ ' ' + dim(`${(s.utilization.lastUtil5h * 100).toFixed(0)}%`));
102
+ lines.push(' ' + pad('7d', 6) +
103
+ fg('cyan', progressBar(s.utilization.lastUtil7d, barWidth)) +
104
+ ' ' + dim(`${(s.utilization.lastUtil7d * 100).toFixed(0)}%`));
105
+ // ── Billing buckets ───────────────────────────────────────
106
+ const buckets = s.window.billingBucketBreakdown;
107
+ const totalBucketCount = Object.values(buckets).reduce((a, b) => a + b, 0);
108
+ if (totalBucketCount > 0) {
109
+ lines.push('');
110
+ lines.push(' ' + brand('Billing'));
111
+ for (const [bucket, count] of Object.entries(buckets)) {
112
+ if (count === 0)
113
+ continue;
114
+ lines.push(' ' + pad(bucket, 22) + dim(`${count} req`));
115
+ }
116
+ }
117
+ // Footer
118
+ lines.push('');
119
+ lines.push(' ' + dim(`Updated ${ago(state.lastFetchAt)}. Press ${fg('cyan', 'r')} to refresh.`));
120
+ return lines.join('\n');
121
+ },
122
+ };
123
+ async function fetchSummary(ctx) {
124
+ ctx.setState({ loading: true });
125
+ try {
126
+ const s = await ctx.client.getJson('/analytics');
127
+ ctx.setState({
128
+ summary: s,
129
+ loading: false,
130
+ lastFetchAt: Date.now(),
131
+ error: null,
132
+ });
133
+ }
134
+ catch (e) {
135
+ ctx.setState({
136
+ loading: false,
137
+ lastFetchAt: Date.now(),
138
+ error: e.message,
139
+ });
140
+ }
141
+ }
142
+ function formatNumber(n) {
143
+ if (n >= 1_000_000)
144
+ return (n / 1_000_000).toFixed(1) + 'M';
145
+ if (n >= 1000)
146
+ return (n / 1000).toFixed(1) + 'k';
147
+ return String(n);
148
+ }
149
+ function shortenModelName(model) {
150
+ return model.replace(/^claude-/, '').slice(0, 18);
151
+ }
152
+ function ago(ts) {
153
+ if (ts === 0)
154
+ return 'never';
155
+ const sec = Math.floor((Date.now() - ts) / 1000);
156
+ if (sec < 1)
157
+ return 'just now';
158
+ if (sec < 60)
159
+ return `${sec}s ago`;
160
+ return `${Math.floor(sec / 60)}m ago`;
161
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Backends tab — OpenAI-compat backends configured locally.
3
+ *
4
+ * Read-only view. apiKey is masked. Mutations via CLI:
5
+ * dario backend add <name> --key=sk-... [--base-url=...]
6
+ * dario backend remove <name>
7
+ */
8
+ import type { Tab } from '../tab.js';
9
+ export interface BackendsState {
10
+ loading: boolean;
11
+ backends: Array<{
12
+ name: string;
13
+ provider: string;
14
+ baseUrl: string;
15
+ }>;
16
+ error: string | null;
17
+ }
18
+ export declare const BackendsTab: Tab<BackendsState>;
19
+ export declare function refreshBackends(): Promise<BackendsState>;
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Backends tab — OpenAI-compat backends configured locally.
3
+ *
4
+ * Read-only view. apiKey is masked. Mutations via CLI:
5
+ * dario backend add <name> --key=sk-... [--base-url=...]
6
+ * dario backend remove <name>
7
+ */
8
+ import { fg, dim, brand, pad } from '../render.js';
9
+ export const BackendsTab = {
10
+ id: 'backends',
11
+ label: 'Backends',
12
+ hotkey: 'b',
13
+ initialState() {
14
+ return { loading: true, backends: [], error: null };
15
+ },
16
+ async onMount() {
17
+ return refreshBackends();
18
+ },
19
+ onKey(state, key) {
20
+ if (key.name === 'printable' && key.ch === 'r' && !key.ctrl) {
21
+ return { ...state, loading: true };
22
+ }
23
+ return undefined;
24
+ },
25
+ render(state, dimv) {
26
+ const lines = [];
27
+ const w = dimv.cols;
28
+ lines.push(' ' + brand('OpenAI-compat Backends'));
29
+ if (state.loading && state.backends.length === 0) {
30
+ lines.push('');
31
+ lines.push(' ' + dim('Loading backends…'));
32
+ return lines.join('\n');
33
+ }
34
+ if (state.backends.length === 0) {
35
+ lines.push('');
36
+ lines.push(' ' + dim('No OpenAI-compat backends configured.'));
37
+ lines.push(' ' + 'Add one: ' + fg('cyan', 'dario backend add openai --key=sk-...'));
38
+ return lines.join('\n');
39
+ }
40
+ // Header
41
+ lines.push(' ' + dim(pad('name', 16) + pad('provider', 12) + pad('base url', 40)));
42
+ lines.push(' ' + dim('─'.repeat(Math.min(w - 4, 68))));
43
+ for (const b of state.backends) {
44
+ lines.push(' ' +
45
+ pad(b.name, 16) +
46
+ pad(b.provider, 12) +
47
+ b.baseUrl);
48
+ }
49
+ if (state.error) {
50
+ lines.push('');
51
+ lines.push(' ' + fg('red', `Load error: ${state.error}`));
52
+ }
53
+ lines.push('');
54
+ lines.push(' ' + dim('Mutations via CLI:'));
55
+ lines.push(' ' + fg('cyan', 'dario backend add <name> --key=sk-... [--base-url=...]'));
56
+ lines.push(' ' + fg('cyan', 'dario backend remove <name>'));
57
+ return lines.join('\n');
58
+ },
59
+ };
60
+ export async function refreshBackends() {
61
+ try {
62
+ const { listBackends } = await import('../../openai-backend.js');
63
+ const all = await listBackends();
64
+ return {
65
+ loading: false,
66
+ backends: all.map(b => ({
67
+ name: b.name,
68
+ provider: b.provider,
69
+ baseUrl: b.baseUrl,
70
+ })),
71
+ error: null,
72
+ };
73
+ }
74
+ catch (e) {
75
+ return { loading: false, backends: [], error: e.message };
76
+ }
77
+ }
@@ -0,0 +1,35 @@
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 type { Tab } from '../tab.js';
23
+ import { type DarioConfig } from '../../config-file.js';
24
+ export interface ConfigState {
25
+ config: DarioConfig;
26
+ /** Loaded snapshot — used to compute dirty. */
27
+ snapshot: DarioConfig;
28
+ selectedIdx: number;
29
+ /** Active edit buffer, or null when not in edit mode. */
30
+ editBuffer: string | null;
31
+ /** Transient status line (e.g. "Saved."). */
32
+ statusMessage: string | null;
33
+ statusKind: 'info' | 'success' | 'error' | null;
34
+ }
35
+ export declare const ConfigTab: Tab<ConfigState>;
@@ -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>;