@askalf/dario 5.4.8 → 5.4.10

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,45 @@
1
+ /**
2
+ * Panel fitting — let a tab spend its row budget deliberately instead of
3
+ * rendering everything and being clipped from the bottom.
4
+ *
5
+ * Background (#862, #866, #868): tabs used to push every panel
6
+ * unconditionally. `renderTui` clips an over-long body so the header and
7
+ * tab strip survive, but clipping is dumb — it drops whatever happens to
8
+ * sort last. On the Status tab that was the Overage-guard panel, so the
9
+ * one tab that tells you the proxy has HALTED hid that fact first, on a
10
+ * default 80x24 terminal.
11
+ *
12
+ * The fix is for each tab to decide what to give up. A panel declares:
13
+ *
14
+ * - `lines` full rendering
15
+ * - `collapsed` a shorter stand-in (typically one summary row)
16
+ * - `priority` lower = more important; drives what degrades first
17
+ * - `required` never drop entirely, even if it doesn't fit
18
+ *
19
+ * `fitPanels` keeps DISPLAY order (the order given) and uses priority
20
+ * only to choose what degrades. Users navigate by position, so reordering
21
+ * panels under pressure would be its own kind of surprise.
22
+ */
23
+ export interface Panel {
24
+ /** Full rendering, including the panel's own trailing blank line if it wants one. */
25
+ lines: string[];
26
+ /** Shorter stand-in used when the full form doesn't fit. */
27
+ collapsed?: string[];
28
+ /** Lower = more important. Least important degrades first. */
29
+ priority: number;
30
+ /** Never dropped entirely (it may still be collapsed). */
31
+ required?: boolean;
32
+ }
33
+ /**
34
+ * Fit `panels` into `budget` rows.
35
+ *
36
+ * Degrades in two passes, least-important first: collapse everything that
37
+ * offers a `collapsed` form, then drop whole panels that aren't
38
+ * `required`. Returns the flattened lines in the original display order.
39
+ *
40
+ * If even the required panels overflow, the result is still returned
41
+ * over-budget — `renderTui`'s clamp is the final floor, and returning a
42
+ * truncated required panel here would hide the very thing marked
43
+ * essential.
44
+ */
45
+ export declare function fitPanels(panels: Panel[], budget: number): string[];
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Panel fitting — let a tab spend its row budget deliberately instead of
3
+ * rendering everything and being clipped from the bottom.
4
+ *
5
+ * Background (#862, #866, #868): tabs used to push every panel
6
+ * unconditionally. `renderTui` clips an over-long body so the header and
7
+ * tab strip survive, but clipping is dumb — it drops whatever happens to
8
+ * sort last. On the Status tab that was the Overage-guard panel, so the
9
+ * one tab that tells you the proxy has HALTED hid that fact first, on a
10
+ * default 80x24 terminal.
11
+ *
12
+ * The fix is for each tab to decide what to give up. A panel declares:
13
+ *
14
+ * - `lines` full rendering
15
+ * - `collapsed` a shorter stand-in (typically one summary row)
16
+ * - `priority` lower = more important; drives what degrades first
17
+ * - `required` never drop entirely, even if it doesn't fit
18
+ *
19
+ * `fitPanels` keeps DISPLAY order (the order given) and uses priority
20
+ * only to choose what degrades. Users navigate by position, so reordering
21
+ * panels under pressure would be its own kind of surprise.
22
+ */
23
+ const size = (p, isCollapsed) => (isCollapsed && p.collapsed ? p.collapsed : p.lines).length;
24
+ /**
25
+ * Fit `panels` into `budget` rows.
26
+ *
27
+ * Degrades in two passes, least-important first: collapse everything that
28
+ * offers a `collapsed` form, then drop whole panels that aren't
29
+ * `required`. Returns the flattened lines in the original display order.
30
+ *
31
+ * If even the required panels overflow, the result is still returned
32
+ * over-budget — `renderTui`'s clamp is the final floor, and returning a
33
+ * truncated required panel here would hide the very thing marked
34
+ * essential.
35
+ */
36
+ export function fitPanels(panels, budget) {
37
+ const collapsed = new Set();
38
+ const dropped = new Set();
39
+ const total = () => panels.reduce((n, p, i) => n + (dropped.has(i) ? 0 : size(p, collapsed.has(i))), 0);
40
+ // Least important first — ties broken by later position, so a trailing
41
+ // panel degrades before an equally-ranked one above it.
42
+ const byLeastImportant = panels
43
+ .map((p, i) => ({ p, i }))
44
+ .sort((a, b) => (b.p.priority - a.p.priority) || (b.i - a.i));
45
+ for (const { p, i } of byLeastImportant) {
46
+ if (total() <= budget)
47
+ break;
48
+ if (p.collapsed && p.collapsed.length < p.lines.length)
49
+ collapsed.add(i);
50
+ }
51
+ for (const { p, i } of byLeastImportant) {
52
+ if (total() <= budget)
53
+ break;
54
+ if (!p.required)
55
+ dropped.add(i);
56
+ }
57
+ const out = [];
58
+ panels.forEach((p, i) => {
59
+ if (dropped.has(i))
60
+ return;
61
+ out.push(...(collapsed.has(i) && p.collapsed ? p.collapsed : p.lines));
62
+ });
63
+ return out;
64
+ }
@@ -11,9 +11,18 @@
11
11
  * State machine is straightforward — fetch + cache; no key interaction
12
12
  * beyond 'r' for forced refresh.
13
13
  */
14
- import { fg, dim, brand, progressBar, pad } from '../render.js';
14
+ import { fg, dim, brand, progressBar, pad, truncate } from '../render.js';
15
15
  import { renderKvRow } from '../layout.js';
16
+ import { fitPanels } from '../panels.js';
16
17
  const POLL_INTERVAL_MS = 2000;
18
+ /**
19
+ * Label column for the gauge rows (5h / 7d / Overage). Was 6, which is
20
+ * narrower than "Overage" — `pad` truncates rather than overflowing, so
21
+ * the row rendered as `Overa…` hard against its bar. That row is the
22
+ * "investigate immediately" signal, so it should be the one row that
23
+ * reads unambiguously.
24
+ */
25
+ const GAUGE_LABEL_W = 8;
17
26
  export const AnalyticsTab = {
18
27
  id: 'analytics',
19
28
  label: 'Analytics',
@@ -52,7 +61,10 @@ export const AnalyticsTab = {
52
61
  const lines = [];
53
62
  const w = dimv.cols;
54
63
  const barWidth = Math.min(36, w - 32);
55
- lines.push(' ' + brand('Analytics') + dim(` last ${state.summary?.window.minutes ?? 60} min`));
64
+ // Bounded like every other row. This is a `required` panel, so the row
65
+ // budget never clips it — without a truncate it overflows a very narrow
66
+ // terminal (25 wide at 24 cols). Found by tools/tui-audit.
67
+ lines.push(truncate(' ' + brand('Analytics') + dim(` — last ${state.summary?.window.minutes ?? 60} min`), w));
56
68
  if (!state.summary && state.loading) {
57
69
  lines.push('');
58
70
  lines.push(' ' + dim('Loading…'));
@@ -70,54 +82,76 @@ export const AnalyticsTab = {
70
82
  return lines.join('\n');
71
83
  }
72
84
  const s = state.summary;
85
+ // Panels are built full-size then fitted to the row budget. Rendering
86
+ // all of them unconditionally overflowed a default 80x24 terminal by
87
+ // four rows (#868); the clip took whatever sorted last rather than
88
+ // whatever mattered least.
89
+ const panels = [{ lines: [...lines], priority: 0, required: true }];
90
+ lines.length = 0;
73
91
  // ── Counters ───────────────────────────────────────────────
74
92
  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.toFixed(0)}%`, w - 4));
93
+ const counters = [''];
94
+ counters.push(' ' + renderKvRow('Requests', `${s.window.requests} ${dim(`(${rpm.toFixed(1)}/min)`)}`, w - 4));
95
+ counters.push(' ' + renderKvRow('Tokens in', formatNumber(s.window.totalInputTokens), w - 4));
96
+ counters.push(' ' + renderKvRow('Tokens out', formatNumber(s.window.totalOutputTokens), w - 4));
97
+ counters.push(' ' + renderKvRow('Thinking tokens', formatNumber(s.window.totalThinkingTokens), w - 4));
98
+ counters.push(' ' + renderKvRow('Avg latency', `${Math.round(s.window.avgLatencyMs)}ms`, w - 4));
99
+ counters.push(' ' + renderKvRow('Subscription %', `${s.window.subscriptionPercent.toFixed(0)}%`, w - 4));
100
+ // Headline numbers — the two that answer "is this costing me money?"
101
+ // survive as the collapsed form.
102
+ panels.push({
103
+ lines: counters,
104
+ collapsed: ['',
105
+ ' ' + renderKvRow('Requests', `${s.window.requests} ${dim(`(${rpm.toFixed(1)}/min)`)}`, w - 4),
106
+ ' ' + renderKvRow('Subscription %', `${s.window.subscriptionPercent.toFixed(0)}%`, w - 4)],
107
+ priority: 1,
108
+ });
82
109
  // ── Per-model bars ─────────────────────────────────────────
83
110
  const models = Object.entries(s.perModel).sort((a, b) => b[1].requests - a[1].requests);
84
111
  if (models.length > 0) {
85
- lines.push('');
86
- lines.push(' ' + brand('Per-model'));
112
+ const perModel = ['', ' ' + brand('Per-model')];
87
113
  const totalReq = Math.max(1, models.reduce((sum, [, m]) => sum + m.requests, 0));
88
114
  for (const [name, m] of models) {
89
115
  const share = m.requests / totalReq;
90
116
  const sharePct = `${(share * 100).toFixed(0)}%`.padStart(4);
91
- lines.push(' ' + pad(shortenModelName(name), 18) +
117
+ perModel.push(' ' + pad(shortenModelName(name), 18) +
92
118
  fg('green', progressBar(share, barWidth)) +
93
119
  ' ' + dim(`${sharePct} (${m.requests})`));
94
120
  }
121
+ // Breakdown, not a signal — degrades first.
122
+ panels.push({
123
+ lines: perModel,
124
+ collapsed: ['', ' ' + renderKvRow('Per-model', dim(`${models.length} model${models.length === 1 ? '' : 's'}`), w - 4)],
125
+ priority: 5,
126
+ });
95
127
  }
96
128
  // ── Rate-limit ────────────────────────────────────────────
97
129
  // Each account hits its OWN 5h/7d windows, so with >1 account an
98
130
  // aggregate gauge is misleading (#600) — show one row per account, the
99
131
  // bar tracking the binding constraint (max of 5h/7d = closest to a limit).
100
- lines.push('');
132
+ const rate = [''];
101
133
  const accts = s.perAccount ? Object.entries(s.perAccount) : [];
134
+ let peakUtil = Math.max(s.utilization.lastUtil5h, s.utilization.lastUtil7d);
102
135
  if (accts.length > 1) {
103
- lines.push(' ' + brand('Rate-limit') + dim(' (per account)'));
136
+ rate.push(' ' + brand('Rate-limit') + dim(' (per account)'));
104
137
  const acctBarWidth = Math.max(8, Math.min(20, w - 48));
105
138
  for (const [alias, a] of accts.sort((x, y) => y[1].requests - x[1].requests)) {
106
139
  const u5 = a.currentUtil5h ?? 0;
107
140
  const u7 = a.currentUtil7d ?? 0;
108
141
  const peak = Math.max(u5, u7);
109
- lines.push(' ' + pad(alias, 14) +
142
+ peakUtil = Math.max(peakUtil, peak);
143
+ rate.push(' ' + pad(alias, 14) +
110
144
  fg(peak >= 0.9 ? 'red' : 'cyan', progressBar(peak, acctBarWidth)) +
111
145
  ' ' + dim(`5h ${(u5 * 100).toFixed(0)}%`.padEnd(8)) +
112
146
  dim(`7d ${(u7 * 100).toFixed(0)}%`));
113
147
  }
114
148
  }
115
149
  else {
116
- lines.push(' ' + brand('Rate-limit'));
117
- lines.push(' ' + pad('5h', 6) +
150
+ rate.push(' ' + brand('Rate-limit'));
151
+ rate.push(' ' + pad('5h', GAUGE_LABEL_W) +
118
152
  fg('cyan', progressBar(s.utilization.lastUtil5h, barWidth)) +
119
153
  ' ' + dim(`${(s.utilization.lastUtil5h * 100).toFixed(0)}%`));
120
- lines.push(' ' + pad('7d', 6) +
154
+ rate.push(' ' + pad('7d', GAUGE_LABEL_W) +
121
155
  fg('cyan', progressBar(s.utilization.lastUtil7d, barWidth)) +
122
156
  ' ' + dim(`${(s.utilization.lastUtil7d * 100).toFixed(0)}%`));
123
157
  }
@@ -129,27 +163,48 @@ export const AnalyticsTab = {
129
163
  const totalCount = Object.values(s.window.billingBucketBreakdown ?? {}).reduce((a, b) => a + b, 0);
130
164
  const overageFrac = totalCount > 0 ? overageCount / totalCount : 0;
131
165
  const overageColor = overageCount > 0 ? 'red' : 'cyan';
132
- lines.push(' ' + pad('Overage', 6) +
166
+ const overageRow = ' ' + pad('Overage', GAUGE_LABEL_W) +
133
167
  fg(overageColor, progressBar(overageFrac, barWidth)) +
134
168
  ' ' + (overageCount > 0
135
169
  ? fg('red', `${overageCount} req`) + dim(` of ${totalCount}`)
136
- : dim('0 ← clean')));
170
+ : dim('0 ← clean'));
171
+ rate.push(overageRow);
172
+ // Overage is the "investigate immediately" signal and utilisation is
173
+ // what predicts a halt, so this panel is never dropped — collapsed it
174
+ // keeps the overage row plus the binding utilisation number.
175
+ panels.push({
176
+ lines: rate,
177
+ collapsed: ['',
178
+ ' ' + renderKvRow('Peak utilisation', `${(peakUtil * 100).toFixed(0)}%`, w - 4),
179
+ overageRow],
180
+ priority: 2,
181
+ required: true,
182
+ });
137
183
  // ── Billing buckets ───────────────────────────────────────
138
184
  const buckets = s.window.billingBucketBreakdown;
139
185
  const totalBucketCount = Object.values(buckets).reduce((a, b) => a + b, 0);
140
186
  if (totalBucketCount > 0) {
141
- lines.push('');
142
- lines.push(' ' + brand('Billing'));
187
+ const billing = ['', ' ' + brand('Billing')];
188
+ let shown = 0;
143
189
  for (const [bucket, count] of Object.entries(buckets)) {
144
190
  if (count === 0)
145
191
  continue;
146
- lines.push(' ' + pad(bucket, 22) + dim(`${count} req`));
192
+ billing.push(' ' + pad(bucket, 22) + dim(`${count} req`));
193
+ shown++;
147
194
  }
195
+ panels.push({
196
+ lines: billing,
197
+ collapsed: ['', ' ' + renderKvRow('Billing', dim(`${shown} bucket${shown === 1 ? '' : 's'}, ${totalBucketCount} req`), w - 4)],
198
+ priority: 4,
199
+ });
148
200
  }
149
- // Footer
150
- lines.push('');
151
- lines.push(' ' + dim(`Updated ${ago(state.lastFetchAt)}. Press ${fg('cyan', 'r')} to refresh.`));
152
- return lines.join('\n');
201
+ // Footer — reserved outside the fit so the refresh key stays reachable.
202
+ // Bounded: `ago()` grows without limit on a long-lived session, and
203
+ // this row is now always rendered (it used to be clipped away with the
204
+ // rest of an over-long body).
205
+ const footer = ['', truncate(' ' + dim(`Updated ${ago(state.lastFetchAt)}. Press ${fg('cyan', 'r')} to refresh.`), w)];
206
+ const body = fitPanels(panels, Math.max(0, dimv.rows - footer.length));
207
+ return [...body, ...footer].join('\n');
153
208
  },
154
209
  };
155
210
  async function fetchSummary(ctx) {
@@ -5,7 +5,7 @@
5
5
  * dario backend add <name> --key=sk-... [--base-url=...]
6
6
  * dario backend remove <name>
7
7
  */
8
- import { fg, dim, brand, pad } from '../render.js';
8
+ import { fg, dim, brand, pad, truncate } from '../render.js';
9
9
  export const BackendsTab = {
10
10
  id: 'backends',
11
11
  label: 'Backends',
@@ -25,35 +25,51 @@ export const BackendsTab = {
25
25
  render(state, dimv) {
26
26
  const lines = [];
27
27
  const w = dimv.cols;
28
- lines.push(' ' + brand('OpenAI-compat Backends'));
28
+ // Bound rows at the push site: the column header is 70 wide and the
29
+ // data rows interpolate `baseUrl`, which is unbounded (#868).
30
+ const push = (s) => lines.push(truncate(s, w));
31
+ push(' ' + brand('OpenAI-compat Backends'));
29
32
  if (state.loading && state.backends.length === 0) {
30
- lines.push('');
31
- lines.push(' ' + dim('Loading backends…'));
33
+ push('');
34
+ push(' ' + dim('Loading backends…'));
32
35
  return lines.join('\n');
33
36
  }
34
37
  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
+ push('');
39
+ push(' ' + dim('No OpenAI-compat backends configured.'));
40
+ push(' ' + 'Add one: ' + fg('cyan', 'dario backend add openai --key=sk-...'));
38
41
  return lines.join('\n');
39
42
  }
43
+ // This tab fits today, but it had no row budget at all — one more
44
+ // backend or one more hint line and it would overflow like the others.
45
+ const errorRows = state.error ? 2 : 0;
46
+ const chromeRows = 1 /*title*/ + 1 /*header*/ + 1 /*rule*/ + errorRows +
47
+ 1 /*blank*/ + 1 /*"Mutations via CLI:"*/ + 2 /*commands*/;
48
+ const listRows = Math.max(1, dimv.rows - chromeRows);
49
+ const shown = state.backends.slice(0, listRows);
50
+ const hidden = state.backends.length - shown.length;
40
51
  // 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(' ' +
52
+ push(' ' + dim(pad('name', 16) + pad('provider', 12) + pad('base url', 40)));
53
+ push(' ' + dim('─'.repeat(Math.min(w - 4, 68))));
54
+ for (const b of shown) {
55
+ push(' ' +
45
56
  pad(b.name, 16) +
46
57
  pad(b.provider, 12) +
47
58
  b.baseUrl);
48
59
  }
60
+ if (hidden > 0) {
61
+ // Spend the last list row saying what it cost, rather than dropping
62
+ // backends silently.
63
+ lines[lines.length - 1] = truncate(' ' + dim(`… ${hidden + 1} more backends — resize for the rest`), w);
64
+ }
49
65
  if (state.error) {
50
- lines.push('');
51
- lines.push(' ' + fg('red', `Load error: ${state.error}`));
66
+ push('');
67
+ push(' ' + fg('red', `Load error: ${state.error}`));
52
68
  }
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>'));
69
+ push('');
70
+ push(' ' + dim('Mutations via CLI:'));
71
+ push(' ' + fg('cyan', 'dario backend add <name> --key=sk-... [--base-url=...]'));
72
+ push(' ' + fg('cyan', 'dario backend remove <name>'));
57
73
  return lines.join('\n');
58
74
  },
59
75
  };
@@ -108,9 +108,27 @@ export const HitsTab = {
108
108
  const lines = [];
109
109
  const w = dimv.cols;
110
110
  const totalRows = dimv.rows;
111
- // Split the body roughly 60/40 between list and detail.
112
- const detailRows = 9;
113
- const listRows = Math.max(3, totalRows - detailRows - 2);
111
+ // Reserve the chrome this render will actually emit, rather than a
112
+ // flat guess. The old `detailRows = 9; totalRows - detailRows - 2`
113
+ // reserved 11 but the tab renders up to 15 non-list rows — the halt
114
+ // banner was missing from the arithmetic entirely and the detail pane
115
+ // is 8 rows, not 9 — so the body overran its budget by 4 (#868).
116
+ const hasSelection = state.selectedIdx >= 0 && state.selectedIdx < state.buffer.length;
117
+ const haltRows = state.halt ? 2 : 0; // pinned banner
118
+ const fixedRows = 1 + // title
119
+ haltRows +
120
+ 1 + // blank before the table
121
+ 1 + // column header
122
+ 1; // scroll hint (reserved; only drawn when the list overflows)
123
+ const detailPaneRows = (hasSelection ? 8 : 2) + 1; // pane + its separator
124
+ // The list is the tab's reason to exist, so the detail pane yields to
125
+ // it rather than the other way round: on a terminal too short to show
126
+ // both, drop the pane and spend the rows on requests. Without this the
127
+ // halt banner + an 8-row pane made a 15-row floor no budget could meet.
128
+ const MIN_LIST = 3;
129
+ const showDetail = totalRows - fixedRows - detailPaneRows >= MIN_LIST;
130
+ const chromeRows = fixedRows + (showDetail ? detailPaneRows : 0);
131
+ const listRows = Math.max(1, totalRows - chromeRows);
114
132
  if (state.buffer.length === 0) {
115
133
  lines.push(truncate(' ' + brand('Hits') + dim(' — live request stream'), w));
116
134
  lines.push('');
@@ -198,9 +216,11 @@ export const HitsTab = {
198
216
  (startIdx > 0 ? '↑ more ' : '') +
199
217
  (endIdx < newestFirst.length ? '↓ more' : '')), w));
200
218
  }
201
- // Separator
219
+ // Separator + detail pane — omitted entirely on a terminal too short
220
+ // to show both the list and the pane (see the budget above).
221
+ if (!showDetail)
222
+ return lines.join('\n');
202
223
  lines.push(' ' + dim(BOX.horizontal.repeat(w - 2)));
203
- // Detail pane
204
224
  if (state.selectedIdx >= 0 && state.selectedIdx < newestFirst.length) {
205
225
  const r = newestFirst[state.selectedIdx];
206
226
  lines.push(truncate(' ' + brand('Selected') + dim(` ${formatTime(r.timestamp)}`), w));
@@ -25,8 +25,9 @@
25
25
  * │ …the advertised catalog, live from /v1/models │
26
26
  * └─────────────────────────────────────────────────┘
27
27
  */
28
- import { fg, dim, brand } from '../render.js';
28
+ import { fg, dim, brand, truncate } from '../render.js';
29
29
  import { renderKvRow } from '../layout.js';
30
+ import { fitPanels } from '../panels.js';
30
31
  export const StatusTab = {
31
32
  id: 'status',
32
33
  label: 'Status',
@@ -85,13 +86,16 @@ export const StatusTab = {
85
86
  return undefined;
86
87
  },
87
88
  render(state, dim_) {
88
- const lines = [];
89
89
  const w = dim_.cols;
90
90
  if (state.loading && !state.health) {
91
- lines.push('');
92
- lines.push(' ' + dim('Loading status…'));
93
- return lines.join('\n');
91
+ return ['', ' ' + dim('Loading status…')].join('\n');
94
92
  }
93
+ // Panels are assembled full-size, then fitted to the row budget by
94
+ // priority (see panels.ts). Rendering everything unconditionally used
95
+ // to overflow a default 80x24 terminal by four rows, and the clip took
96
+ // the LAST panel — Overage-guard. A halted proxy hid the halt.
97
+ const panels = [];
98
+ let lines = [];
95
99
  // ── Proxy section ──────────────────────────────────────────
96
100
  lines.push(' ' + brand('Proxy'));
97
101
  if (state.health) {
@@ -111,7 +115,10 @@ export const StatusTab = {
111
115
  }
112
116
  }
113
117
  lines.push('');
118
+ // Reachability is the tab's reason to exist — never dropped.
119
+ panels.push({ lines, priority: 1, required: true });
114
120
  // ── Config section ─────────────────────────────────────────
121
+ lines = [];
115
122
  lines.push(' ' + brand('Config'));
116
123
  const sourceLabel = state.configSource === 'file' ? '~/.dario/config.json'
117
124
  : state.configSource === 'missing' ? dim('(no file — using defaults)')
@@ -119,20 +126,35 @@ export const StatusTab = {
119
126
  : dim('not loaded');
120
127
  lines.push(' ' + renderKvRow('Source', sourceLabel, w - 4));
121
128
  lines.push('');
129
+ panels.push({
130
+ lines,
131
+ collapsed: [' ' + renderKvRow('Config', sourceLabel, w - 4), ''],
132
+ priority: 4,
133
+ });
122
134
  // ── Models section ─────────────────────────────────────────
123
135
  // Live from the proxy's /v1/models (upstream-autodetected catalog,
124
136
  // baked fallback) so newly-shipped families — Sonnet 5, Fable 5 —
125
137
  // show up here without a TUI change. `[1m]` variants are folded
126
138
  // onto their base id as a +[1m] marker instead of doubling the list.
127
139
  if (state.models && state.models.length > 0) {
140
+ lines = [];
128
141
  lines.push(' ' + brand('Models'));
129
- for (const row of foldLongContextVariants(state.models)) {
142
+ const folded = foldLongContextVariants(state.models);
143
+ for (const row of folded) {
130
144
  lines.push(' ' + renderKvRow(row.base, row.has1m ? dim('+[1m]') : '', w - 4));
131
145
  }
132
146
  lines.push('');
147
+ // Longest panel and the least time-critical — the catalog is static
148
+ // between releases, so it collapses to a count first.
149
+ panels.push({
150
+ lines,
151
+ collapsed: [' ' + renderKvRow('Models', dim(`${folded.length} advertised`), w - 4), ''],
152
+ priority: 5,
153
+ });
133
154
  }
134
155
  // ── Overage-guard section (v4.1, dario#288) ────────────────
135
156
  if (state.overageGuard) {
157
+ lines = [];
136
158
  lines.push(' ' + brand('Overage-guard'));
137
159
  if (state.overageGuard.halted && state.overageGuard.state) {
138
160
  const s = state.overageGuard.state;
@@ -156,12 +178,31 @@ export const StatusTab = {
156
178
  lines.push(' ' + fg(c, state.resumeMessage));
157
179
  }
158
180
  lines.push('');
181
+ // A halt is the loudest thing this tab can say and it carries the
182
+ // resume instructions, so when halted it outranks everything except
183
+ // reachability and is never dropped. Idle, it is just configuration
184
+ // and collapses to a single line.
185
+ const halted = state.overageGuard.halted;
186
+ panels.push(halted
187
+ ? { lines, priority: 0, required: true }
188
+ : {
189
+ lines,
190
+ collapsed: [' ' + renderKvRow('Overage-guard', fg('green', 'normal'), w - 4), ''],
191
+ priority: 3,
192
+ });
159
193
  }
160
194
  // ── Footer hint ────────────────────────────────────────────
161
- lines.push('');
162
195
  const resumeHint = state.overageGuard?.halted ? ` · ${fg('cyan', 'R')} resume` : '';
163
- lines.push(' ' + dim(`Last refresh: ${formatAgo(state.lastRefreshAt)}. ${fg('cyan', 'r')} refresh${resumeHint}.`));
164
- return lines.join('\n');
196
+ const footer = [
197
+ '',
198
+ // Bounded: formatAgo() grows without limit, and this row is now
199
+ // always rendered rather than clipped away with an over-long body.
200
+ truncate(' ' + dim(`Last refresh: ${formatAgo(state.lastRefreshAt)}. ${fg('cyan', 'r')} refresh${resumeHint}.`), w),
201
+ ];
202
+ // Footer is reserved outside the fit so the refresh/resume keys stay
203
+ // reachable no matter how short the terminal is.
204
+ const body = fitPanels(panels, Math.max(0, dim_.rows - footer.length));
205
+ return [...body, ...footer].join('\n');
165
206
  },
166
207
  };
167
208
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@askalf/dario",
3
- "version": "5.4.8",
3
+ "version": "5.4.10",
4
4
  "description": "Use your Claude Pro/Max subscription in any tool — Cursor, Cline, Aider, the Agent SDK, your scripts — at subscription pricing, not per-token API bills. One local Anthropic + OpenAI-compatible endpoint.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -35,7 +35,8 @@
35
35
  "drift:wire": "node scripts/check-wire-drift.mjs",
36
36
  "check:overage": "node scripts/check-overage-live.mjs",
37
37
  "cch:calibrate": "node scripts/cch-calibrate.mjs",
38
- "fix:pkg": "node -e \"const fs=require('fs');fs.writeFileSync('package.json',JSON.stringify(JSON.parse(fs.readFileSync('package.json','utf-8')),null,2)+'\\n')\""
38
+ "fix:pkg": "node -e \"const fs=require('fs');fs.writeFileSync('package.json',JSON.stringify(JSON.parse(fs.readFileSync('package.json','utf-8')),null,2)+'\\n')\"",
39
+ "audit:tui": "node tools/tui-audit/audit.mjs"
39
40
  },
40
41
  "keywords": [
41
42
  "llm",