@timo972/cc-router 0.10.0-rc.5 → 0.10.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.
package/CHANGELOG.md CHANGED
@@ -55,6 +55,17 @@ This project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
55
55
 
56
56
  ### Fixed
57
57
 
58
+ - The dashboard fits the terminal. The frame had a fixed shape — 20 activity
59
+ rows, a detail panel, and every account expanded — which with a real fleet
60
+ of accounts rendered ~70 lines. Ink can only erase as many lines as the
61
+ viewport holds, so on any shorter terminal every 2-second poll re-appended
62
+ the frame and scrolled the header and OPERATIONS panel permanently out of
63
+ view (the "jumps back down" effect, especially in split panes). The layout
64
+ is now height-aware: the activity list absorbs the deficit first (down to 3
65
+ rows), and if the chrome alone still exceeds the viewport the frame is
66
+ clipped at the bottom — the header always wins over the detail panel. The
67
+ fit tracks terminal resizes.
68
+
58
69
  - Anthropic activity rows show their cache rate and token counts. The proxy
59
70
  captures usage by passively parsing the response body, but skipped any
60
71
  compressed response — and since the proxy is byte-transparent, the client's
@@ -1,11 +1,21 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import React, { useState, useEffect, useCallback, useRef } from "react";
3
- import { Box, Text, useInput, useApp } from "ink";
3
+ import { Box, Text, useInput, useApp, useStdout, measureElement } from "ink";
4
4
  import { createAccountsApi } from "./accountsApi.js";
5
5
  import { createModelsApi } from "./modelsApi.js";
6
6
  import { getCurrentVersion } from "../utils/self-update.js";
7
7
  const POLL_INTERVAL_MS = 2_000;
8
+ /** Most activity rows the dashboard will show — the list shrinks below this
9
+ * (down to MIN_LOG_VISIBLE) when the terminal is too short for the full
10
+ * frame. Ink can only erase as many lines as the viewport holds, so a frame
11
+ * taller than the terminal makes every poll re-append it — scrolling the
12
+ * header and OPERATIONS panel permanently out of view. */
8
13
  const LOG_VISIBLE = 20;
14
+ const MIN_LOG_VISIBLE = 3;
15
+ /** The model window may shrink to a single row: it follows its selection, so
16
+ * one visible row IS the selected row — while a larger minimum rendered
17
+ * rows below the clip that the selection could land on invisibly. */
18
+ const MIN_MODELS_VISIBLE = 1;
9
19
  const MODEL_VISIBLE_ROWS = 16;
10
20
  const DASHBOARD_VERSION = getCurrentVersion();
11
21
  // Distinguishes "this machine's daemon" (restartable from this shell) from a
@@ -231,6 +241,166 @@ export function followScrollWindow(scrollTop, selectedIndex, total, visible) {
231
241
  top = selectedIndex - visible + 1;
232
242
  return Math.min(Math.max(0, top), maxTop);
233
243
  }
244
+ /**
245
+ * How long a denied growth stays denied. A denial is measured against
246
+ * concrete rendered row heights, and those can change through ordinary data
247
+ * updates the reset key cannot enumerate (an account gaining or losing
248
+ * capacity rows, a scrolled window showing different entries). Expiry is the
249
+ * general cure: a stale denial costs at most one clipped grow/shrink pair
250
+ * per TTL — invisible under the frame bound — instead of a list that stays
251
+ * collapsed until an unrelated resize.
252
+ */
253
+ export const FIT_DENIAL_TTL_MS = 2_500;
254
+ /**
255
+ * One reallocation step for the height-fitting controller. `excess` is the
256
+ * measured content height minus the viewport budget: positive shrinks lists
257
+ * in array order until covered; negative (slack) grows exactly ONE list —
258
+ * the last eligible in the array — so a single mispredicted growth can be
259
+ * attributed, denied, and refined rather than compounding.
260
+ *
261
+ * Denial rule: a growth whose commit overflows is remembered with the slack
262
+ * it actually needs (the slack it had plus the overflow it caused) and is
263
+ * not retried below that; the next attempt steps DOWN from the denied
264
+ * target. Targets only ever decrease under denial, so refinement
265
+ * terminates. Callers reset the memory whenever row heights may have
266
+ * changed (viewport, fleet, or data identity).
267
+ */
268
+ export function planViewportFit(excess, lists, memory, now = 0) {
269
+ const targets = {};
270
+ // An expired denial stops CLAMPING but is not forgotten: its retry count
271
+ // must survive the lapse, or the escalating backoff restarts at the base
272
+ // TTL on every re-denial and a permanently tall hidden row gets probed
273
+ // every few seconds forever. The record is deleted only when a retried
274
+ // growth finally fits (geometry improved) or the caller resets the memory.
275
+ const activeDenial = (key) => {
276
+ const denied = memory.denials[key];
277
+ return denied && (denied.expiresAt === undefined || denied.expiresAt > now) ? denied : undefined;
278
+ };
279
+ if (excess > 0) {
280
+ for (const list of lists) {
281
+ const attempt = memory.attempts[list.key];
282
+ if (attempt && attempt.to === list.current) {
283
+ // Re-denials escalate the TTL (capped at a minute): a hidden row that
284
+ // stays tall would otherwise be probed every TTL forever, while one
285
+ // that changed shape is picked up on the next expiry. The count
286
+ // continues from ANY prior denial of this list — including a lapsed
287
+ // one, and regardless of the target (the frontier is monotone while
288
+ // active, so a different target means a lapse happened in between).
289
+ const count = (memory.denials[list.key]?.count ?? 0) + 1;
290
+ memory.denials[list.key] = {
291
+ to: attempt.to,
292
+ slack: attempt.slack + excess,
293
+ count,
294
+ expiresAt: now + Math.min(60_000, FIT_DENIAL_TTL_MS * 2 ** (count - 1)),
295
+ };
296
+ }
297
+ delete memory.attempts[list.key];
298
+ }
299
+ let remaining = excess;
300
+ for (const list of lists) {
301
+ if (remaining <= 0)
302
+ break;
303
+ const drop = Math.min(list.current - list.min, Math.ceil(remaining / list.avgRow));
304
+ if (drop > 0) {
305
+ targets[list.key] = list.current - drop;
306
+ remaining = Math.max(0, remaining - drop * list.avgRow);
307
+ }
308
+ }
309
+ return targets;
310
+ }
311
+ for (const list of lists) {
312
+ // A standing attempt in the fitting branch means last commit's growth
313
+ // fit. That disproves a denial only when the growth reached the denied
314
+ // target — a smaller growth fitting says nothing about the larger one,
315
+ // and resetting on it would let the denied target be retried in a loop.
316
+ const attempt = memory.attempts[list.key];
317
+ const denied = memory.denials[list.key];
318
+ if (attempt && denied && attempt.to >= denied.to)
319
+ delete memory.denials[list.key];
320
+ delete memory.attempts[list.key];
321
+ }
322
+ const slack = -excess;
323
+ for (let i = lists.length - 1; i >= 0; i--) {
324
+ const list = lists[i];
325
+ if (list.current >= list.max)
326
+ continue;
327
+ // The slack alone may not cover this list's growth gate after a
328
+ // lower-priority list absorbed it (e.g. logs grew while an account
329
+ // growth was denied). A higher-priority growth may RECLAIM budget by
330
+ // shrinking lower-priority lists in the same step — without this, an
331
+ // expired denial finds no slack left and the list stays collapsed.
332
+ const unitCost = Math.max(1, Math.ceil(list.avgRow));
333
+ const gate = list.growOne ? unitCost + 1 : 2;
334
+ let usable = slack;
335
+ const funding = [];
336
+ if (usable < gate) {
337
+ if (i === 0)
338
+ continue; // no lower-priority lists to fund from — the gate stands
339
+ // Fund one row's ACTUAL cost, not the gate: the +1 hysteresis margin
340
+ // applies only to free-slack growth — a funded growth is protected
341
+ // from flapping by the denial memory, and demanding the margin here
342
+ // made a growth permanently unfundable when lower-priority lists
343
+ // could yield exactly the row's height and nothing more.
344
+ let deficit = unitCost - usable;
345
+ for (let j = 0; j < i && deficit > 0; j++) {
346
+ const funder = lists[j];
347
+ const dropMax = funder.current - funder.min;
348
+ if (dropMax <= 0)
349
+ continue;
350
+ const drop = Math.min(dropMax, Math.ceil(deficit / funder.avgRow));
351
+ funding.push({ key: funder.key, to: funder.current - drop });
352
+ deficit -= drop * funder.avgRow;
353
+ }
354
+ if (deficit > 0)
355
+ continue; // cannot fund this growth — try lower priority
356
+ usable = Math.max(usable, unitCost);
357
+ }
358
+ let target = list.growOne
359
+ ? list.current + 1
360
+ : Math.min(list.max, list.current + Math.max(1, Math.floor((usable - 1) / list.avgRow)));
361
+ const denied = activeDenial(list.key);
362
+ if (denied && usable < denied.slack)
363
+ target = Math.min(target, denied.to - 1);
364
+ if (target <= list.current)
365
+ continue; // denied or no room — funding is discarded
366
+ for (const fund of funding)
367
+ targets[fund.key] = fund.to;
368
+ memory.attempts[list.key] = { to: target, slack: usable };
369
+ targets[list.key] = target;
370
+ break;
371
+ }
372
+ return targets;
373
+ }
374
+ /**
375
+ * Current terminal size, tracking resizes. rows/columns are 0 when unknown.
376
+ *
377
+ * Columns matter even where only rows is consumed: a width-only resize
378
+ * re-wraps text and changes the RENDERED height without changing the row
379
+ * count, and the fitting effect only runs on a React commit. Bailing out
380
+ * when rows is unchanged would leave the freshly wrapped, taller frame
381
+ * unmeasured until the next poll — reintroducing the scroll jump this hook
382
+ * exists to prevent.
383
+ */
384
+ function useTerminalViewport() {
385
+ const { stdout } = useStdout();
386
+ const [viewport, setViewport] = useState({
387
+ rows: stdout?.rows ?? 0,
388
+ columns: stdout?.columns ?? 0,
389
+ });
390
+ useEffect(() => {
391
+ if (!stdout)
392
+ return;
393
+ const onResize = () => setViewport(prev => {
394
+ const rows = stdout.rows ?? 0;
395
+ const columns = stdout.columns ?? 0;
396
+ return prev.rows === rows && prev.columns === columns ? prev : { rows, columns };
397
+ });
398
+ stdout.on("resize", onResize);
399
+ onResize();
400
+ return () => { stdout.off("resize", onResize); };
401
+ }, [stdout]);
402
+ return viewport;
403
+ }
234
404
  export function Dashboard({ port, baseUrl, authToken, onIntent }) {
235
405
  const { exit } = useApp();
236
406
  const [data, setData] = useState(null);
@@ -308,18 +478,117 @@ function LiveDashboard({ data, port, baseUrl, lastUpdate, api, modelsApi, onInte
308
478
  const selectedLogIndex = selectedTs !== null
309
479
  ? Math.max(0, logs.findIndex(l => l.ts === selectedTs))
310
480
  : 0;
481
+ // ── Viewport fitting ──────────────────────────────────────────────────────
482
+ // The frame must fit the terminal or Ink cannot erase it between polls (see
483
+ // LOG_VISIBLE above). Two mechanisms cooperate:
484
+ //
485
+ // 1. A HARD bound applied synchronously from the terminal height on the
486
+ // outer box — every commit is clipped at the bottom, so no settling
487
+ // step, resize, or content growth can ever emit an oversized frame.
488
+ // (One row of slack: a frame of exactly `rows` lines still scrolls by
489
+ // one when the cursor advances past the last line.)
490
+ // 2. A post-render controller that measures the natural content height and
491
+ // reallocates the two windowed lists so the clip normally has nothing to
492
+ // cut: the activity list shrinks first (to MIN_LOG_VISIBLE), then the
493
+ // accounts window (to one account). Growth is stepped and hysteretic so
494
+ // variable-height rows cannot oscillate the layout.
495
+ const { rows: terminalRows, columns: terminalColumns } = useTerminalViewport();
496
+ const frameBound = terminalRows > 0 ? terminalRows - 1 : undefined;
497
+ const [logVisible, setLogVisible] = useState(MIN_LOG_VISIBLE);
498
+ const [accountsVisible, setAccountsVisible] = useState(Number.MAX_SAFE_INTEGER);
499
+ const [modelsVisible, setModelsVisible] = useState(MODEL_VISIBLE_ROWS);
500
+ const shownAccounts = Math.max(1, Math.min(accountsVisible, data.accounts.length));
501
+ const contentRef = useRef(null);
502
+ const accountRowsRef = useRef(null);
503
+ const logRowsRef = useRef(null);
504
+ const modelRowsRef = useRef(null);
505
+ // Growing the accounts window estimates the NEXT (hidden, unmeasurable)
506
+ // row's height from the average of the visible ones. When that account is
507
+ // much taller than average, the growth overflows and is removed again —
508
+ // and without memory the same growth is retried every commit, forever
509
+ // (hundreds of repaints per second). A denied growth is remembered with
510
+ // the slack it would actually need (the slack it had plus the overflow it
511
+ // caused) and not retried below that; the memory resets when the
512
+ // viewport or the fleet changes, since either can change row heights.
513
+ const fitMemoryRef = useRef({ attempts: {}, denials: {} });
514
+ const fitKeyRef = useRef("");
515
+ useEffect(() => {
516
+ // Denials are measured against concrete row heights, so the memory
517
+ // resets whenever those visibly change: viewport size, fleet size, model
518
+ // count, or the newest activity entry (new rows wrap differently).
519
+ // Deliberately NOT part of the key: the window offsets. They derive from
520
+ // the visible counts, so with a selection at the end of a list a
521
+ // controller-driven grow/shrink shifts them — keying on them wiped the
522
+ // pending attempt on the very commit that should have recorded the
523
+ // denial, re-enabling the grow/shrink oscillation. Geometry drift from
524
+ // scrolling (like every other content mutation the key cannot see) is
525
+ // covered by the denial TTL instead.
526
+ const fitKey = `${terminalRows}:${terminalColumns}:${data.accounts.length}:${modelsStatus?.models.length ?? 0}:${logs[0]?.ts ?? 0}`;
527
+ if (fitKeyRef.current !== fitKey) {
528
+ fitKeyRef.current = fitKey;
529
+ fitMemoryRef.current = { attempts: {}, denials: {} };
530
+ }
531
+ if (frameBound === undefined) {
532
+ if (logVisible !== LOG_VISIBLE)
533
+ setLogVisible(LOG_VISIBLE);
534
+ if (accountsVisible !== Number.MAX_SAFE_INTEGER)
535
+ setAccountsVisible(Number.MAX_SAFE_INTEGER);
536
+ if (modelsVisible !== MODEL_VISIBLE_ROWS)
537
+ setModelsVisible(MODEL_VISIBLE_ROWS);
538
+ return;
539
+ }
540
+ const modelsPanelOpen = focus === "models" || modelsStatus !== null;
541
+ const contentH = contentRef.current ? measureElement(contentRef.current).height : 0;
542
+ const accountsH = accountRowsRef.current ? measureElement(accountRowsRef.current).height : 0;
543
+ const logsH = logRowsRef.current ? measureElement(logRowsRef.current).height : 0;
544
+ const modelsH = modelRowsRef.current ? measureElement(modelRowsRef.current).height : 0;
545
+ const shownLogs = Math.min(logVisible, logs.length);
546
+ const shownModels = Math.min(modelsVisible, modelsStatus?.models.length ?? 0);
547
+ // Shrink priority order; growth walks it in reverse, so the models panel
548
+ // (the active surface while open) regrows first and the activity list
549
+ // last. Every list goes through the same measured-average + denial
550
+ // mechanics — each list got its own oscillation bug while the paths were
551
+ // separate (tall accounts, wrapped activity details, wrapped model ids).
552
+ const lists = [
553
+ {
554
+ key: "logs", current: logVisible, min: MIN_LOG_VISIBLE, max: LOG_VISIBLE,
555
+ avgRow: shownLogs > 0 ? Math.max(1, logsH / shownLogs) : 1,
556
+ },
557
+ {
558
+ key: "accounts", current: shownAccounts, min: 1, max: data.accounts.length, growOne: true,
559
+ avgRow: shownAccounts > 0 ? Math.max(1, accountsH / shownAccounts) : 2,
560
+ },
561
+ ...(modelsPanelOpen ? [{
562
+ key: "models", current: modelsVisible, min: MIN_MODELS_VISIBLE, max: MODEL_VISIBLE_ROWS,
563
+ avgRow: shownModels > 0 ? Math.max(1, modelsH / shownModels) : 1,
564
+ }] : []),
565
+ ];
566
+ const targets = planViewportFit(contentH - frameBound, lists, fitMemoryRef.current, Date.now());
567
+ if (targets["logs"] !== undefined)
568
+ setLogVisible(targets["logs"]);
569
+ if (targets["accounts"] !== undefined)
570
+ setAccountsVisible(targets["accounts"]);
571
+ if (targets["models"] !== undefined)
572
+ setModelsVisible(targets["models"]);
573
+ });
311
574
  // First visible activity row. The stored position only moves on navigation;
312
575
  // the derived value re-clamps every render because the selection is
313
576
  // timestamp-anchored — new entries arriving between keypresses can push the
314
577
  // selected row out of the stored window, and it must stay visible anyway.
315
578
  const [logScrollTop, setLogScrollTop] = useState(0);
316
- const logWindowTop = followScrollWindow(logScrollTop, selectedLogIndex, logs.length, LOG_VISIBLE);
579
+ const logWindowTop = followScrollWindow(logScrollTop, selectedLogIndex, logs.length, logVisible);
317
580
  // Selected account by id
318
581
  const [selectedAccountId, setSelectedAccountId] = useState(null);
319
582
  const selectedAccountIndex = selectedAccountId !== null
320
583
  ? Math.max(0, data.accounts.findIndex(a => a.id === selectedAccountId))
321
584
  : 0;
322
585
  const selectedAccount = data.accounts[selectedAccountIndex] ?? null;
586
+ // Same follow-scroll for the accounts window: when the fitting controller
587
+ // shrinks the list below the fleet size, the selected account must stay on
588
+ // screen — account actions (caps, toggle, delete confirmation) target the
589
+ // selection, and acting on an invisible account is how a wrong one dies.
590
+ const [accountScrollTop, setAccountScrollTop] = useState(0);
591
+ const accountWindowTop = followScrollWindow(accountScrollTop, selectedAccountIndex, data.accounts.length, shownAccounts);
323
592
  const [modelsStatus, setModelsStatus] = useState(null);
324
593
  const [selectedModelId, setSelectedModelId] = useState(null);
325
594
  const modelRows = modelsStatus?.models ?? [];
@@ -523,22 +792,24 @@ function LiveDashboard({ data, port, baseUrl, lastUpdate, api, modelsApi, onInte
523
792
  if (key.upArrow) {
524
793
  const next = Math.max(0, selectedLogIndex - 1);
525
794
  setSelectedTs(logs[next]?.ts ?? null);
526
- setLogScrollTop(followScrollWindow(logWindowTop, next, logs.length, LOG_VISIBLE));
795
+ setLogScrollTop(followScrollWindow(logWindowTop, next, logs.length, logVisible));
527
796
  }
528
797
  if (key.downArrow) {
529
798
  const next = Math.min(logs.length - 1, selectedLogIndex + 1);
530
799
  setSelectedTs(logs[next]?.ts ?? null);
531
- setLogScrollTop(followScrollWindow(logWindowTop, next, logs.length, LOG_VISIBLE));
800
+ setLogScrollTop(followScrollWindow(logWindowTop, next, logs.length, logVisible));
532
801
  }
533
802
  }
534
803
  if (focus === "accounts") {
535
804
  if (key.upArrow) {
536
805
  const next = Math.max(0, selectedAccountIndex - 1);
537
806
  setSelectedAccountId(data.accounts[next]?.id ?? null);
807
+ setAccountScrollTop(followScrollWindow(accountWindowTop, next, data.accounts.length, shownAccounts));
538
808
  }
539
809
  if (key.downArrow) {
540
810
  const next = Math.min(data.accounts.length - 1, selectedAccountIndex + 1);
541
811
  setSelectedAccountId(data.accounts[next]?.id ?? null);
812
+ setAccountScrollTop(followScrollWindow(accountWindowTop, next, data.accounts.length, shownAccounts));
542
813
  }
543
814
  // Account actions (only when focus = accounts)
544
815
  if (input === "e") {
@@ -611,15 +882,15 @@ function LiveDashboard({ data, port, baseUrl, lastUpdate, api, modelsApi, onInte
611
882
  }
612
883
  });
613
884
  const selectedLog = logs[selectedLogIndex] ?? null;
614
- const visibleLogs = logs.slice(logWindowTop, logWindowTop + LOG_VISIBLE);
615
- return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, color: "cyan", children: " CC-Router " }), _jsx(Text, { color: "gray", children: "\u00B7 " }), _jsx(Text, { color: "green", children: data.mode }), _jsxs(Text, { color: "gray", children: [" \u2192 ", data.target, " \u00B7 "] }), _jsxs(Text, { children: ["up ", formatUptime(data.uptime)] }), _jsxs(Text, { color: "gray", children: [" \u00B7 updated ", updatedAgo, "s ago \u00B7 [q] quit"] })] }), data.version !== DASHBOARD_VERSION && (_jsxs(Box, { children: [_jsx(Text, { bold: true, color: "yellow", children: " \u26A0 VERSION MISMATCH " }), _jsxs(Text, { color: "yellow", children: [data.version !== undefined
616
- ? `daemon v${data.version}`
617
- : "daemon version unreported (older build)", ` · dashboard v${DASHBOARD_VERSION}`] }), LOCAL_TARGET_RE.test(baseUrl) ? (_jsxs(_Fragment, { children: [_jsx(Text, { color: "gray", children: " \u2014 restart: " }), _jsx(Text, { color: "cyan", children: "cc-router stop --keep-config && cc-router start" })] })) : (
618
- // A remote router can only be restarted where it runs; printing a
619
- // local restart command here would never clear the banner.
620
- _jsxs(Text, { color: "gray", children: [" \u2014 update and restart the daemon on ", baseUrl] }))] })), _jsx(Box, { marginTop: 1 }), data.operational && (_jsxs(_Fragment, { children: [_jsx(OperationsPanel, { operational: data.operational, baseUrl: baseUrl, focus: focus }), _jsx(Box, { marginTop: 1 })] })), (focus === "models" || modelsStatus) && (_jsxs(_Fragment, { children: [_jsx(ModelsPanel, { status: modelsStatus, selectedIndex: selectedModelIndex, focused: focus === "models" }), _jsx(Box, { marginTop: 1 })] })), _jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsxs(Text, { bold: true, children: [" ACCOUNTS ", _jsxs(Text, { color: healthyCount === data.accounts.length ? "green" : "yellow", children: [healthyCount, "/", data.accounts.length, " healthy"] })] }), _jsx(Text, { color: "gray", children: " " }), _jsx(Text, { color: focus === "accounts" ? "white" : "gray", children: "[Tab] focus [e] toggle [a] Claude all [o] OpenAI all [w] 7d cap [s] 5h cap [n] add [d] delete" })] }), _jsx(Box, { marginTop: 1, flexDirection: "column", children: data.accounts.map((a, i) => (_jsx(AccountRow, { account: a, selected: focus === "accounts" && i === selectedAccountIndex }, a.id))) })] }), mode === "editWeekly" && selectedAccount && (_jsxs(Box, { marginTop: 1, paddingLeft: 2, children: [_jsx(Text, { color: "cyan", children: "Set 7d cap for " }), _jsx(Text, { color: "white", bold: true, children: selectedAccount.id }), _jsx(Text, { color: "cyan", children: " (0\u2013100%): " }), _jsx(Text, { color: "white", bold: true, children: editBuffer }), _jsx(Text, { color: "gray", children: "\u2588 [Enter] save [Esc] cancel" })] })), mode === "editSession" && selectedAccount && (_jsxs(Box, { marginTop: 1, paddingLeft: 2, children: [_jsx(Text, { color: "cyan", children: "Set 5h cap for " }), _jsx(Text, { color: "white", bold: true, children: selectedAccount.id }), _jsx(Text, { color: "cyan", children: " (0\u2013100%): " }), _jsx(Text, { color: "white", bold: true, children: editBuffer }), _jsx(Text, { color: "gray", children: "\u2588 [Enter] save [Esc] cancel" })] })), mode === "confirmDelete" && selectedAccount && (_jsx(Box, { marginTop: 1, paddingLeft: 2, children: _jsxs(Text, { color: "red", bold: true, children: ["Delete \"", selectedAccount.id, "\"? [y] yes [n/Esc] cancel"] }) })), banner && (_jsx(Box, { marginTop: 1, paddingLeft: 2, children: _jsxs(Text, { color: banner.color, children: [" ", banner.text] }) })), _jsx(Box, { marginTop: 1 }), _jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, children: " TOTALS " }), _jsx(Text, { children: "requests " }), _jsx(Text, { color: "cyan", children: data.totalRequests }), _jsx(Text, { color: "gray", children: " \u00B7 " }), _jsx(Text, { children: "errors " }), _jsx(Text, { color: data.totalErrors > 0 ? "red" : "green", children: data.totalErrors }), _jsx(Text, { color: "gray", children: " \u00B7 " }), _jsx(Text, { children: "refreshes " }), _jsx(Text, { color: "yellow", children: data.totalRefreshes }), _jsx(CacheHealthBadge, { read: data.totalCacheReadTokens, created: data.totalCacheCreationTokens, input: data.totalInputTokens })] }), _jsx(TokenSummary, { cacheRead: data.totalCacheReadTokens, cacheCreated: data.totalCacheCreationTokens, uncached: data.totalInputTokens, output: data.totalOutputTokens ?? 0 })] }), _jsx(Box, { marginTop: 1 }), _jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, children: " RECENT ACTIVITY" }), _jsx(Box, { marginTop: 1, flexDirection: "column", children: visibleLogs.length === 0
621
- ? _jsx(Text, { color: "gray", children: " No activity yet" })
622
- : visibleLogs.map((log, i) => (_jsx(LogRow, { log: log, selected: focus === "logs" && logWindowTop + i === selectedLogIndex }, `${log.ts}-${i}`))) })] }), focus === "logs" && selectedLog && (_jsxs(_Fragment, { children: [_jsx(Box, { marginTop: 1 }), _jsx(DetailPanel, { log: selectedLog })] }))] }));
885
+ const visibleLogs = logs.slice(logWindowTop, logWindowTop + logVisible);
886
+ return (_jsx(Box, { flexDirection: "column", height: frameBound, overflowY: "hidden", children: _jsxs(Box, { flexDirection: "column", flexShrink: 0, ref: contentRef, children: [_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, color: "cyan", children: " CC-Router " }), _jsx(Text, { color: "gray", children: "\u00B7 " }), _jsx(Text, { color: "green", children: data.mode }), _jsxs(Text, { color: "gray", children: [" \u2192 ", data.target, " \u00B7 "] }), _jsxs(Text, { children: ["up ", formatUptime(data.uptime)] }), _jsxs(Text, { color: "gray", children: [" \u00B7 updated ", updatedAgo, "s ago \u00B7 [q] quit"] })] }), mode === "editWeekly" && selectedAccount && (_jsxs(Box, { paddingLeft: 2, children: [_jsx(Text, { color: "cyan", children: "Set 7d cap for " }), _jsx(Text, { color: "white", bold: true, children: selectedAccount.id }), _jsx(Text, { color: "cyan", children: " (0\u2013100%): " }), _jsx(Text, { color: "white", bold: true, children: editBuffer }), _jsx(Text, { color: "gray", children: "\u2588 [Enter] save [Esc] cancel" })] })), mode === "editSession" && selectedAccount && (_jsxs(Box, { paddingLeft: 2, children: [_jsx(Text, { color: "cyan", children: "Set 5h cap for " }), _jsx(Text, { color: "white", bold: true, children: selectedAccount.id }), _jsx(Text, { color: "cyan", children: " (0\u2013100%): " }), _jsx(Text, { color: "white", bold: true, children: editBuffer }), _jsx(Text, { color: "gray", children: "\u2588 [Enter] save [Esc] cancel" })] })), mode === "confirmDelete" && selectedAccount && (_jsx(Box, { paddingLeft: 2, children: _jsxs(Text, { color: "red", bold: true, children: ["Delete \"", selectedAccount.id, "\"? [y] yes [n/Esc] cancel"] }) })), data.version !== DASHBOARD_VERSION && (_jsxs(Box, { children: [_jsx(Text, { bold: true, color: "yellow", children: " \u26A0 VERSION MISMATCH " }), _jsxs(Text, { color: "yellow", children: [data.version !== undefined
887
+ ? `daemon v${data.version}`
888
+ : "daemon version unreported (older build)", ` · dashboard v${DASHBOARD_VERSION}`] }), LOCAL_TARGET_RE.test(baseUrl) ? (_jsxs(_Fragment, { children: [_jsx(Text, { color: "gray", children: " \u2014 restart: " }), _jsx(Text, { color: "cyan", children: "cc-router stop --keep-config && cc-router start" })] })) : (
889
+ // A remote router can only be restarted where it runs; printing a
890
+ // local restart command here would never clear the banner.
891
+ _jsxs(Text, { color: "gray", children: [" \u2014 update and restart the daemon on ", baseUrl] }))] })), _jsx(Box, { marginTop: 1 }), data.operational && (_jsxs(_Fragment, { children: [_jsx(OperationsPanel, { operational: data.operational, baseUrl: baseUrl, focus: focus }), _jsx(Box, { marginTop: 1 })] })), (focus === "models" || modelsStatus) && (_jsxs(_Fragment, { children: [_jsx(ModelsPanel, { status: modelsStatus, selectedIndex: selectedModelIndex, focused: focus === "models", visibleRows: modelsVisible, rowsRef: modelRowsRef }), _jsx(Box, { marginTop: 1 })] })), _jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsxs(Text, { bold: true, children: [" ACCOUNTS ", _jsxs(Text, { color: healthyCount === data.accounts.length ? "green" : "yellow", children: [healthyCount, "/", data.accounts.length, " healthy"] })] }), shownAccounts < data.accounts.length && (_jsxs(Text, { color: "gray", children: [" · showing ", accountWindowTop + 1, "\u2013", accountWindowTop + shownAccounts] })), _jsx(Text, { color: "gray", children: " " }), _jsx(Text, { color: focus === "accounts" ? "white" : "gray", children: "[Tab] focus [e] toggle [a] Claude all [o] OpenAI all [w] 7d cap [s] 5h cap [n] add [d] delete" })] }), _jsx(Box, { marginTop: 1, flexDirection: "column", ref: accountRowsRef, children: data.accounts.slice(accountWindowTop, accountWindowTop + shownAccounts).map((a, i) => (_jsx(AccountRow, { account: a, selected: focus === "accounts" && accountWindowTop + i === selectedAccountIndex }, a.id))) })] }), banner && (_jsx(Box, { marginTop: 1, paddingLeft: 2, children: _jsxs(Text, { color: banner.color, children: [" ", banner.text] }) })), _jsx(Box, { marginTop: 1 }), _jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, children: " TOTALS " }), _jsx(Text, { children: "requests " }), _jsx(Text, { color: "cyan", children: data.totalRequests }), _jsx(Text, { color: "gray", children: " \u00B7 " }), _jsx(Text, { children: "errors " }), _jsx(Text, { color: data.totalErrors > 0 ? "red" : "green", children: data.totalErrors }), _jsx(Text, { color: "gray", children: " \u00B7 " }), _jsx(Text, { children: "refreshes " }), _jsx(Text, { color: "yellow", children: data.totalRefreshes }), _jsx(CacheHealthBadge, { read: data.totalCacheReadTokens, created: data.totalCacheCreationTokens, input: data.totalInputTokens })] }), _jsx(TokenSummary, { cacheRead: data.totalCacheReadTokens, cacheCreated: data.totalCacheCreationTokens, uncached: data.totalInputTokens, output: data.totalOutputTokens ?? 0 })] }), _jsx(Box, { marginTop: 1 }), _jsx(Text, { bold: true, children: " RECENT ACTIVITY" }), _jsx(Box, { marginTop: 1 })] }), _jsx(Box, { flexDirection: "column", ref: logRowsRef, children: visibleLogs.length === 0
892
+ ? _jsx(Text, { color: "gray", children: " No activity yet" })
893
+ : visibleLogs.map((log, i) => (_jsx(LogRow, { log: log, selected: focus === "logs" && logWindowTop + i === selectedLogIndex }, `${log.ts}-${i}`))) }), focus === "logs" && selectedLog && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { marginTop: 1 }), _jsx(DetailPanel, { log: selectedLog })] }))] }) }));
623
894
  }
624
895
  function OperationsPanel({ operational, baseUrl, focus }) {
625
896
  const authLabel = operational.auth.required ? "protected" : "open";
@@ -630,14 +901,14 @@ function OperationsPanel({ operational, baseUrl, focus }) {
630
901
  const modelsReady = operational.capabilities.dynamicModels;
631
902
  return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, children: " OPERATIONS " }), _jsx(Text, { color: "gray", children: "base " }), _jsx(Text, { color: "cyan", children: baseUrl }), _jsx(Text, { color: "gray", children: " \u00B7 auth " }), _jsx(Text, { color: authColor, children: authLabel }), _jsx(Text, { color: "gray", children: " \u00B7 models " }), _jsx(Text, { color: modelsReady ? "green" : "red", children: modelsReady ? "dynamic" : "off" })] }), _jsxs(Box, { paddingLeft: 2, children: [_jsx(ProviderBadge, { label: "Claude", status: operational.providers.anthropic, ready: claudeReady }), _jsx(Text, { color: "gray", children: " " }), _jsx(ProviderBadge, { label: "OpenAI", status: operational.providers.openai, ready: openAIReady }), _jsx(Text, { color: "gray", children: " \u00B7 cross-route " }), _jsx(Text, { color: crossReady ? "green" : "gray", children: crossReady ? "ready" : "needs OpenAI" })] }), _jsxs(Box, { paddingLeft: 2, children: [_jsx(Text, { color: "gray", children: "endpoints " }), _jsx(Text, { color: "white", children: operational.endpoints.messages }), _jsx(Text, { color: "gray", children: " " }), _jsx(Text, { color: "white", children: operational.endpoints.responses }), _jsx(Text, { color: "gray", children: " " }), _jsx(Text, { color: "white", children: operational.endpoints.models }), _jsx(Text, { color: "gray", children: " " }), _jsx(Text, { color: "white", children: operational.endpoints.accounts })] }), _jsxs(Box, { paddingLeft: 2, children: [_jsx(Text, { color: "gray", children: "routing " }), _jsxs(Text, { color: "white", children: ["claude=", operational.routing.anthropicDefaultModel ?? "default"] }), _jsxs(Text, { color: "gray", children: [" aliases[", operational.routing.anthropicAliases.join(",") || "-", "]"] }), _jsx(Text, { color: "gray", children: " " }), _jsxs(Text, { color: "white", children: ["openai=", operational.routing.openAIDefaultModel ?? "default"] }), _jsxs(Text, { color: "gray", children: [" aliases[", operational.routing.openAIAliases.join(",") || "-", "]"] })] }), _jsxs(Box, { paddingLeft: 2, children: [_jsx(Text, { color: "gray", children: "models " }), _jsx(Text, { color: focus === "models" ? "white" : "cyan", children: "[m] list/select" }), _jsx(Text, { color: "gray", children: " change " }), _jsx(Text, { color: focus === "models" ? "white" : "cyan", children: "[c] Claude [o] OpenAI" })] })] }));
632
903
  }
633
- function ModelsPanel({ status, selectedIndex, focused, }) {
904
+ function ModelsPanel({ status, selectedIndex, focused, visibleRows = MODEL_VISIBLE_ROWS, rowsRef, }) {
634
905
  const models = status?.models ?? [];
635
- const visible = getVisibleModelWindow(models, selectedIndex, MODEL_VISIBLE_ROWS);
906
+ const visible = getVisibleModelWindow(models, selectedIndex, Math.max(1, visibleRows));
636
907
  return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, children: " MODELS " }), _jsx(Text, { color: "gray", children: "[m/r] refresh [\u2191/\u2193] select [c] Claude default [o] OpenAI default [Esc] logs" })] }), _jsxs(Box, { paddingLeft: 2, children: [_jsx(Text, { color: "gray", children: "current " }), _jsxs(Text, { color: "white", children: ["claude=", status?.routing.anthropicDefaultModel ?? "default"] }), _jsx(Text, { color: "gray", children: " " }), _jsxs(Text, { color: "white", children: ["openai=", status?.routing.openAIDefaultModel ?? "default"] })] }), _jsx(Box, { marginTop: 1, flexDirection: "column", children: status === null
637
908
  ? _jsx(Text, { color: "gray", children: " Press [m] to load models from provider APIs" })
638
909
  : models.length === 0
639
910
  ? _jsx(Text, { color: "gray", children: " No models discovered" })
640
- : (_jsxs(_Fragment, { children: [_jsxs(Text, { color: "gray", children: [" showing ", visible.start + 1, "-", visible.end, " of ", models.length] }), visible.rows.map((model, i) => (_jsx(ModelRow, { model: model, selected: focused && visible.start + i === selectedIndex, currentClaude: status.routing.anthropicDefaultModel, currentOpenAI: status.routing.openAIDefaultModel }, model.id)))] })) })] }));
911
+ : (_jsxs(_Fragment, { children: [_jsxs(Text, { color: "gray", children: [" showing ", visible.start + 1, "-", visible.end, " of ", models.length] }), _jsx(Box, { flexDirection: "column", ref: rowsRef, children: visible.rows.map((model, i) => (_jsx(ModelRow, { model: model, selected: focused && visible.start + i === selectedIndex, currentClaude: status.routing.anthropicDefaultModel, currentOpenAI: status.routing.openAIDefaultModel }, model.id))) })] })) })] }));
641
912
  }
642
913
  export function getVisibleModelWindow(models, selectedIndex, maxRows = MODEL_VISIBLE_ROWS) {
643
914
  if (models.length <= maxRows) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@timo972/cc-router",
3
- "version": "0.10.0-rc.5",
3
+ "version": "0.10.0",
4
4
  "description": "Cache-aware session router for Claude Max OAuth tokens — use multiple Claude Max accounts with Claude Code",
5
5
  "type": "module",
6
6
  "bin": {