@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,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>;
|