@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,89 @@
1
+ /**
2
+ * Common interface every tab implements.
3
+ *
4
+ * A tab is a self-contained state machine with:
5
+ * - its own slice of state (typed locally; opaque to the parent)
6
+ * - a render function: state + viewport → string
7
+ * - a key handler: state + key → next state or undefined (no change)
8
+ * - optional lifecycle hooks for mount / unmount / periodic tick
9
+ *
10
+ * The TuiApp composes a fixed set of tabs; it doesn't dynamically
11
+ * register them at runtime. That keeps the type system happy (each
12
+ * tab's state is statically known to the parent) while still letting
13
+ * each tab evolve independently.
14
+ *
15
+ * Tabs that need to fetch data from the proxy receive a
16
+ * `TabContext` with the ProxyClient. Tabs that don't (e.g. Status,
17
+ * which reads local state only) can ignore it.
18
+ */
19
+ import type { Key } from './input.js';
20
+ import type { ProxyClient } from './proxy-client.js';
21
+ export interface TabContext<S = unknown> {
22
+ /** The proxy HTTP client. Tabs use it to fetch /analytics, subscribe to /analytics/stream, etc. */
23
+ client: ProxyClient;
24
+ /**
25
+ * Update the active tab's state slice. Tabs use this from async
26
+ * callbacks (HTTP responses, SSE messages, timers) — the synchronous
27
+ * render/onKey path returns the next state directly. The parent
28
+ * applies the updater to whatever tab is currently active.
29
+ *
30
+ * Loosely-typed across tab implementations; each tab passes its
31
+ * own S as a generic, the parent's dispatcher takes care of the
32
+ * type-erasure.
33
+ */
34
+ setState: (updater: Partial<S> | ((prev: S) => S)) => void;
35
+ /**
36
+ * Register a cleanup function that runs on tab unmount (user switches
37
+ * away) or App shutdown. Tabs use this to close SSE subscriptions,
38
+ * clear intervals, drop event listeners — anything stateful set up
39
+ * in onMount that won't garbage-collect on its own.
40
+ */
41
+ registerCleanup: (fn: () => void) => void;
42
+ }
43
+ /**
44
+ * A renderable tab. S is the tab's local state shape. The parent
45
+ * TuiApp holds a record of all tab states and dispatches based on
46
+ * the active tab id.
47
+ */
48
+ export interface Tab<S> {
49
+ /** Short id, must be unique. Used as the state-record key. */
50
+ id: string;
51
+ /** Human-readable label rendered in the tab strip. */
52
+ label: string;
53
+ /** Single-letter hotkey to jump to this tab. */
54
+ hotkey?: string;
55
+ /** Build the initial state. Called once on TuiApp construction. */
56
+ initialState(): S;
57
+ /**
58
+ * Render the tab's body region into a single string. Caller passes
59
+ * the dimensions of the body region (NOT the full screen — header,
60
+ * footer, and tab strip are drawn by the parent).
61
+ */
62
+ render(state: S, dim: {
63
+ cols: number;
64
+ rows: number;
65
+ }): string;
66
+ /**
67
+ * Handle a keypress. Return the next state (or undefined for no
68
+ * change). Global keys (Tab, q, Ctrl+C, hotkeys) are intercepted
69
+ * by the parent before reaching this; the tab only sees keys the
70
+ * parent didn't claim.
71
+ */
72
+ onKey?(state: S, key: Key): S | undefined;
73
+ /**
74
+ * Run once when this tab becomes active. May fire async work that
75
+ * calls `ctx.setState(...)` to update the slice once the data lands.
76
+ * The synchronous return value (if any) is applied before render.
77
+ */
78
+ onMount?(state: S, ctx: TabContext<S>): S | undefined | Promise<S | undefined>;
79
+ /** Run once when this tab loses focus (user switched tabs). */
80
+ onUnmount?(state: S): void;
81
+ /**
82
+ * Called by the parent's heartbeat (every 100ms by default). Tabs
83
+ * that need periodic refresh (Analytics polling, Hits stale-check)
84
+ * consult their state and decide whether to act. Don't do
85
+ * expensive work synchronously here; kick off async + call
86
+ * ctx.setState when it lands.
87
+ */
88
+ onTick?(state: S, ctx: TabContext<S>): void;
89
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Common interface every tab implements.
3
+ *
4
+ * A tab is a self-contained state machine with:
5
+ * - its own slice of state (typed locally; opaque to the parent)
6
+ * - a render function: state + viewport → string
7
+ * - a key handler: state + key → next state or undefined (no change)
8
+ * - optional lifecycle hooks for mount / unmount / periodic tick
9
+ *
10
+ * The TuiApp composes a fixed set of tabs; it doesn't dynamically
11
+ * register them at runtime. That keeps the type system happy (each
12
+ * tab's state is statically known to the parent) while still letting
13
+ * each tab evolve independently.
14
+ *
15
+ * Tabs that need to fetch data from the proxy receive a
16
+ * `TabContext` with the ProxyClient. Tabs that don't (e.g. Status,
17
+ * which reads local state only) can ignore it.
18
+ */
19
+ export {};
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Accounts tab — list of OAuth subscription accounts in the pool.
3
+ *
4
+ * Read-mostly. Mutations (add/remove) require the CLI — the tab
5
+ * shows the relevant command in the footer.
6
+ *
7
+ * Layout:
8
+ *
9
+ * ┌─ Accounts ──────────────────────────────────────┐
10
+ * │ alias expires util5h util7d │
11
+ * │ ───── ─────── ────── ────── │
12
+ * │ default 7h 41m 12% 4% │
13
+ * │ alt expired 0% 0% │
14
+ * │ … │
15
+ * └─────────────────────────────────────────────────┘
16
+ * To add: `dario accounts add <alias>`
17
+ * To remove: `dario accounts remove <alias>`
18
+ */
19
+ import type { Tab } from '../tab.js';
20
+ export interface AccountsState {
21
+ loading: boolean;
22
+ accounts: Array<{
23
+ alias: string;
24
+ expiresAt: number;
25
+ /** Optional rate-limit fields populated when /accounts endpoint exists. */
26
+ util5h?: number;
27
+ util7d?: number;
28
+ }>;
29
+ error: string | null;
30
+ }
31
+ export declare const AccountsTab: Tab<AccountsState>;
32
+ export declare function refreshAccounts(): Promise<AccountsState>;
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Accounts tab — list of OAuth subscription accounts in the pool.
3
+ *
4
+ * Read-mostly. Mutations (add/remove) require the CLI — the tab
5
+ * shows the relevant command in the footer.
6
+ *
7
+ * Layout:
8
+ *
9
+ * ┌─ Accounts ──────────────────────────────────────┐
10
+ * │ alias expires util5h util7d │
11
+ * │ ───── ─────── ────── ────── │
12
+ * │ default 7h 41m 12% 4% │
13
+ * │ alt expired 0% 0% │
14
+ * │ … │
15
+ * └─────────────────────────────────────────────────┘
16
+ * To add: `dario accounts add <alias>`
17
+ * To remove: `dario accounts remove <alias>`
18
+ */
19
+ import { fg, dim, brand, pad } from '../render.js';
20
+ import { renderKvRow } from '../layout.js';
21
+ export const AccountsTab = {
22
+ id: 'accounts',
23
+ label: 'Accounts',
24
+ hotkey: 'a',
25
+ initialState() {
26
+ return { loading: true, accounts: [], error: null };
27
+ },
28
+ async onMount(_state, _ctx) {
29
+ return refreshAccounts();
30
+ },
31
+ onKey(state, key) {
32
+ if (key.name === 'printable' && key.ch === 'r' && !key.ctrl) {
33
+ return { ...state, loading: true };
34
+ }
35
+ return undefined;
36
+ },
37
+ render(state, dimv) {
38
+ const lines = [];
39
+ const w = dimv.cols;
40
+ lines.push(' ' + brand('Accounts'));
41
+ if (state.loading && state.accounts.length === 0) {
42
+ lines.push('');
43
+ lines.push(' ' + dim('Loading accounts…'));
44
+ return lines.join('\n');
45
+ }
46
+ if (state.accounts.length === 0) {
47
+ lines.push('');
48
+ lines.push(' ' + dim('No accounts in the pool.'));
49
+ lines.push(' ' + 'Add one: ' + fg('cyan', 'dario accounts add <alias>'));
50
+ return lines.join('\n');
51
+ }
52
+ // Header row
53
+ lines.push(' ' + dim(pad('alias', 20) + pad('expires', 16) + pad('source', 24)));
54
+ lines.push(' ' + dim('─'.repeat(Math.min(w - 4, 60))));
55
+ for (const acc of state.accounts) {
56
+ const aliasCol = pad(acc.alias, 20);
57
+ const expiresCol = pad(formatExpiry(acc.expiresAt), 16);
58
+ const sourceCol = '~/.dario/accounts/' + acc.alias + '.json';
59
+ lines.push(' ' + aliasCol + expiresCol + dim(sourceCol));
60
+ }
61
+ lines.push('');
62
+ lines.push(' ' + dim('Mutations via CLI:'));
63
+ lines.push(' ' + fg('cyan', 'dario accounts add <alias>'));
64
+ lines.push(' ' + fg('cyan', 'dario accounts remove <alias>'));
65
+ // Refresh hint
66
+ lines.push('');
67
+ lines.push(' ' + renderKvRow('', '', w - 2)); // spacer
68
+ lines.push(' ' + dim(`Press ${fg('cyan', 'r')} to refresh.`));
69
+ return lines.join('\n');
70
+ },
71
+ };
72
+ export async function refreshAccounts() {
73
+ try {
74
+ const { listAccountAliases, loadAllAccounts } = await import('../../accounts.js');
75
+ const aliases = await listAccountAliases();
76
+ if (aliases.length === 0) {
77
+ // Single-account / login.json path — show a synthetic "default"
78
+ // entry sourced from ~/.dario/credentials.json or keychain.
79
+ return { loading: false, accounts: [], error: null };
80
+ }
81
+ const all = await loadAllAccounts();
82
+ return {
83
+ loading: false,
84
+ accounts: all.map(a => ({
85
+ alias: a.alias,
86
+ expiresAt: a.expiresAt,
87
+ })),
88
+ error: null,
89
+ };
90
+ }
91
+ catch (e) {
92
+ return { loading: false, accounts: [], error: e.message };
93
+ }
94
+ }
95
+ function formatExpiry(expiresAt) {
96
+ if (expiresAt === 0)
97
+ return dim('—');
98
+ const remainingMs = expiresAt - Date.now();
99
+ if (remainingMs < 0)
100
+ return fg('yellow', 'expired');
101
+ const hours = Math.floor(remainingMs / 3_600_000);
102
+ const minutes = Math.floor((remainingMs % 3_600_000) / 60_000);
103
+ if (hours > 24) {
104
+ const days = Math.floor(hours / 24);
105
+ return fg('green', `${days}d ${hours % 24}h`);
106
+ }
107
+ if (hours > 0)
108
+ return fg('green', `${hours}h ${minutes}m`);
109
+ return fg('green', `${minutes}m`);
110
+ }
@@ -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>;