@askalf/dario 5.4.6 → 5.4.7

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.
@@ -19,7 +19,14 @@ export function renderHeader(width, opts) {
19
19
  const left = ` ${brand('dario')} v${opts.version} `;
20
20
  const right = opts.status ? ` ${opts.status} ` : '';
21
21
  const dashWidth = Math.max(0, width - visibleWidth(left) - visibleWidth(right) - 2);
22
- return BOX.topLeft + left + BOX.horizontal.repeat(dashWidth) + right + BOX.topRight;
22
+ const full = BOX.topLeft + left + BOX.horizontal.repeat(dashWidth) + right + BOX.topRight;
23
+ if (visibleWidth(full) <= width)
24
+ return full;
25
+ // Too narrow for brand + status: `dashWidth` clamps at 0 but both ends
26
+ // still render full-length, so the row would overflow and wrap. Drop
27
+ // the status (the proxy URL — also on the Status tab) before clipping
28
+ // the brand, which is this row's only identity.
29
+ return truncate(BOX.topLeft + left + BOX.topRight, width);
23
30
  }
24
31
  /**
25
32
  * Render the bottom footer with key-hint pairs. Wide gaps so it doesn't
@@ -181,9 +181,15 @@ export function pad(text, width, align = 'left') {
181
181
  export function progressBar(value, width, opts = {}) {
182
182
  const filled = opts.filled ?? '█';
183
183
  const empty = opts.empty ?? '░';
184
- const clamped = Math.max(0, Math.min(1, value));
185
- const cells = Math.round(clamped * width);
186
- return filled.repeat(cells) + empty.repeat(width - cells);
184
+ // Callers derive the bar width by subtracting label columns from the
185
+ // terminal width, so a narrow terminal hands us a NEGATIVE width
186
+ // String.repeat() throws RangeError on that, which took the whole TUI
187
+ // down rather than degrading. Collapse to an empty bar instead; the
188
+ // surrounding row still renders.
189
+ const w = Number.isFinite(width) ? Math.max(0, Math.floor(width)) : 0;
190
+ const clamped = Number.isFinite(value) ? Math.max(0, Math.min(1, value)) : 0;
191
+ const cells = Math.round(clamped * w);
192
+ return filled.repeat(cells) + empty.repeat(w - cells);
187
193
  }
188
194
  /**
189
195
  * Box-drawing characters for borders. Set picked to render well on
@@ -24,7 +24,7 @@
24
24
  * To add: `dario accounts add <alias>`
25
25
  * To remove: `dario accounts remove <alias>`
26
26
  */
27
- import { fg, dim, brand, pad } from '../render.js';
27
+ import { fg, dim, brand, pad, truncate } from '../render.js';
28
28
  import { renderKvRow } from '../layout.js';
29
29
  export const AccountsTab = {
30
30
  id: 'accounts',
@@ -57,21 +57,27 @@ export const AccountsTab = {
57
57
  render(state, dimv) {
58
58
  const lines = [];
59
59
  const w = dimv.cols;
60
- lines.push(' ' + brand('Accounts'));
60
+ // Bound every row at the push site rather than at each call site. The
61
+ // column header (68 wide) and the disk-fallback path (which
62
+ // interpolates `~/.dario/accounts/<alias>.json`, unbounded by alias
63
+ // length) both overflowed; hand-auditing 15 separate pushes is how
64
+ // that got missed.
65
+ const push = (s) => lines.push(truncate(s, w));
66
+ push(' ' + brand('Accounts'));
61
67
  if (state.loading && state.accounts.length === 0) {
62
- lines.push('');
63
- lines.push(' ' + dim('Loading accounts…'));
68
+ push('');
69
+ push(' ' + dim('Loading accounts…'));
64
70
  return lines.join('\n');
65
71
  }
66
72
  if (state.accounts.length === 0) {
67
- lines.push('');
73
+ push('');
68
74
  if (state.source === 'single-account') {
69
- lines.push(' ' + dim('Single-account mode (`dario login`) — no pool.'));
70
- lines.push(' ' + 'Start a pool: ' + fg('cyan', 'dario accounts add <alias>'));
75
+ push(' ' + dim('Single-account mode (`dario login`) — no pool.'));
76
+ push(' ' + 'Start a pool: ' + fg('cyan', 'dario accounts add <alias>'));
71
77
  }
72
78
  else {
73
- lines.push(' ' + dim('No accounts in the pool.'));
74
- lines.push(' ' + 'Add one: ' + fg('cyan', 'dario accounts add <alias>'));
79
+ push(' ' + dim('No accounts in the pool.'));
80
+ push(' ' + 'Add one: ' + fg('cyan', 'dario accounts add <alias>'));
75
81
  }
76
82
  return lines.join('\n');
77
83
  }
@@ -79,13 +85,13 @@ export const AccountsTab = {
79
85
  // doesn't, so show the util columns only when the pool populated them.
80
86
  const hasUtil = state.accounts.some((a) => a.util5h !== undefined);
81
87
  if (state.source === 'disk') {
82
- lines.push(' ' + fg('yellow', 'proxy unreachable — showing on-disk accounts (may be stale)'));
88
+ push(' ' + fg('yellow', 'proxy unreachable — showing on-disk accounts (may be stale)'));
83
89
  }
84
90
  // Header row
85
- lines.push(' ' + dim(hasUtil
91
+ push(' ' + dim(hasUtil
86
92
  ? pad('alias', 20) + pad('expires', 14) + pad('util5h', 9) + pad('util7d', 9) + pad('status', 14)
87
93
  : pad('alias', 20) + pad('expires', 16) + pad('source', 24)));
88
- lines.push(' ' + dim('─'.repeat(Math.min(w - 4, 66))));
94
+ push(' ' + dim('─'.repeat(Math.min(w - 4, 66))));
89
95
  for (const acc of state.accounts) {
90
96
  const aliasCol = pad(acc.alias, 20);
91
97
  if (hasUtil) {
@@ -94,22 +100,22 @@ export const AccountsTab = {
94
100
  const u7 = pad(acc.util7d !== undefined ? `${Math.round(acc.util7d * 100)}%` : '—', 9);
95
101
  const statusCol = acc.status ?? '—';
96
102
  const statusFg = statusCol === 'auth-cooldown' ? fg('yellow', statusCol) : dim(statusCol);
97
- lines.push(' ' + aliasCol + expiresCol + u5 + u7 + statusFg);
103
+ push(' ' + aliasCol + expiresCol + u5 + u7 + statusFg);
98
104
  }
99
105
  else {
100
106
  const expiresCol = pad(formatExpiry(acc.expiresAt), 16);
101
107
  const sourceCol = '~/.dario/accounts/' + acc.alias + '.json';
102
- lines.push(' ' + aliasCol + expiresCol + dim(sourceCol));
108
+ push(' ' + aliasCol + expiresCol + dim(sourceCol));
103
109
  }
104
110
  }
105
- lines.push('');
106
- lines.push(' ' + dim('Mutations via CLI:'));
107
- lines.push(' ' + fg('cyan', 'dario accounts add <alias>'));
108
- lines.push(' ' + fg('cyan', 'dario accounts remove <alias>'));
111
+ push('');
112
+ push(' ' + dim('Mutations via CLI:'));
113
+ push(' ' + fg('cyan', 'dario accounts add <alias>'));
114
+ push(' ' + fg('cyan', 'dario accounts remove <alias>'));
109
115
  // Refresh hint
110
- lines.push('');
111
- lines.push(' ' + renderKvRow('', '', w - 2)); // spacer
112
- lines.push(' ' + dim(`Press ${fg('cyan', 'r')} to refresh.`));
116
+ push('');
117
+ push(' ' + renderKvRow('', '', w - 2)); // spacer
118
+ push(' ' + dim(`Press ${fg('cyan', 'r')} to refresh.`));
113
119
  return lines.join('\n');
114
120
  },
115
121
  };
@@ -139,7 +139,8 @@ export const StatusTab = {
139
139
  const remainingMs = Math.max(0, s.cooldownUntil - Date.now());
140
140
  const remaining = formatDuration(remainingMs);
141
141
  // Red banner header — this is the loud surface when halted
142
- lines.push(' ' + fg('red', '⚠ HALTED') + ' ' + dim(`${s.request.claim} detected ${formatAgo(s.since)} ago`));
142
+ // formatAgo() already ends in "ago" don't append a second one.
143
+ lines.push(' ' + fg('red', '⚠ HALTED') + ' ' + dim(`${s.request.claim} detected ${formatAgo(s.since)}`));
143
144
  lines.push(' ' + renderKvRow('Request', `${s.request.model} ${dim('account=' + s.request.account)}`, w - 4));
144
145
  lines.push(' ' + renderKvRow('Cause', `representative-claim = ${fg('red', s.request.claim)}`, w - 4));
145
146
  lines.push(' ' + renderKvRow('Auto-resume in', remaining === '0s' ? fg('yellow', 'now (cooldown elapsed)') : remaining, w - 4));
@@ -39,3 +39,12 @@ export interface TuiAppOpts {
39
39
  version: string;
40
40
  }
41
41
  export declare function startTuiApp(opts: TuiAppOpts): Promise<void>;
42
+ /**
43
+ * Compose one full frame: header, tab strip, rule, active tab body,
44
+ * footer. Exported for tests — the frame-height invariant (never more
45
+ * physical rows than the terminal has) is asserted against this.
46
+ */
47
+ export declare function renderTui(state: TuiState, dim_: {
48
+ cols: number;
49
+ rows: number;
50
+ }, version: string, proxyUrl: string): string;
@@ -10,7 +10,7 @@
10
10
  */
11
11
  import { App } from './app.js';
12
12
  import { ProxyClient } from './proxy-client.js';
13
- import { fg, dim } from './render.js';
13
+ import { fg, dim, truncate } from './render.js';
14
14
  import { renderFooter, renderHeader, renderTabStrip } from './layout.js';
15
15
  import { StatusTab } from './tabs/status.js';
16
16
  import { ConfigTab } from './tabs/config.js';
@@ -180,7 +180,12 @@ function withTabState(s, idx, sliceVal) {
180
180
  return { ...s, [key]: sliceVal };
181
181
  }
182
182
  // ── Rendering ───────────────────────────────────────────────────
183
- function renderTui(state, dim_, version, proxyUrl) {
183
+ /**
184
+ * Compose one full frame: header, tab strip, rule, active tab body,
185
+ * footer. Exported for tests — the frame-height invariant (never more
186
+ * physical rows than the terminal has) is asserted against this.
187
+ */
188
+ export function renderTui(state, dim_, version, proxyUrl) {
184
189
  const cols = dim_.cols;
185
190
  const rows = dim_.rows;
186
191
  const out = [];
@@ -191,11 +196,29 @@ function renderTui(state, dim_, version, proxyUrl) {
191
196
  out.push(renderTabStrip(cols, tabLabels, state.activeTab));
192
197
  out.push(dim('─'.repeat(cols)));
193
198
  // Body — passed (cols, rows-5) so the tab knows it has rows 4..rows-2
194
- const bodyRows = rows - 5;
199
+ const bodyRows = Math.max(1, rows - 5);
195
200
  const tab = TABS[state.activeTab];
196
201
  const slice = stateOf(state, state.activeTab);
197
202
  const body = tab.render(slice, { cols, rows: bodyRows });
198
- out.push(body);
203
+ const bodyLineArr = body.split('\n');
204
+ // A tab that renders past its row budget used to push the header and
205
+ // tab strip off the top of the alt-screen: App.redraw writes the frame
206
+ // from row 1 with no scrollback, so the overflow costs the chrome the
207
+ // user needs most — including which tab is even active. Clip the tail
208
+ // instead, and say how much was dropped so the loss isn't silent.
209
+ //
210
+ // This is a floor, not a substitute for a tab budgeting its own
211
+ // content: a tab that overflows here is still choosing what to drop by
212
+ // accident of ordering. Config and Hits size themselves; the rest do
213
+ // not yet.
214
+ if (bodyLineArr.length > bodyRows) {
215
+ const hidden = bodyLineArr.length - bodyRows + 1;
216
+ const note = ` … ${hidden} more row${hidden === 1 ? '' : 's'} — resize for the rest`;
217
+ out.push(bodyLineArr.slice(0, bodyRows - 1).concat(truncate(dim(note), cols)).join('\n'));
218
+ }
219
+ else {
220
+ out.push(body);
221
+ }
199
222
  // Footer — fixed key hints (tab-cycling stays universal; per-tab
200
223
  // hints are inside each tab body to keep the global footer stable).
201
224
  const footerHints = [
@@ -206,12 +229,14 @@ function renderTui(state, dim_, version, proxyUrl) {
206
229
  // Pad body to fill rows before the footer so the footer's row stays
207
230
  // at the bottom (close to it — slight underflow OK; row count
208
231
  // depends on each tab's content).
209
- const bodyLines = body.split('\n').length;
232
+ const bodyLines = bodyLineArr.length;
210
233
  if (bodyLines < bodyRows) {
211
234
  out.push(''.padEnd(bodyRows - bodyLines, '\n'));
212
235
  }
213
236
  out.push(renderFooter(cols, footerHints));
214
237
  // Connect with newlines
215
238
  void fg; // silence unused if neither tab uses it through this module
216
- return out.join('\n');
239
+ // Absolute floor — whatever a tab did, never hand the terminal more
240
+ // physical rows than it has.
241
+ return out.join('\n').split('\n').slice(0, rows).join('\n');
217
242
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@askalf/dario",
3
- "version": "5.4.6",
3
+ "version": "5.4.7",
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": {