free-coding-models 0.5.28 → 0.5.29
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/bin/free-coding-models.js +10 -2
- package/changelog/v0.5.29.md +19 -0
- package/package.json +1 -1
- package/sources.js +3 -2
- package/src/core/config.js +1 -14
- package/src/core/endpoint-installer.js +5 -9
- package/src/core/legacy-proxy-cleanup.js +2 -0
- package/src/core/model-merger.js +4 -13
- package/src/core/router-daemon.js +76 -41
- package/src/core/router-dashboard.js +63 -25
- package/src/core/shared-helpers.js +117 -0
- package/src/core/sync-set.js +6 -21
- package/src/core/tool-launchers.js +24 -92
- package/src/core/utils.js +26 -100
- package/src/tui/app.js +2 -2
- package/src/tui/command-palette.js +1 -0
- package/src/tui/key-handler.js +77 -20
- package/src/tui/render-table.js +3 -3
- package/web/dist/assets/index-BMU58Jju.js +41 -0
- package/web/dist/assets/{index-BRFowJv5.css → index-Dd1jOGEn.css} +1 -1
- package/web/dist/index.html +2 -2
- package/web/src/components/router/RouterView.jsx +124 -56
- package/web/src/components/router/RouterView.module.css +135 -1
- package/src/core/product-flags.js +0 -9
- package/web/dist/assets/index-Pr3waI0-.js +0 -41
|
@@ -4,8 +4,16 @@
|
|
|
4
4
|
* @description Live terminal availability checker for coding LLM models with OpenCode & OpenClaw integration.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
// 📖 --dev mode: must set FCM_DEV before any module imports resolve daemon paths
|
|
8
|
-
|
|
7
|
+
// 📖 --dev mode: must set FCM_DEV before any module imports resolve daemon paths.
|
|
8
|
+
// 📖 Also auto-detect git checkouts — a repo checkout is always in dev mode because
|
|
9
|
+
// 📖 the router daemon must use dev ports/files to avoid clashing with a production
|
|
10
|
+
// 📖 npm install running on the same machine.
|
|
11
|
+
// 📖 IMPORTANT: these checks MUST run synchronously before any static imports
|
|
12
|
+
// 📖 resolve, because router-daemon.js reads FCM_DEV at module load time.
|
|
13
|
+
import { existsSync } from 'node:fs'
|
|
14
|
+
import { join, dirname } from 'node:path'
|
|
15
|
+
import { fileURLToPath } from 'node:url'
|
|
16
|
+
if (process.argv.includes('--dev') || (!process.env.FCM_DEV && existsSync(join(dirname(fileURLToPath(import.meta.url)), '..', '.git')))) {
|
|
9
17
|
process.env.FCM_DEV = '1'
|
|
10
18
|
}
|
|
11
19
|
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# Changelog v0.5.29 - 2026-06-15
|
|
2
|
+
|
|
3
|
+
### Fixed
|
|
4
|
+
- **Router now respects your priority order (fixes #120, reported by @jammin1911).** Previously, priority was only 20% of the routing score, so a healthy low-priority model (e.g. GPT-OSS 120B) could serve traffic even when higher-priority models you deliberately ranked above it were also healthy. Routing is now **strict priority-first**: `#1` is always tried first while it is healthy, and the health score is only used as a tiebreaker between models that share the same priority (e.g. cold-start ties). Circuit-breaker safety is preserved — closed (healthy) models always come before half-open (recovering) ones. Your fallback chain is now authoritative.
|
|
5
|
+
- **Daemon startup errors are now surfaced instead of swallowed.** The TUI captures `--daemon-bg` stdout/stderr and shows the real failure reason (port in use, config corruption, etc.) as a dashboard notice, instead of silently flipping back to "stopped".
|
|
6
|
+
- **Dev checkouts no longer clash with production installs.** A git checkout now auto-enables dev mode (`FCM_DEV=1`) so its router daemon uses dev ports/files (`29280`, `-dev` suffixed PID/port/log files) and never collides with a globally-installed `free-coding-models` running on the same machine. Dynamic path resolvers pick up `FCM_DEV` changes that happen after module load.
|
|
7
|
+
|
|
8
|
+
### Added
|
|
9
|
+
- **`routingOrder` field in `/stats`** — exposes the exact attempt order the daemon will use for the next request (priority-first among healthy models). `routingOrder[0]` is the model that will serve the next chat completion. Dashboards use it to show which model is active.
|
|
10
|
+
- **Clearer priority indicators in the Router UI (all surfaces)** so users understand the fallback chain at a glance:
|
|
11
|
+
- **Web Dashboard:** the active-set list now labels `#1` as a highlighted **Primary** badge (accent-colored) and the rest as numbered fallbacks. A one-line legend explains *"Primary tries first → Fallback on failure / rate-limit"*. The model that will serve the next request gets a green accent border + tint (**Next up** badge in the legend). Tooltips on every badge explain the semantics.
|
|
12
|
+
- **TUI Dashboard:** a `▶ NEXT` marker (green) is drawn on the exact model the daemon will route to next, so the top of your fallback chain is obvious. Empty-state copy now explains *"Favorites become your router fallback chain — #1 is tried first."*
|
|
13
|
+
- **Always-visible Quick Setup card** on the Router dashboard (Web + TUI) with sensible defaults (`localhost:19280/v1`, model `fcm`, key `fcm-local`), so you can copy your tool config even before starting the daemon. The Web version adds a one-click **Copy all** button and a hero glow when running.
|
|
14
|
+
- **Better empty states** across the Web Router view: the active-set list and request log now show helpful guidance ("No models in the active set → Add models or Sync best", "No requests yet. Start coding to see traffic here.") instead of disappearing.
|
|
15
|
+
- **Auto-expanding request log** — the Web request log expands automatically the first time traffic appears, so you don't have to click to see your routed requests.
|
|
16
|
+
- **More prominent Start button** — the stopped-state "Start Router" CTA is now larger and easier to hit.
|
|
17
|
+
|
|
18
|
+
### Changed
|
|
19
|
+
- Router candidate sorting changed from `score DESC, priority ASC` to `priority ASC, score DESC` — the user's explicit ranking now wins. The `scoring` weights config is retained (still used for the health table and same-tier tiebreaks), so existing user configs and config normalization are unaffected.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "free-coding-models",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.29",
|
|
4
4
|
"description": "Find the fastest coding LLM models in seconds — ping free models from multiple providers, pick the best one for OpenCode, Cursor, or any AI coding assistant.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"nvidia",
|
package/sources.js
CHANGED
|
@@ -549,7 +549,8 @@ export const sources = {
|
|
|
549
549
|
export const MODELS = [];
|
|
550
550
|
for (const [sourceKey, sourceData] of Object.entries(sources)) {
|
|
551
551
|
if (!sourceData || !sourceData.models) continue
|
|
552
|
-
for (const
|
|
553
|
-
|
|
552
|
+
for (const model of sourceData.models) {
|
|
553
|
+
const [modelId, label, tier, sweScore, ctx, addedDate] = model
|
|
554
|
+
MODELS.push([modelId, label, tier, sweScore, ctx, sourceKey, addedDate || null])
|
|
554
555
|
}
|
|
555
556
|
}
|
package/src/core/config.js
CHANGED
|
@@ -464,16 +464,6 @@ export function defaultRouterPrePromptText() {
|
|
|
464
464
|
return DEFAULT_ROUTER_SETTINGS.prePrompt.text
|
|
465
465
|
}
|
|
466
466
|
|
|
467
|
-
function normalizeProfileSettings(settings) {
|
|
468
|
-
const safeSettings = isPlainObject(settings) ? { ...settings } : {}
|
|
469
|
-
return {
|
|
470
|
-
..._emptyProfileSettings(),
|
|
471
|
-
...safeSettings,
|
|
472
|
-
theme: ['dark', 'light', 'auto'].includes(safeSettings.theme) ? safeSettings.theme : 'auto',
|
|
473
|
-
}
|
|
474
|
-
}
|
|
475
|
-
|
|
476
|
-
|
|
477
467
|
|
|
478
468
|
function normalizeConfigShape(config) {
|
|
479
469
|
const safeConfig = isPlainObject(config) ? config : {}
|
|
@@ -527,10 +517,7 @@ function mergeEndpointInstalls(diskEndpointInstalls, incomingEndpointInstalls) {
|
|
|
527
517
|
return [...merged.values()]
|
|
528
518
|
}
|
|
529
519
|
|
|
530
|
-
|
|
531
|
-
// 📖 Profile system removed - return empty object
|
|
532
|
-
return {}
|
|
533
|
-
}
|
|
520
|
+
|
|
534
521
|
|
|
535
522
|
/**
|
|
536
523
|
* 📖 buildPersistedConfig merges the latest disk snapshot with the in-memory config so
|
|
@@ -47,6 +47,7 @@ import { MODELS, sources } from '../../sources.js'
|
|
|
47
47
|
import { getApiKey, saveConfig } from './config.js'
|
|
48
48
|
import { ENV_VAR_NAMES, PROVIDER_METADATA } from './provider-metadata.js'
|
|
49
49
|
import { getToolMeta } from './tool-metadata.js'
|
|
50
|
+
import { ensureDir, readJson as sharedReadJson } from './shared-helpers.js'
|
|
50
51
|
|
|
51
52
|
// 📖 replicate uses /v1/predictions (not /chat/completions), so it's not OpenAI-compatible.
|
|
52
53
|
// 📖 zai and opencode-zen ARE OpenAI-compatible and CAN be installed into any tool.
|
|
@@ -72,9 +73,8 @@ function getDefaultPaths() {
|
|
|
72
73
|
}
|
|
73
74
|
}
|
|
74
75
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
}
|
|
76
|
+
// 📖 ensureDirFor replaced by shared ensureDir (same logic)
|
|
77
|
+
const ensureDirFor = ensureDir
|
|
78
78
|
|
|
79
79
|
function backupIfExists(filePath) {
|
|
80
80
|
if (!existsSync(filePath)) return null
|
|
@@ -83,13 +83,9 @@ function backupIfExists(filePath) {
|
|
|
83
83
|
return backupPath
|
|
84
84
|
}
|
|
85
85
|
|
|
86
|
+
// 📖 readJson with default fallback of {} — matches shared helper's signature
|
|
86
87
|
function readJson(filePath, fallback = {}) {
|
|
87
|
-
|
|
88
|
-
try {
|
|
89
|
-
return JSON.parse(readFileSync(filePath, 'utf8'))
|
|
90
|
-
} catch {
|
|
91
|
-
return fallback
|
|
92
|
-
}
|
|
88
|
+
return sharedReadJson(filePath, fallback)
|
|
93
89
|
}
|
|
94
90
|
|
|
95
91
|
function writeJson(filePath, value, { backup = true } = {}) {
|
|
@@ -81,6 +81,8 @@ function noteError(summary, filePath, error) {
|
|
|
81
81
|
summary.errors.push(`${filePath}: ${error instanceof Error ? error.message : String(error)}`)
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
+
// 📖 Thin wrappers: readJsonFile propagates parse errors (callers have try/catch).
|
|
85
|
+
// 📖 writeJsonFile appends a trailing newline for clean diffs.
|
|
84
86
|
function readJsonFile(filePath, fallback = null) {
|
|
85
87
|
if (!existsSync(filePath)) return fallback
|
|
86
88
|
return JSON.parse(readFileSync(filePath, 'utf8'))
|
package/src/core/model-merger.js
CHANGED
|
@@ -1,15 +1,6 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
function parseCtxK(ctx) {
|
|
4
|
-
if (!ctx) return 0
|
|
5
|
-
const s = ctx.toLowerCase()
|
|
6
|
-
if (s.endsWith('m')) return parseFloat(s) * 1000
|
|
7
|
-
return parseFloat(s) || 0
|
|
8
|
-
}
|
|
1
|
+
import { parseCtxToK, parseSweToNum } from './utils.js'
|
|
9
2
|
|
|
10
|
-
|
|
11
|
-
return parseFloat(swe) || 0
|
|
12
|
-
}
|
|
3
|
+
const TIER_RANK = { 'S+': 0, 'S': 1, 'A+': 2, 'A': 3, 'A-': 4, 'B+': 5, 'B': 6, 'C': 7 }
|
|
13
4
|
|
|
14
5
|
/**
|
|
15
6
|
* Generate a unique slug from a label.
|
|
@@ -60,11 +51,11 @@ export function buildMergedModels(models) {
|
|
|
60
51
|
group.tier = tier
|
|
61
52
|
}
|
|
62
53
|
// Keep highest SWE score
|
|
63
|
-
if (
|
|
54
|
+
if (parseSweToNum(sweScore) > parseSweToNum(group.sweScore)) {
|
|
64
55
|
group.sweScore = sweScore
|
|
65
56
|
}
|
|
66
57
|
// Keep largest context
|
|
67
|
-
if (
|
|
58
|
+
if (parseCtxToK(ctx) > parseCtxToK(group.ctx)) {
|
|
68
59
|
group.ctx = ctx
|
|
69
60
|
}
|
|
70
61
|
}
|
|
@@ -51,6 +51,8 @@ import { buildChatCompletionPingBody, ping, resolveCloudflareUrl, shouldUseDisab
|
|
|
51
51
|
import { benchmarkModel, BENCHMARK_TIMEOUT_MS } from './benchmark.js'
|
|
52
52
|
import { loadChangelog } from './changelog-loader.js'
|
|
53
53
|
import { sendUsageTelemetry } from './telemetry.js'
|
|
54
|
+
import { TIER_ORDER } from './utils.js'
|
|
55
|
+
import { atomicWriteJson, safeJsonParse, sleep, maskApiKey, isRouteableProvider } from './shared-helpers.js'
|
|
54
56
|
|
|
55
57
|
export const ROUTER_DEFAULT_PORT = 19280
|
|
56
58
|
export const ROUTER_MAX_PORT = 19289
|
|
@@ -59,15 +61,28 @@ export const ROUTER_MAX_PORT_DEV = 29289
|
|
|
59
61
|
|
|
60
62
|
// 📖 Dev mode uses -dev suffixed files so the local dev daemon never clashes
|
|
61
63
|
// 📖 with a production install running on the same machine.
|
|
62
|
-
|
|
64
|
+
// 📖 IMPORTANT: _isDev() is a function, not a constant, so it picks up FCM_DEV
|
|
65
|
+
// 📖 changes that happen after module load (e.g. the bin entry point setting
|
|
66
|
+
// 📖 FCM_DEV=1 on git checkouts). Constant exports for PID/PORT/LOG paths
|
|
67
|
+
// 📖 are still computed eagerly — they are only used by the daemon child process
|
|
68
|
+
// 📖 which always has FCM_DEV set before import. The TUI and dashboard use
|
|
69
|
+
// 📖 getRouterPortRange() and getRouterPidPath() for dynamic resolution.
|
|
70
|
+
function _isDev() { return typeof process.env.FCM_DEV !== 'undefined' ? !!process.env.FCM_DEV : false }
|
|
71
|
+
const _dev = _isDev()
|
|
63
72
|
export const ROUTER_PID_PATH = join(homedir(), `.free-coding-models-daemon${_dev ? '-dev' : ''}.pid`)
|
|
64
73
|
export const ROUTER_PORT_PATH = join(homedir(), `.free-coding-models-daemon${_dev ? '-dev' : ''}.port`)
|
|
65
74
|
export const ROUTER_LOG_PATH = join(homedir(), `.free-coding-models-daemon${_dev ? '-dev' : ''}.log`)
|
|
66
75
|
export const ROUTER_TOKENS_PATH = join(homedir(), `.free-coding-models-tokens${_dev ? '-dev' : ''}.json`)
|
|
67
76
|
|
|
77
|
+
// 📖 Dynamic path resolvers — used by the TUI dashboard which may have FCM_DEV
|
|
78
|
+
// 📖 set after module load time (git checkout auto-detection in bin/ entry).
|
|
79
|
+
export function getRouterPidPath() { return join(homedir(), `.free-coding-models-daemon${_isDev() ? '-dev' : ''}.pid`) }
|
|
80
|
+
export function getRouterPortPath() { return join(homedir(), `.free-coding-models-daemon${_isDev() ? '-dev' : ''}.port`) }
|
|
81
|
+
export function getRouterLogPath() { return join(homedir(), `.free-coding-models-daemon${_isDev() ? '-dev' : ''}.log`) }
|
|
82
|
+
|
|
68
83
|
// 📖 Returns effective port range for current mode (dev vs production)
|
|
69
84
|
export function getRouterPortRange() {
|
|
70
|
-
return
|
|
85
|
+
return _isDev()
|
|
71
86
|
? { defaultPort: ROUTER_DEFAULT_PORT_DEV, maxPort: ROUTER_MAX_PORT_DEV }
|
|
72
87
|
: { defaultPort: ROUTER_DEFAULT_PORT, maxPort: ROUTER_MAX_PORT }
|
|
73
88
|
}
|
|
@@ -83,7 +98,6 @@ const MAX_PROBE_WINDOW = 20
|
|
|
83
98
|
const TOKEN_FLUSH_INTERVAL_MS = 60000
|
|
84
99
|
const CONFIG_RELOAD_INTERVAL_MS = 10000
|
|
85
100
|
const STATS_RETENTION_DAYS = 90
|
|
86
|
-
const TIER_ORDER = ['S+', 'S', 'A+', 'A', 'A-', 'B+', 'B', 'C']
|
|
87
101
|
const RETRYABLE_STATUS_CODES = new Set([429, 500, 502, 503])
|
|
88
102
|
const AUTH_STATUS_CODES = new Set([401, 403])
|
|
89
103
|
const RATE_LIMIT_HEADER_NAMES = [
|
|
@@ -110,14 +124,7 @@ function modelKey(provider, model) {
|
|
|
110
124
|
return `${provider}/${model}`
|
|
111
125
|
}
|
|
112
126
|
|
|
113
|
-
|
|
114
|
-
try {
|
|
115
|
-
return JSON.parse(raw)
|
|
116
|
-
} catch {
|
|
117
|
-
return fallback
|
|
118
|
-
}
|
|
119
|
-
}
|
|
120
|
-
|
|
127
|
+
// 📖 parseJsonResult is still local — it returns {ok, value/error} which is different from safeJsonParse
|
|
121
128
|
function parseJsonResult(raw) {
|
|
122
129
|
try {
|
|
123
130
|
return { ok: true, value: JSON.parse(raw) }
|
|
@@ -126,16 +133,6 @@ function parseJsonResult(raw) {
|
|
|
126
133
|
}
|
|
127
134
|
}
|
|
128
135
|
|
|
129
|
-
function atomicWriteJson(path, data, mode = 0o600) {
|
|
130
|
-
const tempPath = `${path}.tmp-${process.pid}-${Date.now()}`
|
|
131
|
-
writeFileSync(tempPath, JSON.stringify(data, null, 2), { mode })
|
|
132
|
-
renameSync(tempPath, path)
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
function sleep(ms) {
|
|
136
|
-
return new Promise((resolve) => setTimeout(resolve, ms))
|
|
137
|
-
}
|
|
138
|
-
|
|
139
136
|
function isProcessAlive(pid) {
|
|
140
137
|
if (!Number.isInteger(pid) || pid <= 0) return false
|
|
141
138
|
try {
|
|
@@ -208,12 +205,6 @@ function isLikelyHtmlResponse(headers, text = '') {
|
|
|
208
205
|
|
|
209
206
|
// ─── Web Dashboard Helpers ─────────────────────────────────────────────────────
|
|
210
207
|
|
|
211
|
-
function maskApiKey(key) {
|
|
212
|
-
if (!key || typeof key !== 'string') return ''
|
|
213
|
-
if (key.length <= 8) return '••••••••'
|
|
214
|
-
return '••••••••' + key.slice(-4)
|
|
215
|
-
}
|
|
216
|
-
|
|
217
208
|
// 📖 Same-origin / loopback check for state-changing or secret-revealing
|
|
218
209
|
// 📖 endpoints. Blocks CSRF from malicious tabs and key exfiltration from
|
|
219
210
|
// 📖 cross-origin scripts. Plain CLI calls (curl/fetch without Origin) are
|
|
@@ -540,11 +531,6 @@ function getApiModelId(providerKey, modelId) {
|
|
|
540
531
|
return providerKey === 'zai' ? modelId.replace(/^zai\//, '') : modelId
|
|
541
532
|
}
|
|
542
533
|
|
|
543
|
-
function isRouteableProvider(providerKey) {
|
|
544
|
-
const source = sources[providerKey]
|
|
545
|
-
return Boolean(source?.url && !source.cliOnly && source.url.includes('/chat/completions'))
|
|
546
|
-
}
|
|
547
|
-
|
|
548
534
|
function resolveProviderUrl(providerKey) {
|
|
549
535
|
const url = sources[providerKey]?.url
|
|
550
536
|
if (!url) return null
|
|
@@ -898,7 +884,7 @@ class RouterRuntime {
|
|
|
898
884
|
tier,
|
|
899
885
|
sweScore,
|
|
900
886
|
ctx,
|
|
901
|
-
routeable: isRouteableProvider(providerKey),
|
|
887
|
+
routeable: isRouteableProvider(providerKey, sources),
|
|
902
888
|
})
|
|
903
889
|
}
|
|
904
890
|
}
|
|
@@ -1178,6 +1164,25 @@ class RouterRuntime {
|
|
|
1178
1164
|
})
|
|
1179
1165
|
}
|
|
1180
1166
|
|
|
1167
|
+
// 📖 getRoutingCandidates — the ordered list of models the router will try,
|
|
1168
|
+
// 📖 in EXACT attempt order. This is the heart of routing.
|
|
1169
|
+
// 📖
|
|
1170
|
+
// 📖 Strategy (priority-first): the user's priority order is authoritative.
|
|
1171
|
+
// 📖 A model ranked #1 is always tried first while it is healthy, even if a
|
|
1172
|
+
// 📖 lower-priority model has a better health score. The health score is only
|
|
1173
|
+
// 📖 used to break ties between models that share the same priority — which
|
|
1174
|
+
// 📖 happens in practice when multiple models tie because they have no probe
|
|
1175
|
+
// 📖 data yet (cold start) or identical stats.
|
|
1176
|
+
// 📖
|
|
1177
|
+
// 📖 Why: before this, priority was only 20% of the score and a fast
|
|
1178
|
+
// 📖 low-priority model could steal traffic from a deliberately higher-ranked
|
|
1179
|
+
// 📖 one (see issue #120 — GPT-OSS 120B served despite higher-priority models
|
|
1180
|
+
// 📖 being healthy). Users set the fallback chain on purpose; routing must
|
|
1181
|
+
// 📖 respect it.
|
|
1182
|
+
// 📖
|
|
1183
|
+
// 📖 Circuit-breaker safety is preserved: CLOSED (healthy) models always come
|
|
1184
|
+
// 📖 before HALF_OPEN (probing after cooldown) models, so a recovering model
|
|
1185
|
+
// 📖 never pre-empts a known-good one.
|
|
1181
1186
|
getRoutingCandidates(set) {
|
|
1182
1187
|
const scored = this.scoreCandidates(set)
|
|
1183
1188
|
const usable = scored.filter((candidate) => {
|
|
@@ -1189,8 +1194,26 @@ class RouterRuntime {
|
|
|
1189
1194
|
})
|
|
1190
1195
|
const closed = usable.filter((candidate) => candidate.circuit.state === 'CLOSED')
|
|
1191
1196
|
const halfOpen = usable.filter((candidate) => candidate.circuit.state === 'HALF_OPEN')
|
|
1192
|
-
|
|
1193
|
-
|
|
1197
|
+
// 📖 Priority ascending (1 before 2); within the same priority, healthier
|
|
1198
|
+
// 📖 score wins so cold-start ties resolve deterministically.
|
|
1199
|
+
const byPriorityThenHealth = (a, b) => a.priority - b.priority || b.score - a.score
|
|
1200
|
+
return [...closed.sort(byPriorityThenHealth), ...halfOpen.sort(byPriorityThenHealth)]
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
// 📖 getRoutingOrder — slim projection of getRoutingCandidates for the /stats
|
|
1204
|
+
// 📖 payload and dashboards. Exposes the EXACT order the router will attempt
|
|
1205
|
+
// 📖 on the next request, so the UI can mark the model that will serve it
|
|
1206
|
+
// 📖 (routingOrder[0]) and label every entry as Primary vs Fallback.
|
|
1207
|
+
// 📖 Cheap to compute: reuses getRoutingCandidates + already-recorded health.
|
|
1208
|
+
getRoutingOrder(set) {
|
|
1209
|
+
return this.getRoutingCandidates(set).map((candidate) => ({
|
|
1210
|
+
key: candidate.key,
|
|
1211
|
+
provider: candidate.provider,
|
|
1212
|
+
model: candidate.model,
|
|
1213
|
+
priority: candidate.priority,
|
|
1214
|
+
state: candidate.circuit?.state || 'UNKNOWN',
|
|
1215
|
+
score: Number(candidate.score.toFixed(4)),
|
|
1216
|
+
}))
|
|
1194
1217
|
}
|
|
1195
1218
|
|
|
1196
1219
|
getModelHealth(set = this.getSet()) {
|
|
@@ -1386,6 +1409,11 @@ class RouterRuntime {
|
|
|
1386
1409
|
...this.statusPayload(),
|
|
1387
1410
|
tokens: this.tokenTracker.summary(),
|
|
1388
1411
|
models: this.getModelHealth(activeSet),
|
|
1412
|
+
// 📖 routingOrder — the exact attempt order for the next request
|
|
1413
|
+
// 📖 (priority-first among healthy models). routingOrder[0] is what will
|
|
1414
|
+
// 📖 serve the next chat completion. Surfaced so dashboards can mark the
|
|
1415
|
+
// 📖 "next" model and label Primary vs Fallback semantics. See issue #120.
|
|
1416
|
+
routingOrder: this.getRoutingOrder(activeSet),
|
|
1389
1417
|
requestLog: this.requestLog.slice(0, 20),
|
|
1390
1418
|
circuitBreakers: Object.fromEntries([...this.circuit.entries()].map(([key, value]) => [key, {
|
|
1391
1419
|
state: value.authError ? 'AUTH_ERROR' : value.stale ? 'STALE' : value.unsupported ? 'UNSUPPORTED' : value.state,
|
|
@@ -1501,7 +1529,7 @@ class RouterRuntime {
|
|
|
1501
1529
|
// 📖 skip that provider as a candidate for replacements.
|
|
1502
1530
|
const providerProbeStats = new Map() // provider -> { probed: n, authError: n, stale: n, alive: n }
|
|
1503
1531
|
for (const [providerKey, source] of Object.entries(sources)) {
|
|
1504
|
-
if (!isRouteableProvider(providerKey)) continue
|
|
1532
|
+
if (!isRouteableProvider(providerKey, sources)) continue
|
|
1505
1533
|
if (!providerProbeStats.has(providerKey)) providerProbeStats.set(providerKey, { probed: 0, authError: 0, stale: 0, alive: 0 })
|
|
1506
1534
|
for (const [modelId, , tier, sweScore, ctx] of source.models || []) {
|
|
1507
1535
|
const key = `${providerKey}/${modelId}`
|
|
@@ -2646,7 +2674,7 @@ class RouterRuntime {
|
|
|
2646
2674
|
if (req.method === 'GET' && url.pathname === '/api/router/catalog') {
|
|
2647
2675
|
const rows = []
|
|
2648
2676
|
for (const [providerKey, source] of Object.entries(sources)) {
|
|
2649
|
-
if (!isRouteableProvider(providerKey)) continue
|
|
2677
|
+
if (!isRouteableProvider(providerKey, sources)) continue
|
|
2650
2678
|
if (!Array.isArray(source.models)) continue
|
|
2651
2679
|
for (const [modelId, label, tier, sweScore, ctx] of source.models) {
|
|
2652
2680
|
rows.push({
|
|
@@ -3028,7 +3056,7 @@ export async function buildDefaultRouterSet(config = {}, maxModels, options = {}
|
|
|
3028
3056
|
if (maxModels === undefined) maxModels = Math.max(5, keyedProviders.size * 2)
|
|
3029
3057
|
const entries = []
|
|
3030
3058
|
for (const [providerKey, source] of Object.entries(sources)) {
|
|
3031
|
-
if (!isRouteableProvider(providerKey)) continue
|
|
3059
|
+
if (!isRouteableProvider(providerKey, sources)) continue
|
|
3032
3060
|
for (const [model, label, tier, sweScore, ctx] of source.models || []) {
|
|
3033
3061
|
entries.push({
|
|
3034
3062
|
provider: providerKey,
|
|
@@ -3184,7 +3212,7 @@ export function createRouterRuntimeForTest({ config, port = 0, logger = null, to
|
|
|
3184
3212
|
function createDefaultProbeFn(apiKeys) {
|
|
3185
3213
|
return async (entry) => {
|
|
3186
3214
|
const { provider, model } = entry
|
|
3187
|
-
if (!isRouteableProvider(provider)) return { ok: false, code: 'NOT_ROUTEABLE', latencyMs: 0 }
|
|
3215
|
+
if (!isRouteableProvider(provider, sources)) return { ok: false, code: 'NOT_ROUTEABLE', latencyMs: 0 }
|
|
3188
3216
|
const url = resolveProviderUrl(provider)
|
|
3189
3217
|
if (!url) return { ok: false, code: 'NO_URL', latencyMs: 0 }
|
|
3190
3218
|
const apiKey = getApiKey({ apiKeys: apiKeys || {} }, provider) || ''
|
|
@@ -3239,7 +3267,7 @@ function buildDefaultRouterSetSync(config = {}, maxModels = 5) {
|
|
|
3239
3267
|
.map(([provider]) => provider))
|
|
3240
3268
|
const entries = []
|
|
3241
3269
|
for (const [providerKey, source] of Object.entries(sources)) {
|
|
3242
|
-
if (!isRouteableProvider(providerKey)) continue
|
|
3270
|
+
if (!isRouteableProvider(providerKey, sources)) continue
|
|
3243
3271
|
for (const [model, label, tier, sweScore, ctx] of source.models || []) {
|
|
3244
3272
|
entries.push({ provider: providerKey, model, label, tier, sweScore, ctx, hasKey: keyedProviders.has(providerKey) })
|
|
3245
3273
|
}
|
|
@@ -3323,7 +3351,7 @@ function buildRouterSetFromFavorites(config) {
|
|
|
3323
3351
|
if (slashIdx < 0) continue
|
|
3324
3352
|
const providerKey = fav.slice(0, slashIdx)
|
|
3325
3353
|
const modelId = fav.slice(slashIdx + 1)
|
|
3326
|
-
if (!isRouteableProvider(providerKey)) continue
|
|
3354
|
+
if (!isRouteableProvider(providerKey, sources)) continue
|
|
3327
3355
|
const source = sources[providerKey]
|
|
3328
3356
|
if (!source) continue
|
|
3329
3357
|
const found = (source.models || []).find((m) => m[0] === modelId)
|
|
@@ -3381,6 +3409,13 @@ async function listenWithFallback(server, preferredPort, logger, host = '127.0.0
|
|
|
3381
3409
|
export async function runRouterDaemon() {
|
|
3382
3410
|
const config = loadConfig()
|
|
3383
3411
|
const router = await ensureRouterConfigForDaemon(config)
|
|
3412
|
+
// 📖 In dev mode, override the saved port with the dev default so a local
|
|
3413
|
+
// 📖 checkout doesn't clash with a production install on the same machine.
|
|
3414
|
+
// 📖 The saved config has port: 19280 (production); dev should use 29280.
|
|
3415
|
+
const { defaultPort: devDefault } = getRouterPortRange()
|
|
3416
|
+
if (_dev && router.port !== devDefault && router.port === DEFAULT_ROUTER_SETTINGS.port) {
|
|
3417
|
+
router.port = devDefault
|
|
3418
|
+
}
|
|
3384
3419
|
const logger = new RouterLogger(ROUTER_LOG_PATH, router.logLevel)
|
|
3385
3420
|
const runtime = new RouterRuntime({ config, port: router.port, logger })
|
|
3386
3421
|
runtime.installProcessSafety()
|
|
@@ -33,6 +33,7 @@
|
|
|
33
33
|
* @exports fetchRouterSets, createRouterSet, renameRouterSet, duplicateRouterSet
|
|
34
34
|
* @exports deleteRouterSet, activateRouterSet, updateRouterSetModels
|
|
35
35
|
* @exports addModelToRouterSet, removeModelFromRouterSet, reorderRouterSetModel
|
|
36
|
+
* @exports setDashboardNotice
|
|
36
37
|
*
|
|
37
38
|
* @see ./router-daemon.js — daemon endpoints consumed by this screen
|
|
38
39
|
* @see ./overlays.js — overlay factory that mounts this renderer
|
|
@@ -42,7 +43,7 @@
|
|
|
42
43
|
import chalk from 'chalk'
|
|
43
44
|
import { existsSync, readFileSync } from 'node:fs'
|
|
44
45
|
import { displayWidth, padEndDisplay, sliceOverlayLines, tintOverlayLines } from '../tui/render-helpers.js'
|
|
45
|
-
import { ROUTER_DEFAULT_PORT, ROUTER_MAX_PORT,
|
|
46
|
+
import { ROUTER_DEFAULT_PORT, ROUTER_MAX_PORT, getRouterPidPath, getRouterPortPath, getRouterPortRange } from './router-daemon.js'
|
|
46
47
|
import { themeColors, getTierRgb } from '../tui/theme.js'
|
|
47
48
|
import { formatTokenTotalCompact } from './token-usage-reader.js'
|
|
48
49
|
import { sendUsageTelemetry } from './telemetry.js'
|
|
@@ -150,14 +151,16 @@ async function fetchJson(url, options = {}) {
|
|
|
150
151
|
}
|
|
151
152
|
|
|
152
153
|
function readDaemonFiles() {
|
|
153
|
-
const
|
|
154
|
-
const
|
|
154
|
+
const portPath = getRouterPortPath()
|
|
155
|
+
const pidPath = getRouterPidPath()
|
|
156
|
+
const recordedPort = readNumberFile(portPath)
|
|
157
|
+
const recordedPid = readNumberFile(pidPath)
|
|
155
158
|
return {
|
|
156
159
|
port: recordedPort,
|
|
157
160
|
pid: recordedPid,
|
|
158
161
|
pidAlive: recordedPid ? isProcessAlive(recordedPid) : false,
|
|
159
|
-
hasPidFile: existsSync(
|
|
160
|
-
hasPortFile: existsSync(
|
|
162
|
+
hasPidFile: existsSync(pidPath),
|
|
163
|
+
hasPortFile: existsSync(portPath),
|
|
161
164
|
}
|
|
162
165
|
}
|
|
163
166
|
|
|
@@ -168,7 +171,7 @@ function buildPortCandidates(state) {
|
|
|
168
171
|
? state.routerDashboardBaseUrl.match(/:(\d+)$/)
|
|
169
172
|
: null
|
|
170
173
|
const baseUrlPort = baseUrlMatch ? Number.parseInt(baseUrlMatch[1], 10) : null
|
|
171
|
-
const filePort = readNumberFile(
|
|
174
|
+
const filePort = readNumberFile(getRouterPortPath())
|
|
172
175
|
const { defaultPort, maxPort } = getRouterPortRange()
|
|
173
176
|
for (const port of [baseUrlPort, currentPort, filePort, defaultPort]) {
|
|
174
177
|
if (Number.isInteger(port) && port > 0 && !ports.includes(port)) ports.push(port)
|
|
@@ -259,6 +262,21 @@ export function normalizeRouterDashboardSnapshot(healthPayload, statsPayload) {
|
|
|
259
262
|
const merged = { ...health, ...stats }
|
|
260
263
|
const models = Array.isArray(stats.models) ? stats.models.map(normalizeModelHealth) : []
|
|
261
264
|
const requestLog = Array.isArray(stats.requestLog) ? stats.requestLog.map(normalizeRequestEntry) : []
|
|
265
|
+
// 📖 routingOrder — priority-first attempt order from /stats (issue #120).
|
|
266
|
+
// 📖 routingOrder[0].key is the model that will serve the next request, so the
|
|
267
|
+
// 📖 TUI can mark it with a ▶ NEXT glyph. Guarded so older daemons without
|
|
268
|
+
// 📖 the field still render fine.
|
|
269
|
+
const routingOrder = Array.isArray(stats.routingOrder)
|
|
270
|
+
? stats.routingOrder
|
|
271
|
+
.filter((entry) => isRecord(entry) && typeof entry.key === 'string')
|
|
272
|
+
.map((entry) => ({
|
|
273
|
+
key: entry.key,
|
|
274
|
+
provider: safeString(entry.provider, ''),
|
|
275
|
+
model: safeString(entry.model, ''),
|
|
276
|
+
priority: toFiniteNumber(entry.priority, 0),
|
|
277
|
+
state: safeString(entry.state, 'UNKNOWN'),
|
|
278
|
+
}))
|
|
279
|
+
: []
|
|
262
280
|
|
|
263
281
|
return {
|
|
264
282
|
ok: merged.ok === true,
|
|
@@ -281,6 +299,7 @@ export function normalizeRouterDashboardSnapshot(healthPayload, statsPayload) {
|
|
|
281
299
|
stalePid: toFiniteNumber(merged.stalePid, null),
|
|
282
300
|
tokens: normalizeTokens(stats.tokens),
|
|
283
301
|
models,
|
|
302
|
+
routingOrder,
|
|
284
303
|
requestLog,
|
|
285
304
|
}
|
|
286
305
|
}
|
|
@@ -331,7 +350,7 @@ function statusBadge(status, snapshot) {
|
|
|
331
350
|
return themeColors.error('○ UNREACHABLE')
|
|
332
351
|
}
|
|
333
352
|
|
|
334
|
-
function setDashboardNotice(state, type, message, ttlMs = 3500) {
|
|
353
|
+
export function setDashboardNotice(state, type, message, ttlMs = 3500) {
|
|
335
354
|
state.routerDashboardNotice = { type, message, at: Date.now() }
|
|
336
355
|
if (state.routerDashboardNoticeTimer) clearTimeout(state.routerDashboardNoticeTimer)
|
|
337
356
|
state.routerDashboardNoticeTimer = setTimeout(() => {
|
|
@@ -843,15 +862,19 @@ export function renderRouterDashboard(state, deps = {}) {
|
|
|
843
862
|
lines.push(` ${paintBanner(bannerLine)}`)
|
|
844
863
|
lines.push('')
|
|
845
864
|
|
|
846
|
-
// ── Quick Setup (connection info)
|
|
847
|
-
|
|
848
|
-
const
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
lines.push(` ${themeColors.
|
|
852
|
-
lines.push(` ${themeColors.dim('
|
|
865
|
+
// ── Quick Setup (connection info) — HERO section ──────────────────────────
|
|
866
|
+
// 📖 Always visible with default port 19280 so users can copy even when stopped.
|
|
867
|
+
const { defaultPort: currentDefaultPort } = getRouterPortRange()
|
|
868
|
+
const port = snapshot.port || state.routerDashboardPort || currentDefaultPort
|
|
869
|
+
const baseUrl = `http://localhost:${port}/v1`
|
|
870
|
+
lines.push(` ${themeColors.textBold('Quick Setup')} ${themeColors.dim('— paste into your coding tool config')}`)
|
|
871
|
+
lines.push(` ${themeColors.dim('URL')} ${themeColors.infoBold(baseUrl)}`)
|
|
872
|
+
lines.push(` ${themeColors.dim('Model')} ${themeColors.infoBold('fcm')}`)
|
|
873
|
+
lines.push(` ${themeColors.dim('API Key')} ${themeColors.infoBold('fcm-local')}`)
|
|
853
874
|
if (isRunning) {
|
|
854
875
|
lines.push(` ${themeColors.dim('Uptime')} ${themeColors.success(formatRouterDuration(snapshot.uptimeSeconds))} ${themeColors.dim('Requests routed:')} ${themeColors.info(String(snapshot.requestsRouted))}`)
|
|
876
|
+
} else {
|
|
877
|
+
lines.push(` ${themeColors.dim('Hint')} ${themeColors.dim('Start the daemon to enable routing')}`)
|
|
855
878
|
}
|
|
856
879
|
lines.push(` ${separator}`)
|
|
857
880
|
lines.push('')
|
|
@@ -868,7 +891,8 @@ export function renderRouterDashboard(state, deps = {}) {
|
|
|
868
891
|
const cursor = state.routerDashboardCursorIndex ?? 0
|
|
869
892
|
|
|
870
893
|
if (favorites.length === 0) {
|
|
871
|
-
lines.push(` ${themeColors.warning('No favorites yet.
|
|
894
|
+
lines.push(` ${themeColors.warning('No favorites yet. Press Esc, then F on any model to add it.')}`)
|
|
895
|
+
lines.push(` ${themeColors.dim('Favorites become your router fallback chain — #1 is tried first.')}`)
|
|
872
896
|
} else {
|
|
873
897
|
// 📖 Priority keycap glyphs for the fallback order
|
|
874
898
|
const KEYCAPS = ['1️⃣','2️⃣','3️⃣','4️⃣','5️⃣','6️⃣','7️⃣','8️⃣','9️⃣','🔟']
|
|
@@ -880,8 +904,13 @@ export function renderRouterDashboard(state, deps = {}) {
|
|
|
880
904
|
healthByKey.set(`${m.provider}/${m.model}`, m)
|
|
881
905
|
}
|
|
882
906
|
|
|
883
|
-
// 📖
|
|
884
|
-
|
|
907
|
+
// 📖 The model the daemon will serve on the next request (priority-first,
|
|
908
|
+
// 📖 see issue #120). Marked with ▶ NEXT so the user understands the top
|
|
909
|
+
// 📖 of the chain is what actually handles traffic — not whichever is fastest.
|
|
910
|
+
const nextToServeKey = snapshot.routingOrder?.[0]?.key || null
|
|
911
|
+
|
|
912
|
+
// 📖 Column headers — leading space lines up with the ▶ NEXT marker column.
|
|
913
|
+
lines.push(` ${themeColors.dim(padEndDisplay('PRI', 4))} ${themeColors.dim(padEndDisplay('MODEL', 42))} ${themeColors.dim(padEndDisplay('STATUS', 16))} ${themeColors.dim(padEndDisplay('AVG PING', 8))} ${themeColors.dim('VERDICT')}`)
|
|
885
914
|
|
|
886
915
|
for (let i = 0; i < favorites.length; i++) {
|
|
887
916
|
const favKey = favorites[i]
|
|
@@ -926,14 +955,14 @@ export function renderRouterDashboard(state, deps = {}) {
|
|
|
926
955
|
}
|
|
927
956
|
|
|
928
957
|
// 📖 Get global metrics from main table state
|
|
929
|
-
let avgPingDisplay = themeColors.dim('
|
|
958
|
+
let avgPingDisplay = themeColors.dim('—')
|
|
930
959
|
let verdictDisplay = themeColors.dim('Pending ⏳')
|
|
931
960
|
|
|
932
961
|
if (mainResult) {
|
|
933
962
|
// Avg Ping
|
|
934
963
|
const avg = getAvg(mainResult)
|
|
935
964
|
if (avg !== Infinity) {
|
|
936
|
-
const str =
|
|
965
|
+
const str = `${avg}ms`
|
|
937
966
|
avgPingDisplay = avg < 500 ? themeColors.metricGood(str) : avg < 1500 ? themeColors.metricWarn(str) : themeColors.metricBad(str)
|
|
938
967
|
}
|
|
939
968
|
|
|
@@ -957,7 +986,13 @@ export function renderRouterDashboard(state, deps = {}) {
|
|
|
957
986
|
verdictDisplay = padEndDisplay(verdictDisplay, 14)
|
|
958
987
|
}
|
|
959
988
|
|
|
960
|
-
|
|
989
|
+
// 📖 Prefix the next-to-serve model with a ▶ NEXT marker so the active
|
|
990
|
+
// 📖 routing target is obvious. Only shown when the daemon is actually
|
|
991
|
+
// 📖 running and has reported a routing order (stopped → no marker).
|
|
992
|
+
const nextMarker = (nextToServeKey && nextToServeKey === favKey)
|
|
993
|
+
? themeColors.successBold('▶')
|
|
994
|
+
: themeColors.dim(' ')
|
|
995
|
+
const rowText = ` ${nextMarker} ${padEndDisplay(priorityGlyph(i), 4)} ${padEndDisplay(favKey, 42)} ${padEndDisplay(healthLabel, 16)} ${padEndDisplay(avgPingDisplay, 8)} ${verdictDisplay}`
|
|
961
996
|
|
|
962
997
|
if (isCursorRow) {
|
|
963
998
|
lines.push(themeColors.bgCursor(rowText + ' '.repeat(Math.max(0, width - displayWidth(rowText) - 3))))
|
|
@@ -996,14 +1031,14 @@ export function renderRouterDashboard(state, deps = {}) {
|
|
|
996
1031
|
lines.push(` ${separator}`)
|
|
997
1032
|
lines.push('')
|
|
998
1033
|
|
|
999
|
-
// ── Token Summary (compact)
|
|
1000
|
-
lines.push(` ${themeColors.textBold('Tokens')} ${themeColors.dim('Today:')} ${themeColors.info(formatTokenTotalCompact(snapshot.tokens.today.total_tokens))} ${themeColors.dim('
|
|
1034
|
+
// ── Token Summary (compact, visual) ─────────────────────────────────────────
|
|
1035
|
+
lines.push(` ${themeColors.textBold('📊 Tokens')} ${themeColors.dim('Today:')} ${themeColors.info(formatTokenTotalCompact(snapshot.tokens.today.total_tokens))} ${themeColors.dim(`(${snapshot.tokens.today.requests} req)`)} ${themeColors.dim('Lifetime:')} ${themeColors.info(formatTokenTotalCompact(snapshot.tokens.all_time.total_tokens))} ${themeColors.dim(`(${snapshot.tokens.all_time.requests} req)`)}`)
|
|
1001
1036
|
|
|
1002
1037
|
// ── Live Request Log (compact) ──────────────────────────────────────────────
|
|
1003
1038
|
const requestRows = requestLogRows(state, snapshot)
|
|
1039
|
+
lines.push('')
|
|
1040
|
+
lines.push(` ${themeColors.textBold('Recent Requests')}`)
|
|
1004
1041
|
if (requestRows.length > 0) {
|
|
1005
|
-
lines.push('')
|
|
1006
|
-
lines.push(` ${themeColors.textBold('Recent Requests')}`)
|
|
1007
1042
|
const header = ` ${padEndDisplay('Time', 10)} ${padEndDisplay('Model', 34)} ${padEndDisplay('Status', 8)} ${padEndDisplay('Latency', 9)} Detail`
|
|
1008
1043
|
lines.push(themeColors.dim(header))
|
|
1009
1044
|
for (const row of requestRows.slice(0, 6)) {
|
|
@@ -1025,6 +1060,8 @@ export function renderRouterDashboard(state, deps = {}) {
|
|
|
1025
1060
|
`${compactText(detail, Math.max(10, width - 68)).trimEnd()}`
|
|
1026
1061
|
)
|
|
1027
1062
|
}
|
|
1063
|
+
} else {
|
|
1064
|
+
lines.push(` ${themeColors.dim('No requests routed yet')}`)
|
|
1028
1065
|
}
|
|
1029
1066
|
|
|
1030
1067
|
// ── Health check speed ──────────────────────────────────────────────────────
|
|
@@ -1048,7 +1085,8 @@ export function renderRouterDashboard(state, deps = {}) {
|
|
|
1048
1085
|
|
|
1049
1086
|
// ── Footer ──────────────────────────────────────────────────────────────────
|
|
1050
1087
|
lines.push('')
|
|
1051
|
-
lines.push(` ${
|
|
1088
|
+
lines.push(` ${separator}`)
|
|
1089
|
+
lines.push(` ${themeColors.hotkey('↑↓')} ${themeColors.dim('Navigate')} ${themeColors.dim('•')} ${themeColors.hotkey('Shift+↑↓')} ${themeColors.dim('Reorder')} ${themeColors.dim('•')} ${themeColors.hotkey('S')} ${themeColors.dim(isStopped ? 'Start daemon' : 'Stop daemon')} ${themeColors.dim('•')} ${themeColors.hotkey('I')} ${themeColors.dim(`Health check: ${probeLabel}`)} ${themeColors.dim('•')} ${themeColors.hotkey('C')} ${themeColors.dim('Clear log')} ${themeColors.dim('•')} ${themeColors.hotkey('R')} ${themeColors.dim('Sync best')} ${themeColors.dim('•')} ${themeColors.hotkey('Esc')} ${themeColors.dim('Back')}`)
|
|
1052
1090
|
|
|
1053
1091
|
const { visible, offset } = sliceOverlayLines(lines, state.routerDashboardScrollOffset || 0, state.terminalRows || 24)
|
|
1054
1092
|
state.routerDashboardScrollOffset = offset
|