diff --git a/.cursor/rules/model-registry.mdc b/.cursor/rules/model-registry.mdc
index e66521a7..25e94b5b 100644
--- a/.cursor/rules/model-registry.mdc
+++ b/.cursor/rules/model-registry.mdc
@@ -1,5 +1,5 @@
---
-description: Electron-main engine model hub (Ollama committed list + LM Studio catalog)
+description: Backend-owned engine model catalogue (Ollama committed list + LM Studio catalog)
alwaysApply: true
---
-# Model Registry — engine model hub
+# Model Registry — engine model catalogue
-- The backend has **no model-search RPC**, so the "Add Model" browse is
- served by a standalone Electron-main module, **`src/electron/model-hub/`**
- (main process, because the renderer cannot call `huggingface.co` under
- CSP+CORS). There is **no generic Hugging Face browse and no shared GGUF
- registry** — each enabled engine has exactly one curated source declared via
- `EngineCaps.engineHub`.
-- **Ollama** (`ollama-library.ts`): a **locked, committed list**, not a live
- scrape. `src/electron/model-hub/ollama-models.json` (wire shape
- `{ scrapedAt, source, count, models: OllamaTagsModel[] }`) is bundled into
- the main process (`resolveJsonModule` + electron-vite inlining), so
- `loadOllamaModels()` returns instantly with **no network access at runtime**.
- Devs regenerate it with `npm run scrape:ollama-models`
- (`scripts/scrape-ollama-models.ts`) when Ollama's catalog changes, then review
- the diff and commit. The scraper is the **only** place Ollama scraping lives:
- two phases — (A) parse the `https://ollama.com/library` index (anchored on
- each card's `` link, capability chips are
+- The "Add Model" browse is served by **`nvpair-engine-manager`** over the
+ **`engine:catalog`** JSON-RPC method (`services/nvpair-engine-manager/catalog.go`).
+ It lives in the backend because both front ends need it: the desktop app's
+ Add Model modal and the terminal interface's model browser. There is **no
+ generic Hugging Face browse and no shared GGUF registry** — each enabled
+ engine has exactly one curated source.
+- **Ollama**: a **locked, committed list**, not a live scrape.
+ `services/nvpair-engine-manager/catalog/ollama-models.json` (wire shape
+ `{ scrapedAt, source, count, models: [...] }`) is compiled in with `go:embed`,
+ so the catalogue is served with **no network access at runtime**. Devs
+ regenerate it with `npm run scrape:ollama-models`
+ (`desktop/scripts/scrape-ollama-models.ts`) when Ollama's catalog changes,
+ then review the diff and commit. The scraper is the **only** place Ollama
+ scraping lives: two phases — (A) parse the `https://ollama.com/library` index
+ (anchored on each card's `` link, capability chips are
`text-indigo-600`, size chips `text-blue-600`, updated timestamp in the stats
span `title`), then (B) fetch each `https://ollama.com/library//tags`
page (concurrency 4, anchored on the self-contained mobile card
@@ -33,25 +32,38 @@ SPDX-License-Identifier: Apache-2.0
variants are filtered. Names are used as-is for `ollama pull`. **When Ollama
changes their markup, update the parser in the script and re-run it** — the
running app is unaffected.
-- **LM Studio** (`lmstudio-catalog.ts`, `lmStudioCatalogCache`): still a **live
- fetch**. GETs
+- **LM Studio**: still a **live fetch**. GETs
`https://huggingface.co/api/models?author=lmstudio-community&sort=downloads&direction=-1&limit=500`
and normalizes each repo to a pull-ready id (e.g.
`lmstudio-community/Qwen3-8B-GGUF`, accepted by `lms get`). This is the
- **only** remaining network call in the model hub, and it is main-process only
- (6-hour TTL + in-flight guard so PAIR never fetches continuously).
-- Both sources normalize to the shared `EngineHubModel`
- (`src/shared/types/engine-api.ts`).
-- `index.ts` exposes `getEngineHubModels(engineType)` (Ollama returns the
- committed list synchronously; LM Studio awaits a cold cache's initial load;
- returns `{ models }`) and `warmEngineHubs()` (warms **only** LM Studio —
- Ollama needs none). The `engine:search-hub` handler in `empty-handlers.ts`
- calls the former; the `overview:ready` handler in `window.ipc.ts` calls the
- latter. Warm on renderer-ready, **not** on service connect: a catalog fetch
- started before the window has painted competes with the renderer's own load,
- and a hanging one leaves an unpainted window behind.
-- The renderer (`src/ui/components/ModelHub/`) fetches an engine's full
- catalog once (cached per-engine), filters by the search box **locally** on
- each keystroke, and sorts client-side by Updated / Name / Size. Pulling goes
- through the backend (`engine:pull-model` → `pull_model`); the hub only
- produces the pull-ready id.
+ **only** network call in the catalogue: 6-hour TTL, a coalescing guard so
+ concurrent callers share one request, and a failure backoff so a dead upstream
+ is not re-dialled on every call.
+- Both sources normalize to `CatalogModel`, which the desktop maps to the shared
+ `EngineHubModel` (`desktop/src/shared/types/engine-api.ts`).
+- **The catalogue is filtered for the platform the models will install on**, not
+ the one serving it. MLX quantizations only install on Apple Silicon, so
+ `engine:catalog` takes an optional `platform` (defaulting to the server's
+ `GOOS`), marks Apple-only rows with `appleOnly`, and echoes the platform it
+ filtered for. A client driving a peer should say which peer; a client that
+ cannot determine it must tell the operator which platform the list applies to
+ rather than presenting it as universal.
+- **Frame size matters here.** The Ollama reply is a single ~1.9 MiB JSON-RPC
+ line. Every hop on its path shares `jsonrpc.WorkerFrameBytes`, because an
+ over-long line is a terminal read error that silently closes the peer while
+ the child keeps running. A test in `catalog_test.go` fails if the marshalled
+ catalogue outgrows the frame; **filter or paginate rather than raising the cap
+ again**.
+- The desktop relays through
+ `desktop/src/electron/service-bridge/model-catalog.ts`, which exposes
+ `getEngineHubModels(engineType)` and `warmEngineHubs()` (warms **only**
+ LM Studio — Ollama needs none). The `engine:search-hub` handler in
+ `empty-handlers.ts` calls the former; the `overview:ready` handler in
+ `window.ipc.ts` calls the latter. Warm on renderer-ready, **not** on service
+ connect: a catalog fetch started before the window has painted competes with
+ the renderer's own load, and a hanging one leaves an unpainted window behind.
+- Both front ends fetch an engine's full catalog once (cached per-engine) and
+ filter **locally** as the user types. The desktop renderer
+ (`src/ui/components/ModelHub/`) sorts client-side by Updated / Name / Size.
+ Pulling goes through the engine manager (`engine:pull-model` → `pull_model`);
+ the catalogue only produces the pull-ready id.
diff --git a/.cursor/rules/system-architecture.mdc b/.cursor/rules/system-architecture.mdc
index 7b3b0f3b..d16b90e7 100644
--- a/.cursor/rules/system-architecture.mdc
+++ b/.cursor/rules/system-architecture.mdc
@@ -46,10 +46,12 @@ Never launch one worker from both Electron and the broker. There is no
broker-absent fallback.
The Inference Demo's `inference-dispatcher` is the one non-broker executable
-Electron launches. It is not a worker: its source lives in
-`scripts/inference-dispatcher` at the monorepo root, it is absent from
-`services/versions.json` and `modular-binaries.ts`, and it ships in its own
-`tools/` resource directory. See "Inference Demo" below.
+Electron launches, and the only child `nvpair-tui` spawns besides its own broker.
+It is not a worker: its source lives in `scripts/inference-dispatcher` at the
+monorepo root, and it is absent from `services/versions.json` and
+`modular-binaries.ts`. It nonetheless ships inside `cli-bin` — beside the
+binaries it is not one of — so both front ends resolve it the same way. See
+"Inference Demo" below.
The connector remains `connecting` until broker `app:ready` and the required
Ollama proxy readiness arrive. A startup deadline routes failures to
@@ -175,20 +177,32 @@ pressure 1 for invalid, missing, or stale data, then ranks by
## Inference Demo
A fixed sixty-second burst of synthetic traffic sent through the local proxies
-so job activity is visible on Overview.
+so job activity is visible.
+
+Both front ends run it, each owning its own schedule:
+
+- Electron main (`src/electron/inference-demo.ts`,
+ `src/electron/inference-demo-schedule.ts`), surfaced on Settings > Service and
+ visible on Overview.
+- `nvpair-tui` (`ui/demo.go`, `ui/demoschedule.go`), on the Jobs tab, driven by
+ the shell's one-second tick because every submission offset is a whole second.
+
+The two schedules must stay identical — cohorts, stages, offsets, and the
+ceiling. They are a contract between the front ends, not an implementation
+detail of either; a change to one is a change to both. Neither drives the other.
-- Electron main owns the schedule (`src/electron/inference-demo.ts`,
- `src/electron/inference-demo-schedule.ts`).
- Each request spawns the bundled `inference-dispatcher`, which behaves as an
- ordinary third-party HTTP client.
+ ordinary third-party HTTP client.
- Requests target a broker-reported proxy port, never an engine's own port. The
- backend decides placement; PAIR must not describe the demo as distributing
- work itself.
-- Progress is broadcast on the `demo:state` Electron push channel
- (`IpcPushChannelMap`), not the service push bus. State is node-local and is
- not synchronized.
-- Children run with `stdio: 'ignore'` and with `INFERENCE_DISPATCHER_*` stripped
- from the environment. Never surface or log prompts or responses.
+ backend decides placement; PAIR must not describe the demo as distributing
+ work itself.
+- State is node-local and is not synchronized. Electron broadcasts it on the
+ `demo:state` Electron push channel (`IpcPushChannelMap`); the terminal keeps it
+ in view state. Neither goes near the service push bus.
+- Stopping cancels only unsent requests. Never cancel, await, or report on one
+ already submitted.
+- Children run with stdio discarded and with `INFERENCE_DISPATCHER_*` stripped
+ from the environment. Never surface or log prompts or responses.
## Engines
@@ -203,12 +217,22 @@ operations.
state.
- Engine and proxy ports are persisted by their backend owners.
- Running adopted engines reject operations that require process ownership.
-- Per-engine environment and CLI argument overrides are not part of the current
- contract.
-
-The model hub is Electron-main functionality under `src/electron/model-hub/`.
-It provides curated Ollama and LM Studio catalogs; model operations still go
-through the engine manager.
+- Per-engine launch arguments and environment are editable through
+ `engine:get-settings`, `engine:preview-settings`, and `engine:apply-settings`,
+ which write the server port, the proxy port, and the launch text together
+ against a revision. Do not write any of the three by another route: a second
+ writer with no shared revision cannot tell that it lost. Validate through the
+ preview and commit the settings it returns, not the draft that was sent.
+- Editability is the snapshot's `Editable` and `Reason`, not a rule restated in
+ a front end. The backend relays these to a peer, so a front end that refuses
+ remote editing on its own is wrong rather than cautious.
+
+The model catalogue is owned by `nvpair-engine-manager` and served over
+`engine:catalog`, so the desktop app and the terminal interface browse one
+implementation. Electron relays it through
+`src/electron/service-bridge/model-catalog.ts`. It provides curated Ollama and
+LM Studio catalogs, filtered for the platform a model will install on; model
+operations go through the engine manager.
## Pairing and security
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 24ae0510..e51b42cb 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -165,11 +165,22 @@ jobs:
# LC_ALL=C makes the sort byte-wise, which is also what comm
# below assumes.
norm() { tr -d '\r' | sed 's/\.exe$//' | LC_ALL=C sort; }
- expected=$(jq -r '.components | keys[]' versions.json | norm)
+ # inference-dispatcher is staged but is not a component, and
+ # both halves of that are deliberate. It is an ordinary HTTP
+ # client the Inference Demo spawns per request -- no JSON-RPC,
+ # nothing supervising it, no entry in versions.json -- but it
+ # ships in this directory because nvpair-tui resolves it
+ # beside its own executable, exactly as it resolves the
+ # broker. Named here rather than filtered by a pattern, so a
+ # second undeclared binary still fails.
+ expected=$(
+ { jq -r '.components | keys[]' versions.json;
+ echo inference-dispatcher; } | norm
+ )
staged=$(ls -1 build/bin | norm)
if [ "$expected" = "$staged" ]; then
- printf 'all %s declared components staged\n' \
+ printf 'all %s expected binaries staged\n' \
"$(printf '%s\n' "$expected" | wc -l | tr -d ' ')"
exit 0
fi
diff --git a/desktop/.gitignore b/desktop/.gitignore
index 6686db7e..ffef4fcc 100644
--- a/desktop/.gitignore
+++ b/desktop/.gitignore
@@ -17,12 +17,10 @@ out
notes.txt
vault-notes.txt
coverage
+# Compiled Go binaries: the services workers, nvpair-tui, and the Inference
+# Demo's inference-dispatcher, all built by `npm run build:modular-binaries`.
cli-bin
-# Compiled inference-dispatcher client (built from ../scripts/inference-dispatcher
-# by `npm run build:tools`; the Go sources are tracked at the monorepo root).
-tools
-
# Generated app-icon artifacts (build/runtime containers derived from the master
# artwork in resources/app-icon/ by `npm run generate:icons`, which runs
# automatically via prebuild:electron / prestart). Source of truth is app-icon/.
@@ -64,4 +62,8 @@ tsconfig.deadcode.node.json
tsconfig.deadcode.web.json
sea-config.json
+# TypeScript incremental build caches. Machine-local and regenerated by any
+# typecheck, so a tracked one produces a diff on every run.
+*.tsbuildinfo
+
review.diff
diff --git a/desktop/docs/architecture.md b/desktop/docs/architecture.md
index 0874b5ff..abfcf7bb 100644
--- a/desktop/docs/architecture.md
+++ b/desktop/docs/architecture.md
@@ -254,17 +254,26 @@ engine's private port. This transport security is backend-owned; Electron only
reflects the advertised proxy port and reads a remote engine's real port from
`engine:remote-get-installed` facts.
-The model hub is Electron-main functionality in `src/electron/model-hub/`:
+The model catalogue is owned by `nvpair-engine-manager` and served over
+`engine:catalog`. Electron relays it through
+`src/electron/service-bridge/model-catalog.ts`; the terminal interface calls the
+same method, so both front ends browse one implementation.
- Ollama models come from a locked, committed list
- (`src/electron/model-hub/ollama-models.json`) bundled into the main process —
- there is no runtime Ollama scraping. Devs regenerate the list with
+ (`services/nvpair-engine-manager/catalog/ollama-models.json`) compiled in with
+ `go:embed` — there is no runtime Ollama scraping. Devs regenerate the list with
`npm run scrape:ollama-models` (`scripts/scrape-ollama-models.ts`) and commit
it when Ollama's catalog changes;
- LM Studio models come from the curated `lmstudio-community` catalog, still
- fetched live from Hugging Face and cached for six hours. The cache is warmed
+ fetched live from Hugging Face and cached for six hours, with concurrent
+ callers coalesced onto one request and a failure backoff. The cache is warmed
when the Overview renderer reports ready, not when the service connects, so a
slow or hanging catalog fetch cannot compete with the window's first paint;
+- the request takes an optional `platform`, marks Apple-only (MLX) rows, and
+ echoes the platform it filtered for, so a client driving a peer is not offered
+ models that peer cannot install;
+- the Ollama reply is a single multi-megabyte frame, so every hop on its path
+ shares `jsonrpc.WorkerFrameBytes`. See `docs/services-backend.md`;
- model pulls still run through `nvpair-engine-manager`.
## Inference Demo
@@ -273,14 +282,22 @@ The Inference Demo sends a fixed sixty-second burst of synthetic inference
traffic through the local proxies so job activity is visible on Overview. It is
the one place Electron launches a non-broker executable.
+Both front ends offer it. The terminal interface runs the same schedule from its
+Jobs tab (`services/nvpair-tui/ui/demoschedule.go`), against the same
+dispatcher, so a headless machine can demonstrate routing too. The two schedules
+are deliberately identical; neither drives the other, because demo state is
+node-local.
+
- The schedule is built and owned by Electron main
(`src/electron/inference-demo.ts` and
`src/electron/inference-demo-schedule.ts`).
- Each scheduled request spawns the bundled `inference-dispatcher` client, a
standalone Go HTTP client that knows nothing about the broker, JSON-RPC, or
- discovery. Its source is `scripts/inference-dispatcher` at the monorepo root
- and it ships in `resources/tools`, outside the services `cli-bin` inventory.
- See [Inference dispatcher](../../docs/inference-dispatcher.mdx).
+ discovery. Its source is `scripts/inference-dispatcher` at the monorepo root.
+ It ships inside `cli-bin` — it is still not a services component and has no
+ entry in `versions.json`, but sharing the directory is what lets `nvpair-tui`
+ find it beside its own executable in a packaged app as well as in a services
+ install. See [Inference dispatcher](../../docs/inference-dispatcher.mdx).
- Requests are addressed to a proxy port reported by the broker, never to an
engine's own port, so the backend places them exactly as it would place any
third-party client's traffic. PAIR makes no routing decision.
diff --git a/desktop/docs/services-api.md b/desktop/docs/services-api.md
index 7aea40f9..dc781ab5 100644
--- a/desktop/docs/services-api.md
+++ b/desktop/docs/services-api.md
@@ -97,6 +97,7 @@
| `errors:clear` | notification (we consume) | ✅ yes |
| `errors:report` | notification (we consume) | ✅ yes |
| `engine:action` | request (we call) | ✅ yes |
+| `engine:catalog` | request (we call) | ✅ yes |
| `engine:configure-launch` | request (we call) | ⚠️ not called |
| `engine:configured-ports` | request (we call) | ⚠️ not called |
| `engine:describe` | request (we call) | ⚠️ not called |
@@ -231,11 +232,21 @@
| Method | Direction | In bridge? |
|---|---|---|
| `cluster:identity-changed` | request (we call) | ✅ yes |
+| `cluster:invite-canceled` | request (we call) | ✅ yes |
+| `cluster:invite-declined` | request (we call) | ✅ yes |
+| `cluster:invite-expired` | request (we call) | ✅ yes |
+| `cluster:invite-failed` | request (we call) | ✅ yes |
| `cluster:invite-received` | request (we call) | ✅ yes |
+| `discovery:nodes-changed` | request (we call) | ✅ yes |
| `engine:install-progress` | request (we call) | ✅ yes |
+| `engine:models-changed` | request (we call) | ✅ yes |
| `engine:pull-progress` | request (we call) | ✅ yes |
+| `engine:remote-progress` | request (we call) | ✅ yes |
+| `engine:settings-changed` | request (we call) | ✅ yes |
+| `engine:settings-disconnected` | request (we call) | ✅ yes |
| `engine:state-changed` | request (we call) | ✅ yes |
| `error` | request (we call) | ✅ yes |
+| `errors:update` | request (we call) | ✅ yes |
| `nodes:changed` | request (we call) | ✅ yes |
| `workloads:remove` | request (we call) | ✅ yes |
| `workloads:upsert` | request (we call) | ✅ yes |
diff --git a/desktop/docs/services-backend.md b/desktop/docs/services-backend.md
index 25d8328c..d806bbab 100644
--- a/desktop/docs/services-backend.md
+++ b/desktop/docs/services-backend.md
@@ -253,9 +253,12 @@ result through the discovery snapshot and must not add a second, shorter
reachability verdict of its own — a failed `/v1/node-info` poll keeps the last
good metrics and never marks a node offline.
-The renderer model hub is not a backend search service. Electron main obtains
-curated Ollama and LM Studio catalogs, then sends pull-ready model IDs through
-the engine manager.
+The model catalogue is backend-owned. `nvpair-engine-manager` serves the curated
+Ollama and LM Studio lists over `engine:catalog`, filtered for the platform a
+model will install on; Electron relays the call and maps rows for the renderer,
+which then sends pull-ready model IDs back through the engine manager. The
+Ollama reply is a single multi-megabyte frame, so every hop on its path shares
+`jsonrpc.WorkerFrameBytes`.
## Pairing and security
diff --git a/desktop/docs/services-parity.md b/desktop/docs/services-parity.md
index c0b8cbc5..f4a90833 100644
--- a/desktop/docs/services-parity.md
+++ b/desktop/docs/services-parity.md
@@ -298,8 +298,10 @@ safety-net timeout (`pending-actions.store.ts`). Loaded state carries no
`sizeVram`/`expiresAt` — the backend delivers the simpler `loadedByEngine`
name-set, not structured details.
-The model hub is intentionally outside the backend: Electron main fetches
-curated catalogs and sends selected pull-ready IDs to the engine manager.
+The model catalogue is owned by the backend: `engine:catalog` on
+`nvpair-engine-manager` serves the curated Ollama and LM Studio lists, and both
+the desktop app and the terminal interface browse it. Electron only relays the
+call and maps rows for the renderer.
## Errors
@@ -443,7 +445,7 @@ provide an equivalent client-facing contract:
| Persist and replay manual node entries | `manual-nodes-store.ts`, `modular-supervisor.ts` |
| Bridge the local node into engine proxies | `modular-supervisor.ts` |
| Present optimistic engine transition state | `pending-actions.store.ts`, bridge state |
-| Serve the model hub (Ollama committed list, LM Studio live) | `src/electron/model-hub/` |
+| Relay the backend model catalogue to the renderer | `service-bridge/model-catalog.ts` |
| Accumulate and reconcile receiver-side pending invites | `modular-state.ts`, `modular-supervisor.ts` |
| Mirror backend-coupled runtime defaults not yet reported | `modular-runtime.ts` |
| Collapse a superseded node row before the scanner proves it | `modular-state.ts`, `modular-runtime.ts` |
diff --git a/desktop/electron-builder.config.ts b/desktop/electron-builder.config.ts
index 1f713d47..1d261ff6 100644
--- a/desktop/electron-builder.config.ts
+++ b/desktop/electron-builder.config.ts
@@ -27,10 +27,7 @@ import {
modularBinaryFileName,
modularShippedBinaryBaseNames
} from './src/shared/constants/modular-binaries'
-import {
- INFERENCE_DISPATCHER_RESOURCE_DIR,
- inferenceDispatcherFileName
-} from './src/shared/constants/inference-dispatcher'
+import { inferenceDispatcherFileName } from './src/shared/constants/inference-dispatcher'
import type { JsonValue } from './src/shared/types/json'
import type { SupportedPlatform } from './src/shared/types/platform'
import { macAfterAllArtifactBuild, macAfterPack } from './scripts/build/macos/hooks'
@@ -87,6 +84,11 @@ function assertCliBinPackagingInputs(): void {
...modularShippedBinaryBaseNames().map(baseName =>
modularBinaryFileName(baseName, platform)
),
+ // The Inference Demo's HTTP client. Not a services component, but it
+ // ships here so the terminal interface can find it beside its own
+ // executable — see INFERENCE_DISPATCHER_BASE_NAME. Named explicitly so
+ // the set stays exact and a genuine stray is still rejected.
+ inferenceDispatcherFileName(platform),
'manifest.json'
])
const entries = readdirSync('cli-bin', { withFileTypes: true })
@@ -144,62 +146,6 @@ function assertCliBinPackagingInputs(): void {
}
}
-/**
- * The same guarantee `assertCliBinPackagingInputs` gives cli-bin, for the
- * `inference-dispatcher` client in `tools/`. Its own manifest records the real
- * target, because the file name alone cannot distinguish a linux build from a
- * macOS one or x64 from arm64.
- */
-function assertToolsPackagingInputs(): void {
- const platform = packagingPlatform()
- const expected = new Set([inferenceDispatcherFileName(platform), 'manifest.json'])
-
- const entries = readdirSync(INFERENCE_DISPATCHER_RESOURCE_DIR, { withFileTypes: true })
- const unexpected = entries
- .filter(entry => !entry.isFile() || !expected.has(entry.name))
- .map(entry => entry.name)
- .sort()
- const missing = [...expected].filter(
- fileName => !entries.some(entry => entry.name === fileName)
- )
-
- if (unexpected.length > 0 || missing.length > 0) {
- throw new Error(
- [
- `Refusing to package an invalid ${INFERENCE_DISPATCHER_RESOURCE_DIR} directory.`,
- unexpected.length > 0 ? `Unexpected: ${unexpected.join(', ')}` : '',
- missing.length > 0 ? `Missing: ${missing.join(', ')}` : '',
- 'Run npm run build:tools for the target platform.'
- ]
- .filter(Boolean)
- .join('\n')
- )
- }
-
- const manifest: JsonValue = JSON.parse(
- readFileSync(`${INFERENCE_DISPATCHER_RESOURCE_DIR}/manifest.json`, 'utf8')
- )
- if (typeof manifest !== 'object' || manifest === null || Array.isArray(manifest)) {
- throw new Error(`${INFERENCE_DISPATCHER_RESOURCE_DIR}/manifest.json is not a JSON object.`)
- }
- const manifestPlatform = manifest['platform']
- const manifestArch = manifest['arch']
- if (manifestPlatform !== platform) {
- throw new Error(
- `${INFERENCE_DISPATCHER_RESOURCE_DIR} was built for platform ` +
- `"${String(manifestPlatform)}" but packaging targets "${platform}". ` +
- 'Run npm run build:tools for the target platform.'
- )
- }
- if (selectedArchs.length !== 1 || selectedArchs[0] !== manifestArch) {
- throw new Error(
- `${INFERENCE_DISPATCHER_RESOURCE_DIR} was built for arch "${String(manifestArch)}" ` +
- `but packaging targets ${selectedArchs.join(', ')}. Package exactly one ` +
- 'architecture (pass --x64 or --arm64).'
- )
- }
-}
-
// Narrow the packaged architectures based on CLI flags / env. Without this,
// declaring `arch: ['x64', 'arm64']` on a target builds both installers even
// when the user passes only `--arm64`.
@@ -214,7 +160,6 @@ const selectedArchs: PkgArch[] =
: ['x64', 'arm64']
assertCliBinPackagingInputs()
-assertToolsPackagingInputs()
// Pin the NSIS payload's 7z branch filter to BCJ.
//
@@ -276,21 +221,17 @@ const config: Configuration = {
/**
* Ship the modular Go subprocesses outside the asar so the Electron main
* process can spawn them from `process.resourcesPath/cli-bin`.
+ *
+ * `cli-bin` also carries the Inference Demo's `inference-dispatcher`, which
+ * is not a services binary. It shares the directory so `nvpair-tui` — which
+ * runs the same demo and resolves the dispatcher next to its own executable
+ * — finds it in a packaged app as well as in a services install.
*/
extraResources: [
{
from: 'cli-bin',
to: 'cli-bin'
},
- {
- // The `inference-dispatcher` HTTP client the Inference Demo spawns
- // (built by scripts/build-inference-dispatcher.ts). It ships beside
- // cli-bin rather than inside it because it is not a services binary:
- // no JSON-RPC, absent from services/versions.json, never supervised
- // by the broker.
- from: INFERENCE_DISPATCHER_RESOURCE_DIR,
- to: INFERENCE_DISPATCHER_RESOURCE_DIR
- },
{
// Repo-root wipe scripts (append-only inventory). Packaged builds call
// these from Electron after shutdown — same entrypoints developers run
diff --git a/desktop/package.json b/desktop/package.json
index 0b0b1b2d..cc8fee6f 100644
--- a/desktop/package.json
+++ b/desktop/package.json
@@ -48,15 +48,8 @@
"build:collect-logs:linux:arm64": "tsx scripts/build-collect-logs.ts --platform=linux --arch=arm64",
"build:collect-logs:mac:x64": "tsx scripts/build-collect-logs.ts --platform=darwin --arch=x64",
"build:collect-logs:mac:arm64": "tsx scripts/build-collect-logs.ts --platform=darwin --arch=arm64",
- "build:tools": "tsx scripts/build-inference-dispatcher.ts",
- "build:tools:win:x64": "tsx scripts/build-inference-dispatcher.ts --platform=win32 --arch=x64",
- "build:tools:win:arm64": "tsx scripts/build-inference-dispatcher.ts --platform=win32 --arch=arm64",
- "build:tools:linux:x64": "tsx scripts/build-inference-dispatcher.ts --platform=linux --arch=x64",
- "build:tools:linux:arm64": "tsx scripts/build-inference-dispatcher.ts --platform=linux --arch=arm64",
- "build:tools:mac:x64": "tsx scripts/build-inference-dispatcher.ts --platform=darwin --arch=x64",
- "build:tools:mac:arm64": "tsx scripts/build-inference-dispatcher.ts --platform=darwin --arch=arm64",
"prestart": "npm run generate:icons",
- "start": "npm run build:modular-binaries && npm run build:tools && electron-vite dev",
+ "start": "npm run build:modular-binaries && electron-vite dev",
"start:mock-updater": "tsx scripts/start-mock-updater.ts",
"start:mock-updater:error": "tsx scripts/start-mock-updater.ts --error",
"build": "npm run build:electron",
@@ -64,13 +57,13 @@
"build:electron": "electron-vite build",
"prebuild:renderer": "npm run vendor:kaizen-css",
"build:renderer": "vite build --config vite.renderer.config.ts",
- "build:electron:win:x64": "npm run licenses && npm run build:modular-binaries:win:x64 -- --force && npm run build:collect-logs:win:x64 && npm run build:tools:win:x64 -- --force && electron-builder -c electron-builder.config.ts --win --x64 --publish never",
- "build:electron:win:arm64": "npm run licenses && npm run build:modular-binaries:win:arm64 -- --force && npm run build:collect-logs:win:arm64 && npm run build:tools:win:arm64 -- --force && electron-builder -c electron-builder.config.ts --win --arm64 --publish never",
- "build:electron:linux:x64": "npm run licenses && npm run build:modular-binaries:linux:x64 -- --force && npm run build:collect-logs:linux:x64 && npm run build:tools:linux:x64 -- --force && electron-builder -c electron-builder.config.ts --linux --x64 --publish never",
- "build:electron:linux:arm64": "npm run licenses && npm run build:modular-binaries:linux:arm64 -- --force && npm run build:collect-logs:linux:arm64 && npm run build:tools:linux:arm64 -- --force && electron-builder -c electron-builder.config.ts --linux --arm64 --publish never",
+ "build:electron:win:x64": "npm run licenses && npm run build:modular-binaries:win:x64 -- --force && npm run build:collect-logs:win:x64 && electron-builder -c electron-builder.config.ts --win --x64 --publish never",
+ "build:electron:win:arm64": "npm run licenses && npm run build:modular-binaries:win:arm64 -- --force && npm run build:collect-logs:win:arm64 && electron-builder -c electron-builder.config.ts --win --arm64 --publish never",
+ "build:electron:linux:x64": "npm run licenses && npm run build:modular-binaries:linux:x64 -- --force && npm run build:collect-logs:linux:x64 && electron-builder -c electron-builder.config.ts --linux --x64 --publish never",
+ "build:electron:linux:arm64": "npm run licenses && npm run build:modular-binaries:linux:arm64 -- --force && npm run build:collect-logs:linux:arm64 && electron-builder -c electron-builder.config.ts --linux --arm64 --publish never",
"build:helper:mac": "tsx scripts/build/macos/build-helper.ts",
- "build:electron:mac:x64": "npm run licenses && npm run build:modular-binaries:mac:x64 -- --force && npm run build:collect-logs:mac:x64 && npm run build:tools:mac:x64 -- --force && npm run build:helper:mac && electron-builder -c electron-builder.config.ts --mac --x64 --publish never",
- "build:electron:mac:arm64": "npm run licenses && npm run build:modular-binaries:mac:arm64 -- --force && npm run build:collect-logs:mac:arm64 && npm run build:tools:mac:arm64 -- --force && npm run build:helper:mac && electron-builder -c electron-builder.config.ts --mac --arm64 --publish never",
+ "build:electron:mac:x64": "npm run licenses && npm run build:modular-binaries:mac:x64 -- --force && npm run build:collect-logs:mac:x64 && npm run build:helper:mac && electron-builder -c electron-builder.config.ts --mac --x64 --publish never",
+ "build:electron:mac:arm64": "npm run licenses && npm run build:modular-binaries:mac:arm64 -- --force && npm run build:collect-logs:mac:arm64 && npm run build:helper:mac && electron-builder -c electron-builder.config.ts --mac --arm64 --publish never",
"build:win": "npm run build && npm run build:electron:win:x64 && npm run build:electron:win:arm64",
"build:win:x64": "npm run build && npm run build:electron:win:x64",
"build:win:arm64": "npm run build && npm run build:electron:win:arm64",
diff --git a/desktop/scripts/build-inference-dispatcher.ts b/desktop/scripts/build-inference-dispatcher.ts
deleted file mode 100644
index f86d3475..00000000
--- a/desktop/scripts/build-inference-dispatcher.ts
+++ /dev/null
@@ -1,293 +0,0 @@
-// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
-// SPDX-License-Identifier: Apache-2.0
-
-/**
- * Build the `inference-dispatcher` Go client from the monorepo's
- * `scripts/inference-dispatcher` module into `tools/`.
- *
- * The dispatcher is not a PAIR service: it speaks no JSON-RPC, is not listed in
- * `services/versions.json`, and is never supervised by the broker. It is a
- * conventional third-party HTTP client that the Inference Demo spawns once per
- * scheduled request (`src/electron/inference-demo.ts`). That is why it ships in
- * its own `tools/` resource directory rather than in `cli-bin/`, whose contents
- * are asserted against the services binary inventory.
- *
- * Like the services binaries it is pure Go with no cgo, so any host can
- * cross-compile any target with `CGO_ENABLED=0 GOOS=… GOARCH=…`. The version is
- * stamped from the desktop `package.json` because the tool now versions with
- * the app that ships it.
- *
- * Usage:
- * tsx scripts/build-inference-dispatcher.ts [--platform=win32|linux|darwin]
- * [--arch=x64|arm64] [--force]
- *
- * A `tools/manifest.json` records the target and source fingerprint so repeat
- * runs (e.g. `npm start`) skip the rebuild when nothing changed. CI passes
- * `--force`.
- */
-
-import { spawnSync } from 'node:child_process'
-import { createHash } from 'node:crypto'
-import {
- chmodSync,
- existsSync,
- mkdirSync,
- readdirSync,
- readFileSync,
- rmSync,
- writeFileSync
-} from 'node:fs'
-import path from 'node:path'
-import {
- INFERENCE_DISPATCHER_BASE_NAME,
- INFERENCE_DISPATCHER_RESOURCE_DIR,
- inferenceDispatcherFileName
-} from '@/shared/constants/inference-dispatcher'
-import type { ModularPackageArch } from '@/shared/constants/modular-binaries'
-import type { SupportedPlatform } from '@/shared/types/platform'
-import { currentPlatform } from '@/shared/utils/platform'
-import { version as appVersion } from '../package.json'
-
-const DESKTOP_ROOT = path.resolve(__dirname, '..')
-const TOOLS_DIR = path.join(DESKTOP_ROOT, INFERENCE_DISPATCHER_RESOURCE_DIR)
-const MANIFEST_PATH = path.join(TOOLS_DIR, 'manifest.json')
-const MODULE_DIR = path.resolve(DESKTOP_ROOT, '..', 'scripts', INFERENCE_DISPATCHER_BASE_NAME)
-
-interface BuildOptions {
- platform: SupportedPlatform
- arch: ModularPackageArch
- force: boolean
-}
-
-interface ToolsManifest {
- sourceFingerprint: string
- version: string
- platform: SupportedPlatform
- arch: ModularPackageArch
- fileName: string
- size: number
- sha256: string
- builtAt: string
-}
-
-function argValue(name: string): string | null {
- const prefix = `--${name}=`
- const arg = process.argv.find(entry => entry.startsWith(prefix))
- return arg ? arg.slice(prefix.length) : null
-}
-
-function normalizePlatform(value: string): SupportedPlatform {
- if (value === 'win32' || value === 'darwin' || value === 'linux') return value
- throw new Error(`Unsupported inference-dispatcher platform: ${value}`)
-}
-
-function normalizeArch(value: string): ModularPackageArch {
- if (value === 'x64' || value === 'arm64') return value
- throw new Error(`Unsupported inference-dispatcher arch: ${value}`)
-}
-
-function readOptions(): BuildOptions {
- const platform = normalizePlatform(
- argValue('platform') ?? process.env.DHC_MODULAR_PLATFORM ?? currentPlatform()
- )
- const arch = normalizeArch(argValue('arch') ?? process.env.DHC_MODULAR_ARCH ?? process.arch)
- const force =
- process.argv.includes('--force') ||
- process.env.DHC_MODULAR_FORCE_FETCH === '1' ||
- process.env.CI === 'true'
- return { platform, arch, force }
-}
-
-function goos(platform: SupportedPlatform): string {
- return platform === 'win32' ? 'windows' : platform
-}
-
-function goarch(arch: ModularPackageArch): string {
- return arch === 'x64' ? 'amd64' : 'arm64'
-}
-
-function moduleDir(): string {
- if (!existsSync(path.join(MODULE_DIR, 'go.mod'))) {
- throw new Error(
- `inference-dispatcher module not found at ${MODULE_DIR} (expected go.mod).\n` +
- 'Run from the monorepo with scripts/ checked out beside desktop/.'
- )
- }
- return MODULE_DIR
-}
-
-function ensureGoToolchain(): void {
- const res = spawnSync('go', ['version'], { encoding: 'utf8' })
- if (res.status !== 0) {
- throw new Error(
- 'Go toolchain not found on PATH.\n' +
- 'Install Go 1.25+ from https://go.dev/dl/ and reopen your terminal so PATH updates.'
- )
- }
- console.log(`[tools-build] ${res.stdout.trim()}`)
-}
-
-/** Content hash of the module's Go sources — not monorepo git HEAD. */
-function sourceFingerprint(dir: string): string {
- const hash = createHash('sha256')
- const files = readdirSync(dir)
- .filter(entry => entry.endsWith('.go') || entry === 'go.mod' || entry === 'go.sum')
- .sort()
- for (const file of files) {
- hash.update(file)
- hash.update('\0')
- hash.update(readFileSync(path.join(dir, file)))
- hash.update('\0')
- }
- return hash.digest('hex')
-}
-
-function sha256(filePath: string): { size: number; sha256: string } {
- const data = readFileSync(filePath)
- return { size: data.byteLength, sha256: createHash('sha256').update(data).digest('hex') }
-}
-
-function readManifest(): ToolsManifest | null {
- if (!existsSync(MANIFEST_PATH)) return null
- const parsed: unknown = JSON.parse(readFileSync(MANIFEST_PATH, 'utf8'))
- if (typeof parsed !== 'object' || parsed === null) return null
- if (!('sourceFingerprint' in parsed) || typeof parsed.sourceFingerprint !== 'string') {
- return null
- }
- if (!('version' in parsed) || typeof parsed.version !== 'string') return null
- if (!('platform' in parsed) || typeof parsed.platform !== 'string') return null
- if (!('arch' in parsed) || typeof parsed.arch !== 'string') return null
- if (!('fileName' in parsed) || typeof parsed.fileName !== 'string') return null
- if (!('size' in parsed) || typeof parsed.size !== 'number') return null
- if (!('sha256' in parsed) || typeof parsed.sha256 !== 'string') return null
- if (
- parsed.platform !== 'win32' &&
- parsed.platform !== 'darwin' &&
- parsed.platform !== 'linux'
- ) {
- return null
- }
- if (parsed.arch !== 'x64' && parsed.arch !== 'arm64') return null
- return {
- sourceFingerprint: parsed.sourceFingerprint,
- version: parsed.version,
- platform: parsed.platform,
- arch: parsed.arch,
- fileName: parsed.fileName,
- size: parsed.size,
- sha256: parsed.sha256,
- builtAt: 'builtAt' in parsed && typeof parsed.builtAt === 'string' ? parsed.builtAt : ''
- }
-}
-
-function manifestIsCurrent(options: BuildOptions, fingerprint: string): boolean {
- const manifest = readManifest()
- if (!manifest) return false
- if (manifest.sourceFingerprint !== fingerprint || !fingerprint) return false
- if (manifest.version !== appVersion) return false
- if (manifest.platform !== options.platform || manifest.arch !== options.arch) return false
-
- const fileName = inferenceDispatcherFileName(options.platform)
- if (manifest.fileName !== fileName) return false
- // A stray file means an earlier build targeted a different platform; the
- // packaging assertion would reject it, so rebuild rather than skip.
- const entries = readdirSync(TOOLS_DIR, { withFileTypes: true })
- const expected = new Set([fileName, 'manifest.json'])
- if (entries.length !== expected.size) return false
- if (!entries.every(entry => entry.isFile() && expected.has(entry.name))) return false
-
- const actual = sha256(path.join(TOOLS_DIR, fileName))
- return actual.size === manifest.size && actual.sha256 === manifest.sha256
-}
-
-function clearToolsDir(): void {
- if (!existsSync(TOOLS_DIR)) return
- for (const entry of readdirSync(TOOLS_DIR)) {
- rmSync(path.join(TOOLS_DIR, entry), { recursive: true, force: true })
- }
-}
-
-/**
- * The linker splits `-ldflags` on whitespace, so a version carrying a space (or
- * other linker syntax) would inject extra directives into the build. Restrict
- * the stamped value before it reaches the command line.
- */
-const SAFE_VERSION = /^[0-9A-Za-z][0-9A-Za-z.+-]*$/
-
-function assertSafeVersion(version: string): void {
- if (!SAFE_VERSION.test(version)) {
- throw new Error(
- `Refusing to build ${INFERENCE_DISPATCHER_BASE_NAME}: unsafe version string ${JSON.stringify(version)} ` +
- 'from package.json (must match /^[0-9A-Za-z][0-9A-Za-z.+-]*$/, no whitespace or ' +
- 'leading dash).'
- )
- }
-}
-
-function main(): void {
- const options = readOptions()
- const dir = moduleDir()
- const fingerprint = sourceFingerprint(dir)
-
- if (!options.force && existsSync(TOOLS_DIR) && manifestIsCurrent(options, fingerprint)) {
- console.log(
- `[tools-build] tools/ is current for fingerprint ${fingerprint.slice(0, 12)} ${options.platform}/${options.arch}; skipping build.`
- )
- return
- }
-
- assertSafeVersion(appVersion)
- ensureGoToolchain()
-
- clearToolsDir()
- mkdirSync(TOOLS_DIR, { recursive: true })
-
- const fileName = inferenceDispatcherFileName(options.platform)
- const outFile = path.join(TOOLS_DIR, fileName)
- const res = spawnSync(
- 'go',
- [
- 'build',
- '-trimpath',
- '-ldflags',
- `-s -w -X main.Version=${appVersion}`,
- '-o',
- outFile,
- '.'
- ],
- {
- cwd: dir,
- env: {
- ...process.env,
- CGO_ENABLED: '0',
- GOOS: goos(options.platform),
- GOARCH: goarch(options.arch)
- },
- stdio: 'inherit'
- }
- )
- if (res.status !== 0) {
- throw new Error(
- `go build failed for ${INFERENCE_DISPATCHER_BASE_NAME} (exit ${res.status ?? 'signal'})`
- )
- }
- if (options.platform !== 'win32') chmodSync(outFile, 0o755)
-
- const hash = sha256(outFile)
- const manifest: ToolsManifest = {
- sourceFingerprint: fingerprint,
- version: appVersion,
- platform: options.platform,
- arch: options.arch,
- fileName,
- size: hash.size,
- sha256: hash.sha256,
- builtAt: new Date().toISOString()
- }
- writeFileSync(MANIFEST_PATH, `${JSON.stringify(manifest, null, 2)}\n`, 'utf8')
- console.log(
- `[tools-build] built ${fileName} (v${appVersion}) for ${options.platform}/${options.arch}`
- )
-}
-
-main()
diff --git a/desktop/scripts/build-modular-binaries.ts b/desktop/scripts/build-modular-binaries.ts
index e84b4995..1fd0ca05 100644
--- a/desktop/scripts/build-modular-binaries.ts
+++ b/desktop/scripts/build-modular-binaries.ts
@@ -29,6 +29,9 @@
import { spawnSync } from 'node:child_process'
import { createHash } from 'node:crypto'
+// The release version, which is what the published tags are named for. It lives
+// outside src/, so the alias cannot reach it and a relative import is correct.
+import pkg from '../package.json' with { type: 'json' }
import {
chmodSync,
existsSync,
@@ -47,6 +50,10 @@ import {
modularShippedBinaryBaseNames
} from '@/shared/constants/modular-binaries'
import type { ModularPackageArch } from '@/shared/constants/modular-binaries'
+import {
+ INFERENCE_DISPATCHER_BASE_NAME,
+ inferenceDispatcherFileName
+} from '@/shared/constants/inference-dispatcher'
import type { SupportedPlatform } from '@/shared/types/platform'
import { currentPlatform } from '@/shared/utils/platform'
@@ -188,6 +195,13 @@ function ensureGoToolchain(): void {
console.log(`[modular-build] ${res.stdout.trim()}`)
}
+/**
+ * Non-Go file types that get compiled into a binary via `go:embed`. Kept as an
+ * explicit list rather than "everything that is not a .go file" so a README or a
+ * stray editor file cannot invalidate every developer's build cache.
+ */
+const EMBEDDED_ASSET_EXTENSIONS = ['.json', '.tmpl', '.html', '.css', '.svg']
+
function listFingerprintFiles(repo: string): string[] {
const out: string[] = []
const versionsPath = path.join(repo, 'versions.json')
@@ -211,18 +225,43 @@ function listFingerprintFiles(repo: string): string[] {
out.push(full)
} else if (entry === 'go.mod' || entry === 'go.sum') {
out.push(full)
+ } else if (full === versionsPath) {
+ // Already added above; the walk would hash it a second time.
+ continue
+ } else if (EMBEDDED_ASSET_EXTENSIONS.some(ext => entry.endsWith(ext))) {
+ // Assets compiled in with go:embed are as much a part of the
+ // binary as the source that reads them. The engine manager
+ // embeds its Ollama catalogue, so regenerating that file changes
+ // what ships — but with only .go files fingerprinted the build
+ // saw no change and reused the previous binary, leaving a stale
+ // catalogue in the app with nothing to indicate it.
+ out.push(full)
}
}
}
walk(repo)
+ // The Inference Demo's client is built into cli-bin too, so a change to it
+ // has to invalidate the same fingerprint. Its module is outside the services
+ // tree, so the walk above cannot reach it — and without this the build would
+ // report cli-bin current and ship the previous dispatcher.
+ const dispatcher = dispatcherSourceDir(repo)
+ if (existsSync(dispatcher)) walk(dispatcher)
return out.sort()
}
-/** Content hash of services Go sources + module files — not monorepo git HEAD. */
+/**
+ * Content hash of the Go sources and module files that produce cli-bin — not
+ * monorepo git HEAD.
+ *
+ * Paths are made relative to the monorepo root rather than the services tree,
+ * because the dispatcher's sources sit outside it and `repo`-relative slicing
+ * would turn them into `../scripts/...` — still stable, but only by accident.
+ */
function servicesSourceFingerprint(repo: string): string {
+ const root = path.resolve(repo, '..')
const hash = createHash('sha256')
for (const file of listFingerprintFiles(repo)) {
- hash.update(file.slice(repo.length + 1))
+ hash.update(path.relative(root, file))
hash.update('\0')
hash.update(readFileSync(file))
hash.update('\0')
@@ -277,9 +316,15 @@ function parseManifest(text: string): BuildManifest | null {
}
function expectedFileNames(platform: SupportedPlatform): string[] {
- return modularShippedBinaryBaseNames().map(baseName =>
- modularBinaryFileName(baseName, platform)
- )
+ return [
+ ...modularShippedBinaryBaseNames().map(baseName =>
+ modularBinaryFileName(baseName, platform)
+ ),
+ // Not a services component, but it ships here — see the note on
+ // INFERENCE_DISPATCHER_BASE_NAME. Listed so cli-bin stays an exact set:
+ // an unexpected file is still rejected, there is just one more expected.
+ inferenceDispatcherFileName(platform)
+ ]
}
function cliBinHasOnlyExpectedFiles(platform: SupportedPlatform): boolean {
@@ -357,7 +402,13 @@ function buildBinary(
repo: string,
options: BuildOptions,
baseName: string,
- version: string
+ version: string,
+ /**
+ * Extra `-X` assignments. Used for `nvpair-tui`, which also carries the
+ * release version: that is what the published tags are named for, so its
+ * own component version means nothing to an update check.
+ */
+ extraLdflags = ''
): ManifestFile {
assertSafeVersion(baseName, version)
const componentDir = path.join(repo, baseName)
@@ -368,7 +419,15 @@ function buildBinary(
const outFile = path.join(CLI_BIN_DIR, fileName)
const res = spawnSync(
'go',
- ['build', '-trimpath', '-ldflags', `-s -w -X main.Version=${version}`, '-o', outFile, '.'],
+ [
+ 'build',
+ '-trimpath',
+ '-ldflags',
+ `-s -w -X main.Version=${version}${extraLdflags}`,
+ '-o',
+ outFile,
+ '.'
+ ],
{
cwd: componentDir,
env: {
@@ -389,6 +448,56 @@ function buildBinary(
return { fileName, size: hash.size, sha256: hash.sha256 }
}
+/**
+ * Build the Inference Demo's HTTP client into `cli-bin/`.
+ *
+ * Its module is outside the services tree — at the monorepo root, because it is
+ * not a service — so it cannot go through `buildBinary`, which resolves a
+ * component directory inside the repo. It carries the services version rather
+ * than a component version for the same reason: it has no entry in
+ * `versions.json` and is not meant to acquire one.
+ */
+function buildDispatcher(repo: string, options: BuildOptions, version: string): ManifestFile {
+ assertSafeVersion(INFERENCE_DISPATCHER_BASE_NAME, version)
+ const sourceDir = dispatcherSourceDir(repo)
+ if (!existsSync(sourceDir)) {
+ throw new Error(
+ `Missing Inference Demo client source: ${sourceDir}\n` +
+ 'It lives at scripts/inference-dispatcher in the monorepo root.'
+ )
+ }
+ const fileName = inferenceDispatcherFileName(options.platform)
+ const outFile = path.join(CLI_BIN_DIR, fileName)
+ const res = spawnSync(
+ 'go',
+ ['build', '-trimpath', '-ldflags', `-s -w -X main.Version=${version}`, '-o', outFile, '.'],
+ {
+ cwd: sourceDir,
+ env: {
+ ...process.env,
+ CGO_ENABLED: '0',
+ GOOS: goos(options.platform),
+ GOARCH: goarch(options.arch)
+ },
+ stdio: 'inherit'
+ }
+ )
+ if (res.status !== 0) {
+ throw new Error(
+ `go build failed for ${INFERENCE_DISPATCHER_BASE_NAME} (exit ${res.status ?? 'signal'})`
+ )
+ }
+ if (options.platform !== 'win32') chmodSync(outFile, 0o755)
+ const hash = sha256(outFile)
+ console.log(`[modular-build] built ${fileName} (v${version})`)
+ return { fileName, size: hash.size, sha256: hash.sha256 }
+}
+
+/** The dispatcher's Go module, a sibling of the services tree. */
+function dispatcherSourceDir(repo: string): string {
+ return path.resolve(repo, '..', 'scripts', INFERENCE_DISPATCHER_BASE_NAME)
+}
+
function main(): void {
const options = readOptions()
const repo = servicesDir()
@@ -408,9 +517,11 @@ function main(): void {
clearCliBin()
mkdirSync(CLI_BIN_DIR, { recursive: true })
- const shipped = modularShippedBinaryBaseNames()
+ // +1 for the Inference Demo's dispatcher, which is built here but is not a
+ // services component and so is not in the inventory this counts.
+ const count = modularShippedBinaryBaseNames().length + 1
console.log(
- `[modular-build] building ${shipped.length} binaries for ${options.platform}/${options.arch} (fingerprint ${sourceFingerprint.slice(0, 12)})`
+ `[modular-build] building ${count} binaries for ${options.platform}/${options.arch} (fingerprint ${sourceFingerprint.slice(0, 12)})`
)
const files: ManifestFile[] = []
@@ -420,8 +531,17 @@ function main(): void {
}
for (const binary of MODULAR_BUNDLED_BINARIES) {
const version = versions.components[binary.baseName] ?? '0.0.0'
- files.push(buildBinary(repo, options, binary.baseName, version))
+ // The terminal client's update notice compares against the published
+ // release tag, so it needs the release version stamped alongside its
+ // own. Mirrors services/build.sh.
+ let extra = ''
+ if (binary.baseName === 'nvpair-tui') {
+ assertSafeVersion('release', pkg.version)
+ extra = ` -X nvpair-tui/ui.ReleaseVersion=${pkg.version}`
+ }
+ files.push(buildBinary(repo, options, binary.baseName, version, extra))
}
+ files.push(buildDispatcher(repo, options, versions.services))
const manifest: BuildManifest = {
source: 'services-build',
diff --git a/desktop/scripts/build/macos/hooks.ts b/desktop/scripts/build/macos/hooks.ts
index d87afaca..bd91befe 100644
--- a/desktop/scripts/build/macos/hooks.ts
+++ b/desktop/scripts/build/macos/hooks.ts
@@ -4,10 +4,6 @@
import { chmodSync, existsSync, readdirSync } from 'node:fs'
import { join } from 'node:path'
import type { AfterPackContext, BuildResult } from 'electron-builder'
-// electron-builder loads this hook through jiti (via electron-builder.config.ts),
-// and jiti does not resolve the `@/*` alias, so shared modules are imported by
-// relative path here. See the same note at the top of electron-builder.config.ts.
-import { INFERENCE_DISPATCHER_RESOURCE_DIR } from '../../../src/shared/constants/inference-dispatcher'
function findAppBundle(appOutDir: string): string {
const entries = readdirSync(appOutDir, { withFileTypes: true })
@@ -19,11 +15,11 @@ function findAppBundle(appOutDir: string): string {
}
/**
- * extraResources directories holding loose Go binaries: the services workers in
- * `cli-bin` and the `inference-dispatcher` client in
- * INFERENCE_DISPATCHER_RESOURCE_DIR.
+ * extraResources directories holding loose Go binaries. One: `cli-bin` carries
+ * the services workers, `nvpair-tui`, and the Inference Demo's
+ * `inference-dispatcher`.
*/
-const GO_BINARY_RESOURCE_DIRS = ['cli-bin', INFERENCE_DISPATCHER_RESOURCE_DIR]
+const GO_BINARY_RESOURCE_DIRS = ['cli-bin']
/**
* The `.dmg` has no installer step (unlike the old `.pkg` postinstall that ran
diff --git a/desktop/scripts/scrape-ollama-models.ts b/desktop/scripts/scrape-ollama-models.ts
index 518010bc..9ce05447 100644
--- a/desktop/scripts/scrape-ollama-models.ts
+++ b/desktop/scripts/scrape-ollama-models.ts
@@ -22,7 +22,27 @@
import { writeFileSync } from 'node:fs'
import path from 'node:path'
import axios, { isAxiosError } from 'axios'
-import type { OllamaTagsModel } from '@/electron/model-hub/ollama-library'
+
+/**
+ * Ollama-tags–shaped model entry: the wire shape this script writes and that
+ * `nvpair-engine-manager` reads back. It is declared here because this script is
+ * now the only TypeScript that knows the shape — the consumer is Go.
+ */
+interface OllamaTagsModel {
+ name: string
+ model: string
+ modified_at: string
+ size: number
+ digest: string
+ details: {
+ parent_model: string
+ format: string
+ family: string
+ families: string[] | null
+ parameter_size: string
+ quantization_level: string
+ }
+}
const OLLAMA_LIBRARY_URL = 'https://ollama.com/library'
const OLLAMA_DETAIL_URL = (base: string): string =>
@@ -42,13 +62,22 @@ const REQUEST_HEADERS: Record = {
Accept: 'text/html,application/xhtml+xml'
}
-/** Destination for the committed list, resolved from the repo root. */
+/**
+ * Destination for the committed list, resolved from the repo root.
+ *
+ * The list lives with `nvpair-engine-manager`, which compiles it in and serves
+ * it over `engine:catalog`, so the desktop app and the terminal interface get
+ * the same models from one implementation. This script stays here because it is
+ * a TypeScript scraper and a development-only tool; only its output crosses into
+ * the services tree.
+ */
const OUTPUT_PATH = path.resolve(
__dirname,
'..',
- 'src',
- 'electron',
- 'model-hub',
+ '..',
+ 'services',
+ 'nvpair-engine-manager',
+ 'catalog',
'ollama-models.json'
)
diff --git a/desktop/src/electron/inference-demo.ts b/desktop/src/electron/inference-demo.ts
index 37e39408..363012fe 100644
--- a/desktop/src/electron/inference-demo.ts
+++ b/desktop/src/electron/inference-demo.ts
@@ -4,12 +4,10 @@
import { execFile, spawn } from 'node:child_process'
import fs from 'node:fs'
import path from 'node:path'
-import { app, BrowserWindow } from 'electron'
+import { BrowserWindow } from 'electron'
+import { getCliBinDir } from '@/electron/service-bridge/modular-supervisor'
import { getModularBridgeState } from '@/electron/service-bridge/modular-state'
-import {
- INFERENCE_DISPATCHER_RESOURCE_DIR,
- inferenceDispatcherFileName
-} from '@/shared/constants/inference-dispatcher'
+import { inferenceDispatcherFileName } from '@/shared/constants/inference-dispatcher'
import { currentPlatform } from '@/shared/utils/platform'
import type { DispatcherBackend, DispatcherModel } from '@/shared/types/inference-dispatcher'
import type { IpcPushChannelKey, IpcPushChannelMap } from '@/shared/types/ipc-channels'
@@ -64,26 +62,24 @@ let deadlineMs = 0
let generation = 0
/**
- * Packaged builds place `extraResources` next to `process.resourcesPath`; in dev
- * they sit at the desktop project root (`app.getAppPath()`). Mirrors
- * `getCliBinDir()` in the modular supervisor, but resolves the dispatcher's own
- * `tools/` directory: the dispatcher is not a services binary and deliberately
- * stays out of the cli-bin inventory.
+ * The dispatcher ships inside `cli-bin/`, beside the services binaries, so this
+ * resolves through the same helper the supervisor uses to find the broker.
+ *
+ * It is still not a services component — it is absent from `versions.json` and
+ * nothing supervises it — but sharing the directory is what lets `nvpair-tui`
+ * find it with one rule, "next to my own executable", in a services install and
+ * in a packaged app alike.
*/
function binaryPath(): string {
- const base = app.isPackaged ? process.resourcesPath : app.getAppPath()
- return path.join(
- base,
- INFERENCE_DISPATCHER_RESOURCE_DIR,
- inferenceDispatcherFileName(currentPlatform())
- )
+ return path.join(getCliBinDir(), inferenceDispatcherFileName(currentPlatform()))
}
function assertBinaryExists(): string {
const executable = binaryPath()
if (!fs.existsSync(executable)) {
throw new Error(
- `Inference dispatcher binary not found at ${executable}. Run npm run build:tools.`
+ `Inference dispatcher binary not found at ${executable}. ` +
+ 'Run npm run build:modular-binaries.'
)
}
return executable
diff --git a/desktop/src/electron/ipc/window.ipc.ts b/desktop/src/electron/ipc/window.ipc.ts
index b017c6fc..f35fd013 100644
--- a/desktop/src/electron/ipc/window.ipc.ts
+++ b/desktop/src/electron/ipc/window.ipc.ts
@@ -5,7 +5,7 @@ import { app, BrowserWindow, clipboard, Menu, nativeImage } from 'electron'
import { safeHandle } from '@/electron/ipc/safe-handle'
import { openExternalSafe } from '@/electron/open-external'
import { createOverviewWindow, focusNodeInOverview, markOverviewReady } from '@/electron/window'
-import { warmEngineHubs } from '@/electron/model-hub'
+import { warmEngineHubs } from '@/electron/service-bridge/model-catalog'
import { APP_DISPLAY_NAME } from '@/shared/constants/app'
import { resizeTrayWindow } from '@/electron/tray'
import { saveDebugLogs } from './debug-log-export'
diff --git a/desktop/src/electron/model-hub/index.ts b/desktop/src/electron/model-hub/index.ts
deleted file mode 100644
index d1fda727..00000000
--- a/desktop/src/electron/model-hub/index.ts
+++ /dev/null
@@ -1,72 +0,0 @@
-// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
-// SPDX-License-Identifier: Apache-2.0
-
-import type { EngineType } from '@/shared/types/engines'
-import type { EngineHubModel, EngineHubSearchResponse } from '@/shared/types/engine-api'
-import { loadOllamaModels, type OllamaTagsModel } from '@/electron/model-hub/ollama-library'
-import {
- lmStudioCatalogCache,
- type LmStudioCatalogModel
-} from '@/electron/model-hub/lmstudio-catalog'
-
-function ollamaToHubModel(m: OllamaTagsModel): EngineHubModel {
- const base = m.name.includes(':') ? m.name.slice(0, m.name.indexOf(':')) : m.name
- return {
- id: m.name,
- name: m.name,
- author: '',
- url: `https://ollama.com/library/${base}`,
- size: m.size > 0 ? m.size : undefined,
- downloads: 0,
- likes: 0,
- updatedAt: m.modified_at || new Date().toISOString(),
- tags: [],
- family: m.details.family || undefined,
- parameterSize: m.details.parameter_size || undefined
- }
-}
-
-function lmStudioToHubModel(m: LmStudioCatalogModel): EngineHubModel {
- return {
- id: m.id,
- name: m.name,
- author: m.author,
- url: m.url,
- downloads: m.downloads,
- likes: m.likes,
- updatedAt: m.updatedAt,
- tags: m.tags
- }
-}
-
-/**
- * Serve an engine's model hub. Ollama is served from the committed, locked list
- * (`ollama-models.json`), so it returns instantly with no network access. LM
- * Studio still fetches its live `lmstudio-community` catalog and awaits a cold
- * cache's initial load. Engines without a hub return empty.
- */
-export async function getEngineHubModels(engineType: EngineType): Promise {
- switch (engineType) {
- case 'ollama':
- return { models: loadOllamaModels().map(ollamaToHubModel) }
- case 'lm-studio':
- await lmStudioCatalogCache.ensureLoaded()
- return { models: lmStudioCatalogCache.list().map(lmStudioToHubModel) }
- default:
- return { models: [] }
- }
-}
-
-/**
- * Kick a background refresh of the live engine hub caches so the first modal
- * open is instant. Ollama needs no warming (it is a committed static list);
- * only LM Studio fetches from the network. Fire-and-forget; failures are logged
- * inside each cache.
- *
- * Called once the Overview renderer reports ready, deliberately not on service
- * connect: a network fetch started before the window has painted competes with
- * the renderer's own load, and a hanging one leaves an unpainted window behind.
- */
-export function warmEngineHubs(): void {
- lmStudioCatalogCache.refresh()
-}
diff --git a/desktop/src/electron/model-hub/lmstudio-catalog.ts b/desktop/src/electron/model-hub/lmstudio-catalog.ts
deleted file mode 100644
index c2e86cf6..00000000
--- a/desktop/src/electron/model-hub/lmstudio-catalog.ts
+++ /dev/null
@@ -1,214 +0,0 @@
-// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
-// SPDX-License-Identifier: Apache-2.0
-
-import axios, { isAxiosError } from 'axios'
-import { createStructuredLogger } from '@/shared/utils/log'
-import getErrorString from '@/shared/utils/get-error-string'
-import { currentPlatform } from '@/shared/utils/platform'
-
-const log = createStructuredLogger('lmstudio-catalog')
-
-/**
- * LM Studio's "Discover" catalog is the `lmstudio-community` Hugging Face org:
- * curated GGUF quantizations whose repo ids (e.g.
- * `lmstudio-community/Qwen3-8B-GGUF`) are exactly the strings `lms get`
- * accepts on the pull path. There is no separate public LM Studio catalog
- * JSON API — the lmstudio.ai/models page is client-rendered and ultimately
- * resolves to these same Hugging Face repos.
- */
-const HF_MODELS_API = 'https://huggingface.co/api/models'
-const CATALOG_AUTHOR = 'lmstudio-community'
-const CATALOG_LIMIT = 500
-const CACHE_TTL_MS = 6 * 60 * 60 * 1000
-
-/**
- * Bounds the whole request, not just the response. `axios`'s own `timeout`
- * maps to a socket timeout, which does not start until a socket exists — so it
- * does not cover name resolution. A machine whose DNS resolver is wedged has
- * been observed holding this request open for 74s against a 20s timeout, long
- * enough to matter to the rest of the main process. An `AbortSignal` covers
- * every phase, so this is the single bound on the call.
- */
-const HTTP_TIMEOUT_MS = 20_000
-const REQUEST_HEADERS: Record = {
- 'User-Agent': 'PAIR/1.0',
- Accept: 'application/json'
-}
-
-/**
- * One normalized catalog row. `id`/`name` carry the pull-ready repo id; the
- * renderer's `mapLmStudioHub` turns this into a display `ModelEntry`.
- */
-export interface LmStudioCatalogModel {
- id: string
- name: string
- author: string
- downloads: number
- likes: number
- updatedAt: string
- tags: string[]
- url: string
-}
-
-function isRecord(v: unknown): v is Record {
- return v !== null && typeof v === 'object'
-}
-
-function readStr(o: Record, key: string): string {
- const v = o[key]
- return typeof v === 'string' ? v : ''
-}
-
-function readNum(o: Record, key: string): number {
- const v = o[key]
- return typeof v === 'number' ? v : 0
-}
-
-function readStringArray(o: Record, key: string): string[] {
- const v = o[key]
- if (!Array.isArray(v)) return []
- return v.filter((s): s is string => typeof s === 'string')
-}
-
-function normalizeEntry(raw: unknown): LmStudioCatalogModel | null {
- if (!isRecord(raw)) return null
- const id = (readStr(raw, 'id') || readStr(raw, 'modelId')).trim()
- if (!id) return null
- const author = id.includes('/') ? id.slice(0, id.indexOf('/')) : CATALOG_AUTHOR
- const updatedAt =
- readStr(raw, 'lastModified') || readStr(raw, 'createdAt') || new Date().toISOString()
- return {
- id,
- name: id,
- author,
- downloads: readNum(raw, 'downloads'),
- likes: readNum(raw, 'likes'),
- updatedAt,
- tags: readStringArray(raw, 'tags'),
- url: `https://huggingface.co/${id}`
- }
-}
-
-function dedupeById(models: LmStudioCatalogModel[]): LmStudioCatalogModel[] {
- const byId = new Map()
- for (const m of models) byId.set(m.id, m)
- return Array.from(byId.values())
-}
-
-/**
- * MLX is Apple's framework: those quantizations only run on Apple Silicon.
- * `lms get` rejects them on Windows/Linux with "No download options available",
- * so listing them off-Mac only offers models that can never install. Detect via
- * the HF `mlx` tag or an `mlx` token in the repo id (e.g. `…-MLX-8bit`).
- */
-function isMlxModel(m: LmStudioCatalogModel): boolean {
- if (m.tags.some(t => t.toLowerCase() === 'mlx')) return true
- return /(?:^|[-_/])mlx(?:[-_/]|$)/i.test(m.id)
-}
-
-/**
- * Drop Mac-only MLX repos on non-Mac platforms. On macOS the catalog keeps both
- * GGUF and MLX since either can run there.
- */
-function filterByPlatform(models: LmStudioCatalogModel[]): LmStudioCatalogModel[] {
- if (currentPlatform() === 'darwin') return models
- return models.filter(m => !isMlxModel(m))
-}
-
-class LmStudioCatalogCache {
- private models: LmStudioCatalogModel[] = []
- private lastFetch = 0
- private fetching = false
- private inflight: Promise | null = null
-
- get isFetching(): boolean {
- return this.fetching
- }
-
- get size(): number {
- return this.models.length
- }
-
- list(): LmStudioCatalogModel[] {
- return this.models
- }
-
- refresh(): void {
- log.info({
- sublevel: 'cache',
- message: `LM Studio catalog refresh requested (current=${this.models.length} fetching=${this.fetching})`
- })
- void this.fetchUpstream()
- }
-
- /** Ensure the catalog has been populated at least once. */
- async ensureLoaded(): Promise {
- if (this.models.length > 0) return
- await this.fetchUpstream()
- }
-
- private async fetchUpstream(): Promise {
- if (this.inflight) return this.inflight
- if (Date.now() - this.lastFetch < CACHE_TTL_MS && this.models.length > 0) {
- log.verbose({
- sublevel: 'cache',
- message: `cache fresh, skipping fetch (${this.models.length} models)`
- })
- return
- }
- this.fetching = true
- this.inflight = this.runFetch().finally(() => {
- this.fetching = false
- this.inflight = null
- })
- return this.inflight
- }
-
- private async runFetch(): Promise {
- try {
- log.verbose({
- sublevel: 'http',
- message: `GET ${HF_MODELS_API}?author=${CATALOG_AUTHOR}`
- })
- const { data } = await axios.get(HF_MODELS_API, {
- signal: AbortSignal.timeout(HTTP_TIMEOUT_MS),
- headers: REQUEST_HEADERS,
- params: {
- author: CATALOG_AUTHOR,
- sort: 'downloads',
- direction: -1,
- limit: CATALOG_LIMIT
- }
- })
- const raw = Array.isArray(data) ? data : []
- const normalized = dedupeById(
- raw.map(normalizeEntry).filter((m): m is LmStudioCatalogModel => m != null)
- )
- const platformModels = filterByPlatform(normalized)
- if (platformModels.length > 0) {
- this.models = platformModels
- this.lastFetch = Date.now()
- log.info({
- sublevel: 'cache',
- message: `LM Studio catalog fetch ok: ${platformModels.length} models (filtered ${
- normalized.length - platformModels.length
- } MLX for ${currentPlatform()})`
- })
- return
- }
- log.warn({
- sublevel: 'cache',
- message: 'LM Studio catalog fetch returned 0 models; keeping prior cache'
- })
- } catch (err) {
- const status = isAxiosError(err) ? err.response?.status : undefined
- const msg = getErrorString(err) || 'catalog fetch failed'
- log.warn({
- sublevel: 'http',
- message: `LM Studio catalog fetch failed: ${status ?? ''} ${msg}`.trim()
- })
- }
- }
-}
-
-export const lmStudioCatalogCache = new LmStudioCatalogCache()
diff --git a/desktop/src/electron/model-hub/ollama-library.ts b/desktop/src/electron/model-hub/ollama-library.ts
deleted file mode 100644
index 4671dc5e..00000000
--- a/desktop/src/electron/model-hub/ollama-library.ts
+++ /dev/null
@@ -1,103 +0,0 @@
-// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
-// SPDX-License-Identifier: Apache-2.0
-
-import ollamaModelsData from './ollama-models.json'
-
-/**
- * Ollama-tags–shaped model entry. This is the wire shape the committed list
- * (`ollama-models.json`) stores and that `getEngineHubModels` maps to
- * `EngineHubModel`.
- *
- * PAIR no longer scrapes ollama.com at runtime — the live scrape was fragile
- * and broke whenever Ollama changed their markup. The list is now a **locked**
- * committed file regenerated on demand by `npm run scrape:ollama-models`
- * (`scripts/scrape-ollama-models.ts`); a dev reviews the diff and commits it.
- */
-export interface OllamaTagsModel {
- name: string
- model: string
- modified_at: string
- size: number
- digest: string
- details: {
- parent_model: string
- format: string
- family: string
- families: string[] | null
- parameter_size: string
- quantization_level: string
- }
-}
-
-function isRecord(v: unknown): v is Record {
- return v !== null && typeof v === 'object'
-}
-
-function readStr(o: Record, key: string): string {
- const v = o[key]
- return typeof v === 'string' ? v : ''
-}
-
-function readStringArrayOrNull(o: Record, key: string): string[] | null {
- const v = o[key]
- if (!Array.isArray(v)) return null
- const arr: string[] = []
- for (const item of v) {
- if (typeof item === 'string') arr.push(item)
- }
- return arr
-}
-
-/**
- * Normalize one committed entry into a fully-typed `OllamaTagsModel`, dropping
- * anything malformed. Reading through `unknown` keeps the loader type-safe
- * (no casts) and guards against a bad hand-edit ever reaching the renderer.
- */
-function normalizeEntry(raw: unknown): OllamaTagsModel | null {
- if (!isRecord(raw)) return null
- const o = raw
- const rawName = readStr(o, 'name') || readStr(o, 'model')
- const name = rawName.trim()
- if (!name) return null
- const model = readStr(o, 'model') || name
- const sizeVal = o.size
- const size = typeof sizeVal === 'number' && sizeVal >= 0 ? sizeVal : 0
- const d: Record = isRecord(o.details) ? o.details : {}
- return {
- name,
- model,
- modified_at: readStr(o, 'modified_at'),
- size,
- digest: readStr(o, 'digest'),
- details: {
- parent_model: readStr(d, 'parent_model'),
- format: readStr(d, 'format'),
- family: readStr(d, 'family'),
- families: readStringArrayOrNull(d, 'families'),
- parameter_size: readStr(d, 'parameter_size'),
- quantization_level: readStr(d, 'quantization_level')
- }
- }
-}
-
-function dedupeByName(models: OllamaTagsModel[]): OllamaTagsModel[] {
- const byName = new Map()
- for (const m of models) byName.set(m.name, m)
- return Array.from(byName.values())
-}
-
-/**
- * The committed model list, normalized once at module load. `ollama-models.json`
- * is bundled into the main process (`resolveJsonModule` + inlined by
- * electron-vite), so there is no filesystem read or path resolution at runtime.
- */
-const OLLAMA_MODELS: OllamaTagsModel[] = dedupeByName(
- (Array.isArray(ollamaModelsData.models) ? ollamaModelsData.models : [])
- .map(normalizeEntry)
- .filter((m): m is OllamaTagsModel => m != null)
-)
-
-/** The locked Ollama model list served to the model hub. */
-export function loadOllamaModels(): OllamaTagsModel[] {
- return OLLAMA_MODELS
-}
diff --git a/desktop/src/electron/service-bridge/empty-handlers.ts b/desktop/src/electron/service-bridge/empty-handlers.ts
index 14fd3d89..adff9270 100644
--- a/desktop/src/electron/service-bridge/empty-handlers.ts
+++ b/desktop/src/electron/service-bridge/empty-handlers.ts
@@ -12,7 +12,7 @@ import {
} from '@/shared/constants/modular-runtime'
import getErrorString from '@/shared/utils/get-error-string'
import { engineManagerName } from '@/shared/utils/engines'
-import { getEngineHubModels } from '@/electron/model-hub'
+import { getEngineHubModels } from '@/electron/service-bridge/model-catalog'
import { getModularSupervisor } from './modular-supervisor'
import {
parseEngineSettings,
diff --git a/desktop/src/electron/service-bridge/model-catalog.ts b/desktop/src/electron/service-bridge/model-catalog.ts
new file mode 100644
index 00000000..301ec3a1
--- /dev/null
+++ b/desktop/src/electron/service-bridge/model-catalog.ts
@@ -0,0 +1,148 @@
+// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+import type { EngineType } from '@/shared/types/engines'
+import type { EngineHubModel, EngineHubSearchResponse } from '@/shared/types/engine-api'
+import { getModularSupervisor } from '@/electron/service-bridge/modular-supervisor'
+import type { JsonObject, JsonValue } from '@/electron/service-bridge/json-rpc-subprocess'
+import { createStructuredLogger } from '@/shared/utils/log'
+import { currentPlatform } from '@/shared/utils/platform'
+import { MODULAR_CATALOG_CALL_TIMEOUT_MS } from '@/shared/constants/modular-runtime'
+import getErrorString from '@/shared/utils/get-error-string'
+
+const log = createStructuredLogger('model-catalog')
+
+/**
+ * The engine's name in the backend's vocabulary. `EngineType` is the renderer's
+ * spelling; the engine manager keys on its manifest names.
+ */
+const BACKEND_ENGINE_NAME: Record = {
+ ollama: 'ollama',
+ 'lm-studio': 'lmstudio'
+}
+
+/**
+ * This machine's platform in the backend's vocabulary.
+ *
+ * `engine:catalog` keys on GOOS, and Node and Go disagree on the spelling for
+ * Windows — `win32` against `windows`. Only `darwin` is consulted today, and it
+ * is spelled the same in both, so sending Node's string happens to filter
+ * correctly; but the value is echoed back in the reply and any future
+ * backend branch on `windows` would silently not match.
+ */
+function backendPlatform(): string {
+ switch (currentPlatform()) {
+ case 'win32':
+ return 'windows'
+ case 'darwin':
+ return 'darwin'
+ default:
+ return 'linux'
+ }
+}
+
+/**
+ * `JsonObject` is what the stdio plane already resolves a reply to, so narrowing
+ * happens against that rather than `unknown` — the boundary is typed, it is only
+ * the shape behind it that has to be checked.
+ */
+function isObject(v: JsonValue | undefined): v is JsonObject {
+ return typeof v === 'object' && v !== null && !Array.isArray(v)
+}
+
+function readStr(o: JsonObject, key: string): string {
+ const v = o[key]
+ return typeof v === 'string' ? v : ''
+}
+
+function readNum(o: JsonObject, key: string): number {
+ const v = o[key]
+ return typeof v === 'number' ? v : 0
+}
+
+function readStringArray(o: JsonObject, key: string): string[] {
+ const v = o[key]
+ if (!Array.isArray(v)) return []
+ return v.filter((s): s is string => typeof s === 'string')
+}
+
+/**
+ * Normalize one backend catalog row, dropping any row with no pull-ready id
+ * rather than offering a model that cannot be downloaded.
+ */
+function toHubModel(raw: JsonValue): EngineHubModel | null {
+ if (!isObject(raw)) return null
+ const id = readStr(raw, 'id') || readStr(raw, 'name')
+ if (!id) return null
+ const size = readNum(raw, 'size')
+ const family = readStr(raw, 'family')
+ const parameterSize = readStr(raw, 'parameterSize')
+ return {
+ id,
+ name: readStr(raw, 'name') || id,
+ author: readStr(raw, 'author'),
+ url: readStr(raw, 'url'),
+ size: size > 0 ? size : undefined,
+ downloads: readNum(raw, 'downloads'),
+ likes: readNum(raw, 'likes'),
+ updatedAt: readStr(raw, 'updatedAt'),
+ tags: readStringArray(raw, 'tags'),
+ family: family || undefined,
+ parameterSize: parameterSize || undefined
+ }
+}
+
+/**
+ * Serve an engine's model hub from the backend's `engine:catalog`.
+ *
+ * The catalog used to be assembled here in the main process: a committed Ollama
+ * list bundled into this bundle, plus a live Hugging Face fetch. Both moved into
+ * `nvpair-engine-manager` so the desktop app and the terminal interface serve
+ * the same models from one implementation rather than each maintaining its own.
+ *
+ * An engine with no curated source is an error at the backend, reported here as
+ * an empty hub so the modal shows its empty state rather than a failure the user
+ * can do nothing about.
+ */
+export async function getEngineHubModels(engineType: EngineType): Promise {
+ const engine = BACKEND_ENGINE_NAME[engineType]
+ if (!engine) return { models: [] }
+ try {
+ // The hub only ever installs to this machine, so the target platform is
+ // this one. Sent explicitly rather than relying on the backend's default
+ // so the request states its own intent.
+ const result = await getModularSupervisor().callProcess(
+ 'broker',
+ 'engine:catalog',
+ { engine, platform: backendPlatform() },
+ MODULAR_CATALOG_CALL_TIMEOUT_MS
+ )
+ const rows = isObject(result) && Array.isArray(result.models) ? result.models : []
+ const models = rows.map(toHubModel).filter((m): m is EngineHubModel => m !== null)
+ log.verbose({
+ sublevel: 'catalog',
+ message: `${engineType} catalog: ${models.length} models`
+ })
+ return { models }
+ } catch (err) {
+ log.warn({
+ sublevel: 'catalog',
+ message: `${engineType} catalog fetch failed: ${getErrorString(err)}`
+ })
+ return { models: [] }
+ }
+}
+
+/**
+ * Warm the backend's catalog cache so the first modal open is instant. Only the
+ * live-fetched source benefits; the committed one is compiled in and costs
+ * nothing. Fire-and-forget: failures are logged by the call itself and the modal
+ * will simply fetch again.
+ *
+ * Called once the Overview renderer reports ready, deliberately not on service
+ * connect: a network fetch started before the window has painted competes with
+ * the renderer's own load, and a hanging one leaves an unpainted window behind.
+ */
+export function warmEngineHubs(): void {
+ void getEngineHubModels('lm-studio')
+}
diff --git a/desktop/src/shared/constants/inference-dispatcher.ts b/desktop/src/shared/constants/inference-dispatcher.ts
index b50c84ed..3557bc1a 100644
--- a/desktop/src/shared/constants/inference-dispatcher.ts
+++ b/desktop/src/shared/constants/inference-dispatcher.ts
@@ -8,24 +8,24 @@ import type { SupportedPlatform } from '@/shared/types/platform'
*
* The dispatcher is deliberately not part of the services binary inventory in
* `modular-binaries.ts`: it speaks no JSON-RPC, is absent from
- * `services/versions.json`, and is never supervised by the broker. Its source
- * lives in the monorepo's `scripts/inference-dispatcher` module and it ships in
- * its own `extraResources` directory so `cli-bin/` can keep asserting an exact
- * match against the services inventory.
+ * `services/versions.json`, and is never supervised by the broker. It is an
+ * ordinary HTTP client, spawned once per Inference Demo request, whose source
+ * lives in the monorepo's `scripts/inference-dispatcher` module.
*
- * Shared by `scripts/build-inference-dispatcher.ts` (producer),
+ * It nonetheless ships **inside `cli-bin/`**, beside the services binaries,
+ * because the terminal interface runs the same demo and finds the dispatcher the
+ * same way it finds the broker: next to its own executable. A separate resource
+ * directory would mean either a second copy of the binary in every package or a
+ * second resolution rule in `nvpair-tui`, and neither is worth keeping the
+ * inventory assertion free of one named exception.
+ *
+ * Shared by `scripts/build-modular-binaries.ts` (producer),
* `electron-builder.config.ts` (packaging assertion), and
* `src/electron/inference-demo.ts` (runtime resolution).
*/
export const INFERENCE_DISPATCHER_BASE_NAME = 'inference-dispatcher'
-/**
- * Resource directory holding the dispatcher, relative to `resourcesPath` in a
- * packaged build and to the desktop project root in development.
- */
-export const INFERENCE_DISPATCHER_RESOURCE_DIR = 'tools'
-
export function inferenceDispatcherFileName(platform: SupportedPlatform): string {
return platform === 'win32'
? `${INFERENCE_DISPATCHER_BASE_NAME}.exe`
diff --git a/desktop/src/shared/constants/modular-runtime.ts b/desktop/src/shared/constants/modular-runtime.ts
index 412827c6..66fc4142 100644
--- a/desktop/src/shared/constants/modular-runtime.ts
+++ b/desktop/src/shared/constants/modular-runtime.ts
@@ -79,6 +79,15 @@ export const MODULAR_ENGINE_LIFECYCLE_CALL_TIMEOUT_MS = 14 * 60_000
// docs/services-parity.md.
export const MODULAR_MODEL_ACTION_TIMEOUT_MS = 120_000
+// Upper bound for `engine:catalog`. The engine manager allows its own LM Studio
+// fetch 20s, and only replies once that resolves, so the RPC envelope has to sit
+// outside that budget or a slow catalogue rejects here while the backend is
+// still working — and the modal renders its empty state, which reads as "this
+// engine has no models" rather than "the catalogue could not be read". Hugging
+// Face has been observed holding the request open far longer than its nominal
+// timeout, so the margin is generous.
+export const MODULAR_CATALOG_CALL_TIMEOUT_MS = 30_000
+
// Poll interval for `cluster:invite-status` while a pairing handshake is open.
// Also drives the pending-invite reconciliation sweep, the backstop for a missed
// receiver-side `cluster:invite-canceled` / `cluster:invite-expired` push.
diff --git a/desktop/src/shared/types/engine-api.ts b/desktop/src/shared/types/engine-api.ts
index 9187e658..4d524f51 100644
--- a/desktop/src/shared/types/engine-api.ts
+++ b/desktop/src/shared/types/engine-api.ts
@@ -11,11 +11,12 @@ import type { EngineStatusData, EngineType } from '@/shared/types/engines'
import type { EngineModels, EngineProgress, EngineUpdateAvailable } from '@/shared/types/engines'
/**
- * One normalized model row returned by an engine-owned hub source (Ollama
- * library scrape, LM Studio community catalog). The Electron-main model-hub
- * module normalizes each upstream registry into this shape so the renderer
- * maps it to a display row without per-engine JSON parsing. `id`/`name` carry
- * the pull-ready identifier the engine's `pull_model` action expects.
+ * One normalized model row returned by an engine-owned hub source (a committed
+ * Ollama library list, the LM Studio community catalog). `nvpair-engine-manager`
+ * owns both sources and normalizes each upstream registry into this shape, so
+ * the desktop app and the terminal interface serve the same models and the
+ * renderer maps a row for display without per-engine JSON parsing. `id`/`name`
+ * carry the pull-ready identifier the engine's `pull_model` action expects.
*/
export interface EngineHubModel {
id: string
diff --git a/desktop/src/ui/types/model-hub.ts b/desktop/src/ui/types/model-hub.ts
index 668e57a7..0a9a2c5e 100644
--- a/desktop/src/ui/types/model-hub.ts
+++ b/desktop/src/ui/types/model-hub.ts
@@ -2,11 +2,12 @@
// SPDX-License-Identifier: Apache-2.0
/**
- * Display row for a model hub result. The Electron-main model-hub module
- * (`src/electron/model-hub/`) returns normalized `EngineHubModel` rows over the
- * `engine:search-hub` channel; `model-hub-search.ts` maps those into this
- * renderer-only display shape. `name`/`id` carry the pull-ready identifier the
- * engine's `pull_model` action expects.
+ * Display row for a model hub result. `nvpair-engine-manager` owns the
+ * catalogues and returns normalized `EngineHubModel` rows, relayed by
+ * `src/electron/service-bridge/model-catalog.ts` over the `engine:search-hub`
+ * channel; `model-hub-search.ts` maps those into this renderer-only display
+ * shape. `name`/`id` carry the pull-ready identifier the engine's `pull_model`
+ * action expects.
*/
export interface ModelEntry {
id: string
diff --git a/desktop/tests/modular/cluster-pairing-timeout.test.ts b/desktop/tests/modular/cluster-pairing-timeout.test.ts
index 4811d208..16f1dd6f 100644
--- a/desktop/tests/modular/cluster-pairing-timeout.test.ts
+++ b/desktop/tests/modular/cluster-pairing-timeout.test.ts
@@ -3,7 +3,7 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { Invite } from '@/shared/types/cluster'
-import type { getEngineHubModels } from '@/electron/model-hub'
+import type { getEngineHubModels } from '@/electron/service-bridge/model-catalog'
import type { getModularSupervisor } from '@/electron/service-bridge/modular-supervisor'
import type { getModularBridgeState } from '@/electron/service-bridge/modular-state'
@@ -29,7 +29,7 @@ vi.mock('@/electron/service-bridge/modular-state', () => ({
isUpstreamUnreachableError: () => false,
parseServiceErrors: () => []
}))
-vi.mock('@/electron/model-hub', () => ({
+vi.mock('@/electron/service-bridge/model-catalog', () => ({
getEngineHubModels: vi.fn()
}))
diff --git a/desktop/tests/modular/engine-command-load.test.ts b/desktop/tests/modular/engine-command-load.test.ts
index 1689cc1d..7392cda4 100644
--- a/desktop/tests/modular/engine-command-load.test.ts
+++ b/desktop/tests/modular/engine-command-load.test.ts
@@ -23,7 +23,7 @@ vi.mock('@/electron/service-bridge/modular-state', () => ({
isUpstreamUnreachableError: () => false,
parseServiceErrors: () => []
}))
-vi.mock('@/electron/model-hub', () => ({ getEngineHubModels: vi.fn() }))
+vi.mock('@/electron/service-bridge/model-catalog', () => ({ getEngineHubModels: vi.fn() }))
import { handleServiceBridgeInvoke } from '@/electron/service-bridge/empty-handlers'
diff --git a/desktop/tests/modular/engine-command-service-down.test.ts b/desktop/tests/modular/engine-command-service-down.test.ts
index 4e16a711..05ba8855 100644
--- a/desktop/tests/modular/engine-command-service-down.test.ts
+++ b/desktop/tests/modular/engine-command-service-down.test.ts
@@ -26,7 +26,7 @@ vi.mock('@/electron/service-bridge/modular-state', () => ({
isUpstreamUnreachableError: () => false,
parseServiceErrors: () => []
}))
-vi.mock('@/electron/model-hub', () => ({ getEngineHubModels: vi.fn() }))
+vi.mock('@/electron/service-bridge/model-catalog', () => ({ getEngineHubModels: vi.fn() }))
import { handleServiceBridgeInvoke } from '@/electron/service-bridge/empty-handlers'
diff --git a/desktop/tests/modular/engine-settings-bridge.test.ts b/desktop/tests/modular/engine-settings-bridge.test.ts
index c356ab41..8d1ba0a2 100644
--- a/desktop/tests/modular/engine-settings-bridge.test.ts
+++ b/desktop/tests/modular/engine-settings-bridge.test.ts
@@ -28,7 +28,7 @@ vi.mock('@/electron/service-bridge/modular-state', () => ({
isUpstreamUnreachableError: () => false,
parseServiceErrors: () => []
}))
-vi.mock('@/electron/model-hub', () => ({ getEngineHubModels: vi.fn() }))
+vi.mock('@/electron/service-bridge/model-catalog', () => ({ getEngineHubModels: vi.fn() }))
import { handleServiceBridgeInvoke } from '@/electron/service-bridge/empty-handlers'
import { MODULAR_ENGINE_LIFECYCLE_CALL_TIMEOUT_MS } from '@/shared/constants/modular-runtime'
diff --git a/desktop/tests/modular/truthful-stop.test.ts b/desktop/tests/modular/truthful-stop.test.ts
index 0b6c0fab..abb73c7e 100644
--- a/desktop/tests/modular/truthful-stop.test.ts
+++ b/desktop/tests/modular/truthful-stop.test.ts
@@ -26,7 +26,7 @@ vi.mock('@/electron/service-bridge/modular-state', () => ({
isUpstreamUnreachableError: () => false,
parseServiceErrors: () => []
}))
-vi.mock('@/electron/model-hub', () => ({ getEngineHubModels: vi.fn() }))
+vi.mock('@/electron/service-bridge/model-catalog', () => ({ getEngineHubModels: vi.fn() }))
import { handleServiceBridgeInvoke } from '@/electron/service-bridge/empty-handlers'
diff --git a/docs/inference-dispatcher.mdx b/docs/inference-dispatcher.mdx
index 4462d967..fc6f6512 100644
--- a/docs/inference-dispatcher.mdx
+++ b/docs/inference-dispatcher.mdx
@@ -31,7 +31,8 @@ already needs. Node is not required. They compile the module to a temporary
directory and run it from the caller's working directory, so a relative
`--result-log` path means what you expect.
-The desktop app ships its own prebuilt copy for the Inference Demo; see
+A prebuilt copy ships beside the other binaries for the Inference Demo, which
+both the desktop app and the terminal interface spawn once per request; see
[architecture.mdx](architecture.mdx). The wrappers exist so the same tool can be
driven by hand.
diff --git a/docs/terminal-interface.mdx b/docs/terminal-interface.mdx
index 1eb99b5f..077097ae 100644
--- a/docs/terminal-interface.mdx
+++ b/docs/terminal-interface.mdx
@@ -13,9 +13,9 @@ desktop environment, or over SSH, where the desktop application cannot run. If a
desktop is available, use the desktop application.
**It has known limitations.** It is an operations tool, not a full replacement.
-It cannot list or delete models, change an engine's port, update an engine,
-control engines on other nodes, or show which node served a workload. The full
-list is in
+It cannot update an engine or send an inference request for you, and it stops
+when your connection drops unless you run it under a terminal multiplexer. The
+full list is in
[What the Terminal Interface Cannot Do](#what-the-terminal-interface-cannot-do),
and it is worth reading before you depend on it.
@@ -65,11 +65,37 @@ cd
| --- | --- |
| `--broker-path ` | Use a service binary that is not beside `nvpair-tui` |
| `--log-level ` | Verbosity of the terminal interface's own logging: `debug`, `info`, `warn`, or `error`. PAIR also reads this from `NVPAIR_LOG_LEVEL` |
+| `--appearance ` | `auto`, `light`, or `dark`. See below |
| `--version` | Print the version and exit |
The interface's own log output goes to stderr, so it never corrupts the display.
Service logs appear on the **Logs** tab instead.
+### If the Colours Are Hard to Read
+
+PAIR picks its colours from your terminal's background. It detects that by
+asking the terminal and reading the reply, which works in most terminals,
+including over SSH — the question and the answer travel the connection like
+anything else.
+
+Two cases cannot be detected. Inside `tmux` or `screen` the question is not
+asked at all, because one session can be attached to several terminals at once
+and there is no single background to report. A terminal that does not implement
+the query simply never answers. Both fall back to assuming a dark background,
+which on a light theme gives washed-out text.
+
+Tell it instead:
+
+```bash
+./nvpair-tui --appearance light
+```
+
+`dark` forces the other way, and `auto` is the default.
+
+A terminal that never answers also makes PAIR wait for the reply before it
+starts, which is a pause of a few seconds on the way in. That wait comes from
+the terminal library and applies whether or not you pass `--appearance`.
+
Press `q` to quit. Quitting shuts the services down cleanly rather than leaving
them running.
@@ -96,162 +122,357 @@ provides.
## The Screen
-The top line shows `NVPAIR TUI` and the connection state on the right:
-`connecting to broker...`, `broker ready v`, or `broker disconnected`.
-The second line is the numbered tab bar. The footer shows the keys available on
-the current tab.
+The top line shows `NVPAIR` and the service state on the right:
+`starting service...`, `service ready v`, or `service disconnected`.
+The second line is the numbered tab bar; when anything has gone wrong, the count
+appears on the **Errors** tab's own label. The footer shows the keys available
+where you are, starting with the ones that always work. When a newer release of
+PAIR exists, a notice sits between the tab bar and the content on every tab
+until you dismiss it — see [Update Notices](#update-notices).
If you see `starting...`, the interface is waiting for the terminal size. If the
-header stays on `connecting to broker...`, the service tree did not come up, and
-the **Logs** tab says why.
+header stays on `starting service...`, the service tree did not come up, and the
+**Logs** tab says why.
-
+
## Moving Around
| Key | Action |
| --- | --- |
-| `tab`, `l`, `→` | Next tab |
-| `shift+tab`, `h`, `←` | Previous tab |
+| `1` – `5` | Go straight to a tab |
+| `tab`, `shift+tab` | Next, previous tab |
| `?` | Toggle full help |
+| `ctrl+x` | Dismiss the update notice, while one is showing |
| `q`, `ctrl+c` | Quit |
Within a table, `j` / `k` (or `↓` / `↑`) move the selection, `f` / `b` page, and
-`g` / `G` jump to the first and last row.
+`home` / `end` jump to the first and last row. `h` / `l` and `←` / `→` move between
+panes rather than between tabs. Where a tab binds one of those letters to its own
+verb — on the Nodes list `d` declines a pending pairing request and `f` finds a
+machine by address, and on a node's detail screen `d` deletes a model in the
+Models pane while `u` uninstalls an engine in the Engines pane — that verb wins
+where it applies, and the paging key works elsewhere. The
+Service tab's settings list is not a table; it moves with `j` / `k` or the
+arrows. On the Nodes list `l` leaves the cluster rather than moving right, so
+it is armed and asks you to confirm.
While you are typing into a field, such as a PIN, an address, a port, or a model
name, every key goes to that field. Press `enter` to submit or `esc` to cancel.
-Tab switching and `q` do not work until you do.
+Tab switching and `q` do not work until you do. The footer shows only those two
+keys while a field is open, so it always reflects what actually works.
+
+Anything that destroys something asks first. Removing a machine from the
+cluster, leaving the cluster, deleting a model, uninstalling an engine, and
+resetting all data each arm on the first press and act only when you then press
+`y`; any other key cancels. The prompt names what will happen and stays on
+screen until you answer it.
+
+The terminal needs to be at least 40 by 12. Below that PAIR says so and waits
+for you to resize rather than drawing something unreadable.
## Tabs
| # | Tab | What It Shows |
| --- | --- | --- |
-| 1 | **Overview** | Service uptime and version, and an `ok` / `DOWN` table for each worker |
-| 2 | **Errors** | Active service errors by severity, age, node, and message |
-| 3 | **Nodes** | Nodes discovered on the network, with `Connected` or `In cluster` status |
-| 4 | **Proxies** | Both compatible proxies: listening port, discovered upstreams, and which node is selected |
-| 5 | **Workloads** | Live inference workloads: ID, model, engine, state, and age |
-| 6 | **Engines** | Local engines: installed, running, healthy, and port |
-| 7 | **Cluster** | This node's identity, cluster membership, and pairing |
-| 8 | **Manual** | Nodes you added by address, with reachability |
-| 9 | **Settings** | Node settings: force ports, cluster auto-sync, and cluster ID and name |
-| 10 | **Logs** | Service log output, with live log-level control |
+| 1 | **Nodes** | Every machine PAIR knows about, whether reachable, and its cluster standing. `enter` opens one machine's engines, models, and hardware |
+| 2 | **Jobs** | The endpoints your clients connect to, and inference work as it runs. `t` sends a minute of test traffic |
+| 3 | **Service** | Uptime and version, an `ok` / `DOWN` / `?` row per worker, settings, and a data reset |
+| 4 | **Errors** | Active service errors by severity, age, node, and message |
+| 5 | **Logs** | Service log output, with a filter and save-to-file |
+
+The **Errors** tab shows its own count in the tab bar — `Errors (2)` — so you can
+see there is something to look at from any other tab.
## Pair This Machine with Another
Pairing is the same six-digit PIN exchange the desktop application uses.
-**To invite a machine you can already see**, from the **Nodes** tab (3):
+You do not create a cluster first. Inviting your first machine forms one
+automatically, and if that invite fails the empty cluster is discarded.
+
+Everything below is on the **Nodes** tab (1).
+
+**To pair with a machine you can already see:**
1. Select it with `j` / `k`.
-2. Press `i`. The status line shows the PIN.
+2. Press `p`. The status line shows the PIN.
3. Read that PIN to whoever is at the other machine.
This is the easier path, because there is no address to type. Prefer it whenever
PAIR has already discovered the machine you want.
-**To invite a machine by address**, from the **Cluster** tab (7):
+The PIN stays on screen until the pairing is answered, then tells you the outcome
+— joined, declined, or expired. A machine that is already a member, or that
+belongs to another cluster, cannot be invited: PAIR says so instead of sending an
+invite that would be refused.
-1. Press `i`.
-2. Type the other machine's host, or `host:port` if it is not on the default
- pairing port.
-3. Press `enter`. The status line shows the PIN.
-4. Read that PIN to whoever is at the other machine.
+**To pair with a machine by address**, press `n`, type the other machine's
+host — or `host:port` if it is not on the default pairing port — and press
+`enter`. Use this when discovery has not found the machine, for example on a
+network that filters multicast.
-Use this when discovery has not found the machine, for example on a network that
-filters multicast.
+**To accept a pairing request from another machine:**
-**To accept an invitation from another machine**, from the **Cluster** tab:
-
-1. Wait for the status line to read `invite received from `.
+1. Wait for the prompt reading `pairing request from `.
2. Press `a`.
-3. Type the PIN displayed on the inviting machine and press `enter`.
+3. Type the PIN displayed on the other machine and press `enter`.
-Press `d` instead to decline. After pairing, the peer appears under **Members**.
+Press `d` instead to decline. After pairing, the peer's `CLUSTER` column reads
+`Member`. If the PIN was wrong, the status line says so and you will need a
+fresh invite — a PIN can only be used once.

-| Key | Action | Tab |
-| --- | --- | --- |
-| `i` | Invite the selected discovered node | Nodes |
-| `i` | Invite by address | Cluster |
-| `a` | Accept an inbound invitation | Cluster |
-| `d` | Decline an inbound invitation | Cluster |
-| `r` | Remove the selected member | Cluster |
-| `L` | Leave the cluster (capital L) | Cluster |
+| Key | Action |
+| --- | --- |
+| `p` | Pair with the selected machine |
+| `n` | Pair with a machine by address |
+| `a` | Accept an inbound pairing request |
+| `d` | Decline an inbound pairing request |
+| `f` | Find a machine by address, without pairing with it |
+| `c` | Cancel the pairing request you sent |
+| `r` | Remove the selected machine from the cluster (asks you to confirm) |
+| `l` | Leave the cluster (asks you to confirm) |
+| `/` | Filter the list by name or address; `esc` clears it |
+
+Cancel an invite with `c` if you read the PIN to the wrong person, or simply
+change your mind. That invalidates the PIN straight away, so it can no longer be
+used to join, and tells the other machine to drop its prompt. An invite you
+leave alone expires on its own, but until it does it stays answerable.
Only pair when you trust both machines and the network. The PIN is a short-lived
bootstrap code, not a durable credential. Refer to the
[security policy](../SECURITY.md).
-## Prepare an Engine and a Model
+## Look at One Machine
-From the **Engines** tab (6), select an engine with `j` / `k`, then:
+Press `enter` on any row of the **Nodes** tab. This works for your own machine
+and for any cluster peer.
+
+The screen opens on that machine's GPU, CPU, and memory, then two panes you move
+between with `h` / `l`:
+
+**Engines** — select one with `j` / `k`:
| Key | Action |
| --- | --- |
| `i` | Install |
| `s` | Start |
| `x` | Stop |
-| `r` | Restart |
-| `u` | Uninstall |
-| `p` | Download a model |
+| `r` | Restart (this machine only) |
+| `u` | Uninstall (this machine only; asks you to confirm) |
+| `e` | Change the engine port |
+| `p` | Change the proxy port |
+| `a` | Edit the startup arguments |
+
+Each engine shows two ports, because they are easy to confuse:
-Pressing `p` opens a prompt. Type the model name, for example `qwen4:12b`, and
-press `enter`. Progress appears on the status line.
+- **ENGINE PORT** is where the engine itself listens. PAIR talks to it there.
+- **PROXY PORT** is where *your* clients connect, served by the proxy that
+ fronts that engine. This is the port you put in a tool's configuration.
+
+Both are changed here, on the engine they belong to.
+
+A port is a request rather than a promise. If a running engine already holds
+the port you asked for, PAIR binds elsewhere and tells you which port it ended
+up on — so a change you did not get is never reported as one you did.
+
+### Startup Arguments
+
+`a` opens the arguments and environment variables the engine is started with,
+as one line:
+
+```text
+OLLAMA_KEEP_ALIVE=5m OLLAMA_ORIGINS="https://example.com" --flag "two words"
+```
+
+Environment assignments come first, then arguments. Quoting works the way it
+does in a shell for single and double quotes, but nothing is expanded: `$HOME`,
+`~` and `%USERPROFILE%` are passed through as text, and pipes, redirects and
+command substitution are rejected rather than interpreted. The engine's own
+executable and its start subcommand are not editable.
+
+Press `enter` to check and save. PAIR validates before it saves anything, so a
+mistake comes back as a message rather than an engine that will not start. If
+the change requires restarting the engine, it says so and waits for `y`.
+
+The port fields and the arguments are saved together, so a port written into
+the arguments and the port in the field cannot drift apart. Editing the field
+rewrites the argument; editing the argument moves the field. If the two
+disagree in a way PAIR cannot settle, it names both numbers and asks you to
+make them agree.
+
+This works on a peer as well as on this machine. Whether a particular engine
+accepts the change is the engine's own answer: one PAIR did not start — one
+that was already running when PAIR found it — reports that it cannot be
+configured, and says why.
+
+**Models** — the models each engine holds, and which are loaded into memory:
+
+| Key | Action |
+| --- | --- |
+| `p` | Browse models to download |
+| `n` | Download by name |
+| `enter` | Load into memory |
+| `e` | Eject from memory |
+| `d` | Delete (asks you to confirm) |
+
+Pressing `p` opens the catalog of models this engine can download. Press `/` to
+filter — by name, family, or parameter size, so both `llama` and `8b` narrow the
+list — `o` to change the sort between popularity, name, and size, `enter` to
+download the highlighted model, and `esc` to back out. The footer shows how many
+models match and when the catalog was compiled.
+
+Press `n` instead if you already know exactly what you want and would rather type
+it. Either way, downloading needs a running engine, and progress appears on the
+status line.
+
+Restart and uninstall need PAIR to own the engine process, so they only apply
+to the machine you are sitting on. They are hidden on a peer rather than
+offered and then refused.
+
+Press `esc` to go back to the list. Switching to another tab also leaves it, so
+returning to **Nodes** always lands on the list rather than back inside the
+machine you were last looking at.
A node can serve a request only when it is online, a compatible engine is
running, and the requested model is present on that node. To route across several
-machines, download the same model on each.
+machines, download the same model on each — the Nodes tab's `MODELS` column, and
+each machine's model list, are how you check.
## Check Routing and Health
-The **Workloads** tab (5) lists inference work as it runs, with its model,
-engine, and state.
+The **Jobs** tab (2) leads with the endpoints your clients connect to, then lists
+inference work as it runs. Press `a` to include finished jobs.
+
+`FROM` is the machine the request arrived at and `RAN ON` is the machine that
+served it. They differ whenever work is routed to a peer, which is how you can
+see routing actually happening. An active job that has not been placed yet reads
+`choosing`.
+
+
+
+Routing is not something you set. PAIR chooses a node per request from pending
+work and GPU pressure, so the proxies always run in automatic mode.
+
+### Generate Some Traffic
+
+If you have no client sending work yet, press `t` on the **Jobs** tab to run the
+same sixty-second test the desktop application's **Test** button runs. It sends
+synthetic requests to your local endpoints, and the jobs appear in the table
+below as they are placed — including on peers, if the cluster has any.
-
+A progress line shows how many requests have gone out and how long is left.
+Press `t` again to stop early; requests already sent still finish. Nothing is
+sent after sixty seconds.
-The **Proxies** tab (4) shows each proxy's listening port and whether it is
-routing automatically (`selected=auto`) or pinned to one node. Press `g` to
-switch between the two engines, `enter` to pin the highlighted upstream, and `a`
-to return to automatic routing. Leave it on automatic unless you are
-deliberately testing one node.
+It needs at least one engine running with a model installed. If nothing is
+available, the tab says so rather than starting.
-The **Overview** tab (1) reports whether each worker is up. Worker status is
-best-effort. `DOWN` means a worker reported a crash.
+The test is local to the machine you run it on. Running it here does not start
+one anywhere else, and a peer running one does not show up as progress here —
+only as jobs.
+
+The **Service** tab (3) reports whether each worker is up. Worker status is
+best-effort: `DOWN` means a worker reported a crash. The error reporting worker
+is the one that collects those reports, so it cannot report its own — its row
+says so. If the service itself is not answering, every row reads `?` rather than
+`ok`: a worker is only known good because the crash stream said nothing about
+it, and that stream comes through the service.
+
+## Update Notices
+
+When a newer release of PAIR has been published, a line appears under the tab
+bar naming the new version, the one you are running, and where to get it. It
+checks shortly after startup and every six hours, the same cadence the desktop
+application uses.
+
+The notice is on every tab, not just **Service**, and stays until you press
+`ctrl+x` to dismiss it. Dismissing it clears it everywhere. A release newer than
+the one you dismissed brings it back, since that is not the version you
+acknowledged. On a narrow terminal the line shortens rather than pushing the
+dismiss key off the screen.
+
+It only tells you. It does not download or install anything, because the
+terminal interface is not a single program — it runs a broker and eleven
+workers from the directory beside it, and swapping some of them for a newer set
+while they are serving inference is how you get a client talking to workers that
+no longer understand it. Install the release the way you installed this one.
+
+If you installed PAIR as the desktop application, you do not need to: the
+terminal interface ships inside the application and its updater replaces this
+binary along with everything else, so `nvpair` picks up the new version the next
+time you run it.
+
+Nothing is reported when the check cannot reach the network — an unreachable
+feed is not news. To stop it checking at all, set `NVPAIR_NO_UPDATE_CHECK=1`
+before starting `nvpair`. Builds run from source never check.
## Look at Errors and Logs
-On **Errors** (2), press `c` to clear the selected entry.
+On **Errors** (4), press `c` to clear the selected entry. The tab label carries
+the count, so you do not have to go looking. The line under the table describes
+whichever entry you have selected — which engine and operation it came from, the
+model if there was one, and what the service suggests doing about it.
-On **Logs** (10), scroll with `j` / `k` and the page keys. Set the log level for
-the whole service fleet with `d` (debug), `i` (info), `w` (warn), or `e` (error).
-This is the first place to look when something has not started.
+Errors are shared across the cluster, but clearing one only works on the machine
+that reported it — the `NODE` column tells you which that is. Select an entry
+from another machine and `c` is withdrawn from the footer, with a line naming the
+node to clear it from. Clearing there removes it everywhere.
+
+On **Logs** (5), scroll with `j` / `k` and the page keys. Press `/` to filter to
+lines containing a string, `c` to clear the filter, and `t` to toggle tailing
+the newest line — scrolling up releases follow on its own. This is the first place
+to look when something has not started.
+
+Press `s` to write the log buffer to a timestamped file in your home directory.
+The whole buffer is saved, not just what the filter shows.
+
+Set how much detail the services log on the **Service** tab, not here.
## Change Settings
-On **Settings** (9), move with `j` / `k` and press `enter`. Booleans toggle
-immediately. Text fields open for editing, with `enter` to save and `esc` to
-cancel.
+On **Service** (3), move with `j` / `k` and press `enter`. The cluster name opens
+as a text field, with `enter` to save and `esc` to cancel. Ports are not changed
+here — they belong to a machine, so they live on its detail screen.
-Change proxy ports on the **Proxies** tab with `p`, not here. Refer to
+**Cluster name** is a label for your own benefit and is stored on the machine you
+set it on. It is not shared with the rest of the cluster, so setting it here does
+not rename anything on your peers — set it on each machine you want it to show
+on. Nothing operational depends on it; the cluster's real identity is an id you
+never have to type.
+
+**Service log level** opens a picker showing all four levels — debug, info,
+warn, and error — with the one currently in force highlighted. Move with `←` /
+`→` (or `h` / `l`), press `enter` to apply, and `esc` to leave it alone. The
+level applies to every service, not just one.
+
+Ports and startup arguments are not changed here. They live on the engine they
+belong to, on that machine's node detail screen — press `enter` on a machine on
+the **Nodes** tab. Refer to
[Ports](getting-started.mdx#7-connecting-your-agents-and-port-information)
for how PAIR arranges ports.
+**Reset all data and quit** deletes this machine's settings, cluster identity,
+and pairing, then exits. It asks for confirmation first, and cannot be undone.
+The services are stopped before anything is deleted, so nothing rewrites the
+files on the way out. You will need to pair again afterwards.
+
## What the Terminal Interface Cannot Do
It is an operations tool, not a full replacement for the desktop application:
-- It cannot list or delete models. You can download one, but the interface shows
- no model inventory.
-- It cannot change an engine's port. The port column is read-only. Use the
- desktop application to change it.
-- It cannot update an engine or control engines on other cluster nodes.
-- It does not show which node served a particular workload.
-- It has no built-in way to send an inference request. Use `curl` or another
- client against the proxy port, as in
- [Getting Started](getting-started.mdx#6-run-your-first-inference).
+- It cannot update an engine to a newer version. Use the desktop application.
+- It has no built-in way to send an inference request of your own. Use `curl`
+ or another client against the endpoint port, as in
+ [Getting Started](getting-started.mdx#6-run-your-first-inference). The
+ synthetic test traffic on the **Jobs** tab is not a substitute: you do not
+ choose the prompt and you never see the reply.
+- It cannot install PAIR updates. It tells you when a release is available;
+ installing one is the same job it was before.
+- It does not survive losing your connection. Use a terminal multiplexer, as
+ above.
## See Also
diff --git a/scripts/inference-dispatcher.ps1 b/scripts/inference-dispatcher.ps1
index 4f693472..74b56907 100644
--- a/scripts/inference-dispatcher.ps1
+++ b/scripts/inference-dispatcher.ps1
@@ -17,9 +17,11 @@
# Requires go on PATH — the same toolchain services\build.bat already needs. No
# Node required, so this runs on a machine that only builds the backend.
#
-# The client itself lives in scripts\inference-dispatcher (Go). The app ships a
-# prebuilt copy for the Inference Demo (desktop\scripts\build-inference-dispatcher.ts);
-# this wrapper exists so the same tool can be driven by hand without a build step.
+# The client itself lives in scripts\inference-dispatcher (Go). Both front ends
+# ship a prebuilt copy for the Inference Demo, built into cli-bin by
+# desktop\scripts\build-modular-binaries.ts and into build\bin by
+# services\build.sh; this wrapper exists so the same tool can be driven by hand
+# without a build step.
#
# Keep in sync with:
# - scripts/inference-dispatcher.sh (Unix twin — update both in the same change)
diff --git a/scripts/inference-dispatcher.sh b/scripts/inference-dispatcher.sh
index 51a5122c..35c8bdef 100644
--- a/scripts/inference-dispatcher.sh
+++ b/scripts/inference-dispatcher.sh
@@ -18,9 +18,11 @@
# Requires go on PATH — the same toolchain services/build.sh already needs. No
# Node required, so this runs on a machine that only builds the backend.
#
-# The client itself lives in scripts/inference-dispatcher (Go). The app ships a
-# prebuilt copy for the Inference Demo (desktop/scripts/build-inference-dispatcher.ts);
-# this wrapper exists so the same tool can be driven by hand without a build step.
+# The client itself lives in scripts/inference-dispatcher (Go). Both front ends
+# ship a prebuilt copy for the Inference Demo, built into cli-bin by
+# desktop/scripts/build-modular-binaries.ts and into build/bin by
+# services/build.sh; this wrapper exists so the same tool can be driven by hand
+# without a build step.
#
# Keep in sync with:
# - scripts/inference-dispatcher.ps1 (Windows twin — update both in the same change)
diff --git a/services/build.bat b/services/build.bat
index 70ae5950..7ec5a655 100644
--- a/services/build.bat
+++ b/services/build.bat
@@ -51,12 +51,29 @@ for /f "delims=" %%V in ('jq -r --arg k "nvpair-cluster-manager" ".components[$
for /f "delims=" %%V in ('jq -r --arg k "nvpair-job-scheduler" ".components[$k]" "%VERSIONS_FILE%"') do set "V_SCHED=%%V"
for /f "delims=" %%V in ('jq -r --arg k "nvpair-tui" ".components[$k]" "%VERSIONS_FILE%"') do set "V_TUI=%%V"
+REM The release version, which lives in desktop/package.json rather than here.
+REM nvpair-tui's update notice compares this against the published release tag,
+REM so it is the only one of PAIR's three numbers that can answer "is there a
+REM newer PAIR than mine". The services suite version is currently the larger
+REM number, so stamping that instead would be silently wrong: every check would
+REM conclude this build is ahead of the feed and say nothing.
+set "PACKAGE_JSON=%ROOT%..\desktop\package.json"
+for /f "delims=" %%V in ('jq -r ".version" "%PACKAGE_JSON%"') do set "V_RELEASE=%%V"
+
if "%V_SERVICES%"=="" (
echo ERROR: failed to parse versions.json
endlocal
exit /b 1
)
+if "%V_RELEASE%"=="" (
+ echo ERROR: failed to read .version from %PACKAGE_JSON%
+ echo nvpair-tui's update notice is stamped from the release version.
+ endlocal
+ exit /b 1
+)
+
+echo release = %V_RELEASE%
echo services = %V_SERVICES%
echo nvpair-proxy = %V_PROXY%
echo nvpair-node-info = %V_NINFO%
@@ -77,64 +94,85 @@ echo Building all components
echo ========================================
echo.
-echo [1/12] Building nvpair-proxy (v%V_PROXY%)...
+echo [1/13] Building nvpair-proxy (v%V_PROXY%)...
cd /d "%ROOT%nvpair-proxy"
go build -ldflags "-X main.Version=%V_PROXY%" -o nvpair-proxy.exe . || goto :fail
echo OK
-echo [2/12] Building nvpair-node-info (v%V_NINFO%)...
+echo [2/13] Building nvpair-node-info (v%V_NINFO%)...
cd /d "%ROOT%nvpair-node-info"
go build -ldflags "-X main.Version=%V_NINFO%" -o nvpair-node-info.exe . || goto :fail
echo OK
-echo [3/12] Building nvpair-node-scanner (v%V_NSCAN%)...
+echo [3/13] Building nvpair-node-scanner (v%V_NSCAN%)...
cd /d "%ROOT%nvpair-node-scanner"
go build -ldflags "-X main.Version=%V_NSCAN%" -o nvpair-node-scanner.exe . || goto :fail
echo OK
-echo [4/12] Building nvpair-manual-nodes (v%V_MNODES%)...
+echo [4/13] Building nvpair-manual-nodes (v%V_MNODES%)...
cd /d "%ROOT%nvpair-manual-nodes"
go build -ldflags "-X main.Version=%V_MNODES%" -o nvpair-manual-nodes.exe . || goto :fail
echo OK
-echo [5/12] Building nvpair-workload-manager (v%V_WLMGR%)...
+echo [5/13] Building nvpair-workload-manager (v%V_WLMGR%)...
cd /d "%ROOT%nvpair-workload-manager"
go build -ldflags "-X main.Version=%V_WLMGR%" -o nvpair-workload-manager.exe . || goto :fail
echo OK
-echo [6/12] Building nvpair-errors (v%V_ERRORS%)...
+echo [6/13] Building nvpair-errors (v%V_ERRORS%)...
cd /d "%ROOT%nvpair-errors"
go build -ldflags "-X main.Version=%V_ERRORS%" -o nvpair-errors.exe . || goto :fail
echo OK
-echo [7/12] Building nvpair-engine-manager (v%V_ENGMGR%)...
+echo [7/13] Building nvpair-engine-manager (v%V_ENGMGR%)...
cd /d "%ROOT%nvpair-engine-manager"
go build -ldflags "-X main.Version=%V_ENGMGR%" -o nvpair-engine-manager.exe . || goto :fail
echo OK
-echo [8/12] Building nvpair-node-settings (v%V_NSETTINGS%)...
+echo [8/13] Building nvpair-node-settings (v%V_NSETTINGS%)...
cd /d "%ROOT%nvpair-node-settings"
go build -ldflags "-X main.Version=%V_NSETTINGS%" -o nvpair-node-settings.exe . || goto :fail
echo OK
-echo [9/12] Building nvpair-ui-broker (v%V_BROKER%)...
+echo [9/13] Building nvpair-ui-broker (v%V_BROKER%)...
cd /d "%ROOT%nvpair-ui-broker"
go build -ldflags "-X main.Version=%V_BROKER%" -o nvpair-ui-broker.exe . || goto :fail
echo OK
-echo [10/12] Building nvpair-cluster-manager (v%V_CLUMGR%)...
+echo [10/13] Building nvpair-cluster-manager (v%V_CLUMGR%)...
cd /d "%ROOT%nvpair-cluster-manager"
go build -ldflags "-X main.Version=%V_CLUMGR%" -o nvpair-cluster-manager.exe . || goto :fail
echo OK
-echo [11/12] Building nvpair-job-scheduler (v%V_SCHED%)...
+echo [11/13] Building nvpair-job-scheduler (v%V_SCHED%)...
cd /d "%ROOT%nvpair-job-scheduler"
go build -ldflags "-X main.Version=%V_SCHED%" -o nvpair-job-scheduler.exe . || goto :fail
echo OK
-echo [12/12] Building nvpair-tui (v%V_TUI%)...
+REM nvpair-tui also carries the release version: that is what the update notice
+REM compares against the published tag, and its own component version means
+REM nothing to that comparison. A -X on a symbol path that does not exist fails
+REM silently, so verify the stamp rather than assuming it.
+echo [12/13] Building nvpair-tui (v%V_TUI%)...
cd /d "%ROOT%nvpair-tui"
-go build -ldflags "-X main.Version=%V_TUI%" -o nvpair-tui.exe . || goto :fail
+go build -ldflags "-X main.Version=%V_TUI% -X nvpair-tui/ui.ReleaseVersion=%V_RELEASE%" -o nvpair-tui.exe . || goto :fail
+echo OK
+
+REM inference-dispatcher is built here but is not a worker: it speaks no
+REM JSON-RPC, nothing supervises it, and it is deliberately absent from
+REM versions.json. It is an ordinary HTTP client the Inference Demo spawns once
+REM per request, built here because the terminal interface runs the same demo
+REM and ships from this bundle. Its module lives outside this tree, at the
+REM monorepo root, for the same reason: it is not a service.
+echo [13/13] Building inference-dispatcher (v%V_SERVICES%)...
+if not exist "%ROOT%..\scripts\inference-dispatcher" (
+ echo ERROR: %ROOT%..\scripts\inference-dispatcher not found. 1>&2
+ echo The Inference Demo client lives at scripts\inference-dispatcher 1>&2
+ echo in the monorepo root; this bundle cannot be built without it. 1>&2
+ goto :fail
+)
+cd /d "%ROOT%..\scripts\inference-dispatcher"
+go build -ldflags "-X main.Version=%V_SERVICES%" -o inference-dispatcher.exe . || goto :fail
echo OK
echo.
@@ -160,6 +198,9 @@ copy /y "%ROOT%nvpair-ui-broker\nvpair-ui-broker.exe" "%BIN_OUT%\nvpair-ui-broke
copy /y "%ROOT%nvpair-cluster-manager\nvpair-cluster-manager.exe" "%BIN_OUT%\nvpair-cluster-manager.exe" >nul || goto :fail
copy /y "%ROOT%nvpair-job-scheduler\nvpair-job-scheduler.exe" "%BIN_OUT%\nvpair-job-scheduler.exe" >nul || goto :fail
copy /y "%ROOT%nvpair-tui\nvpair-tui.exe" "%BIN_OUT%\nvpair-tui.exe" >nul || goto :fail
+REM Beside the binaries it is not one of: nvpair-tui resolves the broker next to
+REM its own executable, and the demo finds this the same way.
+copy /y "%ROOT%..\scripts\inference-dispatcher\inference-dispatcher.exe" "%BIN_OUT%\inference-dispatcher.exe" >nul || goto :fail
echo.
echo ========================================
diff --git a/services/build.sh b/services/build.sh
index 316bb2cd..1ca81d3e 100755
--- a/services/build.sh
+++ b/services/build.sh
@@ -68,11 +68,28 @@ V_CLUMGR=$( jq -r --arg k 'nvpair-cluster-manager' '.components[$k]' "$VERSIONS_
V_SCHED=$( jq -r --arg k 'nvpair-job-scheduler' '.components[$k]' "$VERSIONS_FILE")
V_TUI=$( jq -r --arg k 'nvpair-tui' '.components[$k]' "$VERSIONS_FILE")
+# The release version, which lives in desktop/package.json rather than here.
+#
+# nvpair-tui's update notice compares this against the published release tag, so
+# it is the only one of PAIR's three numbers that can answer "is there a newer
+# PAIR than mine". The services suite version is currently the larger number, so
+# stamping that instead would not merely be wrong, it would be silently wrong:
+# every check would conclude this build is ahead of the feed and say nothing.
+PACKAGE_JSON="$ROOT/../desktop/package.json"
+V_RELEASE=$(jq -r '.version' "$PACKAGE_JSON" 2>/dev/null)
+
if [[ -z "$V_SERVICES" || "$V_SERVICES" == "null" ]]; then
echo "ERROR: failed to parse versions.json" >&2
exit 1
fi
+if [[ -z "$V_RELEASE" || "$V_RELEASE" == "null" ]]; then
+ echo "ERROR: failed to read .version from $PACKAGE_JSON" >&2
+ echo " nvpair-tui's update notice is stamped from the release version." >&2
+ exit 1
+fi
+
+printf ' release = %s\n' "$V_RELEASE"
printf ' services = %s\n' "$V_SERVICES"
printf ' nvpair-proxy = %s\n' "$V_PROXY"
printf ' nvpair-node-info = %s\n' "$V_NINFO"
@@ -95,7 +112,7 @@ echo
build_subbinary() {
local idx="$1" name="$2" version="$3"
- echo "[$idx/12] Building $name (v$version)..."
+ echo "[$idx/13] Building $name (v$version)..."
(cd "$ROOT/$name" && go build -ldflags "-X main.Version=$version" -o "$name" .)
echo " OK"
}
@@ -110,7 +127,37 @@ build_subbinary 8 nvpair-node-settings "$V_NSETTINGS"
build_subbinary 9 nvpair-ui-broker "$V_BROKER"
build_subbinary 10 nvpair-cluster-manager "$V_CLUMGR"
build_subbinary 11 nvpair-job-scheduler "$V_SCHED"
-build_subbinary 12 nvpair-tui "$V_TUI"
+# nvpair-tui also carries the release version: that is what the update notice
+# compares against the published tag, and its own component version means
+# nothing to that comparison. A -X on a symbol path that does not exist fails
+# silently, so verify the stamp rather than assuming it.
+echo "[12/13] Building nvpair-tui (v$V_TUI)..."
+(cd "$ROOT/nvpair-tui" && go build \
+ -ldflags "-X main.Version=$V_TUI -X nvpair-tui/ui.ReleaseVersion=$V_RELEASE" \
+ -o nvpair-tui .)
+echo " OK"
+
+# inference-dispatcher is built here but is not a worker: it speaks no JSON-RPC,
+# nothing supervises it, and it is deliberately absent from versions.json. It is
+# an ordinary HTTP client that the Inference Demo spawns once per request, and it
+# is built here because the terminal interface runs the same demo and ships from
+# this bundle — a demo the desktop app can run and the terminal cannot is not a
+# useful distinction to an operator.
+#
+# Its module lives outside this tree, at the monorepo root, for the same reason:
+# it is not a service. The path is relative to services/, so a checkout without
+# it fails loudly here rather than producing a bundle that is quietly missing a
+# feature.
+DISPATCHER_SRC="$ROOT/../scripts/inference-dispatcher"
+echo "[13/13] Building inference-dispatcher (v$V_SERVICES)..."
+if [ ! -d "$DISPATCHER_SRC" ]; then
+ echo "ERROR: $DISPATCHER_SRC not found." >&2
+ echo " The Inference Demo client lives at scripts/inference-dispatcher" >&2
+ echo " in the monorepo root; this bundle cannot be built without it." >&2
+ exit 1
+fi
+(cd "$DISPATCHER_SRC" && go build -ldflags "-X main.Version=$V_SERVICES" -o inference-dispatcher .)
+echo " OK"
BIN_OUT="$ROOT/build/bin"
@@ -139,6 +186,9 @@ cp "$ROOT/nvpair-ui-broker/nvpair-ui-broker" "$BIN_OUT/nvpair-ui-broker"
cp "$ROOT/nvpair-cluster-manager/nvpair-cluster-manager" "$BIN_OUT/nvpair-cluster-manager"
cp "$ROOT/nvpair-job-scheduler/nvpair-job-scheduler" "$BIN_OUT/nvpair-job-scheduler"
cp "$ROOT/nvpair-tui/nvpair-tui" "$BIN_OUT/nvpair-tui"
+# Beside the binaries it is not one of: nvpair-tui resolves the broker next to
+# its own executable, and the demo finds this the same way.
+cp "$DISPATCHER_SRC/inference-dispatcher" "$BIN_OUT/inference-dispatcher"
echo
echo "========================================"
@@ -157,4 +207,5 @@ printf ' UI Broker: %s\n' "$BIN_OUT/nvpair-ui-broker"
printf ' Cluster Mgr: %s\n' "$BIN_OUT/nvpair-cluster-manager"
printf ' Job Scheduler:%s\n' " $BIN_OUT/nvpair-job-scheduler"
printf ' TUI: %s\n' "$BIN_OUT/nvpair-tui"
+printf ' Demo client: %s\n' "$BIN_OUT/inference-dispatcher"
echo
diff --git a/services/nvpair-engine-manager/README.md b/services/nvpair-engine-manager/README.md
index b401c883..3483e38e 100644
--- a/services/nvpair-engine-manager/README.md
+++ b/services/nvpair-engine-manager/README.md
@@ -42,6 +42,7 @@ Requests (caller → service):
| `engine:action` | `{ engine, action, params }` | the engine's raw response. `action:"pull_model"` is streamed: it emits live `engine:pull-progress` notifications and returns the pull's terminal result (see below). An action whose manifest declares `restart_after` (LM Studio's `delete_model`) restarts a running engine before replying, so the response also means the engine is back and healthy |
| `engine:logs` | `{ engine }` | `{ lines: [LogLine] }` |
| `engine:errors` | — | `{ errors: [ServiceError] }` |
+| `engine:catalog` | `{ engine, platform? }` | `{ models: [{ id, name, author, url, size?, downloads, likes, updatedAt, tags, family?, parameterSize?, appleOnly? }], source, platform?, fetchedAt? }` — the models an engine can **download**, as opposed to `engine:models`, which reports what is already installed. Each engine has one curated source. Ollama's has no public API, so it is a locked list compiled into this binary from `catalog/ollama-models.json`, regenerated on demand by a developer (`npm run scrape:ollama-models` in `desktop/`) who reviews the diff; serving it needs no network. LM Studio's catalog *is* the `lmstudio-community` Hugging Face org, whose repo ids are exactly what `lms get` accepts, so it is fetched live and cached for six hours, with concurrent callers coalesced onto one request, a failed refresh keeping the previous list, and a backoff before the next attempt. `platform` is the GOOS the models will be **installed on**, which is not always this host — a client driving a peer should name that peer; it defaults to this host. MLX quantizations only install on Apple Silicon, so they are marked `appleOnly` and filtered out for a non-`darwin` target; the reply echoes the `platform` it filtered for. `name` is pull-ready: it can be handed to `pull_model` verbatim. An engine with no curated source is an error, not an empty list. The Ollama reply is a single multi-megabyte frame, so every hop on its path must allow `jsonrpc.WorkerFrameBytes`. |
| `engine:models` | — | `{ models: [string], modelsByEngine: { : [string] }, loadedByEngine: { : [string] } }` — the flat de-duplicated union of every running engine's models, the per-engine breakdown keyed by engine name, and the per-engine set of models currently **loaded in memory** (all normalized from each engine's `list_models` / `loaded_models` action `result` spec). `modelsByEngine` carries a key for every running engine whose inventory was successfully queried, including an empty list = "running, no models available"; a missing key means not running / not queryable / invalid response. `loadedByEngine` uses the same known-empty distinction for residency and also omits engines with no loaded endpoint. The `/v1/models` HTTP surface returns the same shape. |
| `engine:remote-get-installed` | `{ node }` | `{ engines: [EngineStatus] }` fetched from the remote node over `ec` mTLS |
| `engine:remote-install` | `{ node, engine, start? }` | `{ opId, status: EngineStatus }` after the remote install (live progress via `engine:remote-progress`) |
diff --git a/services/nvpair-engine-manager/catalog.go b/services/nvpair-engine-manager/catalog.go
new file mode 100644
index 00000000..3ce25ac8
--- /dev/null
+++ b/services/nvpair-engine-manager/catalog.go
@@ -0,0 +1,498 @@
+// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+package main
+
+import (
+ "context"
+ "embed"
+ "encoding/json"
+ "fmt"
+ "io"
+ "log/slog"
+ "net/http"
+ "net/url"
+ "regexp"
+ "runtime"
+ "sort"
+ "strings"
+ "sync"
+ "time"
+)
+
+// engine:catalog serves the set of models an engine can download, as opposed to
+// engine:models, which reports what is already installed. It is the one model
+// surface with no engine-local source: an engine's own API can list what it
+// holds, but not what its upstream offers.
+//
+// It lives here rather than in a client so both the desktop app and the terminal
+// interface see the same catalogue. Each engine has exactly one curated source,
+// and the two are deliberately different in kind:
+//
+// - Ollama has no public library API, only a client-rendered web page. A live
+// scrape was fragile and broke whenever the markup changed, so the list is a
+// locked file, regenerated on demand by a developer who reviews the diff.
+// It is compiled in, so serving it needs no network and cannot fail.
+// - LM Studio's catalogue *is* a Hugging Face org (`lmstudio-community`), whose
+// repo ids are exactly the strings `lms get` accepts. That has a real API, so
+// it is fetched live and cached.
+
+// catalogSourceKind distinguishes how an engine's catalogue is obtained.
+type catalogSourceKind int
+
+const (
+ // catalogEmbedded is compiled into this binary.
+ catalogEmbedded catalogSourceKind = iota
+ // catalogFetched is retrieved over HTTP and cached.
+ catalogFetched
+)
+
+// CatalogModel is one downloadable model, normalized across sources.
+//
+// Name is pull-ready: passing it straight to the engine's pull_model action
+// works. That is the field's whole purpose, so neither source is allowed to put
+// a display-only string here.
+type CatalogModel struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ Author string `json:"author"`
+ URL string `json:"url"`
+ // Size is the download size in bytes, omitted when the source does not
+ // report one (Hugging Face does not expose it on the listing endpoint).
+ Size uint64 `json:"size,omitempty"`
+ Downloads int `json:"downloads"`
+ Likes int `json:"likes"`
+ UpdatedAt string `json:"updatedAt"`
+ Tags []string `json:"tags"`
+ Family string `json:"family,omitempty"`
+ // ParameterSize is a human label such as "8B", not a number: sources report
+ // it as text and it is display-only.
+ ParameterSize string `json:"parameterSize,omitempty"`
+ // AppleOnly marks a quantization that only installs on Apple Silicon (MLX).
+ // Reported rather than silently dropped so the decision can be made against
+ // the platform the model is destined for, which is not always this host.
+ AppleOnly bool `json:"appleOnly,omitempty"`
+}
+
+// CatalogResult is the engine:catalog reply.
+type CatalogResult struct {
+ Models []CatalogModel `json:"models"`
+ // Source describes where the list came from, for support and diagnostics.
+ Source string `json:"source"`
+ // FetchedAt is when the served list was obtained. For an embedded list this
+ // is when it was scraped, which tells an operator how stale it is.
+ FetchedAt string `json:"fetchedAt,omitempty"`
+ // Platform is the GOOS this list was filtered for. Echoed back so a client
+ // can tell the operator which machine the list actually applies to, rather
+ // than presenting a platform-filtered list as universal.
+ Platform string `json:"platform,omitempty"`
+}
+
+// catalogParams is the engine:catalog request.
+type catalogParams struct {
+ Engine string `json:"engine"`
+ // Platform is the GOOS the models will actually be installed on. It matters
+ // because some quantizations are platform-locked, and the machine asking is
+ // not always the machine downloading — a client driving a peer should say
+ // which peer. Empty means this host, which is the common case and preserves
+ // the behaviour of a caller that does not know.
+ Platform string `json:"platform"`
+}
+
+//go:embed catalog/ollama-models.json
+var catalogFS embed.FS
+
+// ollamaCatalogPath is the embedded locked list.
+const ollamaCatalogPath = "catalog/ollama-models.json"
+
+// The LM Studio catalogue endpoint. Its repo ids are the pull-ready strings.
+const (
+ hfModelsAPI = "https://huggingface.co/api/models"
+ lmStudioAuthor = "lmstudio-community"
+ lmStudioLimit = 500
+ catalogCacheTTL = 6 * time.Hour
+ catalogHTTPTimeout = 20 * time.Second
+ // catalogRetryAfterFailure keeps a failed fetch from turning every later
+ // call into a fresh 20-second attempt. Without it an unreachable upstream
+ // made each caller wait out the whole timeout before being handed the stale
+ // list — warm-up, a desktop modal, and the terminal browser stalling in
+ // turn — because only a success stamped the cache.
+ catalogRetryAfterFailure = 2 * time.Minute
+ // maxCatalogBody bounds the listing response. The real payload is a few
+ // hundred KiB; this refuses to buffer an arbitrarily large or compressed
+ // reply into a supervised worker, and matches the ceiling this package
+ // already applies to engine action reads.
+ maxCatalogBody = 8 << 20
+)
+
+// ollamaLibraryFile is the committed list's on-disk shape.
+type ollamaLibraryFile struct {
+ ScrapedAt string `json:"scrapedAt"`
+ Source string `json:"source"`
+ Count int `json:"count"`
+ Models []ollamaLibraryRow `json:"models"`
+}
+
+// ollamaLibraryRow is one committed entry, shaped like an Ollama tags response.
+type ollamaLibraryRow struct {
+ Name string `json:"name"`
+ Model string `json:"model"`
+ ModifiedAt string `json:"modified_at"`
+ Size uint64 `json:"size"`
+ Digest string `json:"digest"`
+ Details struct {
+ Family string `json:"family"`
+ ParameterSize string `json:"parameter_size"`
+ Quantization string `json:"quantization_level"`
+ } `json:"details"`
+}
+
+// hfModelRow is the subset of a Hugging Face listing entry that matters here.
+type hfModelRow struct {
+ ID string `json:"id"`
+ ModelID string `json:"modelId"`
+ Downloads int `json:"downloads"`
+ Likes int `json:"likes"`
+ LastModified string `json:"lastModified"`
+ CreatedAt string `json:"createdAt"`
+ Tags []string `json:"tags"`
+}
+
+// catalogService answers engine:catalog. The embedded list is parsed once; the
+// fetched list is cached with an in-flight guard so concurrent callers share one
+// request rather than each starting their own.
+type catalogService struct {
+ client *http.Client
+
+ ollamaOnce sync.Once
+ ollama []CatalogModel
+ ollamaMeta ollamaLibraryFile
+ ollamaErr error
+
+ mu sync.Mutex
+ lmStudio []CatalogModel
+ lmFetched time.Time
+ // lmFailed is when the last fetch failed, so a dead upstream is retried on a
+ // backoff rather than on every call.
+ lmFailed time.Time
+ inflight chan struct{}
+ // baseURL overrides the listing endpoint in tests. Empty means the real one;
+ // the caching and coalescing around the fetch is the part worth testing, and
+ // it cannot be exercised against the live API.
+ baseURL string
+}
+
+// endpoint is the listing URL to fetch.
+func (c *catalogService) endpoint() string {
+ if c.baseURL != "" {
+ return c.baseURL
+ }
+ return hfModelsAPI
+}
+
+func newCatalogService() *catalogService {
+ // The same redirect policy the rest of this package's HTTP uses: a catalogue
+ // endpoint has no reason to redirect, and following one blindly would let an
+ // upstream (or a captive portal) move the request somewhere else.
+ return &catalogService{client: newEngineHTTPClient(catalogHTTPTimeout)}
+}
+
+// Catalog returns the downloadable models for an engine, filtered for the
+// platform they will be installed on.
+func (c *catalogService) Catalog(ctx context.Context, engine, platform string) (CatalogResult, error) {
+ if platform == "" {
+ platform = runtime.GOOS
+ }
+ switch normalizeCatalogEngine(engine) {
+ case "ollama":
+ models, meta, err := c.ollamaCatalog()
+ if err != nil {
+ return CatalogResult{}, err
+ }
+ return CatalogResult{
+ Models: models,
+ Source: meta.Source,
+ Platform: platform,
+ // The committed Ollama list carries no platform-locked entries, so
+ // nothing is dropped and the caller's platform does not change it.
+ FetchedAt: meta.ScrapedAt,
+ }, nil
+ case "lmstudio":
+ models, fetchedAt, err := c.lmStudioCatalog(ctx)
+ if err != nil {
+ return CatalogResult{}, err
+ }
+ return CatalogResult{
+ Models: filterForPlatform(models, platform),
+ Source: hfModelsAPI + "?author=" + lmStudioAuthor,
+ Platform: platform,
+ FetchedAt: fetchedAt.UTC().Format(time.RFC3339),
+ }, nil
+ default:
+ return CatalogResult{}, fmt.Errorf("no model catalog for engine %q", engine)
+ }
+}
+
+// filterForPlatform drops models that cannot install on the target.
+//
+// MLX quantizations are Apple's framework and only run on Apple Silicon; `lms
+// get` refuses them elsewhere. Filtering happens here, against the *target's*
+// platform rather than this host's, so a client driving a peer is not offered
+// models that peer can never install.
+func filterForPlatform(models []CatalogModel, platform string) []CatalogModel {
+ if platform == "darwin" {
+ return models
+ }
+ out := make([]CatalogModel, 0, len(models))
+ for _, m := range models {
+ if m.AppleOnly {
+ continue
+ }
+ out = append(out, m)
+ }
+ return out
+}
+
+// normalizeCatalogEngine tolerates the spellings a client might send.
+func normalizeCatalogEngine(engine string) string {
+ e := strings.ToLower(strings.TrimSpace(engine))
+ switch e {
+ case "lm-studio", "lm studio", "lmstudio":
+ return "lmstudio"
+ default:
+ return e
+ }
+}
+
+// ollamaCatalog parses the embedded list once and serves it thereafter.
+func (c *catalogService) ollamaCatalog() ([]CatalogModel, ollamaLibraryFile, error) {
+ c.ollamaOnce.Do(func() {
+ raw, err := catalogFS.ReadFile(ollamaCatalogPath)
+ if err != nil {
+ c.ollamaErr = fmt.Errorf("read embedded ollama catalog: %w", err)
+ return
+ }
+ var file ollamaLibraryFile
+ if err := json.Unmarshal(raw, &file); err != nil {
+ c.ollamaErr = fmt.Errorf("parse embedded ollama catalog: %w", err)
+ return
+ }
+ c.ollamaMeta = file
+ c.ollama = normalizeOllamaRows(file.Models)
+ slog.Info("ollama model catalog loaded",
+ "models", len(c.ollama), "scrapedAt", file.ScrapedAt)
+ })
+ return c.ollama, c.ollamaMeta, c.ollamaErr
+}
+
+// normalizeOllamaRows converts committed rows into the shared shape, dropping
+// malformed entries and de-duplicating by pull name.
+func normalizeOllamaRows(rows []ollamaLibraryRow) []CatalogModel {
+ seen := make(map[string]int, len(rows))
+ out := make([]CatalogModel, 0, len(rows))
+ for _, r := range rows {
+ name := strings.TrimSpace(r.Name)
+ if name == "" {
+ name = strings.TrimSpace(r.Model)
+ }
+ if name == "" {
+ continue
+ }
+ // The library page is the only URL an Ollama model has; the tag suffix
+ // is not part of the path.
+ base := name
+ if i := strings.Index(base, ":"); i > 0 {
+ base = base[:i]
+ }
+ model := CatalogModel{
+ ID: name,
+ Name: name,
+ Author: "ollama",
+ URL: "https://ollama.com/library/" + base,
+ Size: r.Size,
+ UpdatedAt: r.ModifiedAt,
+ Tags: catalogTags(r.Details.Family, r.Details.Quantization),
+ Family: r.Details.Family,
+ ParameterSize: r.Details.ParameterSize,
+ }
+ if idx, dup := seen[name]; dup {
+ out[idx] = model
+ continue
+ }
+ seen[name] = len(out)
+ out = append(out, model)
+ }
+ return out
+}
+
+// catalogTags builds a small tag set from the fields the committed list carries.
+func catalogTags(values ...string) []string {
+ tags := make([]string, 0, len(values))
+ for _, v := range values {
+ if v = strings.TrimSpace(v); v != "" {
+ tags = append(tags, v)
+ }
+ }
+ return tags
+}
+
+// lmStudioCatalog serves the cached Hugging Face listing, refreshing it when
+// stale. A refresh that fails keeps the previous list: a stale catalogue is far
+// more useful than an empty one.
+func (c *catalogService) lmStudioCatalog(ctx context.Context) ([]CatalogModel, time.Time, error) {
+ c.mu.Lock()
+ // Serve the cache when it is fresh, and also when a recent attempt failed:
+ // re-fetching on every call against a dead upstream just made each caller
+ // wait out the full timeout for the same stale answer.
+ haveList := len(c.lmStudio) > 0
+ fresh := haveList && time.Since(c.lmFetched) < catalogCacheTTL
+ backingOff := haveList && time.Since(c.lmFailed) < catalogRetryAfterFailure
+ if fresh || backingOff {
+ models, at := c.lmStudio, c.lmFetched
+ c.mu.Unlock()
+ return models, at, nil
+ }
+ // Coalesce concurrent callers onto one request.
+ if c.inflight != nil {
+ wait := c.inflight
+ c.mu.Unlock()
+ select {
+ case <-wait:
+ case <-ctx.Done():
+ return nil, time.Time{}, ctx.Err()
+ }
+ c.mu.Lock()
+ models, at := c.lmStudio, c.lmFetched
+ c.mu.Unlock()
+ if len(models) == 0 {
+ return nil, time.Time{}, fmt.Errorf("lm studio catalog unavailable")
+ }
+ return models, at, nil
+ }
+ done := make(chan struct{})
+ c.inflight = done
+ c.mu.Unlock()
+
+ models, err := c.fetchLmStudio(ctx)
+
+ c.mu.Lock()
+ if err == nil && len(models) > 0 {
+ c.lmStudio = models
+ c.lmFetched = time.Now()
+ c.lmFailed = time.Time{}
+ } else {
+ c.lmFailed = time.Now()
+ }
+ served, at := c.lmStudio, c.lmFetched
+ c.inflight = nil
+ // Closed while still holding the lock, so clearing inflight and releasing
+ // the waiters is atomic with respect to new arrivals. Closing after the
+ // unlock left a window where a caller saw no in-flight request and started
+ // a second one — exactly when a herd is most likely, right after a failure.
+ close(done)
+ c.mu.Unlock()
+
+ if len(served) == 0 {
+ if err != nil {
+ return nil, time.Time{}, err
+ }
+ return nil, time.Time{}, fmt.Errorf("lm studio catalog returned no models")
+ }
+ if err != nil {
+ slog.Warn("lm studio catalog refresh failed; serving the previous list",
+ "models", len(served), "err", err)
+ }
+ return served, at, nil
+}
+
+func (c *catalogService) fetchLmStudio(ctx context.Context) ([]CatalogModel, error) {
+ ctx, cancel := context.WithTimeout(ctx, catalogHTTPTimeout)
+ defer cancel()
+
+ q := url.Values{}
+ q.Set("author", lmStudioAuthor)
+ q.Set("sort", "downloads")
+ q.Set("direction", "-1")
+ q.Set("limit", fmt.Sprint(lmStudioLimit))
+
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.endpoint()+"?"+q.Encode(), nil)
+ if err != nil {
+ return nil, err
+ }
+ req.Header.Set("Accept", "application/json")
+ req.Header.Set("User-Agent", "PAIR/1.0")
+
+ resp, err := c.client.Do(req)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("model catalog returned %s", resp.Status)
+ }
+
+ var rows []hfModelRow
+ if err := json.NewDecoder(io.LimitReader(resp.Body, maxCatalogBody)).Decode(&rows); err != nil {
+ return nil, fmt.Errorf("decode model catalog: %w", err)
+ }
+ return normalizeHFRows(rows), nil
+}
+
+// mlxPattern matches an MLX marker as a whole token in a repo id.
+var mlxPattern = regexp.MustCompile(`(?i)(?:^|[-_/])mlx(?:[-_/]|$)`)
+
+// normalizeHFRows converts listing entries into the shared shape.
+//
+// MLX quantizations are Apple's framework and only run on Apple Silicon; `lms
+// get` refuses them elsewhere with "no download options available". Listing them
+// off-Mac would offer models that can never install, so they are dropped there.
+func normalizeHFRows(rows []hfModelRow) []CatalogModel {
+ seen := make(map[string]int, len(rows))
+ out := make([]CatalogModel, 0, len(rows))
+ for _, r := range rows {
+ id := strings.TrimSpace(r.ID)
+ if id == "" {
+ id = strings.TrimSpace(r.ModelID)
+ }
+ if id == "" {
+ continue
+ }
+ author := lmStudioAuthor
+ if i := strings.Index(id, "/"); i > 0 {
+ author = id[:i]
+ }
+ updated := r.LastModified
+ if updated == "" {
+ updated = r.CreatedAt
+ }
+ model := CatalogModel{
+ ID: id,
+ Name: id,
+ Author: author,
+ URL: "https://huggingface.co/" + id,
+ Downloads: r.Downloads,
+ Likes: r.Likes,
+ UpdatedAt: updated,
+ Tags: r.Tags,
+ AppleOnly: isMLX(id, r.Tags),
+ }
+ if idx, dup := seen[id]; dup {
+ out[idx] = model
+ continue
+ }
+ seen[id] = len(out)
+ out = append(out, model)
+ }
+ sort.SliceStable(out, func(i, j int) bool { return out[i].Downloads > out[j].Downloads })
+ return out
+}
+
+// isMLX reports an Apple-only quantization, by tag or by repo-id token.
+func isMLX(id string, tags []string) bool {
+ for _, t := range tags {
+ if strings.EqualFold(strings.TrimSpace(t), "mlx") {
+ return true
+ }
+ }
+ return mlxPattern.MatchString(id)
+}
diff --git a/desktop/src/electron/model-hub/ollama-models.json b/services/nvpair-engine-manager/catalog/ollama-models.json
similarity index 100%
rename from desktop/src/electron/model-hub/ollama-models.json
rename to services/nvpair-engine-manager/catalog/ollama-models.json
diff --git a/services/nvpair-engine-manager/catalog_test.go b/services/nvpair-engine-manager/catalog_test.go
new file mode 100644
index 00000000..8e610d93
--- /dev/null
+++ b/services/nvpair-engine-manager/catalog_test.go
@@ -0,0 +1,361 @@
+// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+package main
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "runtime"
+ "strings"
+ "sync"
+ "sync/atomic"
+ "testing"
+ "time"
+
+ "nvpair-shared/jsonrpc"
+)
+
+// TestOllamaCatalogLoadsFromEmbeddedFile checks the committed list is compiled in
+// and parses. If the embed directive or the file shape ever breaks, the catalog
+// silently becomes empty, and an empty catalog looks identical to "this engine
+// has nothing to offer".
+func TestOllamaCatalogLoadsFromEmbeddedFile(t *testing.T) {
+ c := newCatalogService()
+ res, err := c.Catalog(context.Background(), "ollama", "linux")
+ if err != nil {
+ t.Fatalf("ollama catalog: %v", err)
+ }
+ if len(res.Models) == 0 {
+ t.Fatal("embedded ollama catalog is empty")
+ }
+ if res.FetchedAt == "" {
+ t.Error("no scrape timestamp; an operator cannot tell how stale the list is")
+ }
+ if !strings.Contains(res.Source, "ollama.com") {
+ t.Errorf("source = %q", res.Source)
+ }
+
+ for _, m := range res.Models {
+ if m.Name == "" {
+ t.Fatal("a model has no pull name")
+ }
+ if m.ID != m.Name {
+ t.Fatalf("id %q and name %q disagree; both must be pull-ready", m.ID, m.Name)
+ }
+ if !strings.HasPrefix(m.URL, "https://ollama.com/library/") {
+ t.Fatalf("model %q has url %q", m.Name, m.URL)
+ }
+ // The tag must not leak into the library URL path.
+ if strings.Contains(strings.TrimPrefix(m.URL, "https://ollama.com/library/"), ":") {
+ t.Fatalf("model %q url carries a tag: %q", m.Name, m.URL)
+ }
+ }
+}
+
+// TestOllamaCatalogIsServedFromCache checks the embedded list is parsed once
+// rather than on every request: it is several thousand entries.
+func TestOllamaCatalogIsServedFromCache(t *testing.T) {
+ c := newCatalogService()
+ first, _, err := c.ollamaCatalog()
+ if err != nil {
+ t.Fatalf("first load: %v", err)
+ }
+ second, _, err := c.ollamaCatalog()
+ if err != nil {
+ t.Fatalf("second load: %v", err)
+ }
+ if len(first) != len(second) {
+ t.Fatal("repeat load produced a different list")
+ }
+ if len(first) > 0 && &first[0] != &second[0] {
+ t.Error("catalog re-parsed instead of being reused")
+ }
+}
+
+// TestOllamaCatalogFitsInAFrame is the guard for the failure that made this
+// method unusable: the reply is one JSON-RPC line, and a line over the
+// worker-path frame cap is a terminal read error, not a dropped message. The
+// broker's peer then closes while the child keeps running, so nothing restarts
+// and every later engine:* call hangs.
+//
+// The check is on the marshalled result rather than the model count, because it
+// is bytes on the wire that matter and a regenerated catalog can grow either by
+// adding rows or by widening them.
+func TestOllamaCatalogFitsInAFrame(t *testing.T) {
+ c := newCatalogService()
+ res, err := c.Catalog(context.Background(), "ollama", "linux")
+ if err != nil {
+ t.Fatalf("ollama catalog: %v", err)
+ }
+ body, err := json.Marshal(res)
+ if err != nil {
+ t.Fatalf("marshal catalog: %v", err)
+ }
+ // The real frame carries a JSON-RPC envelope around this; leave room for it.
+ const envelopeAllowance = 4096
+ if len(body)+envelopeAllowance > jsonrpc.WorkerFrameBytes {
+ t.Errorf("marshalled ollama catalog is %d bytes, over the %d-byte frame cap; "+
+ "filter or paginate engine:catalog rather than raising the cap again",
+ len(body), jsonrpc.WorkerFrameBytes)
+ }
+ t.Logf("ollama catalog: %d models, %d bytes (%.0f%% of the %d-byte frame cap)",
+ len(res.Models), len(body),
+ 100*float64(len(body))/float64(jsonrpc.WorkerFrameBytes), jsonrpc.WorkerFrameBytes)
+}
+
+// TestLmStudioCatalogCoalescesConcurrentCallers is the guard for the request
+// herd. Both front ends plus the warm-up can ask at once, and the point of the
+// in-flight channel is that they share one upstream request.
+//
+// Run under -race this also covers the close-under-lock fix: the channel used to
+// be closed after releasing the mutex, leaving a window where an arriving caller
+// saw no request in flight and started a second one.
+func TestLmStudioCatalogCoalescesConcurrentCallers(t *testing.T) {
+ var requests atomic.Int32
+ release := make(chan struct{})
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ requests.Add(1)
+ <-release // hold the request open so the callers genuinely overlap
+ _, _ = w.Write([]byte(`[{"id":"lmstudio-community/Model-GGUF","downloads":5}]`))
+ }))
+ defer srv.Close()
+
+ c := newCatalogService()
+ c.baseURL = srv.URL
+
+ const callers = 8
+ var wg sync.WaitGroup
+ errs := make([]error, callers)
+ counts := make([]int, callers)
+ for i := range callers {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ models, _, err := c.lmStudioCatalog(context.Background())
+ errs[i], counts[i] = err, len(models)
+ }()
+ }
+
+ // Let the callers pile up behind the one in flight, then answer.
+ time.Sleep(50 * time.Millisecond)
+ close(release)
+ wg.Wait()
+
+ if got := requests.Load(); got != 1 {
+ t.Errorf("%d callers produced %d upstream requests, want 1", callers, got)
+ }
+ for i := range callers {
+ if errs[i] != nil {
+ t.Errorf("caller %d: %v", i, errs[i])
+ }
+ if counts[i] != 1 {
+ t.Errorf("caller %d got %d models, want 1", i, counts[i])
+ }
+ }
+}
+
+// TestLmStudioCatalogBacksOffAfterFailure is the guard for a stall: only a
+// success stamped the cache, so against a dead upstream every later call retried
+// and waited out the full timeout before handing back the same stale list.
+func TestLmStudioCatalogBacksOffAfterFailure(t *testing.T) {
+ var requests atomic.Int32
+ fail := true
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ requests.Add(1)
+ if fail {
+ w.WriteHeader(http.StatusBadGateway)
+ return
+ }
+ _, _ = w.Write([]byte(`[{"id":"lmstudio-community/Model-GGUF","downloads":5}]`))
+ }))
+ defer srv.Close()
+
+ c := newCatalogService()
+ c.baseURL = srv.URL
+
+ // Seed a good list, then start failing.
+ fail = false
+ if _, _, err := c.lmStudioCatalog(context.Background()); err != nil {
+ t.Fatalf("seed fetch: %v", err)
+ }
+ fail = true
+
+ // Force a refresh by ageing the cache past its TTL.
+ c.mu.Lock()
+ c.lmFetched = time.Now().Add(-2 * catalogCacheTTL)
+ c.mu.Unlock()
+
+ before := requests.Load()
+ for range 3 {
+ models, _, err := c.lmStudioCatalog(context.Background())
+ if err != nil {
+ t.Fatalf("a failed refresh should still serve the stale list: %v", err)
+ }
+ if len(models) != 1 {
+ t.Errorf("stale list lost its models: %d", len(models))
+ }
+ }
+ if got := requests.Load() - before; got != 1 {
+ t.Errorf("three calls after a failure made %d requests, want 1 then backoff", got)
+ }
+}
+
+func TestNormalizeCatalogEngine(t *testing.T) {
+ cases := map[string]string{
+ "ollama": "ollama",
+ "Ollama": "ollama",
+ " ollama ": "ollama",
+ "lmstudio": "lmstudio",
+ "LM Studio": "lmstudio",
+ "lm-studio": "lmstudio",
+ "vllm": "vllm",
+ }
+ for in, want := range cases {
+ if got := normalizeCatalogEngine(in); got != want {
+ t.Errorf("normalizeCatalogEngine(%q) = %q, want %q", in, got, want)
+ }
+ }
+}
+
+// TestCatalogRejectsUnknownEngine checks an engine with no curated source errors
+// rather than returning an empty list that reads as "nothing available".
+func TestCatalogRejectsUnknownEngine(t *testing.T) {
+ c := newCatalogService()
+ if _, err := c.Catalog(context.Background(), "vllm", "linux"); err == nil {
+ t.Error("unknown engine returned a catalog")
+ }
+}
+
+// TestNormalizeHFRowsMarksMLX checks Apple-only quantizations are labelled
+// rather than dropped during normalization. `lms get` refuses them anywhere but
+// Apple Silicon, but the machine that asks is not always the machine that
+// installs, so the drop decision belongs to the caller's platform.
+func TestNormalizeHFRowsMarksMLX(t *testing.T) {
+ rows := []hfModelRow{
+ {ID: "lmstudio-community/Qwen3-8B-GGUF", Downloads: 10},
+ {ID: "lmstudio-community/Qwen3-8B-MLX-4bit", Downloads: 99},
+ {ID: "lmstudio-community/Tagged-Model", Downloads: 50, Tags: []string{"MLX"}},
+ }
+
+ got := normalizeHFRows(rows)
+ if len(got) != 3 {
+ t.Fatalf("normalize kept %d models, want all three (filtering happens later)", len(got))
+ }
+ marked := map[string]bool{}
+ for _, m := range got {
+ marked[m.ID] = m.AppleOnly
+ }
+ if marked["lmstudio-community/Qwen3-8B-GGUF"] {
+ t.Error("a GGUF model was marked Apple-only")
+ }
+ if !marked["lmstudio-community/Qwen3-8B-MLX-4bit"] {
+ t.Error("an MLX repo id was not marked Apple-only")
+ }
+ if !marked["lmstudio-community/Tagged-Model"] {
+ t.Error("an MLX tag was not marked Apple-only")
+ }
+}
+
+// TestFilterForPlatform is the guard for the bug this replaced: the list was
+// filtered by whichever machine served it, so browsing for a Mac peer from a
+// Linux box hid every model that peer could actually use.
+func TestFilterForPlatform(t *testing.T) {
+ models := []CatalogModel{
+ {ID: "plain/gguf"},
+ {ID: "apple/mlx", AppleOnly: true},
+ }
+
+ if got := filterForPlatform(models, "darwin"); len(got) != 2 {
+ t.Errorf("darwin target got %d models, want both", len(got))
+ }
+ for _, target := range []string{"linux", "windows"} {
+ got := filterForPlatform(models, target)
+ if len(got) != 1 || got[0].ID != "plain/gguf" {
+ t.Errorf("%s target got %v, want only the portable model", target, got)
+ }
+ }
+}
+
+// TestCatalogEchoesTargetPlatform checks the reply says which platform it was
+// filtered for, so a client can tell the operator rather than presenting a
+// filtered list as universal.
+func TestCatalogEchoesTargetPlatform(t *testing.T) {
+ c := newCatalogService()
+ res, err := c.Catalog(context.Background(), "ollama", "darwin")
+ if err != nil {
+ t.Fatalf("catalog: %v", err)
+ }
+ if res.Platform != "darwin" {
+ t.Errorf("Platform = %q, want the requested target", res.Platform)
+ }
+
+ // An omitted platform means this host.
+ res, err = c.Catalog(context.Background(), "ollama", "")
+ if err != nil {
+ t.Fatalf("catalog: %v", err)
+ }
+ if res.Platform != runtime.GOOS {
+ t.Errorf("Platform = %q, want the local %q", res.Platform, runtime.GOOS)
+ }
+}
+
+// TestNormalizeHFRowsSortsByDownloads checks the most-used models lead, which is
+// what makes an unfiltered first page useful.
+func TestNormalizeHFRowsSortsByDownloads(t *testing.T) {
+ rows := []hfModelRow{
+ {ID: "a/low", Downloads: 1},
+ {ID: "a/high", Downloads: 100},
+ {ID: "a/mid", Downloads: 50},
+ }
+ got := normalizeHFRows(rows)
+ if got[0].ID != "a/high" || got[2].ID != "a/low" {
+ t.Errorf("order = %q, %q, %q", got[0].ID, got[1].ID, got[2].ID)
+ }
+}
+
+// TestNormalizeHFRowsSkipsUnusableEntries checks an entry with no id is dropped
+// rather than becoming a row that cannot be pulled.
+func TestNormalizeHFRowsSkipsUnusableEntries(t *testing.T) {
+ rows := []hfModelRow{
+ {ID: "", ModelID: ""},
+ {ID: "", ModelID: "a/from-modelid"},
+ {ID: "a/normal"},
+ }
+ got := normalizeHFRows(rows)
+ if len(got) != 2 {
+ t.Fatalf("kept %d rows, want 2", len(got))
+ }
+ for _, m := range got {
+ if m.Name == "" || m.ID == "" {
+ t.Error("kept a row with no pull id")
+ }
+ if !strings.HasPrefix(m.URL, "https://huggingface.co/") {
+ t.Errorf("url = %q", m.URL)
+ }
+ if m.Author != "a" {
+ t.Errorf("author = %q, want the id's owner", m.Author)
+ }
+ }
+}
+
+// TestNormalizeOllamaRowsDedupes checks a duplicated pull name yields one row.
+func TestNormalizeOllamaRowsDedupes(t *testing.T) {
+ rows := []ollamaLibraryRow{
+ {Name: "llama3.2:latest", Size: 1},
+ {Name: "llama3.2:latest", Size: 2},
+ {Name: "qwen3:8b"},
+ {Name: " "},
+ }
+ got := normalizeOllamaRows(rows)
+ if len(got) != 2 {
+ t.Fatalf("got %d rows, want 2", len(got))
+ }
+ for _, m := range got {
+ if m.Name == "llama3.2:latest" && m.Size != 2 {
+ t.Errorf("duplicate kept size %d, want the later entry's 2", m.Size)
+ }
+ }
+}
diff --git a/services/nvpair-engine-manager/codec.go b/services/nvpair-engine-manager/codec.go
index c346efbf..219ca88b 100644
--- a/services/nvpair-engine-manager/codec.go
+++ b/services/nvpair-engine-manager/codec.go
@@ -22,11 +22,13 @@ type (
DecodeError = jsonrpc.DecodeError
)
-// maxFrameBytes caps a single inbound JSON-RPC frame. The orchestrator is the
-// sole, trusted peer and only sends small engine:* requests, so 8 MiB is far
-// above any real frame. An inbound frame larger than this surfaces as a
-// terminal read error and manager.readLoop exits cleanly.
-const maxFrameBytes = 8 << 20 // 8 MiB
+// maxFrameBytes caps a single inbound JSON-RPC frame. It is the shared
+// worker-path cap so every hop agrees; the orchestrator only sends small
+// engine:* requests inbound, but this side's *replies* can be megabytes
+// (engine:catalog's model list), and a cap that differs per hop fails the whole
+// path. An inbound frame larger than this surfaces as a terminal read error and
+// manager.readLoop exits cleanly.
+const maxFrameBytes = jsonrpc.WorkerFrameBytes
// NewCodec wraps rw with the shared codec at engine-manager's 8 MiB frame cap.
func NewCodec(rw io.ReadWriter) *Codec { return jsonrpc.NewCodecMaxFrame(rw, maxFrameBytes) }
diff --git a/services/nvpair-engine-manager/manager.go b/services/nvpair-engine-manager/manager.go
index e12f62dc..bb2f9f89 100644
--- a/services/nvpair-engine-manager/manager.go
+++ b/services/nvpair-engine-manager/manager.go
@@ -72,7 +72,10 @@ type Manager struct {
// uses the longer header budget for start/delete (see waitsForEngineReadiness).
remoteHTTP *clustertrust.PeerClientPool
readyHTTP *clustertrust.PeerClientPool
- cancel context.CancelFunc
+ // catalog answers engine:catalog: the models an engine can download, as
+ // opposed to the ones it already holds.
+ catalog *catalogService
+ cancel context.CancelFunc
}
func NewManager(codec *Codec, exec *Executor, mesh *clustertrust.Mesh) *Manager {
@@ -91,7 +94,8 @@ func NewManager(codec *Codec, exec *Executor, mesh *clustertrust.Mesh) *Manager
readyHTTP: clustertrust.NewPeerClientPoolOpts(mesh, clustertrust.PeerClientOptions{
ResponseHeaderTimeout: remoteReadyResponseHeaderTimeout,
}),
- cancel: func() {},
+ catalog: newCatalogService(),
+ cancel: func() {},
}
m.settingsRelay.send = codec.Notify
exec.settingsParent = m.settingsRelay.call
@@ -250,6 +254,9 @@ func (m *Manager) handleMessage(ctx context.Context, msg *Message) {
case "engine:models":
go m.runModels(ctx, msg)
+ case "engine:catalog":
+ go m.runCatalog(ctx, msg)
+
case "engine:install", "engine:uninstall", "engine:start", "engine:stop", "engine:restart":
go m.runOp(ctx, msg)
@@ -354,6 +361,25 @@ func (m *Manager) runModels(ctx context.Context, msg *Message) {
m.codec.Respond(msg.ID, m.exec.ModelsResult(ctx))
}
+// runCatalog answers engine:catalog. It is off the request goroutine because the
+// LM Studio source is a live fetch on a cold cache.
+func (m *Manager) runCatalog(ctx context.Context, msg *Message) {
+ var p catalogParams
+ if !m.parse(msg, &p) {
+ return
+ }
+ if p.Engine == "" {
+ m.codec.RespondError(msg.ID, -32602, "engine is required")
+ return
+ }
+ res, err := m.catalog.Catalog(ctx, p.Engine, p.Platform)
+ if err != nil {
+ m.codec.RespondError(msg.ID, -32603, err.Error())
+ return
+ }
+ m.codec.Respond(msg.ID, res)
+}
+
func (m *Manager) runAction(ctx context.Context, msg *Message) {
var p actionParam
if !m.parse(msg, &p) {
diff --git a/services/nvpair-engine-manager/spec.md b/services/nvpair-engine-manager/spec.md
index ba2e6324..4c4ac812 100644
--- a/services/nvpair-engine-manager/spec.md
+++ b/services/nvpair-engine-manager/spec.md
@@ -120,6 +120,7 @@ Requests (caller → service):
| `engine:action` | `{ engine, action, params }` | the engine's raw response |
| `engine:logs` | `{ engine }` | `{ lines: [LogLine] }` |
| `engine:errors` | — | `{ errors: [ServiceError] }` |
+| `engine:catalog` | `{ engine, platform? }` | `{ models: [CatalogModel], source, platform?, fetchedAt? }` — the models an engine can **download**. One curated source per engine: Ollama's is compiled in (`catalog/ollama-models.json`, no network), LM Studio's is the `lmstudio-community` Hugging Face org fetched live and cached. `platform` is the GOOS the models will be installed on, defaulting to this host; `appleOnly` rows (MLX) are filtered out for a non-`darwin` target. An engine with no curated source is an error, not an empty list. The Ollama reply is a single multi-megabyte frame — every hop on its path must allow `jsonrpc.WorkerFrameBytes`. |
| `engine:remote-get-installed` | `{ node }` | `{ engines: [EngineStatus] }` from the remote node |
| `engine:remote-install` | `{ node, engine, start? }` | `{ opId, status }` after the remote install |
| `engine:remote-pull-model` | `{ node, engine, model?, params? }` | `{ opId, result }` after the remote pull |
diff --git a/services/nvpair-tui/README.md b/services/nvpair-tui/README.md
index 3963d1a8..504b281b 100644
--- a/services/nvpair-tui/README.md
+++ b/services/nvpair-tui/README.md
@@ -23,29 +23,109 @@ JSON over the broker's stdin/stdout). It launches the broker, consumes its
notification stream, and renders a tabbed, keyboard-driven dashboard built
with [Bubble Tea](https://github.com/charmbracelet/bubbletea).
-Tabs:
+The tab set is machine-first: a node is the unit an operator reasons about, so
+everything specific to one machine hangs off its row rather than living in a tab
+of its own.
| Tab | Purpose |
| --- | --- |
-| **Overview** | Broker liveness/version/uptime (`ping`) and a per-worker health table derived from the broker's `supervisor:subprocess-crashed:*` errors. |
-| **Errors** | The service-error datastore (`errors:get-initial` + live `errors:update`); `c` clears the selected entry. |
-| **Nodes** | mDNS-discovered Ollama nodes (`discovery:subscribe` / `discovery:nodes-changed`). |
-| **Proxies** | Ollama and LM Studio reverse proxies: status, discovered upstreams, select a node (`enter`/`a`), set the listen port (`p`). |
-| **Workloads** | Live cluster workloads (`workloads:subscribe` / `workloads:upsert` / `workloads:remove`). |
-| **Engines** | Local inference engines: install (`i`), start (`s`), stop (`x`), restart (`r`), uninstall (`u`). |
-| **Cluster** | Pairing + membership: invite by address (`i`, shows the six-digit PIN — the first invite auto-founds a cluster of one), accept (`a`) / decline (`d`) an inbound invite, remove a member (`r`), leave (`L`). |
-| **Manual** | User-added nodes: add by address (`a`), remove (`r`). |
-| **Settings** | The node-settings store (force-ports, cluster auto-sync, cluster id/name). |
-| **Logs** | The broker's (and workers') stderr, with live log-level control (`d`/`i`/`w`/`e`). |
+| **Nodes** | Every machine PAIR knows about — discovered, added by hand, or paired into the cluster — merged into one table. Reachability (`STATUS`) and membership (`CLUSTER`) are separate columns because they are independent facts. `enter` opens the node's detail screen; `p` pairs, `n` pairs by address, `f` finds by address, `c` cancels a pairing request you sent, `r` removes — un-pairing a member asks you to confirm, dropping a hand-added entry does not — `a`/`d` answer an inbound pairing request, `l` leaves the cluster (with a confirmation), `/` filters by name or address. The keys follow the words on screen: everything here is "pair", so `p` starts one and `a` accepts one. A filtered table says so, and the cluster summary still counts every node rather than the visible ones. |
+| **Jobs** | Inference work across the cluster (`workloads:get-initial` plus the live `workloads:upsert` / `workloads:remove` stream), headed by the proxy endpoints local clients connect to. `FROM` and `RAN ON` are the job's `originatedFrom` and `scheduledOn` nodes. `a` toggles finished work. `t` starts or stops the Inference Demo — a sixty-second burst of synthetic traffic through those endpoints, which is why it lives here rather than with the service controls: the ports it needs are already on this tab and the jobs it produces land in the table below. |
+| **Service** | Broker version and uptime (`ping`), a row per supervised worker derived from `supervisor:subprocess-crashed:*` errors, the cluster name, the fleet log level (a picker over the four `applog` levels), and a confirmed data reset. Ports are not here — they live on the node detail screen beside the engine each one serves. `force-ports` and `cluster-auto-sync` are persisted by `nvpair-node-settings` but not offered: nothing currently acts on either. |
+| **Errors** | The service-error datastore (`errors:get-initial` plus live `errors:update`); `c` clears the selected entry. Only entries this node reported are clearable: `errors:clear` is delete-by-id on the receiving node and cross-node propagation is unbuilt (`shared/errors` stamps `ClearedBy` for it and ignores it), so clearing a peer's entry is reverted by the next sync. The broker acknowledges the relay rather than the outcome, so the reply cannot be used to detect it — the key is withdrawn for a peer's entry instead, naming the node to clear it from. Node ids are resolved to names, and a line under the table carries the selected entry's engine, operation, model, and suggested action. |
+| **Logs** | The broker's and workers' stderr, with a substring filter (`/`), a follow toggle (`t`, for tail — `f` belongs to the viewport's paging), and save-to-file (`s`). |
+
+Diagnostics come last, errors before logs, which is the order you consult them
+in. The **Errors** tab carries its active count in its own label (`Errors (2)`),
+so the tab bar is the indicator and nothing extra has to be learned to notice a
+problem from another tab.
+
+Errors were briefly an overlay on a dedicated key instead. That needed a global
+binding, and every candidate was either a letter that shadowed a view's own verb
+or a digit that looked like a tab number without being one — so it became the
+tab it was already pretending to be.
+
+### Update notice
+
+A newer published release is announced in a row under the tab bar, checked
+shortly after startup and every six hours against the public releases feed.
+
+It belongs to the shell rather than to the **Service** tab: the operator this is
+for is the one who lives on **Nodes** or **Jobs** and has no reason to open
+**Service**. `ctrl+x` dismisses it on every tab at once, and a release newer
+than the dismissed one brings it back — that version was never acknowledged.
+The key is `ctrl+x` because the views between them bind `a` through `y` and the
+table and viewport add the paging keys; the shell handles its own bindings
+before the active view sees them, so a global letter would silently shadow a
+verb.
+
+Two layout constraints, both regression-tested. The row comes out of
+`contentHeight()`, or it is a row the shell then deletes from the bottom of
+whichever view is showing — which is where every view keeps its messages. And
+the line is assembled longest-first against the real width, dropping the URL and
+then the version detail, because the frame is clamped to the terminal and the
+rightmost text is the dismiss hint: the only key that closes it.
+
+It compares `ui.ReleaseVersion`, stamped by both build paths from
+`desktop/package.json`, against the feed's latest stable tag. That is the
+release number users install and the one the tags are named for — this
+component's own version and the services suite version describe parts of the
+build and mean nothing to the comparison. Drafts and prereleases are ignored.
+
+Awareness only: nothing is downloaded or installed, because this client resolves
+the broker beside its own executable and that broker spawns the worker set from
+the same directory — replacing "the client" means swapping every binary
+atomically while they serve inference, and a partial swap leaves a new client
+driving old workers across a JSON-RPC contract that may have changed. Silent on
+failure, skipped for an unstamped build, and disabled by
+`NVPAIR_NO_UPDATE_CHECK`. A desktop-app install needs none of this: `nvpair-tui`
+ships in `cli-bin` and the app's updater replaces it.
+
+### Node detail
+
+`enter` on a node opens a full-screen drill-down with two panes, switched with
+`h`/`l`:
+
+- **Engines** — install (`i`), start (`s`), stop (`x`), and, on this machine
+ only, restart (`r`), uninstall (`u`, confirmed with `y`), the engine's own port (`e`, via
+ `engine:set-port`), and the client-facing port of the proxy fronting it
+ (`p`, via `:set-port`). Both ports are shown per engine because they
+ are easily confused and were previously configured on different tabs.
+- **Models** — the inventory per engine with loaded state, plus browse-and-download
+ (`p`), download by name (`n`), load (`enter`), eject (`e`), and delete (`d`,
+ confirmed with `y`). Every destructive key arms on the first press and acts
+ only on `y`, against the target captured at arm time — these lists re-sort
+ under the cursor whenever a download finishes or a peer republishes.
+
+`p` opens a catalog browser over the engine's downloadable models, served by the
+backend's `engine:catalog`. Filtering (`/`) and sorting (`o`) are local over the
+whole fetched list, because the catalog is thousands of entries for Ollama and
+there is no server-side search.
+
+Both panes work on remote cluster peers through the engine manager's
+`engine:remote-*` methods. Restart, uninstall, and the port change need process
+ownership on the target host, so they are hidden on a peer rather than offered
+and then failed.
+
+A remote node's models come from the discovery snapshot, which the broker
+enriches from each peer's engine manager — no extra request. This machine's come
+from `engine:models` and stay live through `engine:models-changed`.
+
+The detail screen also polls the node's own `/v1/node-info` endpoint over HTTP
+for GPU, CPU, and memory. That is the one reading the broker's JSON-RPC surface
+does not carry, and only the open node is polled.
## Keys
-- `tab` / `shift+tab` (or `→` / `←`, `l` / `h`) — switch tabs
-- `?` — toggle full help
+- `tab` / `shift+tab` or the digits `1`-`5` — switch tabs
+- `?` — full help
+- `ctrl+x` — dismiss the update notice, while one is showing
- `q` / `ctrl+c` — quit (the broker is shut down cleanly on exit)
-- Per-tab keys appear in the footer; while editing a field (port, PIN,
- address, setting) all keys go to the field until you press `enter` or
- `esc`.
+- Per-tab keys appear in the footer. While editing a field (port, PIN, address,
+ model name) every key goes to the field until `enter` or `esc`.
+
+`h` / `l` and the arrows are deliberately **not** bound to tab switching: they
+move within content, and the node detail screen needs them for its panes.
## Running
@@ -56,19 +136,49 @@ installed `bin/` layout). Override with `--broker-path`:
nvpair-tui # broker is a sibling binary
nvpair-tui --broker-path /opt/nvpair/bin/nvpair-ui-broker
nvpair-tui --log-level debug # own logging (to stderr)
+nvpair-tui --appearance light # if the colours come out wrong
nvpair-tui --version
```
Logging goes to stderr (the broker's logs are shown inside the **Logs**
tab, not on the terminal), so it never corrupts the full-screen UI.
+### Colours
+
+Every colour is a `lipgloss.AdaptiveColor` with a light and a dark variant,
+chosen from the terminal's background. Text drawn *on* one of those colours has
+to adapt with it: a fixed foreground over an adaptive background is legible in
+one terminal and not the other, which is how the selected table row came to be
+black on dark blue for anyone using a light theme. `TestTextOnAnAdaptiveBackgroundAdaptsToo`
+pins the pairing.
+
+Detection asks the terminal for its background and reads the reply from stdin.
+lipgloss does that lazily, the first time an adaptive colour resolves, which is
+during the first render — after Bubble Tea has taken the terminal and started
+its own reader, so the answer goes to that reader and the query learns nothing.
+It is therefore forced at startup instead, while stdin is still ours, and the
+result cached behind lipgloss's `sync.Once`.
+
+That query costs nothing on a terminal that answers and five seconds on one
+that does not, since termenv's timeout is a constant. It runs alongside broker
+startup for that reason, and is joined immediately before the first render. It
+cannot be abandoned early: the query owns the terminal until it returns.
+
+termenv declines to ask at all under `screen`, `tmux`, or `TERM=dumb`, which can
+be attached to several terminals at once. Those fall back to assuming dark, and
+`--appearance light|dark` is the answer.
+
## Architecture
```
nvpair-tui (this process)
├── supervisor.go spawn/own nvpair-ui-broker over stdio, graceful teardown
├── rpc/ JSON-RPC 2.0 codec + id-matching client
-└── ui/ Bubble Tea root model + one file per tab
+└── ui/ Bubble Tea root model, one file per tab, plus:
+ ├── table.go shared column layout (accounts for bubbles' cell padding)
+ ├── toast.go transient status lines that expire on their own
+ ├── nodesmodel.go merges the discovery, cluster, and manual feeds
+ └── nodedetail.go the per-node engines + models drill-down
│ stdio (newline-delimited JSON-RPC 2.0)
▼
nvpair-ui-broker ──► nvpair-node-scanner, nvpair-proxy, nvpair-errors, ... (workers)
diff --git a/services/nvpair-tui/main.go b/services/nvpair-tui/main.go
index 4ee976f9..74f7953e 100644
--- a/services/nvpair-tui/main.go
+++ b/services/nvpair-tui/main.go
@@ -18,8 +18,10 @@ import (
"log/slog"
"os"
"os/signal"
+ "path/filepath"
"syscall"
+ "nvpair-shared/appdir"
"nvpair-shared/applog"
"nvpair-tui/ui"
)
@@ -32,6 +34,7 @@ var Version = "dev"
func main() {
brokerPath := flag.String("broker-path", "", "path to nvpair-ui-broker binary (default: ./nvpair-ui-broker alongside this executable)")
showVersion := flag.Bool("version", false, "print version and exit")
+ appearance := flag.String("appearance", "auto", "terminal background: auto, light, or dark")
resolveLevel := applog.RegisterFlag(nil, slog.LevelInfo)
flag.Parse()
@@ -40,6 +43,19 @@ func main() {
os.Exit(0)
}
+ // Started before the program, not merely before the first draw. On auto
+ // this asks the terminal for its background and reads the answer, which
+ // only works while stdin is still ours — once Bubble Tea is running, its
+ // reader takes the reply and the query learns nothing. Joined below, after
+ // the broker is up, so a terminal that never answers costs its timeout
+ // alongside startup rather than in front of it.
+ chosen, ok := ui.ParseAppearance(*appearance)
+ if !ok {
+ fmt.Fprintf(os.Stderr, "unknown --appearance %q: use auto, light, or dark\n", *appearance)
+ os.Exit(2)
+ }
+ appearanceSettled := ui.StartAppearance(chosen)
+
applog.Init("nvpair-tui", resolveLevel())
resolvedBroker, err := resolveBrokerPath(*brokerPath)
@@ -67,13 +83,56 @@ func main() {
os.Exit(1)
}
+ // The last moment the answer can be had: Bubble Tea takes stdin next, and
+ // the first frame is already choosing colours with it.
+ appearanceSettled()
+ // Recorded because a wrong answer is visible but unexplained: the operator
+ // sees colours that do not suit their terminal and has nothing telling
+ // them what PAIR concluded, or that --appearance would override it.
+ slog.Debug("terminal appearance", "requested", string(chosen),
+ "using", string(ui.DetectedAppearance()))
+
// The broker's stderr (its logs plus every worker's, prefixed) is fed
// into the UI's Logs view rather than the terminal, so it never
// collides with the full-screen TUI on stdout.
- if err := ui.Run(sup.Client, sup.Stderr); err != nil {
+ outcome, err := ui.Run(sup.Client, sup.Stderr)
+ if err != nil {
slog.Error("ui error", "err", err)
}
sup.Shutdown()
+
+ // Only now, with every worker joined, is the data directory unowned. Wiping
+ // it while the broker ran would race a shutting-down worker into recreating
+ // the files we deleted.
+ if outcome.WipeData {
+ wipeAppData()
+ }
+
slog.Info("shutdown complete")
}
+
+// wipeAppData deletes the per-user data directory: node settings, cluster
+// identity, trusted peers, and persisted ports. appdir.Dir is the single
+// location every component agrees on, so there is one path to remove and no
+// guessing at layout.
+func wipeAppData() {
+ dir, err := appdir.Dir()
+ if err != nil {
+ slog.Error("cannot resolve the data directory to reset", "err", err)
+ return
+ }
+ // appdir always appends two product segments, so this cannot be a bare home
+ // or root directory today. Asserted anyway: this is the one irreversible
+ // path in the program, and a relative path would be resolved against
+ // whatever directory the process happens to be running in.
+ if !filepath.IsAbs(dir) {
+ slog.Error("refusing to reset a non-absolute data directory", "dir", dir)
+ return
+ }
+ if err := os.RemoveAll(dir); err != nil {
+ slog.Error("failed to reset data directory", "dir", dir, "err", err)
+ return
+ }
+ slog.Info("data directory reset", "dir", dir)
+}
diff --git a/services/nvpair-tui/rpc/client.go b/services/nvpair-tui/rpc/client.go
index d59ce8ad..7ac90108 100644
--- a/services/nvpair-tui/rpc/client.go
+++ b/services/nvpair-tui/rpc/client.go
@@ -6,6 +6,7 @@ package rpc
import (
"context"
"encoding/json"
+ "errors"
"fmt"
"io"
"strconv"
@@ -68,6 +69,13 @@ func (c *Client) Run(ctx context.Context) error {
if err == io.EOF {
return nil
}
+ // A broken transport is terminal: the scanner is finished, so
+ // continuing here spins at full speed forever, never closes the
+ // notifications channel, and leaves the UI reporting a healthy
+ // service it can no longer reach.
+ if errors.Is(err, ErrStreamBroken) {
+ return err
+ }
// A single malformed line should not kill the session; the
// broker may emit a frame we don't model. Skip and continue.
continue
diff --git a/services/nvpair-tui/rpc/codec.go b/services/nvpair-tui/rpc/codec.go
index 8d5e8c83..15337124 100644
--- a/services/nvpair-tui/rpc/codec.go
+++ b/services/nvpair-tui/rpc/codec.go
@@ -15,15 +15,31 @@ package rpc
import (
"bufio"
"encoding/json"
+ "errors"
"fmt"
"io"
"sync"
+
+ "nvpair-shared/jsonrpc"
)
-// maxFrame bounds a single JSON-RPC line. The broker uses a 1 MiB read
-// buffer; we match it so a large discovery/errors snapshot from the
-// broker is never truncated mid-frame.
-const maxFrame = 1024 * 1024
+// maxFrame bounds a single JSON-RPC line. It is the shared worker-path cap, so
+// this reader agrees with the broker's reader and every worker's writer: a reply
+// only arrives if all the hops on its path allow the same size, and a frame over
+// the limit is a terminal read error rather than a skipped message.
+//
+// The largest real frame is engine:catalog's Ollama list, around 1.9 MiB.
+const maxFrame = jsonrpc.WorkerFrameBytes
+
+// ErrStreamBroken marks a read failure the transport cannot recover from, as
+// opposed to a frame this client merely could not parse.
+//
+// The distinction decides whether the read loop may continue. A bufio.Scanner
+// is finished after a read error — including an over-long line, which it cannot
+// skip past — so calling Scan again returns false forever. Treating that like a
+// malformed frame spins the loop at full speed instead of reporting the
+// disconnect, and the UI goes on claiming the service is ready.
+var ErrStreamBroken = errors.New("stream broken")
// Message is a single JSON-RPC 2.0 frame. A frame is a request when it
// has both an id and a method, a notification when it has a method but no
@@ -83,7 +99,7 @@ func NewCodec(r io.Reader, w io.Writer) *Codec {
func (c *Codec) Read() (*Message, error) {
if !c.scanner.Scan() {
if err := c.scanner.Err(); err != nil {
- return nil, fmt.Errorf("read error: %w", err)
+ return nil, fmt.Errorf("%w: %v", ErrStreamBroken, err)
}
return nil, io.EOF
}
diff --git a/services/nvpair-tui/rpc/streambroken_test.go b/services/nvpair-tui/rpc/streambroken_test.go
new file mode 100644
index 00000000..e647221d
--- /dev/null
+++ b/services/nvpair-tui/rpc/streambroken_test.go
@@ -0,0 +1,79 @@
+// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+package rpc
+
+import (
+ "context"
+ "errors"
+ "strings"
+ "testing"
+ "time"
+)
+
+// discardWriter drops writes; these tests only exercise the read side.
+type discardWriter struct{}
+
+func (discardWriter) Write(p []byte) (int, error) { return len(p), nil }
+
+// TestRunReturnsOnBrokenStream is the regression guard for a read loop that
+// span instead of reporting a disconnect.
+//
+// bufio.Scanner is finished after a read error and cannot resync past an
+// over-long line, so Scan returns false forever. The loop treated that like a
+// frame it could not parse and continued, which burned a core, never closed the
+// notifications channel, and left the UI reporting a service it could no longer
+// reach.
+func TestRunReturnsOnBrokenStream(t *testing.T) {
+ // One frame longer than the cap, which is exactly the failure the frame
+ // cap's own comment describes.
+ oversized := `{"jsonrpc":"2.0","method":"x","params":"` +
+ strings.Repeat("A", maxFrame+1024) + `"}` + "\n"
+
+ c := NewClient(strings.NewReader(oversized), discardWriter{})
+
+ done := make(chan error, 1)
+ go func() { done <- c.Run(context.Background()) }()
+
+ select {
+ case err := <-done:
+ if !errors.Is(err, ErrStreamBroken) {
+ t.Errorf("Run returned %v, want ErrStreamBroken", err)
+ }
+ case <-time.After(5 * time.Second):
+ t.Fatal("Run did not return on a broken stream; it is spinning")
+ }
+
+ // Consumers must observe the disconnect.
+ select {
+ case _, open := <-c.Notifications():
+ if open {
+ t.Error("notifications channel delivered after the stream broke")
+ }
+ case <-time.After(time.Second):
+ t.Error("notifications channel was never closed, so the UI never learns it is disconnected")
+ }
+}
+
+// TestRunSkipsUnparseableFrame checks the other direction is unchanged: a frame
+// this client does not model must not kill a working session.
+func TestRunSkipsUnparseableFrame(t *testing.T) {
+ stream := "{not json at all}\n" +
+ `{"jsonrpc":"1.0","method":"wrong-version"}` + "\n" +
+ `{"jsonrpc":"2.0","method":"app:ready"}` + "\n"
+
+ c := NewClient(strings.NewReader(stream), discardWriter{})
+ go func() { _ = c.Run(context.Background()) }()
+
+ select {
+ case msg, ok := <-c.Notifications():
+ if !ok {
+ t.Fatal("session ended on a malformed frame instead of skipping it")
+ }
+ if msg.Method != "app:ready" {
+ t.Errorf("first delivered notification = %q, want app:ready", msg.Method)
+ }
+ case <-time.After(5 * time.Second):
+ t.Fatal("timed out; a malformed frame stalled the loop")
+ }
+}
diff --git a/services/nvpair-tui/supervisor.go b/services/nvpair-tui/supervisor.go
index 546a118d..0369f0dd 100644
--- a/services/nvpair-tui/supervisor.go
+++ b/services/nvpair-tui/supervisor.go
@@ -105,11 +105,37 @@ func Spawn(ctx context.Context, brokerPath string) (*Supervisor, error) {
return &Supervisor{cmd: cmd, stdin: stdin, Client: client, Stderr: stderr}, nil
}
-// Shutdown asks the broker to stop cleanly: send the shutdown RPC, close
-// its stdin (a second, EOF-based stop signal), then wait up to
-// shutdownGrace before killing it. The broker tears its own workers down
-// in response, so this leaves no orphans.
+// enginePrepareTimeout bounds the engine stop that precedes broker teardown.
+//
+// Stopping an engine waits on a third-party process, so this is longer than any
+// other broker call here — but deliberately far shorter than the thirty seconds
+// the desktop allows. The desktop can show a shutting-down window while it
+// waits; a terminal that stops redrawing looks hung, and the operator's next
+// move is ctrl+c, which is worse than a slightly abrupt engine stop. Whatever
+// has not stopped by now is stopped by the broker's own teardown immediately
+// afterwards.
+const enginePrepareTimeout = 6 * time.Second
+
+// Shutdown asks the broker to stop cleanly: stop the engines, send the shutdown
+// RPC, close its stdin (a second, EOF-based stop signal), then wait up to
+// shutdownGrace before killing it. The broker tears its own workers down in
+// response, so this leaves no orphans.
func (s *Supervisor) Shutdown() {
+ // Engines first, and through prepare-shutdown specifically: it stops the
+ // running processes without clearing the persisted desired state, so an
+ // engine the operator had switched on comes back on next launch.
+ //
+ // Without this the broker's teardown raced the engines it supervises, which
+ // is why the architecture requires this call before broker teardown and why
+ // the desktop has always made it. The terminal client never did, so quitting
+ // it could leave an engine mid-stop.
+ //
+ // A failure is not fatal: the broker's own teardown still runs, and refusing
+ // to quit because an engine would not stop would be worse than a slow exit.
+ prepCtx, prepCancel := context.WithTimeout(context.Background(), enginePrepareTimeout)
+ _, _ = s.Client.Call(prepCtx, "engine:prepare-shutdown", nil)
+ prepCancel()
+
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
_, _ = s.Client.Call(ctx, "shutdown", nil)
cancel()
diff --git a/services/nvpair-tui/supervisor_test.go b/services/nvpair-tui/supervisor_test.go
index ab039b31..d4db1b56 100644
--- a/services/nvpair-tui/supervisor_test.go
+++ b/services/nvpair-tui/supervisor_test.go
@@ -23,12 +23,21 @@ func TestMain(m *testing.M) {
runFakeBroker()
return
}
+ if os.Getenv("NVPAIR_TUI_SILENT_BROKER") == "1" {
+ runSilentBroker()
+ return
+ }
os.Exit(m.Run())
}
-// runFakeBroker emits an app:ready handshake, then echoes a result for a
-// shutdown request (and exits on it or on stdin EOF), mimicking the real
-// broker's stdio contract closely enough to exercise the supervisor.
+// runFakeBroker emits an app:ready handshake, then answers the two requests
+// teardown makes — engine:prepare-shutdown and shutdown — exiting on the latter
+// or on stdin EOF, mimicking the real broker's stdio contract closely enough to
+// exercise the supervisor.
+//
+// Answering prepare-shutdown matters: a broker that stays silent is what a hung
+// engine stop looks like, and the supervisor must not wait on it forever. That
+// case is covered separately by TestShutdownProceedsWhenEnginePrepareHangs.
func runFakeBroker() {
fmt.Fprintln(os.Stdout, `{"jsonrpc":"2.0","method":"app:ready","params":{"version":"fake"}}`)
sc := bufio.NewScanner(os.Stdin)
@@ -40,13 +49,25 @@ func runFakeBroker() {
if err := json.Unmarshal(sc.Bytes(), &m); err != nil {
continue
}
- if m.Method == "shutdown" {
+ switch m.Method {
+ case "engine:prepare-shutdown":
+ fmt.Fprintf(os.Stdout, `{"jsonrpc":"2.0","id":%s,"result":null}`+"\n", m.ID)
+ case "shutdown":
fmt.Fprintf(os.Stdout, `{"jsonrpc":"2.0","id":%s,"result":null}`+"\n", m.ID)
os.Exit(0)
}
}
}
+// runSilentBroker handshakes and then answers nothing, standing in for a broker
+// whose engine stop never returns.
+func runSilentBroker() {
+ fmt.Fprintln(os.Stdout, `{"jsonrpc":"2.0","method":"app:ready","params":{"version":"fake"}}`)
+ sc := bufio.NewScanner(os.Stdin)
+ for sc.Scan() {
+ }
+}
+
func TestResolveBrokerPathOverride(t *testing.T) {
dir := t.TempDir()
bin := filepath.Join(dir, "fake-broker")
@@ -66,6 +87,52 @@ func TestResolveBrokerPathOverride(t *testing.T) {
}
}
+// TestShutdownProceedsWhenEnginePrepareHangs pins the quit budget.
+//
+// Teardown asks the engine manager to stop engines before tearing the broker
+// down, and that call waits on third-party processes. If a hung engine stop
+// could stall it, pressing q would leave a terminal that has stopped redrawing
+// and an operator reaching for ctrl+c — which kills the tree the clean shutdown
+// existed to avoid. The wait is bounded, and teardown continues regardless.
+func TestShutdownProceedsWhenEnginePrepareHangs(t *testing.T) {
+ t.Setenv("NVPAIR_TUI_SILENT_BROKER", "1")
+
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+
+ sup, err := Spawn(ctx, os.Args[0])
+ if err != nil {
+ t.Fatalf("spawn: %v", err)
+ }
+ go func() {
+ sc := bufio.NewScanner(sup.Stderr)
+ for sc.Scan() {
+ }
+ }()
+
+ done := make(chan struct{})
+ start := time.Now()
+ go func() {
+ sup.Shutdown()
+ close(done)
+ }()
+
+ // Long enough for the bounded engine wait plus the broker's own grace, and
+ // well short of hanging.
+ budget := enginePrepareTimeout + shutdownGrace + 5*time.Second
+ select {
+ case <-done:
+ case <-time.After(budget):
+ t.Fatalf("shutdown still running after %s against an unresponsive broker", budget)
+ }
+
+ // It must actually have waited for the engine stop rather than skipping it.
+ if elapsed := time.Since(start); elapsed < enginePrepareTimeout {
+ t.Errorf("shutdown returned in %s, before the %s engine-stop wait elapsed; "+
+ "engines are not being given a chance to stop", elapsed, enginePrepareTimeout)
+ }
+}
+
func TestSupervisorReadyAndShutdown(t *testing.T) {
t.Setenv("NVPAIR_TUI_FAKE_BROKER", "1")
diff --git a/services/nvpair-tui/ui/catalog.go b/services/nvpair-tui/ui/catalog.go
new file mode 100644
index 00000000..8a14b077
--- /dev/null
+++ b/services/nvpair-tui/ui/catalog.go
@@ -0,0 +1,400 @@
+// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+package ui
+
+import (
+ "fmt"
+ "sort"
+ "strings"
+
+ "nvpair-tui/rpc"
+
+ "github.com/charmbracelet/bubbles/key"
+ "github.com/charmbracelet/bubbles/table"
+ "github.com/charmbracelet/bubbles/textinput"
+ tea "github.com/charmbracelet/bubbletea"
+)
+
+// catalogModel is one downloadable model from engine:catalog.
+type catalogModel struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ Size uint64 `json:"size"`
+ Downloads int `json:"downloads"`
+ UpdatedAt string `json:"updatedAt"`
+ Tags []string `json:"tags"`
+ Family string `json:"family"`
+ ParameterSize string `json:"parameterSize"`
+}
+
+// catalogSort is the order the browser lists models in.
+type catalogSort int
+
+const (
+ // catalogSortDefault is the order the backend returned: most-downloaded
+ // first for a fetched catalogue, library order for a committed one. It leads
+ // because it is the most useful answer to "what should I get".
+ catalogSortDefault catalogSort = iota
+ catalogSortName
+ catalogSortSize
+)
+
+func (s catalogSort) String() string {
+ switch s {
+ case catalogSortName:
+ return "name"
+ case catalogSortSize:
+ return "size"
+ default:
+ return "popularity"
+ }
+}
+
+type catalogLoadedMsg struct {
+ engine string
+ models []catalogModel
+ fetchedAt string
+ // platform is the OS the served list was filtered for.
+ platform string
+ err error
+}
+
+var (
+ catalogSearchKey = key.NewBinding(key.WithKeys("/"), key.WithHelp("/", "search"))
+ catalogSortKey = key.NewBinding(key.WithKeys("o"), key.WithHelp("o", "sort"))
+ catalogGetKey = key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "download"))
+ catalogCloseKey = key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "back"))
+ // Matches the Logs tab, the other view with a filter that survives being
+ // typed. Not esc, which already closes the browser here — a filter matching
+ // nothing otherwise left throwing the whole catalogue away as the only exit.
+ catalogClearKey = key.NewBinding(key.WithKeys("c"), key.WithHelp("c", "clear filter"))
+)
+
+// catalogBrowser lists the models an engine can download, with a filter.
+//
+// It exists because the alternative was typing a model name from memory: the
+// download worked, but only if you already knew what to ask for. The catalogue
+// comes from the backend's engine:catalog rather than being assembled here, so
+// the terminal interface and the desktop app offer the same models.
+//
+// Filtering is local over the whole fetched list. The catalogue is thousands of
+// entries for Ollama and there is no server-side search, so a keystroke filters
+// what is already in memory instead of issuing a request.
+type catalogBrowser struct {
+ client *rpc.Client
+ engine string
+ // engineLabel is the engine's display name, for the heading.
+ engineLabel string
+ // nodeLabel is the machine the download lands on. The browser is reached
+ // from a peer's detail screen as well as this machine's, and a heading that
+ // does not say which one invites downloading gigabytes to the wrong host.
+ nodeLabel string
+
+ all []catalogModel
+ shown []catalogModel
+ table table.Model
+ sortBy catalogSort
+ filter string
+ input textinput.Model
+ searching bool
+
+ loading bool
+ fetchedAt string
+ // platform is the OS the catalogue was filtered for, and remote marks a
+ // browse aimed at a peer. Together they say whether the list applies to the
+ // target: the catalogue is served by THIS machine's engine-manager and
+ // filtered for its platform, so a peer running another OS can be shown
+ // models it cannot install, or have usable ones hidden.
+ platform string
+ remote bool
+ status toast
+
+ width, height int
+}
+
+// remote marks a browse aimed at a peer. The catalogue is served by this
+// machine's engine-manager and filtered for this machine's platform, so a
+// peer-targeted list carries a caveat rather than pretending to be authoritative
+// for that peer.
+func newCatalogBrowser(client *rpc.Client, engine, label, node string, remote bool) *catalogBrowser {
+ ti := textinput.New()
+ ti.Placeholder = "filter by name"
+ b := &catalogBrowser{
+ client: client,
+ engine: engine,
+ engineLabel: label,
+ nodeLabel: node,
+ remote: remote,
+ input: ti,
+ loading: true,
+ }
+ b.table = newTable(catalogColumns(defaultTableWidth))
+ return b
+}
+
+func catalogColumns(w int) []table.Column {
+ return layoutColumns(w, []column{
+ flexCol("MODEL", 20, 3),
+ fixedCol("SIZE", 9),
+ fixedCol("PARAMS", 7),
+ flexCol("FAMILY", 8, 1),
+ })
+}
+
+func (b *catalogBrowser) Init() tea.Cmd {
+ engine := b.engine
+ return call(b.client, "engine:catalog", map[string]string{"engine": engine},
+ func(msg *rpc.Message, err error) tea.Msg {
+ if err != nil {
+ return catalogLoadedMsg{engine: engine, err: err}
+ }
+ var r struct {
+ Models []catalogModel `json:"models"`
+ FetchedAt string `json:"fetchedAt"`
+ Platform string `json:"platform"`
+ }
+ _ = decodeParams(msg.Result, &r)
+ return catalogLoadedMsg{
+ engine: engine,
+ models: r.Models,
+ fetchedAt: r.FetchedAt,
+ platform: r.Platform,
+ }
+ })
+}
+
+// SetSize records the budget and fixes the table's width. Its height is set in
+// View, from the chrome actually being rendered — see fitTable.
+func (b *catalogBrowser) SetSize(w, h int) {
+ b.width, b.height = w, h
+ b.table.SetColumns(catalogColumns(w))
+ b.table.SetWidth(w)
+}
+
+// update handles a message, returning a command, the model to download when the
+// operator picked one, and whether the browser should stay open.
+func (b *catalogBrowser) update(msg tea.Msg) (tea.Cmd, string, bool) {
+ switch msg := msg.(type) {
+ case catalogLoadedMsg:
+ if msg.engine != b.engine {
+ return nil, "", true
+ }
+ b.loading = false
+ if msg.err != nil {
+ b.status.error("could not load the %s catalog: %s", b.engineLabel, msg.err)
+ return nil, "", true
+ }
+ b.all = msg.models
+ b.fetchedAt = msg.fetchedAt
+ b.platform = msg.platform
+ b.refresh()
+ return nil, "", true
+
+ case tea.KeyMsg:
+ return b.handleKey(msg)
+ }
+ return nil, "", true
+}
+
+func (b *catalogBrowser) handleKey(msg tea.KeyMsg) (tea.Cmd, string, bool) {
+ if b.searching {
+ switch msg.String() {
+ case "enter":
+ b.filter = strings.TrimSpace(b.input.Value())
+ b.searching = false
+ b.input.Blur()
+ b.refresh()
+ return nil, "", true
+ case "esc":
+ b.searching = false
+ b.input.Blur()
+ return nil, "", true
+ }
+ var cmd tea.Cmd
+ b.input, cmd = b.input.Update(msg)
+ return cmd, "", true
+ }
+
+ switch {
+ case key.Matches(msg, catalogCloseKey):
+ return nil, "", false
+ case key.Matches(msg, catalogSearchKey):
+ b.searching = true
+ b.input.SetValue(b.filter)
+ b.input.Focus()
+ return textinput.Blink, "", true
+ case key.Matches(msg, catalogClearKey) && b.filter != "":
+ b.filter = ""
+ b.refresh()
+ return nil, "", true
+ case key.Matches(msg, catalogSortKey):
+ b.sortBy = (b.sortBy + 1) % 3
+ b.refresh()
+ return nil, "", true
+ case key.Matches(msg, catalogGetKey):
+ if m := b.selected(); m != nil {
+ // Name is the pull-ready string the backend guarantees; it is what
+ // the engine's download action accepts verbatim.
+ return nil, m.Name, false
+ }
+ return nil, "", true
+ }
+
+ var cmd tea.Cmd
+ b.table, cmd = b.table.Update(msg)
+ return cmd, "", true
+}
+
+func (b *catalogBrowser) selected() *catalogModel {
+ idx := b.table.Cursor()
+ if idx < 0 || idx >= len(b.shown) {
+ return nil
+ }
+ return &b.shown[idx]
+}
+
+// refresh reapplies the filter and sort, then repaints.
+func (b *catalogBrowser) refresh() {
+ needle := strings.ToLower(b.filter)
+ b.shown = b.shown[:0]
+ for _, m := range b.all {
+ if needle != "" && !catalogMatches(m, needle) {
+ continue
+ }
+ b.shown = append(b.shown, m)
+ }
+ b.sortShown()
+
+ rows := make([]table.Row, 0, len(b.shown))
+ for _, m := range b.shown {
+ size := "-"
+ if m.Size > 0 {
+ size = humanBytes(m.Size)
+ }
+ params := m.ParameterSize
+ if params == "" {
+ params = "-"
+ }
+ family := m.Family
+ if family == "" {
+ family = "-"
+ }
+ rows = append(rows, table.Row{m.Name, size, params, family})
+ }
+ b.table.SetRows(rows)
+ b.table.SetCursor(0)
+}
+
+// catalogMatches tests a model against a lowercase needle. Family and parameter
+// size are searched too, so "llama" and "8b" both narrow usefully.
+func catalogMatches(m catalogModel, needle string) bool {
+ if strings.Contains(strings.ToLower(m.Name), needle) {
+ return true
+ }
+ if strings.Contains(strings.ToLower(m.Family), needle) {
+ return true
+ }
+ if strings.Contains(strings.ToLower(m.ParameterSize), needle) {
+ return true
+ }
+ for _, t := range m.Tags {
+ if strings.Contains(strings.ToLower(t), needle) {
+ return true
+ }
+ }
+ return false
+}
+
+func (b *catalogBrowser) sortShown() {
+ switch b.sortBy {
+ case catalogSortName:
+ sort.SliceStable(b.shown, func(i, j int) bool {
+ return strings.ToLower(b.shown[i].Name) < strings.ToLower(b.shown[j].Name)
+ })
+ case catalogSortSize:
+ // Largest first, and models with no reported size sink rather than
+ // leading the list as if they were empty.
+ sort.SliceStable(b.shown, func(i, j int) bool {
+ return b.shown[i].Size > b.shown[j].Size
+ })
+ default:
+ // The backend's order is already the intended default.
+ }
+}
+
+func (b *catalogBrowser) View() string {
+ // Naming the machine, because this screen is reached from a peer's detail
+ // as well as this one's and the download lands wherever it was opened from.
+ heading := titleStyle.Render(fmt.Sprintf("Download a model for %s on %s",
+ b.engineLabel, b.nodeLabel))
+
+ // One of these three occupies the last row, in this order of precedence.
+ footer := footerStyle.Render(b.summary())
+ if s := b.status.render(); s != "" {
+ footer = s
+ }
+ if b.searching {
+ footer = "filter: " + b.input.View()
+ }
+
+ body := ""
+ switch {
+ case b.loading:
+ body = footerStyle.Render(" Loading the catalog...")
+ case len(b.all) == 0:
+ body = footerStyle.Render(" No catalog available for this engine.")
+ case len(b.shown) == 0:
+ body = footerStyle.Render(fmt.Sprintf(
+ " Nothing matches %q. Press / to change it, c to clear it, or esc to close.",
+ b.filter))
+ case fitTable(&b.table, b.height, heading, footer):
+ body = b.table.View()
+ default:
+ body = footerStyle.Render(" (too little room to list models)")
+ }
+
+ return joinLines(heading, body, footer)
+}
+
+func (b *catalogBrowser) summary() string {
+ if b.loading {
+ return ""
+ }
+ parts := []string{fmt.Sprintf("%d of %d models", len(b.shown), len(b.all))}
+ if b.filter != "" {
+ parts[0] = fmt.Sprintf("%d of %d matching %q", len(b.shown), len(b.all), b.filter)
+ }
+ parts = append(parts, "sorted by "+b.sortBy.String())
+ if b.fetchedAt != "" {
+ parts = append(parts, "catalog "+shortDate(b.fetchedAt))
+ }
+ // Said out loud when it might be wrong. The list comes from this machine's
+ // engine-manager and is filtered for its platform, so a peer on a different
+ // OS may be offered a model it cannot install. Better to state the basis
+ // than to let a filtered list look authoritative for another machine.
+ if b.remote && b.platform != "" {
+ parts = append(parts, "filtered for "+b.platform)
+ }
+ return strings.Join(parts, " ")
+}
+
+// shortDate trims an RFC3339 timestamp to its date, which is the only part that
+// matters for judging how current a catalogue is.
+func shortDate(ts string) string {
+ if len(ts) >= 10 {
+ return ts[:10]
+ }
+ return ts
+}
+
+func (b *catalogBrowser) Help() []key.Binding {
+ if b.searching {
+ return inputHelp("apply filter")
+ }
+ bindings := []key.Binding{catalogCloseKey, catalogSearchKey, catalogSortKey, catalogGetKey}
+ if b.filter != "" {
+ bindings = append(bindings, catalogClearKey)
+ }
+ return bindings
+}
diff --git a/services/nvpair-tui/ui/catalog_test.go b/services/nvpair-tui/ui/catalog_test.go
new file mode 100644
index 00000000..94ce1d8a
--- /dev/null
+++ b/services/nvpair-tui/ui/catalog_test.go
@@ -0,0 +1,231 @@
+// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+package ui
+
+import (
+ "strings"
+ "testing"
+
+ tea "github.com/charmbracelet/bubbletea"
+)
+
+func loadedBrowser() *catalogBrowser {
+ b := newCatalogBrowser(nil, "ollama", "Ollama", "this-host", false)
+ b.SetSize(100, 24)
+ b.update(catalogLoadedMsg{
+ engine: "ollama",
+ fetchedAt: "2026-07-21T02:07:47Z",
+ models: []catalogModel{
+ {ID: "llama3.2:8b", Name: "llama3.2:8b", Size: 5 << 30, Family: "llama", ParameterSize: "8B"},
+ {ID: "qwen3:4b", Name: "qwen3:4b", Size: 2 << 30, Family: "qwen", ParameterSize: "4B"},
+ {ID: "phi4:latest", Name: "phi4:latest", Size: 9 << 30, Family: "phi", ParameterSize: "14B"},
+ },
+ })
+ return b
+}
+
+func browserKey(b *catalogBrowser, k string) (string, bool) {
+ var msg tea.KeyMsg
+ switch k {
+ case "enter":
+ msg = tea.KeyMsg{Type: tea.KeyEnter}
+ case "esc":
+ msg = tea.KeyMsg{Type: tea.KeyEsc}
+ default:
+ msg = tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(k)}
+ }
+ _, picked, open := b.update(msg)
+ return picked, open
+}
+
+// TestCatalogListsModels checks a loaded catalogue reaches the table.
+func TestCatalogListsModels(t *testing.T) {
+ b := loadedBrowser()
+ if b.loading {
+ t.Error("still loading after the reply landed")
+ }
+ if got := len(b.table.Rows()); got != 3 {
+ t.Fatalf("table has %d rows, want 3", got)
+ }
+ if !strings.Contains(b.View(), "llama3.2:8b") {
+ t.Error("view omits a model name")
+ }
+ // The catalogue's age matters for judging staleness.
+ if !strings.Contains(b.View(), "2026-07-21") {
+ t.Errorf("view omits the catalog date: %q", b.summary())
+ }
+}
+
+// TestCatalogFilterNarrowsLocally is the point of the browser: search over the
+// whole list without another request, since there is no server-side search.
+func TestCatalogFilterNarrowsLocally(t *testing.T) {
+ b := loadedBrowser()
+
+ b.filter = "qwen"
+ b.refresh()
+ if len(b.shown) != 1 || b.shown[0].Name != "qwen3:4b" {
+ t.Fatalf("filtering by name gave %d rows", len(b.shown))
+ }
+
+ // Parameter size and family are searched too, so "8b" narrows usefully.
+ b.filter = "8b"
+ b.refresh()
+ if len(b.shown) != 1 || b.shown[0].Name != "llama3.2:8b" {
+ t.Errorf("filtering by parameter size gave %d rows", len(b.shown))
+ }
+
+ b.filter = "nothing-matches-this"
+ b.refresh()
+ if len(b.shown) != 0 {
+ t.Errorf("bogus filter kept %d rows", len(b.shown))
+ }
+ if !strings.Contains(b.View(), "Nothing matches") {
+ t.Error("empty result set gives no explanation")
+ }
+
+ b.filter = ""
+ b.refresh()
+ if len(b.shown) != 3 {
+ t.Errorf("clearing the filter left %d rows", len(b.shown))
+ }
+}
+
+// TestCatalogSortCyclesAndOrders checks the sort key cycles and that size sorts
+// largest-first.
+func TestCatalogSortCyclesAndOrders(t *testing.T) {
+ b := loadedBrowser()
+ if b.sortBy != catalogSortDefault {
+ t.Fatal("did not open on the backend's order")
+ }
+
+ browserKey(b, "o")
+ if b.sortBy != catalogSortName {
+ t.Fatalf("first sort = %v", b.sortBy)
+ }
+ if b.shown[0].Name != "llama3.2:8b" {
+ t.Errorf("name sort leads with %q", b.shown[0].Name)
+ }
+
+ browserKey(b, "o")
+ if b.sortBy != catalogSortSize {
+ t.Fatalf("second sort = %v", b.sortBy)
+ }
+ if b.shown[0].Name != "phi4:latest" {
+ t.Errorf("size sort leads with %q, want the largest", b.shown[0].Name)
+ }
+
+ browserKey(b, "o")
+ if b.sortBy != catalogSortDefault {
+ t.Error("sort did not cycle back round")
+ }
+}
+
+// TestCatalogEnterReturnsPullReadyName checks selecting a model closes the
+// browser and hands back the name the engine's download action accepts.
+func TestCatalogEnterReturnsPullReadyName(t *testing.T) {
+ b := loadedBrowser()
+ b.table.SetCursor(1)
+
+ picked, open := browserKey(b, "enter")
+ if open {
+ t.Error("browser stayed open after a selection")
+ }
+ if picked != "qwen3:4b" {
+ t.Errorf("picked %q, want the highlighted model's pull name", picked)
+ }
+}
+
+// TestCatalogEscapeSelectsNothing checks backing out downloads nothing.
+func TestCatalogEscapeSelectsNothing(t *testing.T) {
+ b := loadedBrowser()
+ picked, open := browserKey(b, "esc")
+ if open {
+ t.Error("esc left the browser open")
+ }
+ if picked != "" {
+ t.Errorf("esc picked %q", picked)
+ }
+}
+
+// TestCatalogSearchCapturesKeys checks the filter field owns the keyboard, so
+// typing a model name cannot trigger the sort or selection keys.
+//
+// The shell-level guarantee is asserted separately, through the detail screen
+// that owns the browser: an earlier version of this test called a
+// CapturingInput method on the browser that nothing in production consulted, so
+// it proved a path that never ran.
+func TestCatalogSearchCapturesKeys(t *testing.T) {
+ b := loadedBrowser()
+ browserKey(b, "/")
+ if !b.searching {
+ t.Fatal("search did not take the keyboard")
+ }
+
+ // 'o' is the sort key outside the field; inside it is a character.
+ before := b.sortBy
+ browserKey(b, "o")
+ if b.sortBy != before {
+ t.Error("a keystroke typed into the filter triggered the sort")
+ }
+
+ browserKey(b, "esc")
+ if b.searching {
+ t.Error("esc did not leave the filter")
+ }
+}
+
+// TestCatalogLoadFailureExplainsItself checks a failed load says so instead of
+// showing an empty list that reads as "this engine has nothing".
+func TestCatalogLoadFailureExplainsItself(t *testing.T) {
+ b := newCatalogBrowser(nil, "ollama", "Ollama", "this-host", false)
+ b.SetSize(100, 24)
+ b.update(catalogLoadedMsg{engine: "ollama", err: errFake{}})
+
+ if b.loading {
+ t.Error("still loading after a failure")
+ }
+ if b.status.render() == "" {
+ t.Error("failure produced no message")
+ }
+}
+
+// TestCatalogIgnoresOtherEnginesReply checks a late reply for an engine the
+// operator has moved on from does not populate this browser.
+func TestCatalogIgnoresOtherEnginesReply(t *testing.T) {
+ b := newCatalogBrowser(nil, "ollama", "Ollama", "this-host", false)
+ b.SetSize(100, 24)
+ b.update(catalogLoadedMsg{
+ engine: "lmstudio",
+ models: []catalogModel{{ID: "x", Name: "x"}},
+ })
+ if len(b.all) != 0 {
+ t.Error("accepted a catalog for a different engine")
+ }
+}
+
+// TestCatalogEmptyCatalogIsDistinctFromNoMatch checks the two empty states read
+// differently, because the fixes are different.
+func TestCatalogEmptyCatalogIsDistinctFromNoMatch(t *testing.T) {
+ b := newCatalogBrowser(nil, "vllm", "vLLM", "this-host", false)
+ b.SetSize(100, 24)
+ b.update(catalogLoadedMsg{engine: "vllm", models: nil})
+
+ if !strings.Contains(b.View(), "No catalog available") {
+ t.Errorf("view = %q", b.View())
+ }
+}
+
+func TestShortDate(t *testing.T) {
+ if got := shortDate("2026-07-21T02:07:47.321Z"); got != "2026-07-21" {
+ t.Errorf("shortDate = %q", got)
+ }
+ if got := shortDate("short"); got != "short" {
+ t.Errorf("shortDate passed through as %q", got)
+ }
+}
+
+// errFake is a minimal error for the failure path.
+type errFake struct{}
+
+func (errFake) Error() string { return "catalog unavailable" }
diff --git a/services/nvpair-tui/ui/cluster.go b/services/nvpair-tui/ui/cluster.go
deleted file mode 100644
index ac334edf..00000000
--- a/services/nvpair-tui/ui/cluster.go
+++ /dev/null
@@ -1,392 +0,0 @@
-// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
-// SPDX-License-Identifier: Apache-2.0
-
-package ui
-
-import (
- "fmt"
- "net"
- "strconv"
- "strings"
-
- "nvpair-tui/rpc"
-
- "github.com/charmbracelet/bubbles/key"
- "github.com/charmbracelet/bubbles/table"
- "github.com/charmbracelet/bubbles/textinput"
- tea "github.com/charmbracelet/bubbletea"
-)
-
-// clusterIdentity is this node's principal, from cluster:get-node-id.
-type clusterIdentity struct {
- NodeUUID string `json:"nodeUuid"`
- NodeID string `json:"nodeId"`
- Name string `json:"name"`
- ClusterID string `json:"clusterId"`
-}
-
-// clusterNode mirrors nvpair-cluster-manager's ClusterNode (a member or
-// pending invitee), the element of nodes:get-initial / nodes:changed.
-type clusterNode struct {
- ID string `json:"id"`
- NodeUUID string `json:"nodeUuid"`
- Name string `json:"name"`
- IPAddress string `json:"ipAddress"`
- Port int `json:"port"`
- State string `json:"state"`
-}
-
-// clusterInvite is the broker-facing view of a pairing session
-// (cluster:invite-received push / cluster:invite-node result).
-type clusterInvite struct {
- InviteID string `json:"inviteId"`
- FromNodeName string `json:"fromNodeName"`
- Pin *string `json:"pin"`
- State string `json:"state"`
-}
-
-type clusterInputMode int
-
-const (
- clusterInputNone clusterInputMode = iota
- clusterInputAddress
- clusterInputPin
-)
-
-// clusterView drives node pairing and membership: identity, the member
-// roster (live from nodes:changed), outbound invites (showing the PIN to
-// read to the joiner), and inbound invites (entering the PIN to accept).
-type clusterView struct {
- client *rpc.Client
- table table.Model
- identity clusterIdentity
- nodes []clusterNode
- pending *clusterInvite // most recent inbound invite awaiting a response
- input textinput.Model
- mode clusterInputMode
- status string
-
- width, height int
-}
-
-type clusterIdentityMsg struct {
- id clusterIdentity
- err error
-}
-
-type clusterNodesMsg struct {
- nodes []clusterNode
- err error
-}
-
-type clusterActionMsg struct {
- what string
- pin string
- rejected bool
- reason string
- err error
-}
-
-var (
- clInviteKey = key.NewBinding(key.WithKeys("i"), key.WithHelp("i", "invite node"))
- clRemoveKey = key.NewBinding(key.WithKeys("r"), key.WithHelp("r", "remove member"))
- clAcceptKey = key.NewBinding(key.WithKeys("a"), key.WithHelp("a", "accept invite"))
- clDeclineKey = key.NewBinding(key.WithKeys("d"), key.WithHelp("d", "decline invite"))
- clLeaveKey = key.NewBinding(key.WithKeys("L"), key.WithHelp("L", "leave cluster"))
-)
-
-func newClusterView(client *rpc.Client) *clusterView {
- ti := textinput.New()
- v := &clusterView{client: client, input: ti}
- v.table = newTable(nil)
- return v
-}
-
-func (v *clusterView) Title() string { return "Cluster" }
-
-func (v *clusterView) Init() tea.Cmd {
- return tea.Batch(v.identityCmd(), v.nodesCmd())
-}
-
-func (v *clusterView) identityCmd() tea.Cmd {
- return call(v.client, "cluster:get-node-id", nil, func(msg *rpc.Message, err error) tea.Msg {
- if err != nil {
- return clusterIdentityMsg{err: err}
- }
- var id clusterIdentity
- _ = decodeParams(msg.Result, &id)
- return clusterIdentityMsg{id: id}
- })
-}
-
-func (v *clusterView) nodesCmd() tea.Cmd {
- return call(v.client, "nodes:get-initial", nil, func(msg *rpc.Message, err error) tea.Msg {
- if err != nil {
- return clusterNodesMsg{err: err}
- }
- var r struct {
- Nodes []clusterNode `json:"nodes"`
- }
- _ = decodeParams(msg.Result, &r)
- return clusterNodesMsg{nodes: r.Nodes}
- })
-}
-
-func (v *clusterView) SetSize(w, h int) {
- v.width, v.height = w, h
- const state, port = 12, 7
- id := clampWidth((w-state-port-2)/2, 8)
- name := clampWidth(w-state-port-id-2, 10)
- v.table.SetColumns([]table.Column{
- {Title: "ID", Width: id},
- {Title: "NAME", Width: name},
- {Title: "STATE", Width: state},
- {Title: "PORT", Width: port},
- })
- v.table.SetWidth(w)
- v.table.SetHeight(clampWidth(h-7, 1))
-}
-
-func (v *clusterView) CapturingInput() bool { return v.mode != clusterInputNone }
-
-func (v *clusterView) Update(msg tea.Msg) tea.Cmd {
- switch msg := msg.(type) {
- case clusterIdentityMsg:
- if msg.err == nil {
- v.identity = msg.id
- }
- return nil
- case clusterNodesMsg:
- if msg.err == nil {
- v.setNodes(msg.nodes)
- }
- return nil
- case clusterActionMsg:
- if msg.err != nil {
- v.status = msg.what + " failed: " + msg.err.Error()
- } else if msg.rejected {
- v.status = fmt.Sprintf("invite rejected (%s) - remove the existing relationship first", rejectReason(msg.reason))
- } else if msg.pin != "" {
- v.status = fmt.Sprintf("invite sent - PIN %s (read it to the joining node)", msg.pin)
- } else {
- v.status = msg.what + " ok"
- }
- return nil
- case NotificationMsg:
- return v.handleNotification(msg.Msg)
- case tea.KeyMsg:
- return v.handleKey(msg)
- }
- return nil
-}
-
-func (v *clusterView) handleNotification(msg *rpc.Message) tea.Cmd {
- switch msg.Method {
- case "nodes:changed":
- var r struct {
- Nodes []clusterNode `json:"nodes"`
- }
- _ = decodeParams(msg.Params, &r)
- v.setNodes(r.Nodes)
- case "cluster:identity-changed":
- var r struct {
- ClusterID string `json:"clusterId"`
- }
- _ = decodeParams(msg.Params, &r)
- v.identity.ClusterID = r.ClusterID
- case "cluster:invite-received":
- var inv clusterInvite
- _ = decodeParams(msg.Params, &inv)
- v.pending = &inv
- v.status = "invite received from " + inv.FromNodeName + " - press a to accept, d to decline"
- }
- return nil
-}
-
-func (v *clusterView) handleKey(msg tea.KeyMsg) tea.Cmd {
- if v.mode != clusterInputNone {
- switch msg.String() {
- case "enter":
- return v.submit()
- case "esc":
- v.cancelInput()
- return nil
- }
- var cmd tea.Cmd
- v.input, cmd = v.input.Update(msg)
- return cmd
- }
-
- switch {
- case key.Matches(msg, clInviteKey):
- v.beginInput(clusterInputAddress, "host (or host:port; default 14321)")
- return textinput.Blink
- case key.Matches(msg, clAcceptKey):
- if v.pending != nil {
- v.beginInput(clusterInputPin, "PIN from inviting node")
- return textinput.Blink
- }
- return nil
- case key.Matches(msg, clDeclineKey):
- return v.respondToInvite(false, "")
- case key.Matches(msg, clRemoveKey):
- return v.removeSelected()
- case key.Matches(msg, clLeaveKey):
- return v.leaveCluster()
- }
- var cmd tea.Cmd
- v.table, cmd = v.table.Update(msg)
- return cmd
-}
-
-func (v *clusterView) beginInput(mode clusterInputMode, placeholder string) {
- v.mode = mode
- v.input.SetValue("")
- v.input.Placeholder = placeholder
- v.input.Focus()
-}
-
-func (v *clusterView) cancelInput() {
- v.mode = clusterInputNone
- v.input.Blur()
-}
-
-func (v *clusterView) submit() tea.Cmd {
- val := strings.TrimSpace(v.input.Value())
- mode := v.mode
- v.cancelInput()
- switch mode {
- case clusterInputAddress:
- if val == "" {
- v.status = "address required"
- return nil
- }
- // nvpair-cluster-manager treats "address" as a bare host and appends the
- // port itself (default 14321). If the operator typed host:port, split
- // it so the port lands in the manager's separate int field instead of
- // being glued onto the host (which would dial [host:port]:14321).
- params := map[string]any{"address": val}
- if host, portStr, err := net.SplitHostPort(val); err == nil {
- if port, perr := strconv.Atoi(portStr); perr == nil {
- params["address"] = host
- params["port"] = port
- }
- }
- v.status = "inviting " + val + "..."
- return inviteNodeCmd(v.client, params, func(res inviteNodeResult, err error) tea.Msg {
- if err != nil {
- return clusterActionMsg{what: "invite", err: err}
- }
- if res.State == "rejected" {
- return clusterActionMsg{what: "invite", rejected: true, reason: res.Reason}
- }
- pin := ""
- if res.Pin != nil {
- pin = *res.Pin
- }
- return clusterActionMsg{what: "invite", pin: pin}
- })
- case clusterInputPin:
- return v.respondToInvite(true, val)
- }
- return nil
-}
-
-func (v *clusterView) respondToInvite(accept bool, pin string) tea.Cmd {
- if v.pending == nil {
- return nil
- }
- params := map[string]any{"inviteId": v.pending.InviteID, "accept": accept}
- if accept && pin != "" {
- params["pin"] = pin
- }
- v.pending = nil
- what := "decline invite"
- if accept {
- what = "accept invite"
- }
- return call(v.client, "cluster:respond-to-invite", params, func(_ *rpc.Message, err error) tea.Msg {
- return clusterActionMsg{what: what, err: err}
- })
-}
-
-// leaveCluster unjoins this node from its cluster (cluster:leave). The
-// cluster-manager tears down local trust and pushes cluster:identity-changed
-// (empty) + nodes:changed (empty), which refresh the view; the broker persists
-// the now-unclustered state so the node stays out after a restart.
-func (v *clusterView) leaveCluster() tea.Cmd {
- if v.identity.ClusterID == "" {
- v.status = "not in a cluster"
- return nil
- }
- return call(v.client, "cluster:leave", nil, func(_ *rpc.Message, err error) tea.Msg {
- return clusterActionMsg{what: "leave cluster", err: err}
- })
-}
-
-func (v *clusterView) removeSelected() tea.Cmd {
- idx := v.table.Cursor()
- if idx < 0 || idx >= len(v.nodes) {
- return nil
- }
- n := v.nodes[idx]
- // Remove by the stable nodeUuid, not the display name: a member that renamed
- // its PC still carries the same UUID, so keying on the (possibly stale) shown
- // name would silently fail to match. Fall back to nodeId only if the
- // manager didn't supply a UUID.
- params := map[string]string{}
- if n.NodeUUID != "" {
- params["nodeUuid"] = n.NodeUUID
- } else {
- params["nodeId"] = n.ID
- }
- return call(v.client, "nodes:remove", params, func(_ *rpc.Message, err error) tea.Msg {
- return clusterActionMsg{what: "remove " + n.ID, err: err}
- })
-}
-
-func (v *clusterView) setNodes(nodes []clusterNode) {
- v.nodes = nodes
- rows := make([]table.Row, 0, len(nodes))
- for _, n := range nodes {
- rows = append(rows, table.Row{
- truncate(n.ID, 14),
- n.Name,
- n.State,
- strconv.Itoa(n.Port),
- })
- }
- v.table.SetRows(rows)
-}
-
-func (v *clusterView) View() string {
- var b strings.Builder
- cluster := v.identity.ClusterID
- if cluster == "" {
- cluster = "(none - invite a node to form one)"
- }
- b.WriteString(titleStyle.Render("This node"))
- b.WriteByte('\n')
- b.WriteString(fmt.Sprintf(" name=%s nodeId=%s\n", v.identity.Name, truncate(v.identity.NodeID, 24)))
- b.WriteString(" cluster=" + cluster + "\n\n")
-
- b.WriteString(titleStyle.Render("Members"))
- b.WriteByte('\n')
- if len(v.nodes) == 0 {
- b.WriteString(footerStyle.Render("No members."))
- } else {
- b.WriteString(v.table.View())
- }
-
- if v.mode != clusterInputNone {
- b.WriteString("\n" + v.input.View())
- }
- if v.status != "" {
- b.WriteString("\n" + footerStyle.Render(v.status))
- }
- return b.String()
-}
-
-func (v *clusterView) Help() []key.Binding {
- return []key.Binding{clInviteKey, clAcceptKey, clDeclineKey, clRemoveKey, clLeaveKey}
-}
diff --git a/services/nvpair-tui/ui/demo.go b/services/nvpair-tui/ui/demo.go
new file mode 100644
index 00000000..04f744e5
--- /dev/null
+++ b/services/nvpair-tui/ui/demo.go
@@ -0,0 +1,400 @@
+// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+package ui
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "runtime"
+ "strconv"
+ "strings"
+ "time"
+
+ tea "github.com/charmbracelet/bubbletea"
+)
+
+// The Inference Demo's runtime half: model discovery, submission, and the
+// node-local state the Jobs tab renders. The plan itself is in demoschedule.go.
+//
+// Demo state is deliberately node-local and unsynchronized. A demo running on a
+// peer is that peer's business; this tracks only what this process started, which
+// is why none of it goes near the service push bus.
+
+// dispatcherName is the HTTP client the demo spawns once per request.
+//
+// It is not a worker: it speaks no JSON-RPC, nothing supervises it, and it is
+// absent from versions.json. It ships beside this binary because services/build.sh
+// puts it there, which is the same rule used to find the broker.
+const dispatcherName = "inference-dispatcher"
+
+// demoStatus is where a run is.
+//
+// There is deliberately no draining state. Stopping, or reaching the end of the
+// schedule, ends the demo immediately as far as the operator is concerned:
+// requests already submitted are left to finish on their own and land as
+// ordinary job activity. Waiting on them would mean waiting on work the demo is
+// explicitly not allowed to cancel or report on.
+type demoStatus int
+
+const (
+ demoIdle demoStatus = iota
+ demoPreparing
+ demoRunning
+)
+
+// demoRunner owns one node's demo.
+//
+// The schedule is driven by the shell's one-second tick rather than by timers of
+// its own. Every submission offset in demoschedule.go is a whole number of
+// seconds, so a one-second tick is exactly enough resolution, and it keeps the
+// whole thing inside Bubble Tea's update loop where it can be tested by stepping
+// a clock instead of by sleeping.
+type demoRunner struct {
+ status demoStatus
+ started time.Time
+ schedule []demoRequest
+ // next is the index of the first request not yet submitted. The schedule is
+ // sorted by time, so this cursor is all that is needed to find what is due.
+ next int
+ submitted int
+ targetCount int
+ engineCount int
+
+ // gen invalidates an in-flight discovery whose run has since been stopped.
+ // Discovery spawns processes and can take seconds, and without this a stop
+ // during it would be overwritten by its own reply.
+ gen int
+
+ // ctx bounds every child this runner spawns, and is cancelled when the
+ // process is shutting down — not when a run stops. Stop must leave
+ // in-flight requests alone; quitting should not orphan them.
+ ctx context.Context
+ cancel context.CancelFunc
+
+ // executable is resolved once, at first start.
+ executable string
+}
+
+func newDemoRunner() *demoRunner {
+ ctx, cancel := context.WithCancel(context.Background())
+ return &demoRunner{ctx: ctx, cancel: cancel}
+}
+
+// close kills anything still running. Called when the client is quitting.
+func (d *demoRunner) close() {
+ if d.cancel != nil {
+ d.cancel()
+ }
+}
+
+// demoTargetsMsg is the outcome of model discovery.
+type demoTargetsMsg struct {
+ gen int
+ targets []demoTarget
+ err error
+}
+
+// resolveDispatcher finds the dispatcher beside this executable.
+//
+// The same rule the broker uses, and for the same reason: an installed bundle
+// puts every binary in one directory, so "next to me" is the only location that
+// is correct for the tarball, the Debian package, and a packaged desktop app
+// alike. There is deliberately no search path — a missing dispatcher is a broken
+// install, and saying so is more useful than finding some other copy.
+func resolveDispatcher() (string, error) {
+ bin := dispatcherName
+ if runtime.GOOS == "windows" {
+ bin += ".exe"
+ }
+ exe, err := os.Executable()
+ if err != nil {
+ return "", fmt.Errorf("locate own executable: %w", err)
+ }
+ candidate := filepath.Join(filepath.Dir(exe), bin)
+ if _, err := os.Stat(candidate); err != nil {
+ return "", fmt.Errorf("%s not found next to nvpair-tui", bin)
+ }
+ return candidate, nil
+}
+
+// dispatcherEnv is the environment for a demo child.
+//
+// The whole INFERENCE_DISPATCHER_* namespace is dropped rather than any single
+// variable: _CONFIG would load an arbitrary JSON config, _RESULT_LOG and
+// _DEBUG_ERROR_LOG would make the child write inference metadata to disk, and
+// _LOOP would run past the sixty-second ceiling. The schedule is the only thing
+// that decides what a demo child does.
+//
+// The comparison is case-insensitive because Windows matches environment names
+// that way: a lowercase inference_dispatcher_loop in this process's environment
+// would still reach the child as INFERENCE_DISPATCHER_LOOP.
+func dispatcherEnv() []string {
+ const prefix = "inference_dispatcher_"
+ src := os.Environ()
+ out := make([]string, 0, len(src))
+ for _, kv := range src {
+ name, _, _ := strings.Cut(kv, "=")
+ if strings.HasPrefix(strings.ToLower(name), prefix) {
+ continue
+ }
+ out = append(out, kv)
+ }
+ return out
+}
+
+// dispatcherModel is one entry of the dispatcher's --list-models output.
+type dispatcherModel struct {
+ Name string `json:"name"`
+ Type string `json:"type"`
+ Capabilities []string `json:"capabilities"`
+}
+
+// generates reports whether a model can serve a text-generation request.
+//
+// Mirrors supportsGeneration in the Go client: an explicit type wins, otherwise
+// capabilities decide, and a model advertising neither gets the benefit of the
+// doubt — which is what the dispatcher itself does.
+func (m dispatcherModel) generates() bool {
+ if m.Type != "" {
+ return strings.EqualFold(m.Type, "llm")
+ }
+ if len(m.Capabilities) == 0 {
+ return true
+ }
+ for _, c := range m.Capabilities {
+ switch strings.ToLower(c) {
+ case "completion", "chat", "generate":
+ return true
+ }
+ }
+ return false
+}
+
+// start begins discovery for a run.
+//
+// Targets are the engine/model pairs the local proxies expose. Ports come from
+// the broker's live proxy status, never a constant, and a proxy that is not ready
+// is simply not a target — an absent engine is not an error.
+func (d *demoRunner) start(proxy *proxyTracker) (tea.Cmd, error) {
+ if d.status != demoIdle {
+ return nil, fmt.Errorf("a demo is already running on this node")
+ }
+ if d.executable == "" {
+ exe, err := resolveDispatcher()
+ if err != nil {
+ return nil, err
+ }
+ d.executable = exe
+ }
+
+ type probe struct {
+ backend string
+ port int
+ }
+ probes := make([]probe, 0, len(proxy.engines))
+ for _, e := range proxy.engines {
+ if !e.ready || e.port == 0 {
+ continue
+ }
+ probes = append(probes, probe{backend: dispatcherBackend(e.engine), port: e.port})
+ }
+ if len(probes) == 0 {
+ return nil, fmt.Errorf("no proxy is listening yet - wait for the endpoints above to come up")
+ }
+
+ d.gen++
+ gen := d.gen
+ d.status = demoPreparing
+ d.schedule, d.next, d.submitted = nil, 0, 0
+ d.targetCount, d.engineCount = 0, 0
+
+ exe, ctx := d.executable, d.ctx
+ return func() tea.Msg {
+ var targets []demoTarget
+ for _, p := range probes {
+ targets = append(targets, probeModels(ctx, exe, p.backend, p.port)...)
+ }
+ return demoTargetsMsg{gen: gen, targets: targets}
+ }, nil
+}
+
+// dispatcherBackend maps an engine-manager engine name to the dispatcher's own
+// spelling of it. They agree for Ollama and differ for LM Studio, and the
+// dispatcher rejects a name it does not know.
+func dispatcherBackend(engine string) string {
+ if strings.EqualFold(engine, "lmstudio") {
+ return "lmstudio"
+ }
+ return engine
+}
+
+// probeModels asks one engine for its inventory. Any failure means that engine
+// contributes no targets; it is never a demo failure.
+func probeModels(ctx context.Context, exe, backend string, port int) []demoTarget {
+ ctx, cancel := context.WithTimeout(ctx, demoProbeTimeout+2*time.Second)
+ defer cancel()
+
+ cmd := exec.CommandContext(ctx, exe,
+ "--backend", backend,
+ "--port", strconv.Itoa(port),
+ "--timeout", strconv.Itoa(int(demoProbeTimeout.Seconds())),
+ "--list-models",
+ )
+ cmd.Env = dispatcherEnv()
+ out, err := cmd.Output()
+ if err != nil {
+ return nil
+ }
+
+ var models []dispatcherModel
+ if err := json.Unmarshal(out, &models); err != nil {
+ return nil
+ }
+ targets := make([]demoTarget, 0, len(models))
+ for _, m := range models {
+ if m.Name == "" || !m.generates() {
+ continue
+ }
+ targets = append(targets, demoTarget{backend: backend, port: port, model: m.Name})
+ }
+ return targets
+}
+
+// armed folds a discovery reply into a live run, reporting whether the run
+// started. A stale generation or an empty inventory both leave the runner idle.
+func (d *demoRunner) armed(msg demoTargetsMsg) bool {
+ if msg.gen != d.gen || d.status != demoPreparing {
+ return false
+ }
+ if len(msg.targets) == 0 {
+ d.reset()
+ return false
+ }
+
+ engines := map[string]struct{}{}
+ for _, t := range msg.targets {
+ engines[fmt.Sprintf("%s:%d", t.backend, t.port)] = struct{}{}
+ }
+
+ d.schedule = buildDemoSchedule(msg.targets)
+ d.next, d.submitted = 0, 0
+ d.targetCount = len(msg.targets)
+ d.engineCount = len(engines)
+ d.started = time.Now()
+ d.status = demoRunning
+ return true
+}
+
+// tick submits whatever the clock has made due and reports whether the window
+// has closed.
+//
+// Elapsed time is measured against the wall clock rather than counted in ticks,
+// so a stalled or suspended process resumes at the right point in the schedule
+// instead of stretching the run. The ceiling is enforced on the same clock,
+// which is what makes "nothing at or after sixty seconds" true even if the
+// update loop was blocked across it.
+func (d *demoRunner) tick() (cmds []tea.Cmd, finished bool) {
+ if d.status != demoRunning {
+ return nil, false
+ }
+ elapsed := time.Since(d.started)
+ if elapsed >= demoMaxSubmit {
+ d.reset()
+ return nil, true
+ }
+
+ exe, ctx := d.executable, d.ctx
+ for d.next < len(d.schedule) && d.schedule[d.next].at <= elapsed {
+ req := d.schedule[d.next]
+ d.next++
+ d.submitted++
+ cmds = append(cmds, submitDemoRequest(ctx, exe, req))
+ }
+ if d.next >= len(d.schedule) {
+ // Every planned request is away. The window is over as far as the
+ // operator is concerned; the requests themselves finish on their own.
+ d.reset()
+ return cmds, true
+ }
+ return cmds, false
+}
+
+// submitDemoRequest spawns one dispatcher and does not wait for it.
+//
+// The command is returned rather than run inline so the spawn happens off the
+// update loop. Nothing about demo state depends on when the child finishes: the
+// evidence it produces is a job on the table, which arrives over the workload
+// stream like any other.
+func submitDemoRequest(ctx context.Context, exe string, req demoRequest) tea.Cmd {
+ stage := demoStages[req.stage]
+ args := []string{
+ "--backend", req.target.backend,
+ "--port", strconv.Itoa(req.target.port),
+ "--model", req.target.model,
+ "--prompt", stage.prompt,
+ "--count", "1",
+ "--mode", "series",
+ "--concurrency", "1",
+ "--timeout", strconv.Itoa(int(demoRequestTimeout.Seconds())),
+ "--max-tokens", strconv.Itoa(stage.maxTokens),
+ "--temperature", strconv.FormatFloat(stage.temperature, 'f', -1, 64),
+ }
+ // Ollama's separate reasoning channel would inflate latency and token counts
+ // without changing what the demo shows, so it is switched off where it exists.
+ if req.target.backend == "ollama" {
+ args = append(args, "--ollama-think", "false")
+ }
+
+ return func() tea.Msg {
+ cmd := exec.CommandContext(ctx, exe, args...)
+ cmd.Env = dispatcherEnv()
+ // No pipes: the child's output is inference content, and this process
+ // has no business reading it. Start rather than Run, and Wait in a
+ // goroutine purely to reap the child.
+ if err := cmd.Start(); err != nil {
+ // One request failing to spawn is not a demo failure. It is also not
+ // worth a message: the operator asked for traffic, not a per-request
+ // report, and the schedule carries on.
+ return nil
+ }
+ go func() { _ = cmd.Wait() }()
+ return nil
+ }
+}
+
+// stop ends the run now. In-flight requests are deliberately left alone.
+func (d *demoRunner) stop() {
+ d.gen++
+ d.reset()
+}
+
+func (d *demoRunner) reset() {
+ d.status = demoIdle
+ d.schedule, d.next, d.submitted = nil, 0, 0
+ d.targetCount, d.engineCount = 0, 0
+ d.started = time.Time{}
+}
+
+// note is the demo's line on the Jobs tab, or empty when nothing is running.
+func (d *demoRunner) note() string {
+ switch d.status {
+ case demoPreparing:
+ return footerStyle.Render(" Inference demo: finding models...")
+ case demoRunning:
+ left := demoMaxSubmit - time.Since(d.started)
+ if left < 0 {
+ left = 0
+ }
+ return statusOKStyle.Render(fmt.Sprintf(
+ " Inference demo: %d/%d sent across %d model(s) on %d engine(s) - %ds left, press t to stop",
+ d.submitted, len(d.schedule), d.targetCount, d.engineCount,
+ int(left.Round(time.Second).Seconds())))
+ default:
+ return ""
+ }
+}
diff --git a/services/nvpair-tui/ui/demo_test.go b/services/nvpair-tui/ui/demo_test.go
new file mode 100644
index 00000000..98bef410
--- /dev/null
+++ b/services/nvpair-tui/ui/demo_test.go
@@ -0,0 +1,420 @@
+// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+package ui
+
+import (
+ "context"
+ "os"
+ "path/filepath"
+ "runtime"
+ "slices"
+ "strings"
+ "testing"
+ "time"
+)
+
+// The demo's guarantees are all about what it does NOT do: nothing at or after
+// the ceiling, no target skipped, no request replayed, nothing left running when
+// the operator stops it. Each of those is a property of the plan or of the
+// cursor, so they are asserted directly rather than by running a demo.
+
+func targets(n int) []demoTarget {
+ out := make([]demoTarget, 0, n)
+ for i := 0; i < n; i++ {
+ out = append(out, demoTarget{
+ backend: "ollama",
+ port: 11434,
+ model: string(rune('a'+i%26)) + "-model",
+ })
+ }
+ return out
+}
+
+func TestScheduleSubmitsNothingAtOrAfterTheCeiling(t *testing.T) {
+ // The ceiling is the demo's one hard promise: a burst that outlives its
+ // window is indistinguishable from a load generator someone forgot about.
+ for _, count := range []int{1, 3, 7, 60, 61, 200} {
+ schedule := buildDemoSchedule(targets(count))
+ if len(schedule) == 0 {
+ t.Fatalf("%d targets produced an empty schedule", count)
+ }
+ for _, req := range schedule {
+ if req.at >= demoMaxSubmit {
+ t.Errorf("%d targets: request planned at %s, ceiling is %s",
+ count, req.at, demoMaxSubmit)
+ }
+ }
+ }
+}
+
+func TestScheduleLastSubmissionLandsAtFiftyEight(t *testing.T) {
+ // Pins the cohort/stage arithmetic against a silent change: if the omitted
+ // 50s cohort were reinstated, or a stage offset moved, the window would
+ // quietly stop being what both front ends document.
+ schedule := buildDemoSchedule(targets(4))
+ var last time.Duration
+ for _, req := range schedule {
+ if req.at > last {
+ last = req.at
+ }
+ }
+ if want := 58 * time.Second; last != want {
+ t.Errorf("last submission at %s, want %s", last, want)
+ }
+}
+
+func TestScheduleTouchesEveryTargetBeforeRepeatingOne(t *testing.T) {
+ // The round-robin exists so a host with many models demonstrates all of
+ // them. Assigning targets before sorting by time would still touch them all
+ // eventually, but not before revisiting some — and a demo that sends four
+ // requests to one model and none to another is not showing the router.
+ const count = 17
+ schedule := buildDemoSchedule(targets(count))
+ if len(schedule) < count {
+ t.Fatalf("schedule has %d requests, too few for %d targets", len(schedule), count)
+ }
+
+ seen := map[string]int{}
+ for _, req := range schedule[:count] {
+ seen[req.target.model]++
+ }
+ if len(seen) != count {
+ t.Errorf("first %d requests covered %d distinct targets, want %d",
+ count, len(seen), count)
+ }
+ for model, n := range seen {
+ if n != 1 {
+ t.Errorf("target %q received %d of the first %d requests, want 1", model, n, count)
+ }
+ }
+}
+
+func TestScheduleAddsAgentsSoEveryTargetFits(t *testing.T) {
+ // Beyond the 60 requests the base agent count produces, the schedule has to
+ // grow rather than drop targets off the end.
+ const count = 130
+ schedule := buildDemoSchedule(targets(count))
+
+ seen := map[string]struct{}{}
+ for _, req := range schedule {
+ seen[req.target.model] = struct{}{}
+ }
+ // 130 targets over a 26-letter model alphabet is 26 distinct names; what
+ // matters is that the schedule is long enough to cover the target count.
+ if len(schedule) < count {
+ t.Errorf("%d targets produced only %d requests", count, len(schedule))
+ }
+ if got := demoAgentsPerCohort(count); got <= demoBaseAgentsPerCohort {
+ t.Errorf("agents per cohort %d did not grow past the base %d",
+ got, demoBaseAgentsPerCohort)
+ }
+}
+
+func TestScheduleIsEmptyWithoutTargets(t *testing.T) {
+ if got := buildDemoSchedule(nil); got != nil {
+ t.Errorf("no targets produced %d requests, want none", len(got))
+ }
+}
+
+// tickAt runs the runner's tick as though the given time had elapsed.
+func tickAt(t *testing.T, d *demoRunner, elapsed time.Duration) (int, bool) {
+ t.Helper()
+ d.started = time.Now().Add(-elapsed)
+ cmds, finished := d.tick()
+ return len(cmds), finished
+}
+
+// armedRunner is a runner mid-run, without spawning anything. The executable is
+// a path that does not exist, which is safe because no test here runs a command.
+func armedRunner(t *testing.T, targetCount int) *demoRunner {
+ t.Helper()
+ d := newDemoRunner()
+ t.Cleanup(d.close)
+ d.executable = "/nonexistent/inference-dispatcher"
+ d.status = demoPreparing
+ d.gen = 1
+ if !d.armed(demoTargetsMsg{gen: 1, targets: targets(targetCount)}) {
+ t.Fatal("runner did not arm")
+ }
+ return d
+}
+
+func TestTickSubmitsEachRequestExactlyOnce(t *testing.T) {
+ // The cursor is what prevents a replay. Ticking repeatedly over the same
+ // window must not resend anything, which a "submit everything due" loop
+ // without the cursor would do on every single tick.
+ d := armedRunner(t, 3)
+ planned := len(d.schedule)
+
+ total := 0
+ for elapsed := time.Duration(0); elapsed < demoMaxSubmit; elapsed += time.Second {
+ n, finished := tickAt(t, d, elapsed)
+ total += n
+ if finished {
+ break
+ }
+ // The counter drives the progress note, so it has to agree with the
+ // spawns while the run is live. It is reset once the window closes,
+ // which is why this is checked here and not after the loop.
+ if d.submitted != total {
+ t.Fatalf("at %s the runner counted %d submitted, %d were spawned",
+ elapsed, d.submitted, total)
+ }
+ }
+ if total != planned {
+ t.Errorf("submitted %d of %d planned requests", total, planned)
+ }
+}
+
+func TestTickSubmitsNothingOnceTheWindowHasClosed(t *testing.T) {
+ // The wall clock, not the tick count, enforces the ceiling — so a process
+ // that was suspended across the whole window must come back to a finished
+ // demo rather than flushing sixty requests at once.
+ d := armedRunner(t, 3)
+
+ n, finished := tickAt(t, d, demoMaxSubmit+30*time.Second)
+ if n != 0 {
+ t.Errorf("submitted %d requests after the ceiling, want 0", n)
+ }
+ if !finished {
+ t.Error("tick past the ceiling did not finish the run")
+ }
+ if d.status != demoIdle {
+ t.Errorf("status %v after the ceiling, want idle", d.status)
+ }
+}
+
+func TestStopEndsTheRunAndIgnoresLateDiscovery(t *testing.T) {
+ // Discovery spawns processes and can take seconds. Without the generation
+ // guard a stop during it would be undone by its own reply, starting a demo
+ // the operator had already cancelled.
+ d := newDemoRunner()
+ t.Cleanup(d.close)
+ d.executable = "/nonexistent/inference-dispatcher"
+ d.status = demoPreparing
+ d.gen = 1
+
+ d.stop()
+ if d.status != demoIdle {
+ t.Fatalf("status %v after stop, want idle", d.status)
+ }
+
+ if d.armed(demoTargetsMsg{gen: 1, targets: targets(2)}) {
+ t.Error("a stale discovery reply started a run")
+ }
+ if d.status != demoIdle {
+ t.Errorf("status %v after a stale reply, want idle", d.status)
+ }
+}
+
+func TestStopMidRunLeavesNothingScheduled(t *testing.T) {
+ d := armedRunner(t, 3)
+ if n, _ := tickAt(t, d, 0); n == 0 {
+ t.Fatal("no requests were due at the start of the window")
+ }
+
+ d.stop()
+ n, finished := tickAt(t, d, 5*time.Second)
+ if n != 0 {
+ t.Errorf("submitted %d requests after stop, want 0", n)
+ }
+ if finished {
+ t.Error("a tick after stop reported the run finishing again")
+ }
+}
+
+func TestEmptyInventoryDoesNotStartARun(t *testing.T) {
+ // An engine that is up but has no model is the common case on a fresh
+ // install, and it must read as "install a model", not as a broken demo.
+ d := newDemoRunner()
+ t.Cleanup(d.close)
+ d.status = demoPreparing
+ d.gen = 1
+
+ if d.armed(demoTargetsMsg{gen: 1}) {
+ t.Error("armed with no targets")
+ }
+ if d.status != demoIdle {
+ t.Errorf("status %v, want idle", d.status)
+ }
+}
+
+func TestStartRefusesWhenNoProxyIsListening(t *testing.T) {
+ // The demo targets proxy ports, never an engine's own, so with no proxy up
+ // there is nowhere legitimate to send traffic. It must refuse rather than
+ // invent a port.
+ d := newDemoRunner()
+ t.Cleanup(d.close)
+ d.executable = "/nonexistent/inference-dispatcher"
+
+ tracker := newProxyTracker()
+ if _, err := d.start(tracker); err == nil {
+ t.Fatal("start succeeded with both proxies down")
+ }
+ if d.status != demoIdle {
+ t.Errorf("status %v after a refused start, want idle", d.status)
+ }
+}
+
+func TestStartRefusesASecondConcurrentRun(t *testing.T) {
+ d := armedRunner(t, 2)
+ tracker := newProxyTracker()
+ tracker.engines[0].ready = true
+ tracker.engines[0].port = 11434
+
+ if _, err := d.start(tracker); err == nil {
+ t.Error("a second demo started while one was running")
+ }
+}
+
+func TestDispatcherEnvDropsTheWholeDispatcherNamespace(t *testing.T) {
+ // _CONFIG loads an arbitrary config, _LOOP runs past the ceiling, and the
+ // log variables write inference metadata to disk. Stripping the prefix
+ // rather than a list is what keeps a newly added variable from becoming a
+ // way to redirect the demo.
+ t.Setenv("INFERENCE_DISPATCHER_LOOP", "true")
+ t.Setenv("INFERENCE_DISPATCHER_CONFIG", "/tmp/evil.json")
+ // Lowercase because Windows matches environment names case-insensitively,
+ // so this would still reach a child as INFERENCE_DISPATCHER_RESULT_LOG.
+ t.Setenv("inference_dispatcher_result_log", "/tmp/leak.jsonl")
+ t.Setenv("PAIR_DEMO_KEEPME", "1")
+
+ var kept bool
+ for _, kv := range dispatcherEnv() {
+ name, _, _ := strings.Cut(kv, "=")
+ if strings.HasPrefix(strings.ToLower(name), "inference_dispatcher_") {
+ t.Errorf("child environment still carries %q", name)
+ }
+ if name == "PAIR_DEMO_KEEPME" {
+ kept = true
+ }
+ }
+ if !kept {
+ t.Error("stripping removed an unrelated variable")
+ }
+ if _, ok := os.LookupEnv("INFERENCE_DISPATCHER_LOOP"); !ok {
+ t.Error("this process's own environment was modified")
+ }
+}
+
+func TestGeneratesMirrorsTheDispatcher(t *testing.T) {
+ // A model advertising neither a type nor capabilities gets the benefit of
+ // the doubt, because that is what the dispatcher itself does — being
+ // stricter here would silently exclude models the demo could have used.
+ cases := []struct {
+ name string
+ model dispatcherModel
+ want bool
+ }{
+ {"explicit llm", dispatcherModel{Type: "LLM"}, true},
+ {"explicit embedding", dispatcherModel{Type: "embeddings"}, false},
+ {"chat capability", dispatcherModel{Capabilities: []string{"vision", "chat"}}, true},
+ {"no generation capability", dispatcherModel{Capabilities: []string{"embedding"}}, false},
+ {"nothing declared", dispatcherModel{}, true},
+ {"type wins over capabilities", dispatcherModel{
+ Type: "embeddings", Capabilities: []string{"chat"}}, false},
+ // Verbatim from a live LM Studio: a real chat model whose advertised
+ // capabilities name neither chat nor completion. Checking capabilities
+ // ahead of the type would exclude the only usable model on the host, so
+ // the ordering above is load-bearing rather than arbitrary.
+ {"real llm with unrelated capabilities", dispatcherModel{
+ Type: "llm",
+ Capabilities: []string{"trained_for_tool_use", "vision"},
+ }, true},
+ {"real embedding model", dispatcherModel{Type: "embedding"}, false},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ if got := tc.model.generates(); got != tc.want {
+ t.Errorf("generates() = %v, want %v", got, tc.want)
+ }
+ })
+ }
+}
+
+// fakeDispatcher writes an executable that records its arguments and prints the
+// given stdout, and returns its path plus the path it records into.
+func fakeDispatcher(t *testing.T, stdout string) (exe, argsFile string) {
+ t.Helper()
+ if runtime.GOOS == "windows" {
+ t.Skip("shell-script stub is not executable on Windows")
+ }
+ dir := t.TempDir()
+ exe = filepath.Join(dir, "inference-dispatcher")
+ argsFile = filepath.Join(dir, "args")
+ script := "#!/bin/sh\nprintf '%s\\n' \"$@\" > " + argsFile + "\ncat <<'JSON'\n" + stdout + "\nJSON\n"
+ if err := os.WriteFile(exe, []byte(script), 0o700); err != nil {
+ t.Fatal(err)
+ }
+ return exe, argsFile
+}
+
+func TestProbeAsksTheDispatcherCorrectlyAndKeepsOnlyGenerativeModels(t *testing.T) {
+ // This runs the real command path. A mistyped flag would otherwise show up
+ // only as a demo that always says no model is available — the failure mode
+ // least likely to be read as a bug in this code.
+ exe, argsFile := fakeDispatcher(t, `[
+ {"name":"llama3.2:latest","type":"llm"},
+ {"name":"nomic-embed-text","type":"embeddings"},
+ {"name":"mystery-model"},
+ {"name":"","type":"llm"}
+ ]`)
+
+ got := probeModels(context.Background(), exe, "ollama", 11434)
+
+ want := []string{"llama3.2:latest", "mystery-model"}
+ if len(got) != len(want) {
+ t.Fatalf("got %d targets %+v, want %d", len(got), got, len(want))
+ }
+ for i, model := range want {
+ if got[i].model != model {
+ t.Errorf("target %d is %q, want %q", i, got[i].model, model)
+ }
+ if got[i].backend != "ollama" || got[i].port != 11434 {
+ t.Errorf("target %d addressed %s:%d, want ollama:11434",
+ i, got[i].backend, got[i].port)
+ }
+ }
+
+ raw, err := os.ReadFile(argsFile)
+ if err != nil {
+ t.Fatal(err)
+ }
+ args := strings.Fields(string(raw))
+ for _, want := range []string{"--backend", "ollama", "--port", "11434", "--list-models"} {
+ if !slices.Contains(args, want) {
+ t.Errorf("dispatcher was not passed %q; got %v", want, args)
+ }
+ }
+}
+
+func TestProbeTreatsAnUnreachableEngineAsNoTargets(t *testing.T) {
+ // An engine that is down is not a demo failure — it just is not a target.
+ if got := probeModels(context.Background(), "/nonexistent/dispatcher", "ollama", 1); got != nil {
+ t.Errorf("got %d targets from a missing dispatcher, want none", len(got))
+ }
+}
+
+func TestProbeIgnoresOutputThatIsNotAModelList(t *testing.T) {
+ exe, _ := fakeDispatcher(t, "Model query failed: connection refused")
+ if got := probeModels(context.Background(), exe, "ollama", 11434); got != nil {
+ t.Errorf("got %d targets from non-JSON output, want none", len(got))
+ }
+}
+
+func TestNoteNamesTheKeyThatStopsIt(t *testing.T) {
+ // The note is the only place the stop key is stated while a demo runs, and
+ // the footer's label is derived from the same state — so an operator who
+ // wants it to stop has somewhere to look.
+ d := armedRunner(t, 2)
+ note := d.note()
+ if !strings.Contains(note, "press t to stop") {
+ t.Errorf("running note does not name the stop key: %q", note)
+ }
+
+ d.stop()
+ if got := d.note(); got != "" {
+ t.Errorf("idle runner still renders a note: %q", got)
+ }
+}
diff --git a/services/nvpair-tui/ui/demoschedule.go b/services/nvpair-tui/ui/demoschedule.go
new file mode 100644
index 00000000..beb2f57d
--- /dev/null
+++ b/services/nvpair-tui/ui/demoschedule.go
@@ -0,0 +1,197 @@
+// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+package ui
+
+import (
+ "sort"
+ "time"
+)
+
+// The Inference Demo's planning half: a fixed, node-local burst of synthetic
+// traffic sent through the local proxies so an operator can watch real work move
+// through the router.
+//
+// Where each request lands is the backend's decision. Requests are addressed to
+// a proxy exactly as a third-party client would address one, so whether that
+// produces work on this machine or several depends on the cluster the proxies
+// see. The demo is not a benchmark and not a diagnostic: no prompt, response,
+// score, or timing is ever shown, and the only visible output is ordinary job
+// activity on the Jobs table.
+//
+// This mirrors the desktop app's schedule (desktop/src/shared/types/
+// inference-demo.ts and src/electron/inference-demo-schedule.ts) deliberately
+// and exactly, because the two front ends are meant to demonstrate the same
+// thing. The constants below are the contract between them; changing one without
+// the other makes "run the demo" mean two different things depending on which
+// interface the operator happened to use.
+//
+// Kept free of process spawning and Bubble Tea so the guarantees that matter —
+// nothing submitted at or after the ceiling, every target touched before any is
+// revisited, open-loop overlap — can be asserted directly.
+
+// Wall-clock offsets at which a cohort of simulated agents starts.
+var demoCohortOffsets = []time.Duration{
+ 0,
+ 10 * time.Second,
+ 20 * time.Second,
+ 30 * time.Second,
+ 40 * time.Second,
+}
+
+// Simulated agent workloads started per cohort, before target-count scaling.
+const demoBaseAgentsPerCohort = 2
+
+// demoMaxSubmit is the hard ceiling on the submission window. Nothing is
+// submitted at or after this point; work already in flight is left alone. The
+// 50-second cohort is omitted on purpose so the last submission lands at 58s.
+const demoMaxSubmit = 60 * time.Second
+
+// demoRequestTimeout is the per-request timeout handed to the dispatcher.
+const demoRequestTimeout = 120 * time.Second
+
+// demoProbeTimeout bounds the model-discovery calls that run before the window
+// opens. Short, because discovery is the operator waiting with nothing on screen.
+const demoProbeTimeout = 10 * time.Second
+
+// demoStage is one request shape of a simulated agent. Offsets are relative to
+// the owning agent's start, so the final submission of the 40-second cohort
+// lands at 58s.
+//
+// The prompts are internal and intentionally generic: they exercise an engine
+// without depending on any real workload. They are never displayed, logged, or
+// retained.
+type demoStage struct {
+ offset time.Duration
+ maxTokens int
+ temperature float64
+ prompt string
+}
+
+var demoStages = []demoStage{
+ {
+ offset: 0,
+ maxTokens: 48,
+ temperature: 0.0,
+ prompt: `Classify the following support request into one category: billing, technical, or account. Request: "My export finished but the download link returns a 404." Answer with the category only.`,
+ },
+ {
+ offset: 3 * time.Second,
+ maxTokens: 192,
+ temperature: 0.1,
+ prompt: "Draft a short numbered plan for diagnosing an intermittent HTTP 404 on a file download endpoint that only affects large exports. Keep it under six steps.",
+ },
+ {
+ offset: 7 * time.Second,
+ maxTokens: 96,
+ temperature: 0.0,
+ prompt: "Given the tools list_objects, read_log, restart_worker, and notify_user, choose the single best next tool for confirming whether an exported file was ever written to object storage. Answer with the tool name and one sentence of justification.",
+ },
+ {
+ offset: 10 * time.Second,
+ maxTokens: 160,
+ temperature: 0.0,
+ prompt: "Rewrite this function so it returns an explicit error instead of nil when the key is missing:\n\nfunc get(m map[string]string, k string) string { return m[k] }",
+ },
+ {
+ offset: 14 * time.Second,
+ maxTokens: 128,
+ temperature: 0.0,
+ prompt: "Summarize in two sentences: the worker log shows 14 successful uploads, 2 uploads that timed out after 30s, and no retry attempts recorded for the timeouts.",
+ },
+ {
+ offset: 18 * time.Second,
+ maxTokens: 256,
+ temperature: 0.1,
+ prompt: "Write a brief incident note covering root cause, user impact, and the single highest-value follow-up action, for an issue where large export uploads timed out and were never retried.",
+ },
+}
+
+// demoTarget is one engine/model pair discovered at demo start.
+//
+// backend is the dispatcher's own engine name, and port is a proxy's listen
+// port — never an engine's own. Addressing the proxy is what makes this a demo
+// of PAIR rather than of Ollama: the request enters the router and the backend
+// places it. Hitting an engine's port directly would bypass routing, which
+// proxy-inference-routing.mdc prohibits.
+type demoTarget struct {
+ backend string
+ port int
+ model string
+}
+
+// demoRequest is one planned submission.
+type demoRequest struct {
+ at time.Duration // after the window opens
+ target demoTarget
+ stage int // index into demoStages
+}
+
+// demoAgentsPerCohort is enough simulated agents that the schedule has at least
+// one request per target.
+//
+// A run produces cohorts x agents x stages requests — 5 x 2 x 6 = 60 at the base
+// count — and targets are assigned per request, so the base already covers up to
+// 60 targets. Beyond that this adds agents rather than dropping targets, so a
+// host exposing eighty models still demonstrates all of them.
+func demoAgentsPerCohort(targets int) int {
+ if targets <= 0 {
+ return demoBaseAgentsPerCohort
+ }
+ capacity := len(demoCohortOffsets) * len(demoStages)
+ needed := (targets + capacity - 1) / capacity
+ if needed < demoBaseAgentsPerCohort {
+ return demoBaseAgentsPerCohort
+ }
+ return needed
+}
+
+// buildDemoSchedule plans a whole run up front.
+//
+// Slots are generated per (cohort, agent, stage), sorted by submission time, and
+// only then assigned targets round-robin. Assigning in time order is what gives
+// "touch every target before revisiting one" for free: the first pass through
+// the ring covers them all.
+//
+// The schedule is open-loop. Offsets are absolute positions in the window and no
+// slot waits on an earlier request finishing, which is the point — overlapping
+// requests are what put more than one job in flight at once.
+func buildDemoSchedule(targets []demoTarget) []demoRequest {
+ if len(targets) == 0 {
+ return nil
+ }
+
+ perCohort := demoAgentsPerCohort(len(targets))
+ type slot struct {
+ at time.Duration
+ stage int
+ }
+ slots := make([]slot, 0, len(demoCohortOffsets)*perCohort*len(demoStages))
+ for _, cohort := range demoCohortOffsets {
+ for agent := 0; agent < perCohort; agent++ {
+ for i, stage := range demoStages {
+ at := cohort + stage.offset
+ // The ceiling is a planning invariant as well as a runtime one.
+ if at >= demoMaxSubmit {
+ continue
+ }
+ slots = append(slots, slot{at: at, stage: i})
+ }
+ }
+ }
+
+ // A stable sort keeps equal-time slots in generation order, so the
+ // round-robin assignment below is reproducible for a given target list —
+ // which is what makes the schedule testable at all.
+ sort.SliceStable(slots, func(i, j int) bool { return slots[i].at < slots[j].at })
+
+ out := make([]demoRequest, len(slots))
+ for i, s := range slots {
+ out[i] = demoRequest{
+ at: s.at,
+ target: targets[i%len(targets)],
+ stage: s.stage,
+ }
+ }
+ return out
+}
diff --git a/services/nvpair-tui/ui/engines.go b/services/nvpair-tui/ui/engines.go
deleted file mode 100644
index 809febfb..00000000
--- a/services/nvpair-tui/ui/engines.go
+++ /dev/null
@@ -1,344 +0,0 @@
-// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
-// SPDX-License-Identifier: Apache-2.0
-
-package ui
-
-import (
- "context"
- "errors"
- "fmt"
- "strconv"
- "strings"
-
- "nvpair-tui/rpc"
-
- "github.com/charmbracelet/bubbles/key"
- "github.com/charmbracelet/bubbles/table"
- "github.com/charmbracelet/bubbles/textinput"
- tea "github.com/charmbracelet/bubbletea"
-)
-
-// engineStatus mirrors nvpair-engine-manager's EngineStatus snapshot, the
-// element of engine:get-installed and the engine:state-changed payload.
-type engineStatus struct {
- Engine string `json:"engine"`
- DisplayName string `json:"display_name"`
- Installed bool `json:"installed"`
- Running bool `json:"running"`
- Healthy bool `json:"healthy"`
- Port int `json:"port"`
-}
-
-// enginesView manages local inference engines via the engine-manager
-// control plane: an installed/running/healthy table plus lifecycle
-// actions, kept live from engine:state-changed and engine:install-progress.
-// It can also pull a model (engine:action{action:"pull_model"}), rendering
-// the live engine:pull-progress feed the way remote pulls already show.
-type enginesView struct {
- client *rpc.Client
- table table.Model
- order []string
- byName map[string]engineStatus
- status string
- input textinput.Model
- pulling bool
- pullEngine string
-
- width, height int
-}
-
-type enginesLoadedMsg struct {
- engines []engineStatus
- err error
-}
-
-type engineOpMsg struct {
- what string
- engine string
- err error
-}
-
-var (
- engStartKey = key.NewBinding(key.WithKeys("s"), key.WithHelp("s", "start"))
- engStopKey = key.NewBinding(key.WithKeys("x"), key.WithHelp("x", "stop"))
- engRestartKey = key.NewBinding(key.WithKeys("r"), key.WithHelp("r", "restart"))
- engInstallKey = key.NewBinding(key.WithKeys("i"), key.WithHelp("i", "install"))
- engUninstallKey = key.NewBinding(key.WithKeys("u"), key.WithHelp("u", "uninstall"))
- engPullKey = key.NewBinding(key.WithKeys("p"), key.WithHelp("p", "pull model"))
-)
-
-func newEnginesView(client *rpc.Client) *enginesView {
- ti := textinput.New()
- ti.Placeholder = "model name (e.g. llama3.2)"
- v := &enginesView{client: client, byName: map[string]engineStatus{}, input: ti}
- v.table = newTable(nil)
- return v
-}
-
-func (v *enginesView) Title() string { return "Engines" }
-
-func (v *enginesView) Init() tea.Cmd {
- return tea.Batch(
- call(v.client, "engine:subscribe", nil, func(_ *rpc.Message, _ error) tea.Msg { return nil }),
- v.loadCmd(),
- )
-}
-
-func (v *enginesView) loadCmd() tea.Cmd {
- return call(v.client, "engine:get-installed", nil, func(msg *rpc.Message, err error) tea.Msg {
- if err != nil {
- return enginesLoadedMsg{err: err}
- }
- var r struct {
- Engines []engineStatus `json:"engines"`
- }
- _ = decodeParams(msg.Result, &r)
- return enginesLoadedMsg{engines: r.Engines}
- })
-}
-
-func (v *enginesView) SetSize(w, h int) {
- v.width, v.height = w, h
- const inst, run, heal, port = 10, 8, 8, 7
- name := clampWidth(w-inst-run-heal-port-2, 10)
- v.table.SetColumns([]table.Column{
- {Title: "ENGINE", Width: name},
- {Title: "INSTALLED", Width: inst},
- {Title: "RUNNING", Width: run},
- {Title: "HEALTHY", Width: heal},
- {Title: "PORT", Width: port},
- })
- v.table.SetWidth(w)
- v.table.SetHeight(clampWidth(h-2, 1))
-}
-
-func (v *enginesView) Update(msg tea.Msg) tea.Cmd {
- switch msg := msg.(type) {
- case enginesLoadedMsg:
- if msg.err != nil {
- v.status = "load engines failed: " + msg.err.Error()
- return nil
- }
- for _, e := range msg.engines {
- v.merge(e)
- }
- return nil
-
- case engineOpMsg:
- if msg.err != nil {
- v.status = fmt.Sprintf("%s %s failed: %s", msg.what, msg.engine, msg.err.Error())
- } else {
- v.status = fmt.Sprintf("%s %s ok", msg.what, msg.engine)
- }
- return nil
-
- case NotificationMsg:
- switch msg.Msg.Method {
- case "engine:state-changed":
- var e engineStatus
- _ = decodeParams(msg.Msg.Params, &e)
- if e.Engine != "" {
- v.merge(e)
- }
- case "engine:install-progress":
- var p struct {
- Engine string `json:"engine"`
- Stage string `json:"stage"`
- Percent int `json:"percent"`
- }
- _ = decodeParams(msg.Msg.Params, &p)
- v.status = fmt.Sprintf("install %s: %s (%d%%)", p.Engine, p.Stage, p.Percent)
- case "engine:pull-progress":
- var p struct {
- Engine string `json:"engine"`
- Stage string `json:"stage"`
- Percent int `json:"percent"`
- Message string `json:"message"`
- }
- _ = decodeParams(msg.Msg.Params, &p)
- // Terminal stages carry no meaningful percent (success is implicitly
- // 100%; error uses -1), so render them as outcomes rather than a
- // misleading "success (0%)". A late failure that arrives after the
- // synchronous call timed out still surfaces here.
- switch p.Stage {
- case "success":
- v.status = fmt.Sprintf("pull %s: done", p.Engine)
- case "error":
- detail := p.Message
- if detail == "" {
- detail = "failed"
- }
- v.status = fmt.Sprintf("pull %s failed: %s", p.Engine, detail)
- default:
- v.status = fmt.Sprintf("pull %s: %s (%d%%)", p.Engine, p.Stage, p.Percent)
- }
- }
- return nil
-
- case tea.KeyMsg:
- return v.handleKey(msg)
- }
- return nil
-}
-
-func (v *enginesView) CapturingInput() bool { return v.pulling }
-
-func (v *enginesView) handleKey(msg tea.KeyMsg) tea.Cmd {
- if v.pulling {
- switch msg.String() {
- case "enter":
- return v.submitPull()
- case "esc":
- v.pulling = false
- v.input.Blur()
- return nil
- }
- var cmd tea.Cmd
- v.input, cmd = v.input.Update(msg)
- return cmd
- }
- if key.Matches(msg, engPullKey) {
- engine := v.selectedEngine()
- if engine == "" {
- return nil
- }
- v.pullEngine = engine
- v.pulling = true
- v.input.SetValue("")
- v.input.Focus()
- return textinput.Blink
- }
- if cmd, handled := v.handleAction(msg); handled {
- return cmd
- }
- var cmd tea.Cmd
- v.table, cmd = v.table.Update(msg)
- return cmd
-}
-
-// pullParams builds the engine:action{action:"pull_model"} params for a pull.
-// The model name is sent under BOTH "name" and "model" — mirroring
-// PullModelStream's own empty-params default — because the two engines key it
-// differently: Ollama's pull_model is HTTP /api/pull (body key "name"), while
-// LM Studio's is a CLI action `lms get {model}` resolved from the "model" key.
-// Sending only one key silently no-ops the pull on the other engine.
-func pullParams(engine, model string) map[string]any {
- return map[string]any{"engine": engine, "action": "pull_model", "params": map[string]string{"name": model, "model": model}}
-}
-
-// submitPull issues engine:action{action:"pull_model"} for the selected engine.
-// Live download progress and the terminal result arrive as engine:pull-progress
-// notifications; the synchronous response can outlast callTimeout for a large
-// model, so a deadline error here is expected and ignored (the progress feed is
-// the real signal).
-func (v *enginesView) submitPull() tea.Cmd {
- v.pulling = false
- v.input.Blur()
- model := strings.TrimSpace(v.input.Value())
- engine := v.pullEngine
- if model == "" || engine == "" {
- v.status = "model name required"
- return nil
- }
- v.status = fmt.Sprintf("pull %s: %s...", engine, model)
- params := pullParams(engine, model)
- return call(v.client, "engine:action", params, func(_ *rpc.Message, err error) tea.Msg {
- if err != nil && !errors.Is(err, context.DeadlineExceeded) {
- return engineOpMsg{what: "pull " + model, engine: engine, err: err}
- }
- return nil
- })
-}
-
-func (v *enginesView) handleAction(msg tea.KeyMsg) (tea.Cmd, bool) {
- var method, what string
- switch {
- case key.Matches(msg, engStartKey):
- method, what = "engine:start", "start"
- case key.Matches(msg, engStopKey):
- method, what = "engine:stop", "stop"
- case key.Matches(msg, engRestartKey):
- method, what = "engine:restart", "restart"
- case key.Matches(msg, engInstallKey):
- method, what = "engine:install", "install"
- case key.Matches(msg, engUninstallKey):
- method, what = "engine:uninstall", "uninstall"
- default:
- return nil, false
- }
- engine := v.selectedEngine()
- if engine == "" {
- return nil, true
- }
- v.status = what + " " + engine + "..."
- return call(v.client, method, map[string]string{"engine": engine}, func(_ *rpc.Message, err error) tea.Msg {
- return engineOpMsg{what: what, engine: engine, err: err}
- }), true
-}
-
-func (v *enginesView) selectedEngine() string {
- idx := v.table.Cursor()
- if idx < 0 || idx >= len(v.order) {
- return ""
- }
- return v.order[idx]
-}
-
-func (v *enginesView) merge(e engineStatus) {
- if _, ok := v.byName[e.Engine]; !ok {
- v.order = append(v.order, e.Engine)
- }
- v.byName[e.Engine] = e
- v.refreshRows()
-}
-
-func (v *enginesView) refreshRows() {
- rows := make([]table.Row, 0, len(v.order))
- for _, name := range v.order {
- e := v.byName[name]
- label := e.Engine
- if e.DisplayName != "" {
- label = e.DisplayName
- }
- port := "-"
- if e.Port != 0 {
- port = strconv.Itoa(e.Port)
- }
- rows = append(rows, table.Row{
- label,
- yesNo(e.Installed),
- yesNo(e.Running),
- yesNo(e.Healthy),
- port,
- })
- }
- v.table.SetRows(rows)
-}
-
-func (v *enginesView) View() string {
- if len(v.order) == 0 {
- if v.status != "" {
- return statusErrStyle.Render(v.status)
- }
- return footerStyle.Render("No engines known on this host.")
- }
- out := v.table.View()
- if v.pulling {
- out += "\npull model: " + v.input.View()
- }
- if v.status != "" {
- out += "\n" + footerStyle.Render(v.status)
- }
- return out
-}
-
-func (v *enginesView) Help() []key.Binding {
- return []key.Binding{engStartKey, engStopKey, engRestartKey, engInstallKey, engUninstallKey, engPullKey}
-}
-
-func yesNo(b bool) string {
- if b {
- return "yes"
- }
- return "no"
-}
diff --git a/services/nvpair-tui/ui/engines_test.go b/services/nvpair-tui/ui/engines_test.go
deleted file mode 100644
index e04a2c77..00000000
--- a/services/nvpair-tui/ui/engines_test.go
+++ /dev/null
@@ -1,31 +0,0 @@
-// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
-// SPDX-License-Identifier: Apache-2.0
-
-package ui
-
-import "testing"
-
-// TestPullParamsSendsBothKeys guards the LM Studio pull fix: the pull params
-// must carry the model under BOTH "name" (Ollama's /api/pull body key) and
-// "model" (LM Studio's `lms get {model}` CLI placeholder). Sending only "name"
-// silently ran `lms get "" --yes`, so a TUI pull never reached LM Studio.
-func TestPullParamsSendsBothKeys(t *testing.T) {
- p := pullParams("lmstudio", "owner/model")
-
- if p["engine"] != "lmstudio" {
- t.Fatalf("engine = %v, want lmstudio", p["engine"])
- }
- if p["action"] != "pull_model" {
- t.Fatalf("action = %v, want pull_model", p["action"])
- }
- inner, ok := p["params"].(map[string]string)
- if !ok {
- t.Fatalf("params = %T, want map[string]string", p["params"])
- }
- if inner["name"] != "owner/model" {
- t.Fatalf(`params["name"] = %q, want "owner/model"`, inner["name"])
- }
- if inner["model"] != "owner/model" {
- t.Fatalf(`params["model"] = %q, want "owner/model" (LM Studio reads this key)`, inner["model"])
- }
-}
diff --git a/services/nvpair-tui/ui/enginesettings.go b/services/nvpair-tui/ui/enginesettings.go
new file mode 100644
index 00000000..ec8a6172
--- /dev/null
+++ b/services/nvpair-tui/ui/enginesettings.go
@@ -0,0 +1,160 @@
+// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+package ui
+
+import (
+ "crypto/rand"
+ "encoding/hex"
+
+ "nvpair-shared/enginesettings"
+ "nvpair-tui/rpc"
+
+ tea "github.com/charmbracelet/bubbletea"
+)
+
+// Engine launch settings: the arguments and environment an engine is started
+// with, edited here and owned by nvpair-engine-manager.
+//
+// The wire types are the backend's own (nvpair-shared/enginesettings) rather
+// than a local copy. A second declaration of a revision-carrying contract is a
+// second thing to keep in step, and getting the revision wrong is not a
+// compile error — it is a lost update.
+//
+// Three calls, in a fixed order, because the backend validates and normalizes
+// before it commits:
+//
+// 1. engine:get-settings returns the snapshot, including the revision every
+// later write must carry and whether the engine is editable at all.
+// 2. engine:preview-settings validates a draft without saving it. It answers
+// with normalized settings, per-field errors, a port conflict, and whether
+// applying would restart the engine.
+// 3. engine:apply-settings commits the *normalized* settings the preview
+// returned, not the raw draft, so what is saved is what was validated.
+//
+// The broker routes all three to a peer when the request names one, so this is
+// the same path for this machine and for a node across the cluster.
+
+// newSettingsRequestID mints the identifier a commit is required to carry.
+//
+// It is an idempotency key, not a trace id. The backend records a receipt
+// against it, so replaying the same id with the same settings returns the
+// original outcome instead of applying twice — and replaying it with different
+// settings is refused outright. That makes it wrong to reuse one across edits
+// and wrong to send none at all, which is what an apply without it was: the
+// backend rejected it with "a request identifier is required" after the
+// preview had already passed.
+//
+// Random rather than a counter, because the backend keys receipts per engine
+// across every client and a restarted terminal would begin counting again.
+// Sixteen bytes of hex is 32 characters, well inside the 128 the broker allows.
+func newSettingsRequestID() string {
+ var id [16]byte
+ if _, err := rand.Read(id[:]); err != nil {
+ // crypto/rand does not fail in practice, and an empty id would be
+ // rejected by the broker rather than silently losing idempotency.
+ return ""
+ }
+ return hex.EncodeToString(id[:])
+}
+
+// settingsResolution tells the backend which side wins when the server-port
+// field and the port inside the command text disagree.
+//
+// They are two views of one number, and a user editing either one expects
+// theirs to stick. Sending no resolution is not a third option: the backend
+// reports the disagreement as a conflict rather than guessing, which is the
+// right default for an API and the wrong experience in an editor.
+const (
+ // resolutionServer: the numeric field was edited, so rewrite the command.
+ resolutionServer = "server"
+ // resolutionLaunch: the command text was edited, so update the field.
+ resolutionLaunch = "launch"
+)
+
+// engineSettingsMsg carries a fetched or pushed snapshot.
+type engineSettingsMsg struct {
+ snapshot enginesettings.Snapshot
+ err error
+}
+
+// enginePreviewMsg carries a validated draft, still uncommitted.
+//
+// It holds the request that produced it so the apply step can reuse the
+// revision and target without rebuilding them from view state that may have
+// moved on.
+type enginePreviewMsg struct {
+ request enginesettings.Request
+ preview enginesettings.Preview
+ err error
+}
+
+// engineSettingsAppliedMsg is the outcome of a commit.
+type engineSettingsAppliedMsg struct {
+ engine string
+ err error
+}
+
+// getEngineSettingsCmd fetches one engine's settings snapshot. An empty nodeID
+// means this machine.
+func getEngineSettingsCmd(client *rpc.Client, nodeID, engine string) tea.Cmd {
+ return call(client, "engine:get-settings",
+ enginesettings.Request{NodeID: nodeID, Engine: engine},
+ func(msg *rpc.Message, err error) tea.Msg {
+ if err != nil {
+ return engineSettingsMsg{err: err}
+ }
+ var snap enginesettings.Snapshot
+ if derr := decodeParams(msg.Result, &snap); derr != nil {
+ return engineSettingsMsg{err: derr}
+ }
+ return engineSettingsMsg{snapshot: snap}
+ })
+}
+
+// previewEngineSettingsCmd validates a draft without saving it.
+func previewEngineSettingsCmd(client *rpc.Client, req enginesettings.Request) tea.Cmd {
+ return call(client, "engine:preview-settings", req,
+ func(msg *rpc.Message, err error) tea.Msg {
+ if err != nil {
+ return enginePreviewMsg{request: req, err: err}
+ }
+ var preview enginesettings.Preview
+ if derr := decodeParams(msg.Result, &preview); derr != nil {
+ return enginePreviewMsg{request: req, err: derr}
+ }
+ return enginePreviewMsg{request: req, preview: preview}
+ })
+}
+
+// applyEngineSettingsCmd commits settings the backend has already normalized.
+//
+// The caller is expected to have settled the draft through a preview first —
+// see judgeSettingsPreview, which both substitutes the normalized settings and
+// drops the resolution. This does not re-check either, because a commit that
+// quietly repaired its own request would hide the bug that produced it.
+func applyEngineSettingsCmd(client *rpc.Client, req enginesettings.Request) tea.Cmd {
+ return call(client, "engine:apply-settings", req,
+ func(_ *rpc.Message, err error) tea.Msg {
+ return engineSettingsAppliedMsg{engine: req.Engine, err: err}
+ })
+}
+
+// settingsUnavailableReason explains why an engine cannot be configured, or is
+// empty when it can.
+//
+// The backend decides this and says why; repeating its rules here would be a
+// second opinion that drifts. The only judgement made locally is to supply
+// wording when it reports a bare "not editable".
+func settingsUnavailableReason(snap enginesettings.Snapshot) string {
+ if snap.Editable {
+ return ""
+ }
+ if snap.Reason != "" {
+ return snap.Reason
+ }
+ if snap.Adopted {
+ return "this engine was already running when PAIR found it, so PAIR does not own how it starts"
+ }
+ return "this engine's startup settings cannot be edited right now"
+}
diff --git a/services/nvpair-tui/ui/engineswire.go b/services/nvpair-tui/ui/engineswire.go
new file mode 100644
index 00000000..04e028bf
--- /dev/null
+++ b/services/nvpair-tui/ui/engineswire.go
@@ -0,0 +1,222 @@
+// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+package ui
+
+import (
+ "context"
+ "errors"
+
+ "nvpair-tui/rpc"
+
+ tea "github.com/charmbracelet/bubbletea"
+)
+
+// engineStatus mirrors nvpair-engine-manager's EngineStatus snapshot, the
+// element of engine:get-installed and the engine:state-changed payload.
+type engineStatus struct {
+ Engine string `json:"engine"`
+ DisplayName string `json:"display_name"`
+ Installed bool `json:"installed"`
+ Running bool `json:"running"`
+ Healthy bool `json:"healthy"`
+ Port int `json:"port"`
+}
+
+func (e engineStatus) label() string {
+ if e.DisplayName != "" {
+ return e.DisplayName
+ }
+ return e.Engine
+}
+
+// modelsResult mirrors nvpair-engine-manager's ModelsResult, the engine:models
+// reply and the engine:models-changed payload. LoadedByEngine names the models
+// currently resident in memory, which the engine-manager polls for and pushes —
+// so a client never has to poll to know what is loaded.
+type modelsResult struct {
+ Models []string `json:"models"`
+ ModelsByEngine map[string][]string `json:"modelsByEngine"`
+ LoadedByEngine map[string][]string `json:"loadedByEngine"`
+}
+
+// engineOp is one lifecycle request and how to describe it to the operator.
+type engineOp struct {
+ method string
+ what string
+ // localOnly marks an operation the engine manager exposes no remote
+ // equivalent for, so it is hidden on a peer's node rather than offered and
+ // then failing.
+ localOnly bool
+}
+
+// engineOps are the lifecycle operations, keyed by the local method name. The
+// remote variants take a node and cover a deliberately smaller set: the manager
+// has remote install/start/stop but no remote restart, uninstall, or port
+// change, because those need process ownership on the target host.
+var engineOps = map[string]engineOp{
+ "install": {method: "engine:install", what: "install"},
+ "start": {method: "engine:start", what: "start"},
+ "stop": {method: "engine:stop", what: "stop"},
+ "restart": {method: "engine:restart", what: "restart", localOnly: true},
+ "uninstall": {method: "engine:uninstall", what: "uninstall", localOnly: true},
+}
+
+// remoteEngineMethods maps a local lifecycle method to its remote counterpart.
+var remoteEngineMethods = map[string]string{
+ "engine:install": "engine:remote-install",
+ "engine:start": "engine:remote-start",
+ "engine:stop": "engine:remote-stop",
+}
+
+// modelAction is one model operation: the remote method that performs it on a
+// peer, and the operator-facing verb. The local engine:action name and params
+// are per engine — see modelActionWire.
+type modelAction struct {
+ op string
+ remote string
+ what string
+}
+
+var modelActions = map[string]modelAction{
+ "load": {op: "load", remote: "engine:remote-load-model", what: "load"},
+ // "eject" to match the key's own label and the docs; the backend's method
+ // keeps its own name. A key labelled eject that reports "unload requested"
+ // leaves the operator wondering whether it did something else.
+ "unload": {op: "unload", remote: "engine:remote-unload-model", what: "eject"},
+ "delete": {op: "delete", remote: "engine:remote-delete-model", what: "delete"},
+ "pull": {op: "pull", remote: "engine:remote-pull-model", what: "download"},
+}
+
+// modelActionWire builds the local engine:action name and params for a model
+// operation on a specific engine.
+//
+// The engines do not share a contract here, so one action name for both is
+// wrong in ways that fail quietly. This mirrors nvpair-engine-manager's own
+// modelActionWire, which is authoritative:
+//
+// - Ollama has no load action at all. Warming a model is run_model with
+// streaming off; sending load_model just errors.
+// - Ollama only frees a model when keep_alive is 0. Without it the request
+// succeeds and the model stays resident.
+// - The two engines key the model differently — Ollama's delete takes "name",
+// LM Studio's takes "model" — so both are sent where a name is all that is
+// needed, which is also why a pull works on either engine.
+func modelActionWire(engine, op, model string) map[string]any {
+ both := map[string]string{"name": model, "model": model}
+ switch op {
+ case "load":
+ if engine == "ollama" {
+ return actionParams(engine, "run_model",
+ map[string]any{"model": model, "stream": false})
+ }
+ return actionParams(engine, "load_model", map[string]any{"model": model})
+ case "unload":
+ if engine == "ollama" {
+ return actionParams(engine, "unload_model",
+ map[string]any{"model": model, "keep_alive": 0})
+ }
+ return actionParams(engine, "unload_model", map[string]any{"model": model})
+ case "delete":
+ return actionParams(engine, "delete_model", anyMap(both))
+ default: // pull
+ return actionParams(engine, "pull_model", anyMap(both))
+ }
+}
+
+// actionParams wraps an engine:action envelope around per-action params.
+func actionParams(engine, action string, params map[string]any) map[string]any {
+ return map[string]any{"engine": engine, "action": action, "params": params}
+}
+
+func anyMap(in map[string]string) map[string]any {
+ out := make(map[string]any, len(in))
+ for k, v := range in {
+ out[k] = v
+ }
+ return out
+}
+
+// engineOpMsg is the outcome of a lifecycle or model command.
+type engineOpMsg struct {
+ what string
+ engine string
+ err error
+ // detached marks a request that outlived its reply deadline but is still
+ // running on the engine. Distinct from both success and failure: nothing
+ // went wrong, and nothing has finished either.
+ detached bool
+}
+
+// engineCmd issues a lifecycle request against a node. An empty node means this
+// machine and uses the local method; otherwise the remote counterpart is used
+// and the node travels in the params.
+func engineCmd(client *rpc.Client, node, engine, method, op, what string) tea.Cmd {
+ params := map[string]any{"engine": engine}
+ if node != "" {
+ remote, ok := remoteEngineMethods[method]
+ if !ok {
+ return func() tea.Msg {
+ return engineOpMsg{what: what, engine: engine,
+ err: errors.New("not supported on a remote node")}
+ }
+ }
+ method = remote
+ params["node"] = node
+ }
+ return call(client, method, params, func(_ *rpc.Message, err error) tea.Msg {
+ return classifyOpResult(what, engine, op, err)
+ })
+}
+
+// modelCmd issues a model operation against a node.
+//
+// A download that outlasts callTimeout is reported as detached rather than
+// failed: a multi-gigabyte pull routinely exceeds the reply deadline while the
+// engine keeps working, and the engine:pull-progress feed carries the real
+// outcome. Reporting a failure there would be wrong — but so was the silence
+// this replaced, which left the operator with no acknowledgement that the
+// download had started at all, and nothing to distinguish it from a keystroke
+// that missed.
+func modelCmd(client *rpc.Client, node, engine string, act modelAction, model string) tea.Cmd {
+ method := "engine:action"
+ params := modelActionWire(engine, act.op, model)
+ if node != "" {
+ // The remote methods take the operation in the method name, so the
+ // engine's own action vocabulary stays on the target's side.
+ method = act.remote
+ params = map[string]any{"node": node, "engine": engine, "model": model}
+ }
+ what := act.what + " " + model
+ return call(client, method, params, func(_ *rpc.Message, err error) tea.Msg {
+ return classifyOpResult(what, engine, act.op, err)
+ })
+}
+
+// longRunningOps are the operations whose real duration is set by how much data
+// has to move or how slow an engine is to become ready, not by the RPC.
+//
+// A deadline on one of these means the reply was slow, not that the work
+// failed — the engine keeps going and reports the true outcome on its progress
+// feed. Reporting a failure is actively misleading: the operator sees "load
+// failed" at the same moment the model finishes loading.
+//
+// Ollama's load is `run_model` with streaming off, which does not answer until
+// the model is resident and has produced a response, so a large model on cold
+// storage exceeds the reply deadline routinely. Install downloads an engine.
+// Delete and unload, by contrast, are quick, and a deadline there is a real
+// fault worth surfacing.
+var longRunningOps = map[string]bool{
+ "pull": true,
+ "load": true,
+ "install": true,
+ "uninstall": true,
+}
+
+// classifyOpResult reports an operation as done, failed, or still running.
+func classifyOpResult(what, engine, op string, err error) tea.Msg {
+ if err != nil && longRunningOps[op] && errors.Is(err, context.DeadlineExceeded) {
+ return engineOpMsg{what: what, engine: engine, detached: true}
+ }
+ return engineOpMsg{what: what, engine: engine, err: err}
+}
diff --git a/services/nvpair-tui/ui/engineswire_test.go b/services/nvpair-tui/ui/engineswire_test.go
new file mode 100644
index 00000000..eb0240b2
--- /dev/null
+++ b/services/nvpair-tui/ui/engineswire_test.go
@@ -0,0 +1,228 @@
+// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+package ui
+
+import (
+ "context"
+ "errors"
+ "testing"
+)
+
+// actionOf unwraps an engine:action envelope for assertions.
+func actionOf(t *testing.T, envelope map[string]any) (string, map[string]any) {
+ t.Helper()
+ action, _ := envelope["action"].(string)
+ params, ok := envelope["params"].(map[string]any)
+ if !ok {
+ t.Fatalf("params = %T, want map[string]any", envelope["params"])
+ }
+ return action, params
+}
+
+// TestPullSendsBothKeys guards the LM Studio fix: a pull must carry the model
+// under BOTH "name" (Ollama's /api/pull body key) and "model" (LM Studio's
+// `lms get {model}` CLI placeholder). Sending only "name" silently ran
+// `lms get "" --yes`, so the download never reached LM Studio.
+func TestPullSendsBothKeys(t *testing.T) {
+ for _, engine := range []string{"ollama", "lmstudio"} {
+ envelope := modelActionWire(engine, "pull", "owner/model")
+ if envelope["engine"] != engine {
+ t.Fatalf("engine = %v, want %s", envelope["engine"], engine)
+ }
+ action, params := actionOf(t, envelope)
+ if action != "pull_model" {
+ t.Fatalf("%s: action = %q, want pull_model", engine, action)
+ }
+ if params["name"] != "owner/model" || params["model"] != "owner/model" {
+ t.Errorf("%s: pull params = %v, want both name and model set", engine, params)
+ }
+ }
+}
+
+// TestOllamaLoadUsesRunModel guards the contract the two engines do NOT share.
+// Ollama has no load action — warming a model is run_model with streaming off —
+// so sending load_model errors, and the failure is quiet enough to look like the
+// model simply not loading.
+func TestOllamaLoadUsesRunModel(t *testing.T) {
+ envelope := modelActionWire("ollama", "load", "llama3.2")
+ action, params := actionOf(t, envelope)
+
+ if action != "run_model" {
+ t.Errorf("ollama load action = %q, want run_model", action)
+ }
+ if params["model"] != "llama3.2" {
+ t.Errorf("model = %v", params["model"])
+ }
+ if params["stream"] != false {
+ t.Errorf("stream = %v, want false; a streaming load never completes here", params["stream"])
+ }
+
+ // LM Studio does declare a real load action.
+ lmEnvelope := modelActionWire("lmstudio", "load", "owner/model")
+ if lmAction, _ := actionOf(t, lmEnvelope); lmAction != "load_model" {
+ t.Errorf("lmstudio load action = %q, want load_model", lmAction)
+ }
+}
+
+// TestOllamaUnloadSendsKeepAlive guards the other asymmetry: Ollama only frees a
+// model when keep_alive is 0. Without it the request succeeds and the model
+// stays resident, so eject appears to do nothing.
+func TestOllamaUnloadSendsKeepAlive(t *testing.T) {
+ envelope := modelActionWire("ollama", "unload", "llama3.2")
+ action, params := actionOf(t, envelope)
+
+ if action != "unload_model" {
+ t.Errorf("action = %q, want unload_model", action)
+ }
+ if params["keep_alive"] != 0 {
+ t.Errorf("keep_alive = %v, want 0; without it the model is not evicted", params["keep_alive"])
+ }
+
+ // LM Studio's unload takes no keep_alive.
+ lmEnvelope := modelActionWire("lmstudio", "unload", "owner/model")
+ if _, lmParams := actionOf(t, lmEnvelope); lmParams["keep_alive"] != nil {
+ t.Errorf("lmstudio unload sent keep_alive = %v, want absent", lmParams["keep_alive"])
+ }
+}
+
+// TestPullDeadlineReportsDetachedNotSilence guards the acknowledgement for a
+// long download. A multi-gigabyte pull outlasts the reply deadline while the
+// engine keeps working, so a failure would be wrong — but the previous silence
+// was too, leaving the operator unable to tell a started download from a
+// keystroke that missed.
+func TestPullDeadlineReportsDetachedNotSilence(t *testing.T) {
+ msg := classifyOpResult("download big-model", "ollama", "pull", context.DeadlineExceeded)
+ if msg == nil {
+ t.Fatal("a pull that outran its deadline produced no message at all")
+ }
+ op, ok := msg.(engineOpMsg)
+ if !ok {
+ t.Fatalf("got %T, want engineOpMsg", msg)
+ }
+ if op.err != nil {
+ t.Errorf("a still-running download was reported as failed: %v", op.err)
+ }
+ if !op.detached {
+ t.Error("a still-running download was reported as complete")
+ }
+}
+
+// TestDeadlineLeniencyTracksOperationLength checks which operations are excused
+// for a slow reply.
+//
+// The set is not "downloads": it is every operation whose duration is set by how
+// much data moves or how slow an engine is to become ready. Ollama's load is
+// run_model with streaming off, which does not answer until the model is
+// resident, so a large model on cold storage exceeds the deadline routinely —
+// and reporting "load failed" at the moment the model finishes loading is worse
+// than saying nothing. Quick operations get no such excuse, because a deadline
+// there is a real fault.
+func TestDeadlineLeniencyTracksOperationLength(t *testing.T) {
+ // Only operations engineOps actually declares: the TUI offers no engine
+ // update, so listing one here would assert against a path nothing reaches.
+ for _, op := range []string{"pull", "load", "install", "uninstall"} {
+ result, ok := classifyOpResult("x", "ollama", op, context.DeadlineExceeded).(engineOpMsg)
+ if !ok {
+ t.Fatalf("%s: unexpected message type", op)
+ }
+ if !result.detached {
+ t.Errorf("%s timing out was reported as a failure, but it is still running", op)
+ }
+ if result.err != nil {
+ t.Errorf("%s carried an error despite still running: %v", op, result.err)
+ }
+ }
+
+ for _, op := range []string{"unload", "delete", "start", "stop", "restart"} {
+ result, ok := classifyOpResult("x", "ollama", op, context.DeadlineExceeded).(engineOpMsg)
+ if !ok {
+ t.Fatalf("%s: unexpected message type", op)
+ }
+ if result.detached {
+ t.Errorf("%s timing out was excused as still running; a quick operation "+
+ "that times out has really failed", op)
+ }
+ if result.err == nil {
+ t.Errorf("%s timing out was reported as success", op)
+ }
+ }
+
+ // A real error is still an error, however long the operation usually takes.
+ result, _ := classifyOpResult("x", "ollama", "pull", errors.New("no such model")).(engineOpMsg)
+ if result.detached || result.err == nil {
+ t.Errorf("a genuine pull error was not reported: %+v", result)
+ }
+}
+
+// TestDeleteSendsBothKeys checks delete works on either engine, since Ollama
+// keys it as "name" and LM Studio as "model".
+func TestDeleteSendsBothKeys(t *testing.T) {
+ for _, engine := range []string{"ollama", "lmstudio"} {
+ envelope := modelActionWire(engine, "delete", "victim")
+ action, params := actionOf(t, envelope)
+ if action != "delete_model" {
+ t.Errorf("%s: action = %q", engine, action)
+ }
+ if params["name"] != "victim" || params["model"] != "victim" {
+ t.Errorf("%s: delete params = %v, want both keys", engine, params)
+ }
+ }
+}
+
+// TestLongRunningOpsAreRealOperations keeps the leniency set honest: every
+// entry must be an operation the interface can actually issue, or the set
+// documents behaviour nothing exercises.
+func TestLongRunningOpsAreRealOperations(t *testing.T) {
+ for op := range longRunningOps {
+ _, isLifecycle := engineOps[op]
+ _, isModel := modelActions[op]
+ if !isLifecycle && !isModel {
+ t.Errorf("longRunningOps names %q, which is neither a lifecycle nor a model operation", op)
+ }
+ }
+}
+
+// TestModelActionsCarryRemoteEquivalents checks every model operation has a
+// remote method, since all four are offered on a peer's node.
+func TestModelActionsCarryRemoteEquivalents(t *testing.T) {
+ for name, act := range modelActions {
+ if act.op == "" {
+ t.Errorf("%s: no operation name", name)
+ }
+ if act.remote == "" {
+ t.Errorf("%s: no remote method, but model operations are offered on remote nodes", name)
+ }
+ if act.what == "" {
+ t.Errorf("%s: no operator-facing verb", name)
+ }
+ }
+}
+
+// TestLifecycleRemoteCoverageMatchesManager pins which lifecycle operations have
+// a remote counterpart. The engine manager has remote install, start, and stop
+// but no remote restart, uninstall, or port change — those need process
+// ownership on the target host — so those three must be marked local-only or the
+// UI would offer an operation that always fails.
+func TestLifecycleRemoteCoverageMatchesManager(t *testing.T) {
+ for name, op := range engineOps {
+ _, hasRemote := remoteEngineMethods[op.method]
+ if op.localOnly && hasRemote {
+ t.Errorf("%s is marked local-only but a remote method exists", name)
+ }
+ if !op.localOnly && !hasRemote {
+ t.Errorf("%s is offered on remote nodes but has no remote method", name)
+ }
+ }
+
+ for _, name := range []string{"restart", "uninstall"} {
+ if !engineOps[name].localOnly {
+ t.Errorf("%s must be local-only: the manager exposes no remote variant", name)
+ }
+ }
+ for _, name := range []string{"install", "start", "stop"} {
+ if engineOps[name].localOnly {
+ t.Errorf("%s has a remote variant and should not be local-only", name)
+ }
+ }
+}
diff --git a/services/nvpair-tui/ui/errors.go b/services/nvpair-tui/ui/errors.go
index 17e93e0e..899f9b0e 100644
--- a/services/nvpair-tui/ui/errors.go
+++ b/services/nvpair-tui/ui/errors.go
@@ -5,7 +5,9 @@ package ui
import (
"fmt"
+ "strings"
"time"
+ "unicode/utf8"
svcerrors "nvpair-shared/errors"
"nvpair-tui/rpc"
@@ -13,6 +15,7 @@ import (
"github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/bubbles/table"
tea "github.com/charmbracelet/bubbletea"
+ "github.com/charmbracelet/lipgloss"
)
// errorsView shows the broker's service-error datastore: the initial
@@ -20,13 +23,22 @@ import (
// push. The user can clear the selected entry; note clears are in-memory
// in nvpair-errors and a fresh producer emit resurrects the entry.
type errorsView struct {
- client *rpc.Client
- table table.Model
- errs []svcerrors.ServiceError
- status string
+ client *rpc.Client
+ table table.Model
+ errs []svcerrors.ServiceError
+ // namer resolves the node UUID the broker stamps on each report. Without it
+ // the NODE column showed the same random-looking id the jobs list did.
+ namer *nodeNamer
+ status toast
width, height int
}
+// errorsIdentityMsg carries this machine's identity for the NODE column.
+type errorsIdentityMsg struct {
+ id clusterIdentity
+ err error
+}
+
// errorsLoadedMsg carries the result of errors:get-initial.
type errorsLoadedMsg struct {
errs []svcerrors.ServiceError
@@ -36,7 +48,6 @@ type errorsLoadedMsg struct {
// errorsClearedMsg reports the outcome of an errors:clear request. The
// refreshed list arrives separately via an errors:update push.
type errorsClearedMsg struct {
- id string
err error
}
@@ -46,48 +57,60 @@ var clearKey = key.NewBinding(
)
func newErrorsView(client *rpc.Client) *errorsView {
- v := &errorsView{client: client}
- v.table = newTable(nil)
+ v := &errorsView{client: client, width: defaultTableWidth, namer: newNodeNamer()}
+ v.table = newTable(v.columns())
return v
}
-func (v *errorsView) Title() string { return "Errors" }
+// Title carries the active error count, so the tab bar itself is the indicator
+// and no separate badge is needed anywhere. Title is re-read every render, so
+// the count follows the errors:update stream without extra plumbing.
+func (v *errorsView) Title() string {
+ if n := v.count(); n > 0 {
+ return fmt.Sprintf("Errors (%d)", n)
+ }
+ return "Errors"
+}
func (v *errorsView) Init() tea.Cmd {
- return call(v.client, "errors:get-initial", nil, func(msg *rpc.Message, err error) tea.Msg {
- if err != nil {
- return errorsLoadedMsg{err: err}
- }
- var errs []svcerrors.ServiceError
- _ = decodeParams(msg.Result, &errs)
- return errorsLoadedMsg{errs: errs}
- })
+ return tea.Batch(
+ call(v.client, "errors:get-initial", nil, func(msg *rpc.Message, err error) tea.Msg {
+ if err != nil {
+ return errorsLoadedMsg{err: err}
+ }
+ var errs []svcerrors.ServiceError
+ _ = decodeParams(msg.Result, &errs)
+ return errorsLoadedMsg{errs: errs}
+ }),
+ nodeIdentityCmd(v.client, func(id clusterIdentity, err error) tea.Msg {
+ return errorsIdentityMsg{id: id, err: err}
+ }),
+ )
}
+// SetSize records the budget and fixes the table's width. Its height is set in
+// View, from the chrome actually being rendered — see fitTable.
func (v *errorsView) SetSize(w, h int) {
v.width, v.height = w, h
v.table.SetWidth(w)
- v.table.SetHeight(clampWidth(h-1, 1))
v.table.SetColumns(v.columns())
}
func (v *errorsView) columns() []table.Column {
- // Fixed columns first; Message takes whatever width is left.
- const sev, age, node = 9, 6, 16
- msg := clampWidth(v.width-sev-age-node-2, 10)
- return []table.Column{
- {Title: "SEV", Width: sev},
- {Title: "AGE", Width: age},
- {Title: "NODE", Width: node},
- {Title: "MESSAGE", Width: msg},
- }
+ // Fixed columns first; MESSAGE takes whatever width is left.
+ return layoutColumns(v.width, []column{
+ fixedCol("SEV", 9),
+ fixedCol("AGE", 6),
+ fixedCol("NODE", 16),
+ flexCol("MESSAGE", 10, 1),
+ })
}
func (v *errorsView) Update(msg tea.Msg) tea.Cmd {
switch msg := msg.(type) {
case errorsLoadedMsg:
if msg.err != nil {
- v.status = "failed to load errors: " + msg.err.Error()
+ v.status.error("failed to load errors: %s", msg.err)
return nil
}
v.setErrors(msg.errs)
@@ -95,17 +118,40 @@ func (v *errorsView) Update(msg tea.Msg) tea.Cmd {
case errorsClearedMsg:
if msg.err != nil {
- v.status = "clear failed: " + msg.err.Error()
+ v.status.error("clear failed: %s", msg.err)
} else {
- v.status = "cleared " + msg.id
+ // The row disappearing is the confirmation; the id is an internal
+ // supervisor identifier and means nothing to a reader.
+ v.status.ok("cleared")
+ }
+ return nil
+
+ case errorsIdentityMsg:
+ if msg.err == nil {
+ v.namer.setSelf(msg.id)
+ v.setErrors(v.errs)
}
return nil
+ case TickMsg:
+ // AGE is relative and errors:update only fires on change, so without
+ // this a sticky error shows the age it had when first reported for the
+ // rest of the session — reading as a brand-new failure.
+ v.setErrors(v.errs)
+ return nil
+
case NotificationMsg:
- if msg.Msg.Method == "errors:update" {
+ switch msg.Msg.Method {
+ case "errors:update":
var errs []svcerrors.ServiceError
_ = decodeParams(msg.Msg.Params, &errs)
v.setErrors(errs)
+ case "discovery:nodes-changed":
+ // The only source of the UUID-to-name mapping for the NODE column.
+ var nodes []availableNode
+ _ = decodeParams(msg.Msg.Params, &nodes)
+ v.namer.learnDiscovered(nodes)
+ v.setErrors(v.errs)
}
return nil
@@ -121,6 +167,10 @@ func (v *errorsView) Update(msg tea.Msg) tea.Cmd {
}
func (v *errorsView) clearSelected() tea.Cmd {
+ if len(v.errs) == 0 {
+ v.status.info("no error selected")
+ return nil
+ }
row := v.table.SelectedRow()
if row == nil {
return nil
@@ -129,45 +179,254 @@ func (v *errorsView) clearSelected() tea.Cmd {
if idx < 0 || idx >= len(v.errs) {
return nil
}
- id := v.errs[idx].ID
- return call(v.client, "errors:clear", svcerrors.ClearParams{ID: id}, func(_ *rpc.Message, err error) tea.Msg {
- return errorsClearedMsg{id: id, err: err}
+ e := v.errs[idx]
+
+ // Clearing is delete-by-id on this node only. Cross-node propagation is
+ // designed but not built — shared/errors documents ClearedBy as stamped for
+ // it and ignored "for now" — so a peer's error is deleted here and restored
+ // by the next sync from the node that owns it. Refusing beats reporting a
+ // success that undoes itself a second later; the operator's real option is
+ // to clear it where it came from.
+ if !v.clearable(e) {
+ v.status.error("%s reported this - clear it there; clearing here would not stick",
+ v.namer.name(e.NodeID))
+ return nil
+ }
+
+ return call(v.client, "errors:clear", svcerrors.ClearParams{ID: e.ID}, func(_ *rpc.Message, err error) tea.Msg {
+ return errorsClearedMsg{err: err}
})
}
+// clearable reports whether clearing this entry will stick.
+//
+// An error with no node id predates attribution or comes from a producer that
+// does not stamp one; it is treated as local, which is where it almost
+// certainly came from and keeps the key working rather than refusing on a
+// missing field. Before identity arrives the namer has no self, and everything
+// is clearable — the alternative is disabling the key for the first second of
+// every session.
+func (v *errorsView) clearable(e svcerrors.ServiceError) bool {
+ if e.NodeID == "" || v.namer.selfUUID == "" {
+ return true
+ }
+ return e.NodeID == v.namer.selfUUID
+}
+
func (v *errorsView) setErrors(errs []svcerrors.ServiceError) {
+ selected := v.selectedErrorID()
v.errs = errs
rows := make([]table.Row, 0, len(errs))
for _, e := range errs {
rows = append(rows, table.Row{
severityLabel(e.Severity),
ageLabel(e.Timestamp),
- truncate(e.NodeID, 16),
+ v.namer.name(e.NodeID),
e.Message,
})
}
+ // Keep the highlight on the same error across a refresh. errors:update is a
+ // full snapshot sorted by id, so a new error that sorts earlier shifts every
+ // row below it — and c would then clear a different entry than the one on
+ // screen. The cursor restore additionally covers startup, where the initial
+ // list is empty and would otherwise leave c dead.
v.table.SetRows(rows)
+ v.restoreSelection(selected)
+ restoreCursor(&v.table, len(rows))
+}
+
+// selectedErrorID identifies the highlighted error across a refresh.
+func (v *errorsView) selectedErrorID() string {
+ i := v.table.Cursor()
+ if i < 0 || i >= len(v.errs) {
+ return ""
+ }
+ return v.errs[i].ID
+}
+
+// restoreSelection puts the cursor back on an error after the list changed. One
+// that has been cleared or resolved leaves the cursor where bubbles clamped it.
+func (v *errorsView) restoreSelection(id string) {
+ if id == "" {
+ return
+ }
+ for i, e := range v.errs {
+ if e.ID == id {
+ v.table.SetCursor(i)
+ return
+ }
+ }
}
func (v *errorsView) View() string {
+ empty := ""
+ context := ""
if len(v.errs) == 0 {
- body := statusOKStyle.Render("No active errors.")
- if v.status != "" {
- body += "\n" + footerStyle.Render(v.status)
+ empty = statusOKStyle.Render("No active errors.")
+ } else if detail := v.selectedContext(); detail != "" {
+ context = footerStyle.Render(detail)
+ }
+
+ // The table takes what the context and status lines leave, so a selected
+ // error with full context cannot push the status line off the frame.
+ status := v.status.render()
+ body := empty
+ if len(v.errs) > 0 {
+ if fitTable(&v.table, v.height, context, status) {
+ body = v.table.View()
+ } else {
+ body = footerStyle.Render(fmt.Sprintf(
+ " (too little room to list %d errors)", len(v.errs)))
+ // The context is the only thing here whose height comes from the
+ // data rather than the layout: a long enough message wraps past the
+ // whole budget. Bounded to what is left once the one-line body and
+ // the status have taken theirs.
+ context = clampLines(context, v.height-countLines(body)-countLines(status))
}
- return body
}
- out := v.table.View()
- if v.status != "" {
- out += "\n" + footerStyle.Render(v.status)
+ return joinLines(body, context, status)
+}
+
+// selectedContext describes the highlighted error in more detail than its row.
+//
+// The producer stamps which engine, operation, and model a failure came from,
+// and what it suggests doing about it, but only the message reached the screen —
+// so "install failed" arrived with no way to tell which engine it referred to on
+// a node running two. Shown for the selected row rather than as columns because
+// the table has to stay readable on an 80-column terminal.
+func (v *errorsView) selectedContext() string {
+ i := v.table.Cursor()
+ if i < 0 || i >= len(v.errs) {
+ return ""
+ }
+ e := v.errs[i]
+
+ // The message first, in full. The table hard-truncates its MESSAGE cell to
+ // whatever width is left — about forty characters at eighty columns — and
+ // this tab exists to show that message, so there has to be somewhere it can
+ // be read in its entirety.
+ lines := []string{indentWrap(e.Message, v.width)}
+
+ parts := make([]string, 0, 4)
+ if e.EngineType != "" {
+ parts = append(parts, "engine "+engineDisplayName(e.EngineType))
+ }
+ if e.Operation != "" {
+ parts = append(parts, "during "+e.Operation)
+ }
+ if e.ModelName != "" {
+ parts = append(parts, "model "+e.ModelName)
}
- return out
+ // "none" is the producer saying there is nothing to do, which is not worth a
+ // line; anything else is a hint the operator can act on.
+ if e.Action != "" && e.Action != "none" {
+ parts = append(parts, "suggested: "+e.Action)
+ }
+ if len(parts) > 0 {
+ // Wrapped like the message. A model id plus an engine plus an operation
+ // runs past eighty columns routinely, and the field that fell off the
+ // end was the suggested action — the one thing here that tells the
+ // operator what to do.
+ lines = append(lines, indentWrap(strings.Join(parts, " "), v.width))
+ }
+ return strings.Join(lines, "\n")
+}
+
+// clampLines truncates a rendered block to at most n rows, marking the cut.
+func clampLines(s string, n int) string {
+ if n <= 0 {
+ return ""
+ }
+ lines := strings.Split(s, "\n")
+ if len(lines) <= n {
+ return s
+ }
+ lines = lines[:n]
+ lines[n-1] = footerStyle.Render(" ...")
+ return strings.Join(lines, "\n")
+}
+
+// indentWrap folds s onto lines no wider than width, indented by two spaces so
+// it reads as detail belonging to the row above.
+//
+// Measured in display cells, not bytes, and it breaks a token that cannot fit
+// on a line of its own. Both matter for what actually lands here: engine errors
+// quote model ids, blob paths, and URLs, none of which contain a space, and a
+// token longer than the terminal is exactly the case that defeated the purpose
+// of showing the message in full — the shell truncates an over-wide line rather
+// than wrapping it, so the tail was lost either way.
+func indentWrap(s string, width int) string {
+ const indent = " "
+ limit := width - lipgloss.Width(indent)
+ if limit < 8 {
+ limit = 8
+ }
+
+ var out []string
+ line := ""
+ flush := func() {
+ if line != "" {
+ out = append(out, indent+line)
+ line = ""
+ }
+ }
+ for _, word := range strings.Fields(s) {
+ for lipgloss.Width(word) > limit {
+ // Longer than a whole line: split it rather than emit an over-wide
+ // row. The head goes out on its own so the break is visible.
+ flush()
+ head, rest := splitCells(word, limit)
+ out = append(out, indent+head)
+ word = rest
+ }
+ switch {
+ case line == "":
+ line = word
+ case lipgloss.Width(line)+1+lipgloss.Width(word) <= limit:
+ line += " " + word
+ default:
+ flush()
+ line = word
+ }
+ }
+ flush()
+ return strings.Join(out, "\n")
+}
+
+// splitCells cuts s at the first n display cells, returning the head and the
+// remainder. Cuts on rune boundaries so a multi-byte character is never halved.
+func splitCells(s string, n int) (head, rest string) {
+ used := 0
+ for i, r := range s {
+ w := lipgloss.Width(string(r))
+ if used+w > n {
+ if i == 0 {
+ // A single rune wider than the whole allowance. Emit it anyway:
+ // returning an empty head makes no progress, and the caller
+ // loops until the word is consumed.
+ _, size := utf8.DecodeRuneInString(s)
+ return s[:size], s[size:]
+ }
+ return s[:i], s[i:]
+ }
+ used += w
+ }
+ return s, ""
}
func (v *errorsView) Help() []key.Binding {
+ // Withdrawn on a peer's error rather than offered and refused. The footer is
+ // the promise; a key that only ever answers "you cannot do that here" should
+ // not be in it.
+ if i := v.table.Cursor(); i >= 0 && i < len(v.errs) && !v.clearable(v.errs[i]) {
+ return nil
+ }
return []key.Binding{clearKey}
}
+// count is how many active errors the service is reporting, for the tab label.
+func (v *errorsView) count() int { return len(v.errs) }
+
func severityLabel(s string) string {
if s == "" {
return "info"
@@ -192,12 +451,23 @@ func ageLabel(tsMillis int64) string {
}
}
+// truncate shortens s to at most max characters, marking a cut with an ellipsis.
+//
+// Counted and sliced in runes, not bytes. A byte slice can land inside a
+// multi-byte sequence and emit an invalid rune, and every caller pairs this with
+// a %-Ns field — fmt pads by rune count — so a byte-based cut also made the
+// column narrower than intended for any non-ASCII name. GPU and CPU model names
+// and hostnames all reach here.
func truncate(s string, max int) string {
- if len(s) <= max {
+ if max <= 0 {
+ return ""
+ }
+ r := []rune(s)
+ if len(r) <= max {
return s
}
- if max <= 1 {
- return s[:max]
+ if max == 1 {
+ return "…"
}
- return s[:max-1] + "…"
+ return string(r[:max-1]) + "…"
}
diff --git a/services/nvpair-tui/ui/errors_test.go b/services/nvpair-tui/ui/errors_test.go
new file mode 100644
index 00000000..87b1c5ac
--- /dev/null
+++ b/services/nvpair-tui/ui/errors_test.go
@@ -0,0 +1,185 @@
+// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+package ui
+
+import (
+ "strings"
+ "testing"
+ "time"
+
+ svcerrors "nvpair-shared/errors"
+)
+
+// TestClearIsOfferedOnlyWhereItSticks is the guard for a clear that reported
+// success and then undid itself.
+//
+// Clearing is delete-by-id on the node that receives it; cross-node propagation
+// is designed but unbuilt, so clearing a peer's error locally is reverted by the
+// next sync from the node that owns it. The broker acknowledges the relay rather
+// than the outcome, so the reply is a success either way — which is why this has
+// to be decided before the call, not from its result.
+func TestClearIsOfferedOnlyWhereItSticks(t *testing.T) {
+ const self = "self-uuid"
+
+ mine := svcerrors.ServiceError{ID: "e-local", Message: "boom", NodeID: self}
+ theirs := svcerrors.ServiceError{ID: "e-peer", Message: "boom", NodeID: "peer-uuid"}
+ unattributed := svcerrors.ServiceError{ID: "e-old", Message: "boom"}
+
+ v := newErrorsView(nil)
+ v.SetSize(100, 30)
+ v.namer.setSelf(clusterIdentity{NodeUUID: self, Name: "this-host"})
+ v.namer.learn("peer-uuid", "peer-host")
+ v.setErrors([]svcerrors.ServiceError{mine, theirs, unattributed})
+
+ if !v.clearable(mine) {
+ t.Error("own error is not clearable")
+ }
+ if v.clearable(theirs) {
+ t.Error("a peer's error is offered as clearable; the clear would not stick")
+ }
+ if !v.clearable(unattributed) {
+ t.Error("an error with no node id should stay clearable")
+ }
+
+ // Selecting the peer's row withdraws the key and explains why, naming the
+ // node to go to rather than just refusing.
+ v.table.SetCursor(1)
+ if got := v.Help(); len(got) != 0 {
+ t.Errorf("footer still advertises %d binding(s) on a peer's error", len(got))
+ }
+ if cmd := v.clearSelected(); cmd != nil {
+ t.Error("clearing a peer's error still issued a request")
+ }
+ if msg := v.status.render(); !strings.Contains(msg, "peer-host") {
+ t.Errorf("refusal does not name the node to clear it from: %q", msg)
+ }
+
+ // And the local row still works.
+ v.table.SetCursor(0)
+ if got := v.Help(); len(got) == 0 {
+ t.Error("footer withdrew the clear key on this machine's own error")
+ }
+ if cmd := v.clearSelected(); cmd == nil {
+ t.Error("clearing this machine's own error issued no request")
+ }
+}
+
+// TestErrorContextIsShownForTheSelectedRow is the guard for context the producer
+// sends and the screen threw away.
+//
+// A failure is stamped with the engine, operation, and model it came from, but
+// only the message reached the table — so "install failed" arrived with no way
+// to tell which engine it meant on a node running two.
+func TestErrorContextIsShownForTheSelectedRow(t *testing.T) {
+ v := newErrorsView(nil)
+ v.SetSize(100, 20)
+ v.setErrors([]svcerrors.ServiceError{{
+ ID: "e1",
+ Message: "install failed",
+ Timestamp: time.Now().UnixMilli(),
+ Severity: "error",
+ EngineType: "ollama",
+ Operation: "install",
+ ModelName: "llama3.2",
+ Action: "retry",
+ }})
+
+ got := v.View()
+ // The display name, not the wire id: the operator sees "Ollama" everywhere
+ // else, and an error is a poor place to introduce a second name for it.
+ for _, want := range []string{"Ollama", "install", "llama3.2", "retry"} {
+ if !contains(got, want) {
+ t.Errorf("context %q is missing from the view:\n%s", want, got)
+ }
+ }
+}
+
+// TestErrorContextShowsTheFullMessage checks the detail block carries the whole
+// message. The table hard-truncates its MESSAGE cell — about forty characters at
+// eighty columns — and this tab exists to show that message, so a long one has
+// to be readable somewhere.
+func TestErrorContextShowsTheFullMessage(t *testing.T) {
+ long := "install failed: could not resolve the download host after three attempts, " +
+ "check the machine's network configuration and proxy settings"
+
+ v := newErrorsView(nil)
+ v.SetSize(80, 20)
+ v.setErrors([]svcerrors.ServiceError{{
+ ID: "e1", Message: long, Timestamp: time.Now().UnixMilli(), Severity: "error",
+ }})
+
+ got := v.selectedContext()
+ // Compared word by word, since the block is wrapped across lines.
+ flat := strings.Join(strings.Fields(got), " ")
+ if !contains(flat, strings.Join(strings.Fields(long), " ")) {
+ t.Errorf("the full message is not in the detail block:\n%s", got)
+ }
+ // And it must wrap rather than run off the side.
+ for _, line := range strings.Split(got, "\n") {
+ if len(line) > 80 {
+ t.Errorf("detail line is %d columns wide, past the terminal: %q", len(line), line)
+ }
+ }
+}
+
+// TestErrorContextOmitsAbsentFields checks a bare error adds no empty furniture
+// beyond its own message.
+func TestErrorContextOmitsAbsentFields(t *testing.T) {
+ v := newErrorsView(nil)
+ v.SetSize(100, 20)
+ v.setErrors([]svcerrors.ServiceError{{
+ ID: "e1",
+ Message: "something went wrong",
+ Timestamp: time.Now().UnixMilli(),
+ Severity: "warning",
+ }})
+
+ got := v.selectedContext()
+ for _, unwanted := range []string{"engine ", "during ", "model ", "suggested"} {
+ if contains(got, unwanted) {
+ t.Errorf("context invented a %q field: %q", unwanted, got)
+ }
+ }
+}
+
+// TestErrorActionNoneIsNotAdvice checks the producer's way of saying "nothing to
+// do" is not rendered as a suggestion.
+func TestErrorActionNoneIsNotAdvice(t *testing.T) {
+ v := newErrorsView(nil)
+ v.SetSize(100, 20)
+ v.setErrors([]svcerrors.ServiceError{{
+ ID: "e1",
+ Message: "informational",
+ Timestamp: time.Now().UnixMilli(),
+ EngineType: "lmstudio",
+ Action: "none",
+ }})
+
+ got := v.selectedContext()
+ if contains(got, "suggested") {
+ t.Errorf("action=none was rendered as advice: %q", got)
+ }
+ if !contains(got, "LM Studio") {
+ t.Errorf("the engine was dropped along with it: %q", got)
+ }
+}
+
+// TestErrorAgeRefreshesOnTick guards the column that used to freeze at whatever
+// it read when the error first arrived, making a week-old failure look new.
+func TestErrorAgeRefreshesOnTick(t *testing.T) {
+ v := newErrorsView(nil)
+ v.SetSize(100, 20)
+ v.setErrors([]svcerrors.ServiceError{{
+ ID: "e1",
+ Message: "stuck",
+ Timestamp: time.Now().Add(-90 * time.Second).UnixMilli(),
+ }})
+
+ v.Update(TickMsg{})
+ if got := v.View(); !contains(got, "1m") {
+ t.Errorf("age did not refresh on the tick:\n%s", got)
+ }
+}
+
+var _ View = (*errorsView)(nil)
diff --git a/services/nvpair-tui/ui/framebudget_test.go b/services/nvpair-tui/ui/framebudget_test.go
new file mode 100644
index 00000000..c3b6d3a7
--- /dev/null
+++ b/services/nvpair-tui/ui/framebudget_test.go
@@ -0,0 +1,644 @@
+// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+package ui
+
+import (
+ "strings"
+ "testing"
+ "time"
+
+ svcerrors "nvpair-shared/errors"
+ "nvpair-shared/noderec"
+
+ tea "github.com/charmbracelet/bubbletea"
+)
+
+// These tests measure what a view *renders*, before the shell clamps it.
+//
+// TestViewFrameIsExactlyTerminalSized cannot catch an overflowing view: the
+// shell's fitLines makes the frame the right height by construction, so a view
+// that emits too many rows still produces a correctly sized frame — with its
+// last line silently deleted. And the last line is always the worst one to
+// lose, because the optional rows at the bottom are the messages: the status
+// toast, the feed warning, the inline editor's prompt.
+//
+// So these assert the pre-truncation line count against the same budget the
+// shell hands the view, with every combination of optional chrome present.
+
+// renderedRows is how many terminal rows a view's View() actually emits.
+func renderedRows(s string) int {
+ if s == "" {
+ return 0
+ }
+ return len(strings.Split(s, "\n"))
+}
+
+// contentBudget is the row budget the shell gives a view at the given size.
+func contentBudget(t *testing.T, w, h int) int {
+ t.Helper()
+ m := newTestModel(defaultViews(nil)...)
+ m.width, m.height = w, h
+ m.resizeViews()
+ return m.contentHeight()
+}
+
+// TestNodesViewNeverOverflowsItsBudget covers the tab with the most optional
+// chrome: a filter note, a feed warning, an inbound-invite prompt, an inline
+// editor, and a status toast can all be present at once.
+func TestNodesViewNeverOverflowsItsBudget(t *testing.T) {
+ const w, h = 80, 24
+ budget := contentBudget(t, w, h)
+
+ build := func() *nodesView {
+ v := newNodesView(nil)
+ v.SetSize(w, budget)
+ nodes := make([]availableNode, 12)
+ for i := range nodes {
+ nodes[i] = availableNode{
+ HostUUID: string(rune('a' + i)),
+ Name: "node-" + string(rune('a'+i)),
+ IPAddress: "10.0.0." + string(rune('1'+i)),
+ }
+ }
+ v.feeds.discovered = nodes
+ v.rebuild()
+ return v
+ }
+
+ cases := map[string]func(*nodesView){
+ "plain": func(*nodesView) {},
+ "filter": func(v *nodesView) { v.filter = "node"; v.rebuild() },
+ "warning": func(v *nodesView) { v.noteFeed(feedManual, errStub{}) },
+ "status": func(v *nodesView) { v.status.error("something failed") },
+ "inbound": func(v *nodesView) { v.inbound = &clusterInvite{FromNodeName: "peer"} },
+ "editor": func(v *nodesView) { v.beginInput(nodesInputManualAddress, "host") },
+ "filter+warning": func(v *nodesView) {
+ v.filter = "node"
+ v.rebuild()
+ v.noteFeed(feedManual, errStub{})
+ },
+ "everything": func(v *nodesView) {
+ v.filter = "node"
+ v.rebuild()
+ v.noteFeed(feedManual, errStub{})
+ v.inbound = &clusterInvite{FromNodeName: "peer"}
+ v.beginInput(nodesInputManualAddress, "host")
+ v.status.error("something failed")
+ },
+ "filter matches nothing": func(v *nodesView) {
+ v.filter = "no-such-node"
+ v.rebuild()
+ v.status.error("something failed")
+ },
+ }
+
+ for name, setup := range cases {
+ v := build()
+ setup(v)
+ if got := renderedRows(v.View()); got > budget {
+ t.Errorf("%s: rendered %d rows into a %d-row budget; the shell will delete the last %d line(s)",
+ name, got, budget, got-budget)
+ }
+ }
+}
+
+// TestErrorsViewNeverOverflowsItsBudget covers the context line added for the
+// selected error, which appears exactly when an engine failure is highlighted.
+func TestErrorsViewNeverOverflowsItsBudget(t *testing.T) {
+ const w, h = 80, 24
+ budget := contentBudget(t, w, h)
+
+ build := func(n int) *errorsView {
+ v := newErrorsView(nil)
+ v.SetSize(w, budget)
+ errs := make([]svcerrors.ServiceError, n)
+ for i := range errs {
+ errs[i] = svcerrors.ServiceError{
+ ID: "e" + string(rune('a'+i)),
+ Message: "install failed",
+ Timestamp: time.Now().UnixMilli(),
+ Severity: "error",
+ EngineType: "ollama",
+ Operation: "install",
+ ModelName: "llama3.2",
+ Action: "retry",
+ }
+ }
+ v.setErrors(errs)
+ return v
+ }
+
+ for _, n := range []int{0, 1, 30} {
+ v := build(n)
+ if got := renderedRows(v.View()); got > budget {
+ t.Errorf("%d errors: rendered %d rows into %d", n, got, budget)
+ }
+
+ v = build(n)
+ v.status.ok("cleared")
+ if got := renderedRows(v.View()); got > budget {
+ t.Errorf("%d errors + status: rendered %d rows into %d; the status line is what gets cut",
+ n, got, budget)
+ }
+ }
+}
+
+// TestNodeDetailNeverOverflowsItsBudget covers the screen with two tables
+// sharing one budget plus a hardware block whose height depends on the node.
+func TestNodeDetailNeverOverflowsItsBudget(t *testing.T) {
+ const w, h = 80, 24
+ budget := contentBudget(t, w, h)
+
+ build := func() *nodeDetail {
+ d := newNodeDetail(nil, nodeRow{key: "self", name: "this-host", self: true})
+ d.engines = []engineStatus{
+ {Engine: "ollama", Installed: true, Running: true, Port: 11434},
+ {Engine: "lmstudio", Installed: true, Running: false, Port: 1234},
+ }
+ models := make([]string, 40)
+ for i := range models {
+ models[i] = "model-" + string(rune('a'+i%26))
+ }
+ d.models = modelsResult{Models: models, ModelsByEngine: map[string][]string{"ollama": models}}
+ d.SetSize(w, budget)
+ d.refreshEngines()
+ d.refreshModels()
+ return d
+ }
+
+ cases := map[string]func(*nodeDetail){
+ "plain": func(*nodeDetail) {},
+ "editor": func(d *nodeDetail) { d.mode = detailInputEnginePort },
+ "status": func(d *nodeDetail) { d.status.error("start failed: no such engine") },
+ "editor+status": func(d *nodeDetail) {
+ d.mode = detailInputModelName
+ d.status.error("download failed")
+ },
+ "hardware": func(d *nodeDetail) {
+ d.telemetryOK = true
+ d.telemetry = nodeTelemetry{TelemetryValid: true}
+ d.SetSize(w, budget)
+ },
+ }
+
+ for name, setup := range cases {
+ d := build()
+ setup(d)
+ if got := renderedRows(d.View()); got > budget {
+ t.Errorf("%s: rendered %d rows into a %d-row budget", name, got, budget)
+ }
+ }
+}
+
+// TestEveryViewRendersWithinBudget is the blanket guard.
+//
+// The per-view tests below it were written one at a time and the Jobs tab was
+// simply never given one — which is how a splice bug that panicked on the
+// tab's ordinary state reached a review. This walks every view the shell can
+// show, at every size worth caring about, with and without a status line, and
+// asserts two things: it does not panic, and it does not overflow. A new view
+// is covered the moment it is added to defaultViews.
+func TestEveryViewRendersWithinBudget(t *testing.T) {
+ // Every height from the supported minimum upward, not a handful of sampled
+ // sizes. The sampled list jumped from a budget of 21 to 11 and stepped
+ // straight over the band where the Service tab overran by one row — a hole
+ // exactly where a view overflowed, in the test whose whole purpose is to
+ // prove none does.
+ sizes := make([][2]int, 0, 64)
+ for h := minTerminalHeight; h <= 44; h++ {
+ sizes = append(sizes, [2]int{80, h})
+ }
+ sizes = append(sizes, [2]int{200, 60}, [2]int{120, 40}, [2]int{60, 14}, [2]int{40, 12})
+
+ for _, size := range sizes {
+ w, h := size[0], size[1]
+ budget := contentBudget(t, w, h)
+ if budget <= 0 {
+ continue
+ }
+
+ for _, withStatus := range []bool{false, true} {
+ for _, v := range populatedViews(t) {
+ if withStatus {
+ noteStatus(v)
+ }
+ v.SetSize(w, budget)
+
+ // A panic here is a crash of the whole program, so it is worth
+ // naming the view and size rather than letting the suite die.
+ func() {
+ defer func() {
+ if r := recover(); r != nil {
+ t.Errorf("%s panicked at %dx%d (status=%v): %v",
+ v.Title(), w, h, withStatus, r)
+ }
+ }()
+ if got := renderedRows(v.View()); got > budget {
+ t.Errorf("%s rendered %d rows into a %d-row budget at %dx%d (status=%v)",
+ v.Title(), got, budget, w, h, withStatus)
+ }
+ }()
+ }
+ }
+ }
+}
+
+// TestServiceConfirmationSurvivesEveryHeight is the regression guard for the
+// one irreversible action losing its prompt.
+//
+// The hidden-worker note used to be guessed before the table was sized and
+// substituted afterwards, so across a band of ordinary heights one unbudgeted
+// row appeared and the shell deleted the last line — which is the status row
+// carrying "press y to confirm" for the data reset. The operator pressed enter
+// on the wipe, saw nothing change, and was left armed with no prompt.
+func TestServiceConfirmationSurvivesEveryHeight(t *testing.T) {
+ for h := minTerminalHeight; h <= 44; h++ {
+ budget := contentBudget(t, 80, h)
+ if budget <= 0 {
+ continue
+ }
+ v := newServiceView(nil)
+ v.refreshWorkers()
+ v.SetSize(80, budget)
+ v.confirming = len(v.items) - 1
+ v.status.arm("Reset all data and quit - press y to confirm, any other key to cancel")
+
+ out := v.View()
+ if got := renderedRows(out); got > budget {
+ t.Errorf("80x%d (budget %d): rendered %d rows", h, budget, got)
+ }
+ // Within the budget is necessary but not sufficient: the prompt must be
+ // among the rows that survive the shell's clamp.
+ if !contains(fitLines(out, budget), "press y to confirm") {
+ t.Errorf("80x%d: the reset confirmation is not on the frame:\n%s", h, out)
+ }
+ }
+}
+
+// TestDetailSurvivesAManyGpuHost guards the one block whose height comes from
+// the machine rather than the layout. A host reports a line per GPU, and an
+// eight-GPU box produced ten unshrinkable lines that pushed the engine table,
+// the models list, and the status line off the frame — leaving a hardware
+// readout and no sign anything was missing.
+func TestDetailSurvivesAManyGpuHost(t *testing.T) {
+ for _, gpuCount := range []int{1, 2, 4, 8, 16} {
+ gpus := make([]noderec.GPUInfo, gpuCount)
+ for i := range gpus {
+ gpus[i] = noderec.GPUInfo{Name: "NVIDIA GPU", VramBytes: 1 << 30}
+ }
+ for h := minTerminalHeight; h <= 30; h++ {
+ budget := contentBudget(t, 80, h)
+ if budget <= 0 {
+ continue
+ }
+ d := newNodeDetail(nil, nodeRow{key: "self", name: "host", self: true})
+ d.engines = []engineStatus{
+ {Engine: "ollama", Installed: true, Running: true},
+ {Engine: "lmstudio", Installed: true},
+ }
+ d.telemetryOK = true
+ d.telemetry = nodeTelemetry{
+ TelemetryValid: true, GPUs: gpus,
+ CPU: &noderec.CPUInfo{Name: "CPU", Cores: 8},
+ Memory: &noderec.MemoryInfo{TotalBytes: 1 << 34, UsedBytes: 1 << 33},
+ }
+ d.SetSize(80, budget)
+ d.refreshEngines()
+ d.status.error("start lmstudio failed: port in use")
+
+ out := d.View()
+ if got := renderedRows(out); got > budget {
+ t.Errorf("%d GPUs at 80x%d (budget %d): rendered %d rows",
+ gpuCount, h, budget, got)
+ }
+ // The status line is what the operator just caused; it must not be
+ // the thing a long device list displaces.
+ if !contains(fitLines(out, budget), "start lmstudio failed") {
+ t.Errorf("%d GPUs at 80x%d: the status line was displaced:\n%s",
+ gpuCount, h, out)
+ }
+ }
+ }
+}
+
+// TestArmedActionsOwnTheKeyboardEverywhere enumerates the confirmation gates.
+//
+// An armed action that does not capture input can be escaped with tab or a
+// digit, which leaves it armed behind a prompt that is no longer on screen — to
+// be confirmed by whatever the operator presses on returning. One of the three
+// sites had this and the other two did not.
+func TestArmedActionsOwnTheKeyboardEverywhere(t *testing.T) {
+ // Armed through the real key paths, so the prompt is set the way it is at
+ // runtime rather than by poking the flag.
+ nodesLeave := newNodesView(nil)
+ nodesLeave.SetSize(80, 20)
+ nodesLeave.identity.ClusterID = "cluster-1"
+ nodesLeave.handleKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("l")})
+ if !nodesLeave.confirmLeave {
+ t.Fatal("leave did not arm")
+ }
+
+ nodesRemove := newNodesView(nil)
+ nodesRemove.SetSize(80, 20)
+ nodesRemove.feeds.discovered = []availableNode{{
+ HostUUID: "peer", Name: "peer", IPAddress: "10.0.0.2", Trusted: true,
+ }}
+ nodesRemove.rebuild()
+ nodesRemove.selectedKey = "peer"
+ nodesRemove.removeSelected()
+ if nodesRemove.confirmRemove == "" {
+ t.Fatal("remove did not arm")
+ }
+
+ svc := newServiceView(nil)
+ svc.SetSize(80, 20)
+ svc.cursor = len(svc.items) - 1
+ svc.handleKey(tea.KeyMsg{Type: tea.KeyEnter})
+ if svc.confirming < 0 {
+ t.Fatal("reset did not arm")
+ }
+
+ detail := localDetail()
+ detail.models = modelsResult{ModelsByEngine: map[string][]string{"ollama": {"victim"}}}
+ detail.refreshModels()
+ detail.pane = detailModels
+ detail.deleteSelectedModel()
+
+ for name, ic := range map[string]inputCapturer{
+ "nodes/leave": nodesLeave,
+ "nodes/remove": nodesRemove,
+ "service/reset": svc,
+ "detail/delete": detail,
+ } {
+ if !ic.CapturingInput() {
+ t.Errorf("%s: armed but not capturing input; tab or a digit escapes the confirmation", name)
+ }
+ }
+
+ // And every armed prompt must stay on screen for as long as it is armed.
+ for name, s := range map[string]*toast{
+ "nodes/leave": &nodesLeave.status,
+ "nodes/remove": &nodesRemove.status,
+ "service/reset": &svc.status,
+ "detail/delete": &detail.status,
+ } {
+ if s.expired() {
+ t.Errorf("%s: the confirmation prompt expired while the action stayed armed", name)
+ }
+ if !contains(s.render(), "press y to confirm") {
+ t.Errorf("%s: the prompt does not say how to confirm: %q", name, s.render())
+ }
+ }
+}
+
+// TestDetailStaysInBudgetWithAStaleEngineList is the regression guard for the
+// fifth round's blocker.
+//
+// A peer that has gone away fails its engine poll, and the "not answering" note
+// makes the engines section one row taller than its table. The drop guard was
+// comparing the room left against the table's minimum rather than against what
+// it was about to render, so the section went through at exactly the sizes
+// where it did not fit — and the shell deleted the status line, which is where
+// an armed "press y to confirm" lives.
+func TestDetailStaysInBudgetWithAStaleEngineList(t *testing.T) {
+ gpus := make([]noderec.GPUInfo, 4)
+ for i := range gpus {
+ gpus[i] = noderec.GPUInfo{Name: "GPU", VramBytes: 1 << 30}
+ }
+ for _, stale := range []bool{false, true} {
+ for h := minTerminalHeight; h <= 30; h++ {
+ budget := contentBudget(t, 80, h)
+ if budget <= 0 {
+ continue
+ }
+ d := newNodeDetail(nil, nodeRow{key: "peer", name: "peer-01"})
+ d.engines = []engineStatus{{Engine: "ollama", Installed: true, Running: true}}
+ d.enginesStale = stale
+ d.telemetryOK = true
+ d.telemetry = nodeTelemetry{
+ TelemetryValid: true, GPUs: gpus,
+ CPU: &noderec.CPUInfo{Name: "CPU", Cores: 8},
+ Memory: &noderec.MemoryInfo{TotalBytes: 1 << 34, UsedBytes: 1 << 33},
+ }
+ d.SetSize(80, budget)
+ d.refreshEngines()
+ d.status.error("start failed: port in use")
+
+ out := d.View()
+ if got := renderedRows(out); got > budget {
+ t.Errorf("stale=%v at 80x%d (budget %d): rendered %d rows",
+ stale, h, budget, got)
+ }
+ if !contains(fitLines(out, budget), "start failed") {
+ t.Errorf("stale=%v at 80x%d: the status line was displaced", stale, h)
+ }
+ }
+ }
+}
+
+// TestFooterHidesGlobalsWhileCapturing is the regression guard for a footer
+// that undid every view's input help at the composition point.
+//
+// Each view narrows its own help to enter and esc while a field or a
+// confirmation owns the keyboard, and the footer prepended the five global
+// bindings regardless — so the composed line advertised keys that no longer
+// reached the shell. In a port field the digits are what you are meant to type,
+// and q typed a q instead of quitting.
+func TestFooterHidesGlobalsWhileCapturing(t *testing.T) {
+ m := newTestModel(defaultViews(nil)...)
+ m.width, m.height = 80, 24
+ m.resizeViews()
+
+ nodes, ok := m.views[0].(*nodesView)
+ if !ok {
+ t.Fatal("first view is not the nodes tab")
+ }
+
+ // Not capturing: the globals are there, and first, so a narrow terminal
+ // cannot truncate away the way out.
+ if got := m.footerView(); !contains(got, "quit") {
+ t.Errorf("idle footer does not offer quit: %s", got)
+ }
+
+ for name, arm := range map[string]func(){
+ "text field": func() { nodes.beginInput(nodesInputManualAddress, "host") },
+ "confirmation": func() {
+ nodes.cancelInput()
+ nodes.confirmLeave = true
+ },
+ } {
+ arm()
+ footer := m.footerView()
+ for _, dead := range []string{"quit", "go to tab", "next"} {
+ if contains(footer, dead) {
+ t.Errorf("%s: footer advertises %q, which does not reach the shell: %s",
+ name, dead, footer)
+ }
+ }
+ }
+}
+
+// populatedViews builds every tab with enough content that its table is real
+// rather than an empty state, since an empty table cannot overflow.
+func populatedViews(t *testing.T) []View {
+ t.Helper()
+
+ nodes := newNodesView(nil)
+ discovered := make([]availableNode, 12)
+ for i := range discovered {
+ discovered[i] = availableNode{
+ HostUUID: string(rune('a' + i)),
+ Name: "node-" + string(rune('a'+i)),
+ IPAddress: "10.0.0." + string(rune('1'+i)),
+ }
+ }
+ nodes.feeds.discovered = discovered
+ nodes.rebuild()
+
+ jobs := newJobsView(nil)
+ for i := range 20 {
+ jobs.upsert(workload{
+ ID: "j" + string(rune('a'+i)), OriginatedFrom: "node",
+ Model: "llama3.2", Engine: "ollama", State: "running",
+ })
+ }
+
+ errs := newErrorsView(nil)
+ entries := make([]svcerrors.ServiceError, 8)
+ for i := range entries {
+ entries[i] = svcerrors.ServiceError{
+ ID: "e" + string(rune('a'+i)), Message: "install failed",
+ Timestamp: time.Now().UnixMilli(), Severity: "error",
+ EngineType: "ollama", Operation: "install", ModelName: "llama3.2",
+ }
+ }
+ errs.setErrors(entries)
+
+ svc := newServiceView(nil)
+ svc.refreshWorkers()
+
+ logs := newLogsView(nil)
+
+ return []View{nodes, jobs, svc, errs, logs}
+}
+
+// noteStatus turns on every optional row a view has, since those are the rows
+// most likely to be the ones pushed off the frame — they are at the bottom, and
+// they are the messages.
+func noteStatus(v View) {
+ switch t := v.(type) {
+ case *nodesView:
+ t.status.error("something failed")
+ case *jobsView:
+ t.status.error("something failed")
+ // A running demo adds a progress line, and a trimmed history adds
+ // another. Both sit below the table, so the tab has to be measured with
+ // them present.
+ t.showAll, t.trimmed = true, 3
+ t.demo.status = demoPreparing
+ t.demo.gen = 1
+ t.demo.armed(demoTargetsMsg{gen: 1, targets: []demoTarget{
+ {backend: "ollama", port: 11434, model: "llama3.2"},
+ }})
+ case *serviceView:
+ t.status.error("something failed")
+ case *errorsView:
+ t.status.error("something failed")
+ }
+}
+
+// TestCatalogBrowserStaysInBudget covers the overlay, which replaces a whole
+// screen and so gets the full content budget.
+func TestCatalogBrowserStaysInBudget(t *testing.T) {
+ for _, size := range [][2]int{{120, 40}, {80, 24}, {60, 12}, {40, 12}} {
+ w, h := size[0], size[1]
+ budget := contentBudget(t, w, h)
+ if budget <= 0 {
+ continue
+ }
+
+ b := newCatalogBrowser(nil, "ollama", "Ollama", "this-host", false)
+ models := make([]catalogModel, 30)
+ for i := range models {
+ models[i] = catalogModel{Name: "model-" + string(rune('a'+i)), Size: 1 << 30}
+ }
+ b.all = models
+ b.loading = false
+ b.refresh()
+ b.SetSize(w, budget)
+
+ if got := renderedRows(b.View()); got > budget {
+ t.Errorf("catalog rendered %d rows into %d at %dx%d", got, budget, w, h)
+ }
+
+ b.status.error("download failed")
+ if got := renderedRows(b.View()); got > budget {
+ t.Errorf("catalog + status rendered %d rows into %d at %dx%d", got, budget, w, h)
+ }
+ }
+}
+
+// TestNodeDetailStaysInBudgetWhenShort covers the drill-down at sizes below the
+// 80x24 the per-view test uses, where its two tables and hardware block compete.
+func TestNodeDetailStaysInBudgetWhenShort(t *testing.T) {
+ for _, size := range [][2]int{{80, 24}, {80, 16}, {80, 13}, {60, 12}, {40, 12}} {
+ w, h := size[0], size[1]
+ budget := contentBudget(t, w, h)
+ if budget <= 0 {
+ continue
+ }
+
+ d := newNodeDetail(nil, nodeRow{key: "self", name: "this-host", self: true})
+ d.engines = []engineStatus{
+ {Engine: "ollama", Installed: true, Running: true, Port: 11434},
+ {Engine: "lmstudio", Installed: true, Running: false, Port: 1234},
+ }
+ models := make([]string, 20)
+ for i := range models {
+ models[i] = "model-" + string(rune('a'+i))
+ }
+ d.models = modelsResult{Models: models, ModelsByEngine: map[string][]string{"ollama": models}}
+ d.SetSize(w, budget)
+ d.refreshEngines()
+ d.refreshModels()
+ d.status.error("start failed: no such engine")
+ d.mode = detailInputEnginePort
+
+ if got := renderedRows(d.View()); got > budget {
+ t.Errorf("node detail rendered %d rows into %d at %dx%d", got, budget, w, h)
+ }
+ }
+}
+
+// TestNarrowAndShortTerminalsStayInBudget checks the smallest sizes anyone
+// plausibly uses, where the budget can go to almost nothing.
+func TestNarrowAndShortTerminalsStayInBudget(t *testing.T) {
+ for _, size := range [][2]int{{80, 24}, {80, 14}, {60, 12}, {40, 12}} {
+ w, h := size[0], size[1]
+ budget := contentBudget(t, w, h)
+ if budget <= 0 {
+ continue
+ }
+
+ v := newNodesView(nil)
+ v.SetSize(w, budget)
+ v.feeds.discovered = []availableNode{
+ {HostUUID: "a", Name: "alpha", IPAddress: "10.0.0.1"},
+ {HostUUID: "b", Name: "beta", IPAddress: "10.0.0.2"},
+ }
+ v.rebuild()
+ v.filter = "a"
+ v.rebuild()
+ v.noteFeed(feedManual, errStub{})
+ v.status.error("failed")
+
+ if got := renderedRows(v.View()); got > budget {
+ t.Errorf("%dx%d: nodes rendered %d rows into %d", w, h, got, budget)
+ }
+ }
+}
+
+type errStub struct{}
+
+func (errStub) Error() string { return "worker down" }
diff --git a/services/nvpair-tui/ui/health.go b/services/nvpair-tui/ui/health.go
deleted file mode 100644
index 587ecf7d..00000000
--- a/services/nvpair-tui/ui/health.go
+++ /dev/null
@@ -1,246 +0,0 @@
-// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
-// SPDX-License-Identifier: Apache-2.0
-
-package ui
-
-import (
- "fmt"
- "strings"
- "time"
-
- "nvpair-shared/engines"
- svcerrors "nvpair-shared/errors"
- "nvpair-tui/rpc"
-
- "github.com/charmbracelet/bubbles/key"
- "github.com/charmbracelet/bubbles/table"
- tea "github.com/charmbracelet/bubbletea"
-)
-
-// crashPrefix is the id prefix the broker stamps on its sticky
-// "subprocess X exited unexpectedly" errors. The Health view derives
-// per-worker liveness from the presence/absence of these in the
-// errors:update snapshot, since the broker exposes no dedicated
-// workers:get-status RPC.
-const crashPrefix = "supervisor:subprocess-crashed:"
-
-// healthPollInterval is how often the Overview re-pings the broker for
-// liveness + uptime.
-const healthPollInterval = 5 * time.Second
-
-// healthWorkers are the workers the broker supervises and reports crashes
-// for. nvpair-errors is deliberately absent: it is the error sink itself and
-// cannot report its own death, so it has no crash entry to key on.
-//
-// The proxy appears once, as engines.ProxyComponent, because one nvpair-proxy
-// process hosts every engine's facade under one supervisor — so the broker
-// reports one crash for the process rather than one per engine. Keying this on
-// the per-engine ComponentName instead would be silent in both directions: the
-// real crash entry would match no row, and the per-engine rows could never
-// leave "ok". See the identity split in nvpair-shared/engines.
-var healthWorkers = []string{
- "scanner",
- "node-info",
- engines.ProxyComponent,
- "workload-manager",
- "engine-manager",
- "manual-nodes",
- "settings",
- "cluster-manager",
- // The scheduler produces the rankings every facade routes on. Without it
- // each facade holds an empty ranking, takes no reservations, and dispatch
- // stops spreading — so its death is exactly the kind an operator would come
- // to this screen to find. Its crash key is "scheduler", from
- // startOptionalWorker in the broker.
- "scheduler",
-}
-
-// healthView is the Overview tab: broker liveness/version/uptime from
-// periodic pings, plus a per-worker health table derived from the broker's
-// crash-error stream.
-type healthView struct {
- client *rpc.Client
- table table.Model
-
- brokerVersion string
- uptime time.Duration
- pingErr error
- crashed map[string]svcerrors.ServiceError
-
- // localNodeUUID is this host's stable per-host UUID (from cluster:get-node-id).
- // The broker stamps local-origin reports' NodeID with this UUID, so a crash
- // entry whose NodeID differs belongs to a peer and must be ignored —
- // errors:update is the full cross-node snapshot when nvpair-errors runs with
- // --peer-sync. (Filtering on the display hostname instead would reject every
- // local UUID-stamped crash as remote.)
- localNodeUUID string
- // lastErrs is the most recent errors:update snapshot, retained so the
- // crash table can be re-filtered once localNodeID resolves.
- lastErrs []svcerrors.ServiceError
-
- width, height int
-}
-
-type healthTickMsg struct{}
-
-type healthPingMsg struct {
- version string
- uptime time.Duration
- err error
-}
-
-type healthNodeIDMsg struct {
- nodeUUID string
- err error
-}
-
-func newHealthView(client *rpc.Client) *healthView {
- v := &healthView{client: client, crashed: map[string]svcerrors.ServiceError{}}
- v.table = newTable([]table.Column{
- {Title: "WORKER", Width: 20},
- {Title: "STATUS", Width: 10},
- {Title: "DETAIL", Width: 40},
- })
- return v
-}
-
-func (v *healthView) Title() string { return "Overview" }
-
-func (v *healthView) Init() tea.Cmd {
- return tea.Batch(v.pingCmd(), v.tickCmd(), v.nodeIDCmd())
-}
-
-// nodeIDCmd resolves this host's stable UUID so the crash table can drop
-// peer-origin entries from the cross-node errors:update snapshot. It keys on
-// NodeUUID (not the display NodeID/hostname) to match the UUID the broker stamps
-// on local reports.
-func (v *healthView) nodeIDCmd() tea.Cmd {
- return call(v.client, "cluster:get-node-id", nil, func(msg *rpc.Message, err error) tea.Msg {
- if err != nil {
- return healthNodeIDMsg{err: err}
- }
- var id clusterIdentity
- _ = decodeParams(msg.Result, &id)
- return healthNodeIDMsg{nodeUUID: id.NodeUUID}
- })
-}
-
-func (v *healthView) pingCmd() tea.Cmd {
- return call(v.client, "ping", nil, func(msg *rpc.Message, err error) tea.Msg {
- if err != nil {
- return healthPingMsg{err: err}
- }
- var r struct {
- Version string `json:"version"`
- UptimeMS int64 `json:"uptime_ms"`
- }
- _ = decodeParams(msg.Result, &r)
- return healthPingMsg{version: r.Version, uptime: time.Duration(r.UptimeMS) * time.Millisecond}
- })
-}
-
-func (v *healthView) tickCmd() tea.Cmd {
- return tea.Tick(healthPollInterval, func(time.Time) tea.Msg { return healthTickMsg{} })
-}
-
-func (v *healthView) SetSize(w, h int) {
- v.width, v.height = w, h
- detail := clampWidth(w-20-10-2, 10)
- v.table.SetColumns([]table.Column{
- {Title: "WORKER", Width: 20},
- {Title: "STATUS", Width: 10},
- {Title: "DETAIL", Width: detail},
- })
- v.table.SetWidth(w)
- v.table.SetHeight(clampWidth(h-3, 1))
-}
-
-func (v *healthView) Update(msg tea.Msg) tea.Cmd {
- switch msg := msg.(type) {
- case healthTickMsg:
- return tea.Batch(v.pingCmd(), v.tickCmd())
-
- case healthPingMsg:
- v.pingErr = msg.err
- if msg.err == nil {
- v.brokerVersion = msg.version
- v.uptime = msg.uptime
- }
- return nil
-
- case healthNodeIDMsg:
- if msg.err == nil && msg.nodeUUID != "" {
- v.localNodeUUID = msg.nodeUUID
- // Re-filter the last snapshot now that we know who we are.
- v.rebuildCrashes(v.lastErrs)
- }
- return nil
-
- case NotificationMsg:
- if msg.Msg.Method == "errors:update" {
- var errs []svcerrors.ServiceError
- _ = decodeParams(msg.Msg.Params, &errs)
- v.rebuildCrashes(errs)
- }
- return nil
-
- case tea.KeyMsg:
- var cmd tea.Cmd
- v.table, cmd = v.table.Update(msg)
- return cmd
- }
- return nil
-}
-
-func (v *healthView) rebuildCrashes(errs []svcerrors.ServiceError) {
- v.lastErrs = errs
- crashed := map[string]svcerrors.ServiceError{}
- for _, e := range errs {
- // errors:update is the full cross-node snapshot (nvpair-errors runs
- // with --peer-sync in a cluster), so a peer's crashed worker would
- // otherwise paint this host's same-named worker DOWN. Keep only
- // local-origin crashes. Once localNodeUUID is known, drop entries
- // whose NodeID (a UUID, as the broker stamps it) is a different node;
- // an empty NodeID is treated as local (be defensive).
- if v.localNodeUUID != "" && e.NodeID != "" && e.NodeID != v.localNodeUUID {
- continue
- }
- if strings.HasPrefix(e.ID, crashPrefix) {
- crashed[strings.TrimPrefix(e.ID, crashPrefix)] = e
- }
- }
- v.crashed = crashed
- v.refreshRows()
-}
-
-func (v *healthView) refreshRows() {
- rows := make([]table.Row, 0, len(healthWorkers))
- for _, w := range healthWorkers {
- status, detail := "ok", ""
- if e, down := v.crashed[w]; down {
- status, detail = "DOWN", e.Message
- }
- rows = append(rows, table.Row{w, status, detail})
- }
- v.table.SetRows(rows)
-}
-
-func (v *healthView) View() string {
- var b strings.Builder
- if v.pingErr != nil {
- b.WriteString(statusErrStyle.Render("broker not responding: " + v.pingErr.Error()))
- } else {
- summary := fmt.Sprintf("broker v%s up %s", v.brokerVersion, v.uptime.Round(time.Second))
- b.WriteString(statusOKStyle.Render(summary))
- }
- b.WriteString("\n\n")
- if len(v.table.Rows()) == 0 {
- v.refreshRows()
- }
- b.WriteString(v.table.View())
- b.WriteString("\n")
- b.WriteString(footerStyle.Render("status is best-effort: DOWN means the broker reported a crash; nvpair-errors self-crashes are not surfaced here"))
- return b.String()
-}
-
-func (v *healthView) Help() []key.Binding { return nil }
diff --git a/services/nvpair-tui/ui/health_test.go b/services/nvpair-tui/ui/health_test.go
deleted file mode 100644
index f674f5d5..00000000
--- a/services/nvpair-tui/ui/health_test.go
+++ /dev/null
@@ -1,89 +0,0 @@
-// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
-// SPDX-License-Identifier: Apache-2.0
-
-package ui
-
-import (
- "testing"
-
- "nvpair-shared/engines"
- svcerrors "nvpair-shared/errors"
-)
-
-// The Overview's proxy row must key on the identity the broker actually stamps
-// on a proxy crash.
-//
-// One nvpair-proxy process hosts every engine's facade under one supervisor, so
-// the broker reports one crash for the process. Keying on the per-engine
-// ComponentName was silent in both directions: the real crash entry matched no
-// row, so a dead proxy was invisible in the one view meant to show worker
-// liveness, and the per-engine rows could never leave "ok". Nothing else in the
-// build catches that — the mismatch compiles and every other test passes — so
-// this assertion is what makes the identity rule in nvpair-shared/engines
-// enforceable rather than advisory.
-func TestHealthProxyRowMatchesTheBrokerCrashIdentity(t *testing.T) {
- found := false
- for _, w := range healthWorkers {
- if w == engines.ProxyComponent {
- found = true
- }
- for _, e := range engines.All() {
- if w == e.ComponentName() {
- t.Errorf("health row %q keys on a per-facade identity; the broker reports proxy crashes against %q",
- w, engines.ProxyComponent)
- }
- }
- }
- if !found {
- t.Fatalf("no health row keys on %q, so a proxy crash would have no row at all: %v",
- engines.ProxyComponent, healthWorkers)
- }
-
- // End to end through the real matcher, with the id the broker builds.
- v := newHealthView(nil)
- v.localNodeUUID = "self-uuid"
- v.rebuildCrashes([]svcerrors.ServiceError{{
- ID: crashPrefix + engines.ProxyComponent,
- Message: "proxy crashed",
- NodeID: "self-uuid",
- }})
- if _, down := v.crashed[engines.ProxyComponent]; !down {
- t.Fatalf("a proxy crash did not register: %v", v.crashed)
- }
-}
-
-// TestHealthRebuildCrashesFiltersByUUID: the Overview
-// keeps only local-origin crashes, keyed on this host's stable UUID (the value
-// the broker stamps on local reports). A peer's crash must be dropped, and a
-// local UUID-stamped crash must NOT be misclassified as remote.
-func TestHealthRebuildCrashesFiltersByUUID(t *testing.T) {
- v := newHealthView(nil)
- v.localNodeUUID = "self-uuid"
-
- crash := func(worker, nodeID string) svcerrors.ServiceError {
- return svcerrors.ServiceError{ID: crashPrefix + worker, Message: worker + " crashed", NodeID: nodeID}
- }
- v.rebuildCrashes([]svcerrors.ServiceError{
- crash("scanner", "self-uuid"), // local crash — keep
- crash("ollama-proxy", "peer-uuid"), // a peer's crash — drop
- })
-
- if _, down := v.crashed["scanner"]; !down {
- t.Fatal("local UUID-stamped crash should be surfaced, not filtered as remote")
- }
- if _, down := v.crashed["ollama-proxy"]; down {
- t.Fatal("a peer's crash must be filtered out of the local health view")
- }
-}
-
-// TestHealthRebuildCrashesBeforeIdentity: before the local UUID resolves, all
-// crashes are kept (fail-open) so the view isn't blank during startup.
-func TestHealthRebuildCrashesBeforeIdentity(t *testing.T) {
- v := newHealthView(nil)
- v.rebuildCrashes([]svcerrors.ServiceError{
- {ID: crashPrefix + "scanner", Message: "x", NodeID: "whatever-uuid"},
- })
- if _, down := v.crashed["scanner"]; !down {
- t.Fatal("crashes should be kept until the local UUID is known")
- }
-}
diff --git a/services/nvpair-tui/ui/invite.go b/services/nvpair-tui/ui/invite.go
index 039c3430..f03f0654 100644
--- a/services/nvpair-tui/ui/invite.go
+++ b/services/nvpair-tui/ui/invite.go
@@ -13,9 +13,47 @@ import (
// PIN to display on success, or an explicit rejection (e.g. the target is
// already clustered) carrying its reason. No PIN accompanies a rejection.
type inviteNodeResult struct {
- State string `json:"state"`
- Pin *string `json:"pin"`
- Reason string `json:"reason"`
+ // InviteID identifies this pairing session. The manager mints one per
+ // invite and stamps it on every terminal notification, so it is what lets a
+ // view tell its own invite's outcome from a concurrent one's.
+ InviteID string `json:"inviteId"`
+ State string `json:"state"`
+ Pin *string `json:"pin"`
+ Reason string `json:"reason"`
+}
+
+// inviteResolution is how the UI reports an invite that is no longer pending.
+type inviteResolution struct {
+ kind toastKind
+ label string
+}
+
+// inviteRef is the invite a terminal notification refers to. The manager
+// supports concurrent pairings, so an event has to be matched against the
+// session it belongs to; acting on the method alone let an outbound decline
+// erase an unrelated inbound PIN prompt.
+type inviteRef struct {
+ InviteID string `json:"inviteId"`
+}
+
+// inviteOutcome maps a cluster-manager terminal invite event to its report.
+// These notifications are the only signal that a sent invite has stopped being
+// pending — the synchronous cluster:invite-node result only covers the handoff —
+// so a view that displays a PIN must consume them or leave a dead invite on
+// screen looking live.
+func inviteOutcome(method string) (inviteResolution, bool) {
+ switch method {
+ case "cluster:invite-declined":
+ return inviteResolution{kind: toastError, label: "declined by the other node"}, true
+ case "cluster:invite-expired":
+ return inviteResolution{kind: toastError, label: "expired before it was accepted"}, true
+ case "cluster:invite-canceled":
+ return inviteResolution{kind: toastInfo, label: "canceled"}, true
+ case "cluster:invite-failed":
+ return inviteResolution{kind: toastError, label: "failed - check the Logs tab"}, true
+ default:
+ return inviteResolution{}, false
+ }
}
// inviteNodeCmd issues a single cluster:invite-node request and maps the
diff --git a/services/nvpair-tui/ui/jobs.go b/services/nvpair-tui/ui/jobs.go
new file mode 100644
index 00000000..acf79ae2
--- /dev/null
+++ b/services/nvpair-tui/ui/jobs.go
@@ -0,0 +1,499 @@
+// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+package ui
+
+import (
+ "fmt"
+ "strings"
+
+ "nvpair-tui/rpc"
+
+ "github.com/charmbracelet/bubbles/key"
+ "github.com/charmbracelet/bubbles/table"
+ tea "github.com/charmbracelet/bubbletea"
+)
+
+// workload is the subset of the workload-manager's object the view shows.
+//
+// OriginatedFrom and ScheduledOn are both stable node UUIDs, not names: the
+// broker stamps its own resolveLocalNodeID onto local-origin events, and peers
+// carry theirs. They are resolved through a nodeNamer before display.
+type workload struct {
+ ID string `json:"id"`
+ Model string `json:"model"`
+ Engine string `json:"engine"`
+ State string `json:"state"`
+ // OriginatedFrom is the node the request arrived at.
+ OriginatedFrom string `json:"originatedFrom"`
+ // ScheduledOn is the node that actually ran it, absent until the backend
+ // has chosen a target. Origin and target differ whenever work is routed to
+ // a peer, which is the whole point of a cluster — so both are shown.
+ ScheduledOn string `json:"scheduledOn"`
+ CreatedAt int64 `json:"createdAt"` // Unix millis
+}
+
+// jobsView shows inference work across the cluster, headed by where local
+// clients should send it.
+//
+// The two belong together: a job list is the answer to "is my traffic being
+// served", and the proxy endpoints are the answer to "where do I send it". The
+// jobs themselves come from a baseline snapshot plus the live stream — both are
+// needed, since subscribing alone leaves work that was already running
+// invisible until its next state change, which for a long generation is minutes.
+type jobsView struct {
+ client *rpc.Client
+ table table.Model
+ proxy *proxyTracker
+ // namer turns the node UUIDs on each job into names. It is fed from the
+ // discovery and membership pushes the shell broadcasts to every view, so it
+ // costs one identity call at startup and nothing after that.
+ namer *nodeNamer
+
+ order []string
+ byKey map[string]workload
+ // trimmed counts finished jobs dropped to stay inside maxFinishedJobs, so
+ // the tab can say the history is not complete. Every other list here
+ // discloses when it is showing less than everything; this one silently
+ // discarded the oldest entries.
+ trimmed int
+ // sinceProxyPoll counts shell ticks since the last proxy status read.
+ sinceProxyPoll int
+ status toast
+
+ // showAll includes finished work. The manager keeps history, and a
+ // completed job is the evidence that routing worked, so it is reachable —
+ // but active work is what an operator is usually watching.
+ showAll bool
+
+ // demo is the Inference Demo. It lives on this tab rather than with the rest
+ // of the service controls because the proxy ports it needs are already here,
+ // and because the traffic it produces appears in the table below it — so
+ // starting it and seeing the result are the same screen.
+ demo *demoRunner
+
+ width, height int
+}
+
+type workloadsSubscribedMsg struct{ err error }
+
+// workloadsLoadedMsg carries the workloads:get-initial baseline.
+type workloadsLoadedMsg struct {
+ workloads []workload
+ err error
+}
+
+// jobsIdentityMsg carries this machine's identity, so its own jobs show a name.
+type jobsIdentityMsg struct {
+ id clusterIdentity
+ err error
+}
+
+var (
+ jobsAllKey = key.NewBinding(key.WithKeys("a"), key.WithHelp("a", "active/all"))
+ // t for test, matching the desktop app's button. The bubbles table binds
+ // j/k, f/b, u/d, g/G and the arrows, so t is one of the few letters left
+ // that does not fight the paging keys on a scrollable table.
+ jobsDemoKey = key.NewBinding(key.WithKeys("t"), key.WithHelp("t", "test traffic"))
+)
+
+func newJobsView(client *rpc.Client) *jobsView {
+ v := &jobsView{
+ client: client,
+ byKey: map[string]workload{},
+ proxy: newProxyTracker(),
+ namer: newNodeNamer(),
+ demo: newDemoRunner(),
+ }
+ v.table = newTable(workloadColumns(defaultTableWidth))
+ return v
+}
+
+// workloadColumns is the job table's layout, shared by construction and resize
+// so the two cannot drift.
+func workloadColumns(w int) []table.Column {
+ return layoutColumns(w, []column{
+ flexCol("MODEL", 10, 2),
+ fixedCol("ENGINE", 10),
+ fixedCol("STATE", 11),
+ flexCol("FROM", 8, 1),
+ flexCol("RAN ON", 8, 1),
+ fixedCol("AGE", 6),
+ })
+}
+
+func (v *jobsView) Title() string { return "Jobs" }
+
+// Init subscribes and fetches the baseline. Subscribing first means a workload
+// that changes state between the two calls arrives as a push and is merged by
+// key rather than lost.
+func (v *jobsView) Init() tea.Cmd {
+ return tea.Batch(
+ call(v.client, "workloads:subscribe", nil, func(_ *rpc.Message, err error) tea.Msg {
+ return workloadsSubscribedMsg{err: err}
+ }),
+ v.loadCmd(),
+ v.proxy.init(v.client),
+ nodeIdentityCmd(v.client, func(id clusterIdentity, err error) tea.Msg {
+ return jobsIdentityMsg{id: id, err: err}
+ }),
+ )
+}
+
+func (v *jobsView) loadCmd() tea.Cmd {
+ return call(v.client, "workloads:get-initial", nil, func(msg *rpc.Message, err error) tea.Msg {
+ if err != nil {
+ return workloadsLoadedMsg{err: err}
+ }
+ var r struct {
+ Workloads []workload `json:"workloads"`
+ }
+ _ = decodeParams(msg.Result, &r)
+ return workloadsLoadedMsg{workloads: r.Workloads}
+ })
+}
+
+// SetSize records the budget and fixes the table's width. Its height is set in
+// View, from the chrome actually being rendered — see fitTable.
+func (v *jobsView) SetSize(w, h int) {
+ v.width, v.height = w, h
+ v.table.SetColumns(workloadColumns(w))
+ v.table.SetWidth(w)
+}
+
+func (v *jobsView) Update(msg tea.Msg) tea.Cmd {
+ switch msg := msg.(type) {
+ case workloadsSubscribedMsg:
+ if msg.err != nil {
+ v.status.error("workloads subscribe failed: %s", msg.err)
+ }
+ return nil
+
+ case workloadsLoadedMsg:
+ if msg.err != nil {
+ v.status.error("load jobs failed: %s", msg.err)
+ return nil
+ }
+ // Merged, not assigned: a push may already have landed for a workload
+ // in this snapshot, and the push is the fresher of the two.
+ for _, w := range msg.workloads {
+ if _, seen := v.byKey[workloadKey(w.OriginatedFrom, w.ID)]; !seen {
+ v.upsert(w)
+ }
+ }
+ return nil
+
+ case proxyStatusMsg:
+ v.proxy.apply(msg)
+ return nil
+
+ case jobsIdentityMsg:
+ if msg.err == nil {
+ v.namer.setSelf(msg.id)
+ v.refreshRows()
+ }
+ return nil
+
+ case demoTargetsMsg:
+ if msg.err != nil {
+ v.status.error("inference demo: %s", msg.err)
+ return nil
+ }
+ if !v.demo.armed(msg) {
+ // Either the run was stopped while discovery was out, or no engine
+ // offered a model. Only the second is worth saying, and it needs to
+ // name the remedy rather than the failure.
+ if v.demo.status == demoIdle && len(msg.targets) == 0 {
+ v.status.error("no model available to send traffic to - install one from a node's detail screen first")
+ }
+ return nil
+ }
+ return nil
+
+ case TickMsg:
+ // AGE is relative, so the table has to repaint on the clock.
+ v.refreshRows()
+ // The demo's whole schedule runs off this tick; every submission offset
+ // is a whole number of seconds, so one second is enough resolution and
+ // the run needs no timer of its own.
+ demoCmds, finished := v.demo.tick()
+ if finished {
+ v.status.info("inference demo finished - the work it sent is in the table")
+ }
+ // Re-read proxy readiness periodically, but not on every tick. A proxy
+ // that crashes emits no error frame, so a strip corrected only by pushes
+ // can advertise a port nothing is listening on for the rest of the
+ // session — but the shell ticks once a second, and two broker round
+ // trips per second, each relayed on to a worker, is a poor trade on the
+ // headless machines this client is meant to be left running on.
+ v.sinceProxyPoll++
+ if v.sinceProxyPoll >= proxyPollTicks {
+ v.sinceProxyPoll = 0
+ demoCmds = append(demoCmds, v.proxy.refreshCmd(v.client))
+ }
+ return tea.Batch(demoCmds...)
+
+ case NotificationMsg:
+ switch msg.Msg.Method {
+ case "discovery:nodes-changed":
+ // Not a job event, but the only place the UUID-to-name mapping for
+ // the FROM and RAN ON columns comes from.
+ var nodes []availableNode
+ _ = decodeParams(msg.Msg.Params, &nodes)
+ v.namer.learnDiscovered(nodes)
+ v.refreshRows()
+ case "nodes:changed":
+ var r struct {
+ Nodes []clusterNode `json:"nodes"`
+ }
+ _ = decodeParams(msg.Msg.Params, &r)
+ v.namer.learnMembers(r.Nodes)
+ v.refreshRows()
+ case "workloads:upsert":
+ var p struct {
+ WorkloadInfo workload `json:"workloadInfo"`
+ }
+ _ = decodeParams(msg.Msg.Params, &p)
+ v.upsert(p.WorkloadInfo)
+ case "workloads:remove":
+ var p struct {
+ WorkloadID string `json:"workloadId"`
+ OriginatedFrom string `json:"originatedFrom"`
+ }
+ _ = decodeParams(msg.Msg.Params, &p)
+ v.remove(workloadKey(p.OriginatedFrom, p.WorkloadID))
+ default:
+ v.proxy.handleNotification(msg.Msg)
+ }
+ return nil
+
+ case tea.KeyMsg:
+ if key.Matches(msg, jobsAllKey) {
+ v.showAll = !v.showAll
+ v.refreshRows()
+ return nil
+ }
+ if key.Matches(msg, jobsDemoKey) {
+ return v.toggleDemo()
+ }
+ var cmd tea.Cmd
+ v.table, cmd = v.table.Update(msg)
+ return cmd
+ }
+ return nil
+}
+
+// proxyPollTicks is how many one-second shell ticks pass between proxy status
+// reads. Five matches the service tab's own poll: a proxy that has stopped
+// listening should be noticed promptly, but it is a rare event and the check
+// costs a broker round trip that fans out to a worker.
+const proxyPollTicks = 5
+
+// maxFinishedJobs bounds the completed and failed jobs kept in memory.
+//
+// The terminal client is meant to be left running — that is the point of a
+// status screen — and the broker never asks a client to forget a job, so
+// without a bound every job the cluster has ever run accumulates for the life
+// of the process. The desktop caps its history at 30 for the same reason; this
+// is more generous because scrolling a terminal table is cheap, and it is a cap
+// on finished work only, so no in-flight job is ever dropped.
+const maxFinishedJobs = 200
+
+func (v *jobsView) upsert(w workload) {
+ key := workloadKey(w.OriginatedFrom, w.ID)
+ if _, ok := v.byKey[key]; !ok {
+ v.order = append(v.order, key)
+ }
+ v.byKey[key] = w
+ v.trimFinished()
+ v.refreshRows()
+}
+
+// finishedCount is how many retained jobs have finished.
+func (v *jobsView) finishedCount() int {
+ n := 0
+ for _, key := range v.order {
+ if !workloadActive(v.byKey[key].State) {
+ n++
+ }
+ }
+ return n
+}
+
+// trimFinished drops the oldest finished jobs once there are too many.
+//
+// Only finished ones are eligible: an active job is what the operator is
+// watching, and the count of those is bounded by the cluster's own capacity
+// anyway. Eviction walks oldest-first because v.order is append-ordered, so the
+// history the operator loses is the history they are least likely to want.
+func (v *jobsView) trimFinished() {
+ finished := 0
+ for _, key := range v.order {
+ if !workloadActive(v.byKey[key].State) {
+ finished++
+ }
+ }
+ if finished <= maxFinishedJobs {
+ return
+ }
+
+ drop := finished - maxFinishedJobs
+ kept := make([]string, 0, len(v.order))
+ for _, key := range v.order {
+ if drop > 0 && !workloadActive(v.byKey[key].State) {
+ delete(v.byKey, key)
+ drop--
+ v.trimmed++
+ continue
+ }
+ kept = append(kept, key)
+ }
+ v.order = kept
+}
+
+func (v *jobsView) remove(key string) {
+ if _, ok := v.byKey[key]; !ok {
+ return
+ }
+ delete(v.byKey, key)
+ for i, k := range v.order {
+ if k == key {
+ v.order = append(v.order[:i], v.order[i+1:]...)
+ break
+ }
+ }
+ v.refreshRows()
+}
+
+// workloadActive reports whether a job is still in flight. The manager's state
+// vocabulary grows over time, so this names the terminal states and treats
+// anything else as active rather than silently hiding unfamiliar work.
+func workloadActive(state string) bool {
+ switch strings.ToLower(state) {
+ case "completed", "complete", "done", "failed", "error", "errored", "cancelled", "canceled":
+ return false
+ default:
+ return true
+ }
+}
+
+func (v *jobsView) visible() []workload {
+ out := make([]workload, 0, len(v.order))
+ for _, k := range v.order {
+ w := v.byKey[k]
+ if v.showAll || workloadActive(w.State) {
+ out = append(out, w)
+ }
+ }
+ return out
+}
+
+func (v *jobsView) refreshRows() {
+ jobs := v.visible()
+ rows := make([]table.Row, 0, len(jobs))
+ for _, w := range jobs {
+ rows = append(rows, table.Row{
+ w.Model,
+ engineDisplayName(w.Engine),
+ w.State,
+ v.namer.name(w.OriginatedFrom),
+ v.ranOn(w),
+ ageLabel(w.CreatedAt),
+ })
+ }
+ v.table.SetRows(rows)
+ // The last SetRows without one. Jobs starts empty on every launch, which is
+ // the case that strands the cursor at -1; nothing is keyed off the selection
+ // here today, but a table with no visible highlight is wrong on its own and
+ // this becomes a real defect the moment a row action is added.
+ restoreCursor(&v.table, len(rows))
+}
+
+// ranOn renders the node that served a job. The backend fills scheduledOn once
+// it has chosen a target, so an unplaced job says so rather than showing a blank
+// that reads as "nowhere".
+func (v *jobsView) ranOn(w workload) string {
+ if w.ScheduledOn == "" {
+ if workloadActive(w.State) {
+ return footerStyle.Render("choosing")
+ }
+ return unknownNodeLabel
+ }
+ return v.namer.name(w.ScheduledOn)
+}
+
+func (v *jobsView) View() string {
+ strip := v.proxy.strip()
+
+ empty := ""
+ if len(v.table.Rows()) == 0 {
+ empty = footerStyle.Render(v.emptyHint())
+ }
+ // Disclosed for the same reason the node table says "showing N of M": a
+ // list showing less than everything looks complete otherwise. Two ways it
+ // can be short — the active-only filter, and the history cap.
+ trimNote := ""
+ switch {
+ case !v.showAll && v.finishedCount() > 0:
+ trimNote = footerStyle.Render(fmt.Sprintf(
+ "%d finished job(s) hidden - press a to include them", v.finishedCount()))
+ case v.showAll && v.trimmed > 0:
+ trimNote = footerStyle.Render(fmt.Sprintf(
+ "%d older finished job(s) dropped to bound memory", v.trimmed))
+ }
+
+ // The blank line after the strip is a real row and is measured as one.
+ const separator = " "
+ demoNote := v.demo.note()
+ status := v.status.render()
+
+ body := empty
+ if len(v.table.Rows()) > 0 {
+ if fitTable(&v.table, v.height, strip, separator, trimNote, demoNote, status) {
+ body = v.table.View()
+ } else {
+ body = footerStyle.Render(" (too little room to list jobs)")
+ }
+ }
+ return joinLines(strip, separator, body, trimNote, demoNote, status)
+}
+
+// close releases the demo's children. Part of the closer contract in model.go.
+func (v *jobsView) close() { v.demo.close() }
+
+// toggleDemo starts a demo, or stops the one already running.
+//
+// One key for both, because the note on screen says which it will do and a
+// second key would sit unused for all but sixty seconds of a session.
+func (v *jobsView) toggleDemo() tea.Cmd {
+ if v.demo.status != demoIdle {
+ v.demo.stop()
+ v.status.info("inference demo stopped - requests already sent will still finish")
+ return nil
+ }
+ cmd, err := v.demo.start(v.proxy)
+ if err != nil {
+ v.status.error("inference demo: %s", err)
+ return nil
+ }
+ return cmd
+}
+
+func (v *jobsView) emptyHint() string {
+ if !v.showAll && len(v.order) > 0 {
+ return "No active jobs. Press a to include finished ones."
+ }
+ return "No jobs yet. Inference sent to a proxy endpoint above will appear here."
+}
+
+func (v *jobsView) Help() []key.Binding {
+ demo := jobsDemoKey
+ if v.demo.status != demoIdle {
+ // Relabelled rather than replaced, so the footer and the note on screen
+ // agree about what the key does right now.
+ demo = key.NewBinding(key.WithKeys("t"), key.WithHelp("t", "stop test"))
+ }
+ return []key.Binding{jobsAllKey, demo}
+}
+
+func workloadKey(origin, id string) string { return origin + "/" + id }
diff --git a/services/nvpair-tui/ui/jobs_test.go b/services/nvpair-tui/ui/jobs_test.go
new file mode 100644
index 00000000..3cb99f52
--- /dev/null
+++ b/services/nvpair-tui/ui/jobs_test.go
@@ -0,0 +1,87 @@
+// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+package ui
+
+import (
+ "fmt"
+ "testing"
+)
+
+// TestJobsHistoryIsBounded is the guard for a leak in a program meant to be left
+// running. The broker never tells a client to forget a job, so without a cap
+// every job the cluster has ever run accumulates for the life of the process,
+// and the "show all" table grows with it.
+func TestJobsHistoryIsBounded(t *testing.T) {
+ v := newJobsView(nil)
+ for i := range maxFinishedJobs + 50 {
+ v.upsert(workload{
+ ID: fmt.Sprintf("job-%d", i),
+ OriginatedFrom: "node",
+ State: "completed",
+ })
+ }
+
+ if got := len(v.byKey); got > maxFinishedJobs {
+ t.Errorf("kept %d finished jobs, want at most %d", got, maxFinishedJobs)
+ }
+ if len(v.order) != len(v.byKey) {
+ t.Errorf("order (%d) and index (%d) disagree after eviction, so a key leaked",
+ len(v.order), len(v.byKey))
+ }
+
+ // Eviction is oldest-first, so the most recent job must survive.
+ newest := workloadKey("node", fmt.Sprintf("job-%d", maxFinishedJobs+49))
+ if _, ok := v.byKey[newest]; !ok {
+ t.Error("the newest finished job was evicted; eviction is not oldest-first")
+ }
+}
+
+// TestJobsNeverEvictsActiveWork checks the cap only reclaims finished jobs. An
+// in-flight job is the thing the operator is watching, and dropping one would
+// make a busy cluster look idle.
+func TestJobsNeverEvictsActiveWork(t *testing.T) {
+ v := newJobsView(nil)
+ v.upsert(workload{ID: "live", OriginatedFrom: "node", State: "running"})
+ for i := range maxFinishedJobs + 50 {
+ v.upsert(workload{
+ ID: fmt.Sprintf("done-%d", i),
+ OriginatedFrom: "node",
+ State: "completed",
+ })
+ }
+
+ if _, ok := v.byKey[workloadKey("node", "live")]; !ok {
+ t.Error("a running job was evicted by history trimming")
+ }
+}
+
+// TestJobsUpsertReplacesRatherThanDuplicating checks a job progressing through
+// its states occupies one row, not one per update.
+func TestJobsUpsertReplacesRatherThanDuplicating(t *testing.T) {
+ v := newJobsView(nil)
+ for _, state := range []string{"queued", "running", "completed"} {
+ v.upsert(workload{ID: "j1", OriginatedFrom: "node", State: state})
+ }
+
+ if len(v.order) != 1 {
+ t.Errorf("one job produced %d rows across its state changes", len(v.order))
+ }
+ if got := v.byKey[workloadKey("node", "j1")].State; got != "completed" {
+ t.Errorf("state = %q, want the latest", got)
+ }
+}
+
+// TestJobsKeyIsScopedByOrigin checks two nodes can use the same job id without
+// colliding, since ids are only unique to the node that issued them.
+func TestJobsKeyIsScopedByOrigin(t *testing.T) {
+ v := newJobsView(nil)
+ v.upsert(workload{ID: "1", OriginatedFrom: "node-a", State: "running"})
+ v.upsert(workload{ID: "1", OriginatedFrom: "node-b", State: "running"})
+
+ if len(v.order) != 2 {
+ t.Errorf("same id from two nodes collapsed into %d row(s)", len(v.order))
+ }
+}
+
+var _ View = (*jobsView)(nil)
diff --git a/services/nvpair-tui/ui/keys.go b/services/nvpair-tui/ui/keys.go
index 8ab7ff76..d6f0e85f 100644
--- a/services/nvpair-tui/ui/keys.go
+++ b/services/nvpair-tui/ui/keys.go
@@ -3,27 +3,68 @@
package ui
-import "github.com/charmbracelet/bubbles/key"
+import (
+ "strconv"
+
+ "github.com/charmbracelet/bubbles/key"
+)
// globalKeyMap holds the bindings that work in every view. View-specific
// bindings are returned by each View's Help and handled inside its Update.
type globalKeyMap struct {
NextTab key.Binding
PrevTab key.Binding
+ JumpTab key.Binding
Help key.Binding
Quit key.Binding
+ Dismiss key.Binding
+}
+
+// While a text field owns the keyboard, these two are the only keys that do
+// anything — so they are the only two a view should advertise.
+//
+// Every view previously kept listing its normal verbs mid-entry, which is the
+// worst kind of help: it names six keys that all now type a character instead,
+// and omits the two that work. The label is per-field because "save" and
+// "apply filter" are not the same promise.
+var (
+ inputCancelKey = key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "cancel"))
+)
+
+// inputHelp is the footer for a view whose text field has the keyboard.
+func inputHelp(submitLabel string) []key.Binding {
+ return []key.Binding{
+ key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", submitLabel)),
+ inputCancelKey,
+ }
}
-func newGlobalKeyMap() globalKeyMap {
+// newGlobalKeyMap builds the shell's bindings for a given number of tabs.
+//
+// The count is a parameter because the digit binding has to match the tab bar
+// exactly: it advertised "1-9 go to tab" against five tabs, promising four
+// shortcuts that did nothing.
+func newGlobalKeyMap(tabs int) globalKeyMap {
return globalKeyMap{
+ // Deliberately not h/l or the arrows. Those are how you move *within*
+ // content, and a view with a horizontal axis — switching between the
+ // panes of a node's detail, say — cannot have them swallowed by the tab
+ // bar. The digits below make tab switching direct anyway.
NextTab: key.NewBinding(
- key.WithKeys("tab", "l", "right"),
+ key.WithKeys("tab"),
key.WithHelp("tab", "next"),
),
PrevTab: key.NewBinding(
- key.WithKeys("shift+tab", "h", "left"),
+ key.WithKeys("shift+tab"),
key.WithHelp("shift+tab", "prev"),
),
+ // The tab bar numbers every tab, so the digits have to select them —
+ // otherwise the labels promise a shortcut that does nothing. No view
+ // binds a digit, so these never shadow a view binding.
+ JumpTab: key.NewBinding(
+ key.WithKeys(tabDigits(tabs)...),
+ key.WithHelp(tabDigitsHelp(tabs), "go to tab"),
+ ),
Help: key.NewBinding(
key.WithKeys("?"),
key.WithHelp("?", "help"),
@@ -32,5 +73,44 @@ func newGlobalKeyMap() globalKeyMap {
key.WithKeys("q", "ctrl+c"),
key.WithHelp("q", "quit"),
),
+ // ctrl+x because every letter is already spoken for — the views between
+ // them bind a through y, and the table and viewport add b, g, G, space,
+ // and the ctrl+u / ctrl+d / ctrl+f / ctrl+b paging pairs. The shell
+ // handles its own keys before the active view sees them, so a global on
+ // any of those would silently shadow a verb.
+ //
+ // Not in the footer: it applies only while the banner is up, and the
+ // banner names it. A permanent entry for a key that is usually inert
+ // would cost a slot the footer truncates away from the right.
+ Dismiss: key.NewBinding(
+ key.WithKeys("ctrl+x"),
+ key.WithHelp("ctrl+x", "dismiss notice"),
+ ),
+ }
+}
+
+// tabDigits is the digit keys that select a tab, one per tab.
+//
+// Capped at nine because a tenth tab would need a two-key sequence, and the tab
+// bar has no room for ten labels at eighty columns anyway.
+func tabDigits(tabs int) []string {
+ if tabs > 9 {
+ tabs = 9
+ }
+ keys := make([]string, 0, tabs)
+ for i := 1; i <= tabs; i++ {
+ keys = append(keys, strconv.Itoa(i))
+ }
+ return keys
+}
+
+// tabDigitsHelp labels those keys: "1" alone, or "1-N".
+func tabDigitsHelp(tabs int) string {
+ if tabs > 9 {
+ tabs = 9
+ }
+ if tabs <= 1 {
+ return "1"
}
+ return "1-" + strconv.Itoa(tabs)
}
diff --git a/services/nvpair-tui/ui/logs.go b/services/nvpair-tui/ui/logs.go
index dfe5a105..f4b9b71d 100644
--- a/services/nvpair-tui/ui/logs.go
+++ b/services/nvpair-tui/ui/logs.go
@@ -4,45 +4,76 @@
package ui
import (
+ "errors"
+ "fmt"
+ "os"
+ "path/filepath"
"strings"
+ "time"
- "nvpair-shared/applog"
"nvpair-tui/rpc"
"github.com/charmbracelet/bubbles/key"
+ "github.com/charmbracelet/bubbles/textinput"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
)
-// maxLogLines bounds the in-memory scrollback so a long-running session
-// can't grow without limit.
+// maxLogLines bounds the in-memory scrollback so a long-running session cannot
+// grow without limit.
const maxLogLines = 5000
-// logsView shows the broker's (and its workers') stderr in a scrollable
-// pane and lets the operator change the fleet-wide log level live via
-// log/set-level, which the broker fans out to every worker.
+// logsView shows the service tree's stderr — the broker's own logs plus every
+// worker's, prefixed — with a substring filter, a follow toggle, and a way to
+// write the buffer out.
+//
+// Saving matters more here than in a desktop app. The graphical UI can open a
+// log file in a file manager; over SSH there is no file manager, and the
+// operator needs the lines somewhere they can be copied off the machine.
+//
+// The log level is not set here. It is a fleet-wide setting that belongs with
+// the rest of the service configuration, and having it on a single keystroke
+// next to the scroll keys made it far too easy to change the whole tree's
+// verbosity by accident.
type logsView struct {
client *rpc.Client
vp viewport.Model
lines []string
- status string
+
+ filter string
+ filterInput textinput.Model
+ editing bool
+ // follow pins the viewport to the newest line. Scrolling up is the normal
+ // way to read back, so it releases automatically rather than fighting the
+ // operator for control of the viewport.
+ follow bool
+
+ status toast
ready bool
+
+ width, height int
}
-type logLevelSetMsg struct {
- level string
- err error
+type logsSavedMsg struct {
+ path string
+ err error
}
var (
- logDebugKey = key.NewBinding(key.WithKeys("d"), key.WithHelp("d", "debug"))
- logInfoKey = key.NewBinding(key.WithKeys("i"), key.WithHelp("i", "info"))
- logWarnKey = key.NewBinding(key.WithKeys("w"), key.WithHelp("w", "warn"))
- logErrorKey = key.NewBinding(key.WithKeys("e"), key.WithHelp("e", "error"))
+ logFilterKey = key.NewBinding(key.WithKeys("/"), key.WithHelp("/", "filter"))
+ // t for tail, not f. The viewport binds f to page-down, and this is the one
+ // view whose whole purpose is scrolling back through a buffer — so f threw
+ // the operator to the bottom on the first press of the standard paging key.
+ // tail is also the vocabulary anyone reaching for this already has.
+ logFollowKey = key.NewBinding(key.WithKeys("t"), key.WithHelp("t", "follow"))
+ logSaveKey = key.NewBinding(key.WithKeys("s"), key.WithHelp("s", "save to file"))
+ logClearKey = key.NewBinding(key.WithKeys("c"), key.WithHelp("c", "clear filter"))
)
func newLogsView(client *rpc.Client) *logsView {
- return &logsView{client: client}
+ ti := textinput.New()
+ ti.Placeholder = "substring to show (case-insensitive)"
+ return &logsView{client: client, filterInput: ti, follow: true}
}
func (v *logsView) Title() string { return "Logs" }
@@ -50,7 +81,8 @@ func (v *logsView) Title() string { return "Logs" }
func (v *logsView) Init() tea.Cmd { return nil }
func (v *logsView) SetSize(w, h int) {
- // Reserve one line for the status/level footer.
+ v.width, v.height = w, h
+ // One row for the status/filter footer.
vh := clampWidth(h-1, 1)
if !v.ready {
v.vp = viewport.New(w, vh)
@@ -59,73 +91,198 @@ func (v *logsView) SetSize(w, h int) {
v.vp.Width = w
v.vp.Height = vh
}
- v.vp.SetContent(strings.Join(v.lines, "\n"))
+ v.render()
}
+func (v *logsView) CapturingInput() bool { return v.editing }
+
func (v *logsView) Update(msg tea.Msg) tea.Cmd {
switch msg := msg.(type) {
case LogLineMsg:
- atBottom := v.vp.AtBottom()
v.lines = append(v.lines, msg.Line)
if len(v.lines) > maxLogLines {
v.lines = v.lines[len(v.lines)-maxLogLines:]
}
- v.vp.SetContent(strings.Join(v.lines, "\n"))
- if atBottom {
- v.vp.GotoBottom()
- }
+ v.render()
return nil
- case logLevelSetMsg:
+ case logsSavedMsg:
if msg.err != nil {
- v.status = "set-level failed: " + msg.err.Error()
+ v.status.error("save failed: %s", msg.err)
} else {
- v.status = "log level set to " + msg.level
+ v.status.ok("saved to %s", msg.path)
}
return nil
case tea.KeyMsg:
- switch {
- case key.Matches(msg, logDebugKey):
- return v.setLevel("debug")
- case key.Matches(msg, logInfoKey):
- return v.setLevel("info")
- case key.Matches(msg, logWarnKey):
- return v.setLevel("warn")
- case key.Matches(msg, logErrorKey):
- return v.setLevel("error")
+ return v.handleKey(msg)
+ }
+ return nil
+}
+
+func (v *logsView) handleKey(msg tea.KeyMsg) tea.Cmd {
+ if v.editing {
+ switch msg.String() {
+ case "enter":
+ v.filter = strings.TrimSpace(v.filterInput.Value())
+ v.editing = false
+ v.filterInput.Blur()
+ v.render()
+ return nil
+ case "esc":
+ v.editing = false
+ v.filterInput.Blur()
+ return nil
}
var cmd tea.Cmd
- v.vp, cmd = v.vp.Update(msg)
+ v.filterInput, cmd = v.filterInput.Update(msg)
return cmd
}
- return nil
+
+ switch {
+ case key.Matches(msg, logFilterKey):
+ v.editing = true
+ v.filterInput.SetValue(v.filter)
+ v.filterInput.Focus()
+ return textinput.Blink
+ case key.Matches(msg, logFollowKey):
+ v.follow = !v.follow
+ if v.follow {
+ v.vp.GotoBottom()
+ }
+ return nil
+ case key.Matches(msg, logClearKey):
+ v.filter = ""
+ v.render()
+ return nil
+ case key.Matches(msg, logSaveKey):
+ return v.saveCmd()
+ }
+
+ before := v.vp.YOffset
+ var cmd tea.Cmd
+ v.vp, cmd = v.vp.Update(msg)
+ // Scrolling away from the bottom releases follow, so reading back does not
+ // get yanked forward by the next log line.
+ if v.vp.YOffset != before && !v.vp.AtBottom() {
+ v.follow = false
+ }
+ return cmd
}
-func (v *logsView) setLevel(level string) tea.Cmd {
- return call(v.client, applog.SetLevelMethod, applog.SetLevelParams{Level: level}, func(msg *rpc.Message, err error) tea.Msg {
+// visibleLines is the buffer after the filter, which is applied at render time
+// so changing it re-reads the whole retained buffer rather than only new lines.
+func (v *logsView) visibleLines() []string {
+ if v.filter == "" {
+ return v.lines
+ }
+ needle := strings.ToLower(v.filter)
+ out := make([]string, 0, len(v.lines))
+ for _, l := range v.lines {
+ if strings.Contains(strings.ToLower(l), needle) {
+ out = append(out, l)
+ }
+ }
+ return out
+}
+
+func (v *logsView) render() {
+ if !v.ready {
+ return
+ }
+ v.vp.SetContent(strings.Join(v.visibleLines(), "\n"))
+ if v.follow {
+ v.vp.GotoBottom()
+ }
+}
+
+// saveCmd writes the retained buffer to a timestamped file in the operator's
+// home directory. The filter is deliberately not applied: a saved log is
+// evidence, and silently omitting lines that did not match a transient filter
+// would make it misleading.
+func (v *logsView) saveCmd() tea.Cmd {
+ lines := append([]string(nil), v.lines...)
+ return func() tea.Msg {
+ home, err := os.UserHomeDir()
if err != nil {
- return logLevelSetMsg{err: err}
+ return logsSavedMsg{err: err}
}
- var r struct {
- Level string `json:"level"`
+ stamp := time.Now().Format("20060102-150405")
+ body := strings.Join(lines, "\n") + "\n"
+
+ // Never overwrite. The name is only precise to the second, and two saves
+ // inside the same second is exactly what happens when an operator
+ // captures evidence, changes a filter, and captures again — silently
+ // destroying the first file is the opposite of what saving is for.
+ // O_EXCL makes the check and the create one step, so a second save
+ // cannot land between them.
+ for attempt := 0; attempt < 100; attempt++ {
+ name := fmt.Sprintf("nvpair-logs-%s.log", stamp)
+ if attempt > 0 {
+ name = fmt.Sprintf("nvpair-logs-%s-%d.log", stamp, attempt+1)
+ }
+ path := filepath.Join(home, name)
+ f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
+ if errors.Is(err, os.ErrExist) {
+ continue
+ }
+ if err != nil {
+ return logsSavedMsg{err: err}
+ }
+ _, writeErr := f.WriteString(body)
+ closeErr := f.Close()
+ if writeErr != nil {
+ return logsSavedMsg{err: writeErr}
+ }
+ if closeErr != nil {
+ return logsSavedMsg{err: closeErr}
+ }
+ return logsSavedMsg{path: path}
}
- _ = decodeParams(msg.Result, &r)
- return logLevelSetMsg{level: r.Level}
- })
+ return logsSavedMsg{err: fmt.Errorf("could not find a free name for nvpair-logs-%s", stamp)}
+ }
}
func (v *logsView) View() string {
- footer := footerStyle.Render("set fleet log level: d debug i info w warn e error")
- if v.status != "" {
- footer = footerStyle.Render(v.status) + " " + footer
- }
if !v.ready {
- return footer
+ return footerStyle.Render("starting...")
+ }
+ var b strings.Builder
+ b.WriteString(v.vp.View())
+ b.WriteByte('\n')
+
+ if v.editing {
+ b.WriteString("filter: " + v.filterInput.View())
+ return b.String()
+ }
+ if s := v.status.render(); s != "" {
+ b.WriteString(s)
+ return b.String()
+ }
+ b.WriteString(footerStyle.Render(v.footerSummary()))
+ return b.String()
+}
+
+func (v *logsView) footerSummary() string {
+ follow := "off"
+ if v.follow {
+ follow = "on"
+ }
+ shown := len(v.visibleLines())
+ if v.filter == "" {
+ return fmt.Sprintf("%d lines follow %s", shown, follow)
}
- return v.vp.View() + "\n" + footer
+ return fmt.Sprintf("%d of %d lines matching %q follow %s",
+ shown, len(v.lines), v.filter, follow)
}
func (v *logsView) Help() []key.Binding {
- return []key.Binding{logDebugKey, logInfoKey, logWarnKey, logErrorKey}
+ if v.editing {
+ return inputHelp("apply filter")
+ }
+ bindings := []key.Binding{logFilterKey, logFollowKey, logSaveKey}
+ if v.filter != "" {
+ bindings = append(bindings, logClearKey)
+ }
+ return bindings
}
diff --git a/services/nvpair-tui/ui/logs_test.go b/services/nvpair-tui/ui/logs_test.go
new file mode 100644
index 00000000..62ffe6dd
--- /dev/null
+++ b/services/nvpair-tui/ui/logs_test.go
@@ -0,0 +1,207 @@
+// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+package ui
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ tea "github.com/charmbracelet/bubbletea"
+)
+
+// The Logs view had no behavioural coverage at all, which is how a keybinding
+// collision with the viewport's own paging keys survived four review rounds in
+// the one view whose purpose is scrolling.
+
+// logsWith builds a sized view holding n lines.
+func logsWith(t *testing.T, n int) *logsView {
+ t.Helper()
+ v := newLogsView(nil)
+ v.SetSize(80, 20)
+ for i := range n {
+ logLine(v, "line "+string(rune('a'+i%26)))
+ }
+ return v
+}
+
+// logLine feeds one captured stderr line in, the way the shell does.
+func logLine(v *logsView, s string) { v.Update(LogLineMsg{Line: s}) }
+
+func logsKey(v *logsView, k string) {
+ if k == "esc" {
+ v.Update(tea.KeyMsg{Type: tea.KeyEsc})
+ return
+ }
+ v.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(k)})
+}
+
+// TestLogsFollowKeyDoesNotCollideWithPaging is the regression guard for the
+// binding that made the tab unusable for its own purpose.
+//
+// The viewport binds f to page-down. Binding the follow toggle to the same key
+// meant the standard paging key threw the operator to the bottom of the buffer
+// on first press, in the view they had opened to scroll back through.
+func TestLogsFollowKeyDoesNotCollideWithPaging(t *testing.T) {
+ for _, reserved := range []string{"f", "b", "u", "d", "g", "G", " "} {
+ for _, bound := range logFollowKey.Keys() {
+ if bound == reserved {
+ t.Errorf("follow is bound to %q, which the viewport uses for scrolling", bound)
+ }
+ }
+ }
+
+ // And it still toggles.
+ v := logsWith(t, 50)
+ before := v.follow
+ logsKey(v, logFollowKey.Keys()[0])
+ if v.follow == before {
+ t.Error("the follow key did not toggle follow")
+ }
+}
+
+// TestLogsFilterRoundTrip covers opening the filter, applying it, and clearing
+// it — the whole reason the tab is useful when something has gone wrong.
+func TestLogsFilterRoundTrip(t *testing.T) {
+ v := logsWith(t, 0)
+ logLine(v, "engine started ok")
+ logLine(v, "cluster pairing failed")
+ logLine(v, "engine stopped")
+
+ logsKey(v, "/")
+ if !v.editing {
+ t.Fatal("/ did not open the filter")
+ }
+ for _, r := range "engine" {
+ logsKey(v, string(r))
+ }
+ v.Update(tea.KeyMsg{Type: tea.KeyEnter})
+
+ if v.editing {
+ t.Error("enter did not close the filter field")
+ }
+ if v.filter != "engine" {
+ t.Errorf("filter = %q", v.filter)
+ }
+ body := v.View()
+ if !contains(body, "engine started") || contains(body, "pairing failed") {
+ t.Errorf("filter did not narrow the buffer:\n%s", body)
+ }
+
+ logsKey(v, "c")
+ if v.filter != "" {
+ t.Errorf("c did not clear the filter, got %q", v.filter)
+ }
+ if !contains(v.View(), "pairing failed") {
+ t.Error("clearing the filter did not restore the hidden lines")
+ }
+}
+
+// TestLogsFilterCanBeAbandoned checks esc leaves the previous filter alone
+// rather than committing a half-typed one.
+func TestLogsFilterCanBeAbandoned(t *testing.T) {
+ v := logsWith(t, 5)
+ v.filter = "engine"
+
+ logsKey(v, "/")
+ for _, r := range "zzz" {
+ logsKey(v, string(r))
+ }
+ v.Update(tea.KeyMsg{Type: tea.KeyEsc})
+
+ if v.editing {
+ t.Error("esc did not close the field")
+ }
+ if v.filter != "engine" {
+ t.Errorf("esc committed the abandoned text: filter = %q", v.filter)
+ }
+}
+
+// TestLogsHelpReflectsTheFieldState checks the footer names the keys that work
+// while the filter has the keyboard, rather than the ones that now type.
+func TestLogsHelpReflectsTheFieldState(t *testing.T) {
+ v := logsWith(t, 5)
+ logsKey(v, "/")
+
+ keys := make([]string, 0, 2)
+ for _, b := range v.Help() {
+ keys = append(keys, b.Help().Key)
+ }
+ joined := strings.Join(keys, ",")
+ if !contains(joined, "enter") || !contains(joined, "esc") {
+ t.Errorf("filter-mode help = %v, want enter and esc", keys)
+ }
+ if contains(joined, "/") {
+ t.Errorf("filter-mode help still advertises %v, which now type characters", keys)
+ }
+}
+
+// TestLogsSaveWritesTheWholeBuffer checks the file contains every line, not
+// just what a transient filter was showing — a saved log is evidence.
+func TestLogsSaveWritesTheWholeBuffer(t *testing.T) {
+ home := t.TempDir()
+ t.Setenv("HOME", home)
+ t.Setenv("USERPROFILE", home) // Windows
+
+ v := logsWith(t, 0)
+ logLine(v, "engine started")
+ logLine(v, "something failed")
+ v.filter = "engine" // showing one line
+
+ msg, ok := v.saveCmd()().(logsSavedMsg)
+ if !ok {
+ t.Fatalf("save produced %T", v.saveCmd()())
+ }
+ if msg.err != nil {
+ t.Fatalf("save failed: %v", msg.err)
+ }
+
+ body, err := os.ReadFile(msg.path)
+ if err != nil {
+ t.Fatalf("read back: %v", err)
+ }
+ for _, want := range []string{"engine started", "something failed"} {
+ if !contains(string(body), want) {
+ t.Errorf("saved file is missing %q; the filter should not narrow it", want)
+ }
+ }
+ if filepath.Dir(msg.path) != home {
+ t.Errorf("saved to %q, want the home directory", msg.path)
+ }
+}
+
+// TestLogsSaveNeverOverwrites checks a second save in the same second does not
+// destroy the first, which is exactly the capture-twice case.
+func TestLogsSaveNeverOverwrites(t *testing.T) {
+ home := t.TempDir()
+ t.Setenv("HOME", home)
+ t.Setenv("USERPROFILE", home)
+
+ v := logsWith(t, 0)
+ logLine(v, "first")
+ first, ok := v.saveCmd()().(logsSavedMsg)
+ if !ok || first.err != nil {
+ t.Fatalf("first save: %+v", first)
+ }
+
+ logLine(v, "second")
+ second, ok := v.saveCmd()().(logsSavedMsg)
+ if !ok || second.err != nil {
+ t.Fatalf("second save: %+v", second)
+ }
+
+ if first.path == second.path {
+ t.Fatal("the second save reused the first file's name and destroyed it")
+ }
+ body, err := os.ReadFile(first.path)
+ if err != nil {
+ t.Fatalf("the first file is gone: %v", err)
+ }
+ if contains(string(body), "second") {
+ t.Error("the first file was overwritten")
+ }
+}
+
+var _ View = (*logsView)(nil)
diff --git a/services/nvpair-tui/ui/manualnodes.go b/services/nvpair-tui/ui/manualnodes.go
deleted file mode 100644
index fa316806..00000000
--- a/services/nvpair-tui/ui/manualnodes.go
+++ /dev/null
@@ -1,219 +0,0 @@
-// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
-// SPDX-License-Identifier: Apache-2.0
-
-package ui
-
-import (
- "strings"
- "time"
-
- "nvpair-tui/rpc"
-
- "github.com/charmbracelet/bubbles/key"
- "github.com/charmbracelet/bubbles/table"
- "github.com/charmbracelet/bubbles/textinput"
- tea "github.com/charmbracelet/bubbletea"
-)
-
-// manualRefreshInterval re-lists manual nodes so the probe-driven
-// reachability columns stay current (nvpair-manual-nodes re-probes every
-// 10s).
-const manualRefreshInterval = 10 * time.Second
-
-// manualNode is the subset of nvpair-manual-nodes' ManualNodeStatus shown.
-type manualNode struct {
- ID string `json:"id"`
- Name string `json:"name"`
- Address string `json:"address"`
- OllamaUp bool `json:"ollama_up"`
- NodeInfoUp bool `json:"node_info_up"`
-}
-
-// manualView manages user-added nodes that don't appear via mDNS: list,
-// add by address, and remove. The list refreshes on a timer to reflect
-// the manager's periodic reachability probes.
-type manualView struct {
- client *rpc.Client
- table table.Model
- nodes []manualNode
- input textinput.Model
- adding bool
- status string
- width, height int
-}
-
-type manualNodesMsg struct {
- nodes []manualNode
- err error
-}
-
-type manualTickMsg struct{}
-
-type manualActionMsg struct {
- what string
- err error
-}
-
-var (
- manualAddKey = key.NewBinding(key.WithKeys("a"), key.WithHelp("a", "add node"))
- manualRemoveKey = key.NewBinding(key.WithKeys("r"), key.WithHelp("r", "remove node"))
-)
-
-func newManualView(client *rpc.Client) *manualView {
- ti := textinput.New()
- // nvpair-manual-nodes appends its own fixed ports (11434 for Ollama, 14318
- // for node-info) to whatever's entered, so a host:port form yields a
- // malformed URL and the node always reads down. Only a bare host works.
- ti.Placeholder = "host"
- v := &manualView{client: client, input: ti}
- v.table = newTable(nil)
- return v
-}
-
-func (v *manualView) Title() string { return "Manual" }
-
-func (v *manualView) Init() tea.Cmd {
- return tea.Batch(v.listCmd(), v.tickCmd())
-}
-
-func (v *manualView) listCmd() tea.Cmd {
- return call(v.client, "nodes/list", nil, func(msg *rpc.Message, err error) tea.Msg {
- if err != nil {
- return manualNodesMsg{err: err}
- }
- var r struct {
- Nodes []manualNode `json:"nodes"`
- }
- _ = decodeParams(msg.Result, &r)
- return manualNodesMsg{nodes: r.Nodes}
- })
-}
-
-func (v *manualView) tickCmd() tea.Cmd {
- return tea.Tick(manualRefreshInterval, func(time.Time) tea.Msg { return manualTickMsg{} })
-}
-
-func (v *manualView) SetSize(w, h int) {
- v.width, v.height = w, h
- const ollama, nodeinfo = 8, 9
- id := clampWidth((w-ollama-nodeinfo-2)/2, 8)
- addr := clampWidth(w-ollama-nodeinfo-id-2, 10)
- v.table.SetColumns([]table.Column{
- {Title: "ID", Width: id},
- {Title: "ADDRESS", Width: addr},
- {Title: "OLLAMA", Width: ollama},
- {Title: "NODEINFO", Width: nodeinfo},
- })
- v.table.SetWidth(w)
- v.table.SetHeight(clampWidth(h-2, 1))
-}
-
-func (v *manualView) CapturingInput() bool { return v.adding }
-
-func (v *manualView) Update(msg tea.Msg) tea.Cmd {
- switch msg := msg.(type) {
- case manualNodesMsg:
- if msg.err == nil {
- v.setNodes(msg.nodes)
- }
- return nil
- case manualTickMsg:
- return tea.Batch(v.listCmd(), v.tickCmd())
- case manualActionMsg:
- if msg.err != nil {
- v.status = msg.what + " failed: " + msg.err.Error()
- } else {
- v.status = msg.what + " ok"
- }
- return v.listCmd()
- case tea.KeyMsg:
- return v.handleKey(msg)
- }
- return nil
-}
-
-func (v *manualView) handleKey(msg tea.KeyMsg) tea.Cmd {
- if v.adding {
- switch msg.String() {
- case "enter":
- return v.submitAdd()
- case "esc":
- v.adding = false
- v.input.Blur()
- return nil
- }
- var cmd tea.Cmd
- v.input, cmd = v.input.Update(msg)
- return cmd
- }
- switch {
- case key.Matches(msg, manualAddKey):
- v.adding = true
- v.input.SetValue("")
- v.input.Focus()
- return textinput.Blink
- case key.Matches(msg, manualRemoveKey):
- return v.removeSelected()
- }
- var cmd tea.Cmd
- v.table, cmd = v.table.Update(msg)
- return cmd
-}
-
-func (v *manualView) submitAdd() tea.Cmd {
- v.adding = false
- v.input.Blur()
- addr := strings.TrimSpace(v.input.Value())
- if addr == "" {
- v.status = "address required"
- return nil
- }
- return call(v.client, "node/add", map[string]string{"address": addr}, func(_ *rpc.Message, err error) tea.Msg {
- return manualActionMsg{what: "add " + addr, err: err}
- })
-}
-
-func (v *manualView) removeSelected() tea.Cmd {
- idx := v.table.Cursor()
- if idx < 0 || idx >= len(v.nodes) {
- return nil
- }
- id := v.nodes[idx].ID
- return call(v.client, "node/remove", map[string]string{"id": id}, func(_ *rpc.Message, err error) tea.Msg {
- return manualActionMsg{what: "remove " + id, err: err}
- })
-}
-
-func (v *manualView) setNodes(nodes []manualNode) {
- v.nodes = nodes
- rows := make([]table.Row, 0, len(nodes))
- for _, n := range nodes {
- rows = append(rows, table.Row{
- truncate(n.ID, 16),
- n.Address,
- yesNo(n.OllamaUp),
- yesNo(n.NodeInfoUp),
- })
- }
- v.table.SetRows(rows)
-}
-
-func (v *manualView) View() string {
- var b strings.Builder
- if len(v.nodes) == 0 {
- b.WriteString(footerStyle.Render("No manual nodes. Press a to add one by address."))
- } else {
- b.WriteString(v.table.View())
- }
- if v.adding {
- b.WriteString("\nadd node: " + v.input.View())
- }
- if v.status != "" {
- b.WriteString("\n" + footerStyle.Render(v.status))
- }
- return b.String()
-}
-
-func (v *manualView) Help() []key.Binding {
- return []key.Binding{manualAddKey, manualRemoveKey}
-}
diff --git a/services/nvpair-tui/ui/model.go b/services/nvpair-tui/ui/model.go
index 971ab063..831742fa 100644
--- a/services/nvpair-tui/ui/model.go
+++ b/services/nvpair-tui/ui/model.go
@@ -5,6 +5,7 @@ package ui
import (
"fmt"
+ "strconv"
"strings"
"nvpair-tui/rpc"
@@ -15,9 +16,13 @@ import (
"github.com/charmbracelet/lipgloss"
)
-// chromeHeight is the number of rows the shell reserves outside the
-// content area: one header line, one tab-bar line, one footer line.
-const chromeHeight = 3
+// headerHeight and tabBarHeight are the fixed single-row bands above the
+// content area. The footer's height is variable, so it is measured at render
+// time rather than declared here.
+const (
+ headerHeight = 1
+ tabBarHeight = 1
+)
// Model is the root Bubble Tea model: a tab bar over a set of Views, a
// header showing broker status, and a footer of contextual help. It owns
@@ -37,24 +42,64 @@ type Model struct {
brokerVersion string
disconnected bool
showFullHelp bool
+
+ // updateLatest is a published release newer than this build, and
+ // updateDismissed records the operator saying they have seen it.
+ //
+ // Shell state rather than a view's, because the notice is on every tab: an
+ // operator who lives on Nodes or Jobs would never see it on the one screen
+ // they have no reason to open.
+ updateLatest string
+ updateDismissed bool
+
+ // wipeOnExit records a confirmed reset request. The deletion itself happens
+ // in the caller after the broker has stopped, because the workers hold those
+ // files while it runs.
+ wipeOnExit bool
+}
+
+// closer is a view holding something that outlives the update loop and has to
+// be released on the way out — a spawned child, a file handle.
+type closer interface{ close() }
+
+// close releases every view that holds one. Called once the program loop has
+// finished, so nothing can still be scheduled.
+func (m Model) close() {
+ for _, v := range m.views {
+ if c, ok := v.(closer); ok {
+ c.close()
+ }
+ }
+}
+
+// Outcome reports what the operator asked for on the way out, for work that can
+// only be done once the service tree is down.
+type Outcome struct {
+ WipeData bool
}
// New builds the root model over a connected broker client, the broker's
// captured stderr line channel, and the set of views (tabs) to present,
// in tab order.
func New(client *rpc.Client, logCh <-chan string, views []View) Model {
+ h := help.New()
+ styleHelp(&h)
return Model{
client: client,
logCh: logCh,
- keys: newGlobalKeyMap(),
- help: help.New(),
+ keys: newGlobalKeyMap(len(views)),
+ help: h,
views: views,
}
}
-// Init starts each view and arms the broker notification + log loops.
+// Init starts each view and arms the broker notification + log loops and the
+// shared render tick.
func (m Model) Init() tea.Cmd {
- cmds := []tea.Cmd{waitForNotification(m.client), waitForLog(m.logCh)}
+ // Both are nil when the check is disabled or the build is unstamped, and
+ // tea.Batch drops nils, so this needs no guard.
+ cmds := []tea.Cmd{waitForNotification(m.client), waitForLog(m.logCh), uiTick(),
+ checkUpdateCmd(), updateCheckTickCmd()}
for _, v := range m.views {
if c := v.Init(); c != nil {
cmds = append(cmds, c)
@@ -72,6 +117,13 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil
case tea.KeyMsg:
+ // Interrupt is never captured. Everything else may be, but a terminal
+ // program that cannot be stopped with ctrl+c is broken, and a text field
+ // has no business consuming it — "q" is a legitimate character to type
+ // into a filter, ctrl+c is not.
+ if msg.Type == tea.KeyCtrlC {
+ return m, tea.Quit
+ }
// A view editing a text field (e.g. a port or PIN entry) captures
// all keys, so global bindings like tab/q don't steal characters
// mid-input.
@@ -81,6 +133,14 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
}
switch {
+ case key.Matches(msg, m.keys.Dismiss):
+ // Only meaningful while the banner is up. Swallowed either way,
+ // which is fine: no view binds it.
+ if m.banner() != "" {
+ m.updateDismissed = true
+ m.resizeViews()
+ }
+ return m, nil
case key.Matches(msg, m.keys.Quit):
return m, tea.Quit
case key.Matches(msg, m.keys.Help):
@@ -88,10 +148,17 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.resizeViews()
return m, nil
case key.Matches(msg, m.keys.NextTab):
- m.active = (m.active + 1) % len(m.views)
+ m.selectTab(m.active + 1)
return m, nil
case key.Matches(msg, m.keys.PrevTab):
- m.active = (m.active - 1 + len(m.views)) % len(m.views)
+ m.selectTab(m.active - 1)
+ return m, nil
+ case key.Matches(msg, m.keys.JumpTab):
+ // The binding only carries digits, so this parse cannot fail.
+ n, err := strconv.Atoi(msg.String())
+ if err == nil && n >= 1 && n <= len(m.views) {
+ m.selectTab(n - 1)
+ }
return m, nil
}
// Anything else is for the active view only.
@@ -109,6 +176,36 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
cmds = append(cmds, waitForNotification(m.client))
return m, tea.Batch(cmds...)
+ case TickMsg:
+ // The redraw is the point: views rendering relative ages or a
+ // transient status refresh without holding any tick state.
+ cmds := m.broadcast(msg)
+ cmds = append(cmds, uiTick())
+ return m, tea.Batch(cmds...)
+
+ case updateCheckMsg:
+ // A failure is dropped, not reported. Someone on a network that cannot
+ // reach the feed does not need telling every six hours, and this is the
+ // least important thing on the screen.
+ if msg.err == nil && newerVersion(ReleaseVersion, msg.latest) {
+ // A newer release than the one already announced un-dismisses the
+ // banner: the operator acknowledged the previous version, not this
+ // one, and a long-running session would otherwise never mention it.
+ if msg.latest != m.updateLatest {
+ m.updateLatest = msg.latest
+ m.updateDismissed = false
+ m.resizeViews()
+ }
+ }
+ return m, nil
+
+ case updateCheckDueMsg:
+ return m, tea.Batch(checkUpdateCmd(), updateCheckTickCmd())
+
+ case wipeDataMsg:
+ m.wipeOnExit = true
+ return m, tea.Quit
+
case DisconnectedMsg:
m.disconnected = true
return m, nil
@@ -128,21 +225,103 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
}
+// View composes a frame of exactly m.height rows and m.width columns. The
+// content region is forced to its allotted height and the whole frame is
+// clamped to the terminal width, so a view that renders more rows than it was
+// given — or a status line longer than the terminal — cannot push the footer
+// off screen or leave the previous frame's tail behind after a resize.
func (m Model) View() string {
- if m.width == 0 {
+ if m.width == 0 || m.height == 0 {
return "starting..."
}
- var b strings.Builder
- b.WriteString(m.headerView())
- b.WriteByte('\n')
- b.WriteString(m.tabBarView())
- b.WriteByte('\n')
+ if m.width < minTerminalWidth || m.height < minTerminalHeight {
+ return m.tooSmallView()
+ }
+ // The content budget depends on the footer, and the footer's height is the
+ // active view's own help — which changes with in-view state, not only with
+ // the events resizeViews runs on. Switching the detail's pane in full-help
+ // mode adds two bindings, so the budget shrank under a view still sized for
+ // the old one and the shell then deleted the difference.
+ //
+ // Sizing here, from the same measurement the frame is built with, is what
+ // makes the two agree by construction rather than by remembering to re-size
+ // on every state change that might affect the footer.
+ budget := m.contentHeight()
+ body := ""
if v := m.activeView(); v != nil {
- b.WriteString(v.View())
+ v.SetSize(m.width, budget)
+ body = v.View()
+ }
+ // joinLines rather than a fixed slice: the banner is usually absent, and an
+ // empty entry in a Join is still a blank row. It sits below the tab bar so
+ // it reads as belonging to the whole window rather than to the active tab,
+ // and above the content so it cannot be mistaken for a view's own status.
+ frame := joinLines(
+ m.headerView(),
+ m.tabBarView(),
+ m.banner(),
+ fitLines(body, budget),
+ m.footerView(),
+ )
+ return lipgloss.NewStyle().MaxWidth(m.width).Render(frame)
+}
+
+// The smallest terminal any tab is designed for. Below this there is no honest
+// layout: the header, tab bar, and footer alone claim four rows, and a view
+// still has a summary line, a heading, and a table to place in what is left.
+//
+// Saying so once, here, is better than making every view degrade separately.
+// A view contorting itself into five rows produces something unreadable that
+// still looks like it is working, and it puts a size nobody uses in the way of
+// every layout decision. This is a single, legible answer instead.
+const (
+ minTerminalWidth = 40
+ minTerminalHeight = 12
+)
+
+// tooSmallView replaces the whole frame when the terminal cannot hold a tab.
+func (m Model) tooSmallView() string {
+ msg := fmt.Sprintf("Terminal too small - %dx%d needed, this one is %dx%d.",
+ minTerminalWidth, minTerminalHeight, m.width, m.height)
+ // No keypress needed: the resize itself repaints.
+ frame := fitLines(statusErrStyle.Render(msg)+"\nResize the window to continue.", m.height)
+ return lipgloss.NewStyle().MaxWidth(m.width).Render(frame)
+}
+
+// fitLines forces s to exactly n lines, dropping any excess and padding when
+// short.
+func fitLines(s string, n int) string {
+ lines := strings.Split(s, "\n")
+ if len(lines) > n {
+ lines = lines[:n]
+ }
+ for len(lines) < n {
+ lines = append(lines, "")
+ }
+ return strings.Join(lines, "\n")
+}
+
+// selectTab moves to idx, wrapping at both ends, and re-sizes the views: the
+// footer's height depends on the active view's bindings, so the content region
+// can change size when the tab does.
+func (m *Model) selectTab(idx int) {
+ if len(m.views) == 0 {
+ return
+ }
+ // The tab being left goes back to its own top-level screen. A drill-down
+ // is where the operator was, not where they asked to return to: leaving
+ // Nodes inside one machine's detail and coming back put them on that
+ // machine again, with the list they wanted one keypress further away and
+ // no indication of why.
+ //
+ // Safe to do unconditionally because a view holding a text field or an
+ // armed confirmation captures the keyboard, so the tab cannot be changed
+ // out from under it in the first place.
+ if r, ok := m.activeView().(resetter); ok {
+ r.reset()
}
- b.WriteByte('\n')
- b.WriteString(m.footerView())
- return b.String()
+ m.active = ((idx % len(m.views)) + len(m.views)) % len(m.views)
+ m.resizeViews()
}
func (m Model) activeView() View {
@@ -152,6 +331,8 @@ func (m Model) activeView() View {
return m.views[m.active]
}
+// broadcast delivers a non-key message to every view, so background tabs stay
+// current — and their labels with them — while another tab is on screen.
func (m *Model) broadcast(msg tea.Msg) []tea.Cmd {
cmds := make([]tea.Cmd, 0, len(m.views))
for _, v := range m.views {
@@ -162,30 +343,78 @@ func (m *Model) broadcast(msg tea.Msg) []tea.Cmd {
return cmds
}
-func (m *Model) resizeViews() {
- footerH := 1
- if m.showFullHelp {
- footerH = len(m.views) // rough room for the full help block
+// contentHeight is the number of rows left for the active view once the header,
+// tab bar, and footer have taken theirs. The footer is measured rather than
+// estimated: its height varies with the active view's bindings and with the
+// full-help toggle, and guessing it was what let the frame overflow.
+func (m Model) contentHeight() int {
+ h := m.height - headerHeight - tabBarHeight - lipgloss.Height(m.footerView())
+ // The banner is chrome like the rest, so the views have to be told about it
+ // — a row added to the frame without coming out of the budget is a row the
+ // shell then deletes from the bottom of whichever view is showing, and the
+ // bottom is where every view keeps its messages.
+ //
+ // Measured, not assumed one: lipgloss.Height("") is 1, so an absent banner
+ // would otherwise cost a row it never draws.
+ if b := m.banner(); b != "" {
+ h -= lipgloss.Height(b)
}
- contentH := m.height - (chromeHeight - 1) - footerH
- if contentH < 1 {
- contentH = 1
+ if h < 1 {
+ h = 1
}
+ return h
+}
+
+// banner is the shell-wide notice row, or empty when there is nothing to say.
+//
+// On every tab, until dismissed, because the operator this is for is the one who
+// never opens the Service tab. It names the versions and where to get the
+// release, and offers no key to install it — this client cannot, see
+// updatecheck.go.
+func (m Model) banner() string {
+ if m.updateLatest == "" || m.updateDismissed {
+ return ""
+ }
+
+ const dismiss = " ctrl+x to dismiss"
+ // Assembled longest-first against the real width rather than written out
+ // once. The frame is clamped to the terminal, so an over-long line loses its
+ // tail — and the tail is the dismiss hint, the one part that has to survive,
+ // being the only way to get rid of the banner. At 118 columns the full
+ // sentence already lost its last character, and the URL alone is 52.
+ //
+ // The shortest option fits minTerminalWidth, so one of these always fits.
+ for _, text := range []string{
+ fmt.Sprintf(" PAIR %s is available (you have %s) - %s",
+ m.updateLatest, ReleaseVersion, updateReleasesPage),
+ fmt.Sprintf(" PAIR %s is available (you have %s)", m.updateLatest, ReleaseVersion),
+ fmt.Sprintf(" PAIR %s is available", m.updateLatest),
+ " Update available",
+ } {
+ if lipgloss.Width(text+dismiss) <= m.width {
+ return statusOKStyle.Render(text + dismiss)
+ }
+ }
+ return statusOKStyle.Render(dismiss)
+}
+
+func (m *Model) resizeViews() {
+ contentH := m.contentHeight()
for _, v := range m.views {
v.SetSize(m.width, contentH)
}
}
func (m Model) headerView() string {
- left := titleStyle.Render("NVPAIR TUI")
+ left := titleStyle.Render("NVPAIR")
var status string
switch {
case m.disconnected:
- status = statusErrStyle.Render("broker disconnected")
+ status = statusErrStyle.Render("service disconnected")
case m.ready:
- status = statusOKStyle.Render(fmt.Sprintf("broker ready v%s", m.brokerVersion))
+ status = statusOKStyle.Render(fmt.Sprintf("service ready v%s", m.brokerVersion))
default:
- status = footerStyle.Render("connecting to broker...")
+ status = footerStyle.Render("starting service...")
}
gap := m.width - lipgloss.Width(left) - lipgloss.Width(status)
if gap < 1 {
@@ -208,7 +437,21 @@ func (m Model) tabBarView() string {
}
func (m Model) footerView() string {
- global := []key.Binding{m.keys.NextTab, m.keys.PrevTab, m.keys.Help, m.keys.Quit}
+ // JumpTab is included so the numbers on the tab bar are documented
+ // somewhere; the bar promises a shortcut and nothing else mentioned it.
+ global := []key.Binding{m.keys.NextTab, m.keys.PrevTab, m.keys.JumpTab, m.keys.Help, m.keys.Quit}
+
+ // None of them while a view owns the keyboard. Each view narrows its own
+ // help to enter and esc in that state, and the footer used to prepend the
+ // globals anyway — so the composed line advertised five keys that no longer
+ // reached the shell. In a port field the digits are what you are meant to
+ // type, and pressing q put a q in the field rather than quitting.
+ //
+ // ctrl+c is deliberately not listed here or anywhere: it is handled ahead of
+ // the capture check and always works, which is what makes withdrawing q safe.
+ if v, ok := m.activeView().(inputCapturer); ok && v.CapturingInput() {
+ global = nil
+ }
var viewKeys []key.Binding
if v := m.activeView(); v != nil {
viewKeys = v.Help()
@@ -216,7 +459,13 @@ func (m Model) footerView() string {
if m.showFullHelp {
return m.help.FullHelpView([][]key.Binding{global, viewKeys})
}
- return m.help.ShortHelpView(append(viewKeys, global...))
+ // Globals first. bubbles truncates the short help from the right once it
+ // exceeds the terminal width, so whatever is last is what disappears — and
+ // with the view's own verbs first, an eighty-column terminal dropped "q
+ // quit" on every tab and "? help" on most. Losing a verb is recoverable
+ // because "?" lists them all; losing the way out, and the key that would
+ // have revealed it, is the one truncation that traps someone.
+ return m.help.ShortHelpView(append(global, viewKeys...))
}
func readyVersion(msg *rpc.Message) string {
diff --git a/services/nvpair-tui/ui/model_test.go b/services/nvpair-tui/ui/model_test.go
new file mode 100644
index 00000000..99f2f370
--- /dev/null
+++ b/services/nvpair-tui/ui/model_test.go
@@ -0,0 +1,335 @@
+// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+package ui
+
+import (
+ "strconv"
+ "strings"
+ "testing"
+
+ svcerrors "nvpair-shared/errors"
+
+ "github.com/charmbracelet/bubbles/key"
+ tea "github.com/charmbracelet/bubbletea"
+ "github.com/charmbracelet/lipgloss"
+)
+
+// stubView renders a fixed block of rows, optionally far more (and far wider)
+// than the size it was handed, standing in for a view whose own height
+// accounting is wrong.
+type stubView struct {
+ title string
+ rows int
+ width int
+}
+
+func (s *stubView) Title() string { return s.title }
+func (s *stubView) Init() tea.Cmd { return nil }
+func (s *stubView) SetSize(_, _ int) {}
+func (s *stubView) Update(tea.Msg) tea.Cmd { return nil }
+func (s *stubView) Help() []key.Binding { return nil }
+func (s *stubView) View() string {
+ line := strings.Repeat("x", s.width)
+ lines := make([]string, s.rows)
+ for i := range lines {
+ lines[i] = line
+ }
+ return strings.Join(lines, "\n")
+}
+
+func newTestModel(views ...View) Model {
+ m := New(nil, nil, views)
+ m.width, m.height = 80, 24
+ return m
+}
+
+// TestViewFrameIsExactlyTerminalSized is the regression guard for the duplicated
+// bottom rows after a resize. The shell must emit exactly as many rows and
+// columns as the terminal has, whatever the active view renders: an over-tall
+// frame scrolls the alt screen and leaves the previous frame's tail behind.
+func TestViewFrameIsExactlyTerminalSized(t *testing.T) {
+ cases := []struct {
+ name string
+ view *stubView
+ }{
+ {"view renders far too many rows", &stubView{title: "Over", rows: 200, width: 40}},
+ {"view renders too few rows", &stubView{title: "Under", rows: 1, width: 40}},
+ {"view renders lines wider than the terminal", &stubView{title: "Wide", rows: 5, width: 500}},
+ {"view renders nothing", &stubView{title: "Empty", rows: 0, width: 0}},
+ }
+
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ m := newTestModel(tc.view)
+ out := m.View()
+ if got := lipgloss.Height(out); got != m.height {
+ t.Errorf("frame is %d rows, terminal is %d", got, m.height)
+ }
+ if got := lipgloss.Width(out); got > m.width {
+ t.Errorf("frame is %d columns wide, terminal is %d", got, m.width)
+ }
+ })
+ }
+}
+
+// TestFrameStaysExactWithTheUpdateBanner is the arithmetic check for the notice
+// row.
+//
+// The banner adds a row to the frame and takes one from the content budget, and
+// those two have to cancel at every height. If they do not, the frame is either
+// a row too tall — which scrolls the alt screen and leaves the previous frame's
+// tail behind — or a row short of the terminal.
+//
+// Swept across every supported height rather than sampled, and over the real
+// views, because the budget also depends on the active view's footer.
+func TestFrameStaysExactWithTheUpdateBanner(t *testing.T) {
+ withReleaseVersion(t, "0.91.7")
+
+ for h := minTerminalHeight; h <= 44; h++ {
+ for i := range defaultViews(nil) {
+ m := newTestModel(defaultViews(nil)...)
+ m.width, m.height = 80, h
+ m.selectTab(i)
+
+ plain := m.contentHeight()
+ m = send(m, updateCheckMsg{latest: "0.92.0"})
+
+ if got := lipgloss.Height(m.View()); got != h {
+ t.Fatalf("height %d, tab %d: frame is %d rows with the banner up", h, i+1, got)
+ }
+ // One row taken, unless the budget had already bottomed out at its
+ // floor of one — below that there is nothing left to give.
+ if withBanner := m.contentHeight(); plain > 1 && withBanner != plain-1 {
+ t.Fatalf("height %d, tab %d: budget %d -> %d, want one row taken",
+ h, i+1, plain, withBanner)
+ }
+ }
+ }
+}
+
+// TestLeavingATabReturnsItToItsOwnFirstScreen checks a drill-down does not
+// outlive the visit that opened it.
+//
+// The Nodes tab replaces itself with one machine's detail screen. Left open,
+// switching to another tab and back put the operator inside that machine
+// again rather than on the list they asked for — the list one more keypress
+// away, with nothing on screen explaining why.
+func TestLeavingATabReturnsItToItsOwnFirstScreen(t *testing.T) {
+ nodes := newNodesView(nil)
+ nodes.feeds.discovered = []availableNode{
+ {HostUUID: "u1", Name: "this-host", IPAddress: "10.0.0.1", Port: 1},
+ }
+ nodes.rebuild()
+
+ m := newTestModel(nodes, &stubView{title: "Jobs", rows: 1})
+ nodes.openDetail()
+ if nodes.detail == nil {
+ t.Fatal("the detail screen did not open")
+ }
+
+ m.selectTab(1)
+ if nodes.detail != nil {
+ t.Error("leaving the tab left the drill-down open")
+ }
+
+ // And the tab still works normally on return: opening one again, then
+ // coming back to it directly, also lands on the list.
+ m.selectTab(0)
+ nodes.openDetail()
+ m.selectTab(0)
+ if nodes.detail != nil {
+ t.Error("re-selecting the tab did not return to the list")
+ }
+}
+
+// TestViewFrameHeightAcrossTerminalSizes checks the budget holds at the small
+// sizes where the header, tab bar, and footer alone can exceed the terminal.
+func TestViewFrameHeightAcrossTerminalSizes(t *testing.T) {
+ for _, h := range []int{4, 5, 10, 24, 60} {
+ m := New(nil, nil, []View{&stubView{title: "T", rows: 100, width: 10}})
+ m.width, m.height = 80, h
+ if got := lipgloss.Height(m.View()); got != h {
+ t.Errorf("height %d: frame is %d rows", h, got)
+ }
+ }
+}
+
+// TestViewBeforeFirstResize checks the shell renders a placeholder rather than a
+// zero-sized frame before the terminal size arrives.
+func TestViewBeforeFirstResize(t *testing.T) {
+ m := New(nil, nil, []View{&stubView{title: "T", rows: 3, width: 10}})
+ if out := m.View(); out != "starting..." {
+ t.Errorf("pre-resize view = %q, want the placeholder", out)
+ }
+}
+
+// TestJumpDigitsMatchTheTabsExactly checks the digit binding is neither short
+// nor long.
+//
+// Short means a tab reachable only by tabbing to it, with nothing on screen to
+// explain why its number did nothing. Long is what shipped: the footer read
+// "1-9 go to tab" against five tabs, advertising four keys that do nothing —
+// which is the more common failure, because the range is a string a reader has
+// to remember to update.
+func TestJumpDigitsMatchTheTabsExactly(t *testing.T) {
+ views := defaultViews(nil)
+ keys := newGlobalKeyMap(len(views)).JumpTab
+
+ if got, want := len(keys.Keys()), len(views); got != want {
+ t.Errorf("%d jump digits (%v) for %d tabs", got, keys.Keys(), want)
+ }
+ if got, want := keys.Help().Key, "1-"+strconv.Itoa(len(views)); got != want {
+ t.Errorf("footer advertises %q, want %q", got, want)
+ }
+}
+
+// TestJumpDigitsLabelDegenerateCounts covers the label at the edges, since it is
+// assembled rather than written out.
+func TestJumpDigitsLabelDegenerateCounts(t *testing.T) {
+ cases := map[int]string{1: "1", 2: "1-2", 5: "1-5", 9: "1-9", 12: "1-9"}
+ for tabs, want := range cases {
+ if got := tabDigitsHelp(tabs); got != want {
+ t.Errorf("%d tabs labelled %q, want %q", tabs, got, want)
+ }
+ // A twelfth tab must not produce a tenth key that cannot be typed.
+ if got := len(tabDigits(tabs)); got > 9 {
+ t.Errorf("%d tabs produced %d keys, want at most 9", tabs, got)
+ }
+ }
+}
+
+// TestDigitKeysSelectTabs pins the shortcut the numbered tab bar advertises.
+func TestDigitKeysSelectTabs(t *testing.T) {
+ m := newTestModel(
+ &stubView{title: "One", rows: 1},
+ &stubView{title: "Two", rows: 1},
+ &stubView{title: "Three", rows: 1},
+ )
+
+ press := func(k string) {
+ updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(k)})
+ m = updated.(Model)
+ }
+
+ press("3")
+ if m.active != 2 {
+ t.Errorf("after '3', active = %d, want 2", m.active)
+ }
+ press("1")
+ if m.active != 0 {
+ t.Errorf("after '1', active = %d, want 0", m.active)
+ }
+ // Out of range for three tabs: the selection must not move.
+ press("9")
+ if m.active != 0 {
+ t.Errorf("after out-of-range '9', active = %d, want 0", m.active)
+ }
+}
+
+// TestTabWrapsBothDirections checks prev from the first tab lands on the last
+// rather than going negative.
+func TestTabWrapsBothDirections(t *testing.T) {
+ m := newTestModel(
+ &stubView{title: "One", rows: 1},
+ &stubView{title: "Two", rows: 1},
+ )
+ m.selectTab(-1)
+ if m.active != 1 {
+ t.Errorf("selectTab(-1) = %d, want 1", m.active)
+ }
+ m.selectTab(2)
+ if m.active != 0 {
+ t.Errorf("selectTab(2) = %d, want 0", m.active)
+ }
+}
+
+// TestErrorTabLabelCarriesTheCount checks the tab bar is the error indicator, so
+// nothing extra is needed to notice a problem from another tab.
+func TestErrorTabLabelCarriesTheCount(t *testing.T) {
+ v := newErrorsView(nil)
+
+ if got := v.Title(); got != "Errors" {
+ t.Errorf("clean label = %q, want a bare title", got)
+ }
+
+ v.setErrors([]svcerrors.ServiceError{{ID: "a", Message: "boom", Severity: "error"}})
+ if got := v.Title(); got != "Errors (1)" {
+ t.Errorf("label = %q, want a count", got)
+ }
+
+ v.setErrors([]svcerrors.ServiceError{
+ {ID: "a", Message: "boom", Severity: "error"},
+ {ID: "b", Message: "meh", Severity: "warning"},
+ })
+ if got := v.Title(); got != "Errors (2)" {
+ t.Errorf("label = %q, want the updated count", got)
+ }
+
+ // Clearing the last error takes the count away again.
+ v.setErrors(nil)
+ if got := v.Title(); got != "Errors" {
+ t.Errorf("label = %q after clearing, want a bare title", got)
+ }
+}
+
+// TestErrorsTabIsReachableLikeAnyOther checks it behaves as a plain tab: the
+// digit selects it and nothing intercepts the keyboard.
+func TestErrorsTabIsReachableLikeAnyOther(t *testing.T) {
+ errors := newErrorsView(nil)
+ m := newTestModel(
+ &stubView{title: "One", rows: 1},
+ &stubView{title: "Two", rows: 1},
+ errors,
+ )
+
+ updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("3")})
+ m = updated.(Model)
+ if m.active != 2 {
+ t.Fatalf("digit 3 selected tab %d, want the errors tab", m.active)
+ }
+ if m.activeView() != View(errors) {
+ t.Error("active view is not the errors tab")
+ }
+
+ // And tabbing away works, unlike an overlay that had to be dismissed.
+ updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("1")})
+ m = updated.(Model)
+ if m.active != 0 {
+ t.Error("could not leave the errors tab with a digit")
+ }
+}
+
+// TestErrorsTabFrameStaysBounded checks a long error list obeys the same frame
+// budget as any other tab.
+func TestErrorsTabFrameStaysBounded(t *testing.T) {
+ errors := newErrorsView(nil)
+ m := newTestModel(errors)
+ m.resizeViews()
+
+ errs := make([]svcerrors.ServiceError, 200)
+ for i := range errs {
+ errs[i] = svcerrors.ServiceError{ID: strconv.Itoa(i), Message: strings.Repeat("y", 300)}
+ }
+ errors.setErrors(errs)
+
+ out := m.View()
+ if got := lipgloss.Height(out); got != m.height {
+ t.Errorf("frame is %d rows, terminal is %d", got, m.height)
+ }
+ if got := lipgloss.Width(out); got > m.width {
+ t.Errorf("frame is %d columns, terminal is %d", got, m.width)
+ }
+}
+
+func TestFitLines(t *testing.T) {
+ if got := fitLines("a\nb\nc", 2); got != "a\nb" {
+ t.Errorf("truncate: got %q", got)
+ }
+ if got := fitLines("a", 3); got != "a\n\n" {
+ t.Errorf("pad: got %q", got)
+ }
+ if got := fitLines("a\nb", 2); got != "a\nb" {
+ t.Errorf("exact: got %q", got)
+ }
+}
diff --git a/services/nvpair-tui/ui/nodedetail.go b/services/nvpair-tui/ui/nodedetail.go
new file mode 100644
index 00000000..29aaeb27
--- /dev/null
+++ b/services/nvpair-tui/ui/nodedetail.go
@@ -0,0 +1,1855 @@
+// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+package ui
+
+import (
+ "fmt"
+ "sort"
+ "strconv"
+ "strings"
+ "time"
+
+ "nvpair-shared/enginesettings"
+ "nvpair-tui/rpc"
+
+ "github.com/charmbracelet/bubbles/key"
+ "github.com/charmbracelet/bubbles/table"
+ "github.com/charmbracelet/bubbles/textinput"
+ tea "github.com/charmbracelet/bubbletea"
+)
+
+// detailPane is which half of the node detail screen has the keyboard.
+type detailPane int
+
+const (
+ detailEngines detailPane = iota
+ detailModels
+)
+
+// detailInputMode is which text field, if any, is capturing keys.
+type detailInputMode int
+
+const (
+ detailInputNone detailInputMode = iota
+ detailInputModelName
+ detailInputEnginePort
+ detailInputProxyPort
+ // detailInputLaunchArgs edits the arguments and environment an engine is
+ // started with, in the notation LAUNCH_TEXT.md describes.
+ detailInputLaunchArgs
+)
+
+// nodeDetail is the drill-down for one machine: its engines and the models each
+// engine holds, with the operations that apply to them.
+//
+// It is a full-screen screen rather than a split pane under the table. Engines
+// and models are both lists needing their own selection and their own verbs, and
+// six rows at the bottom of a table cannot carry that without becoming a
+// puzzle. It is also why the model list finally exists at all: the operations
+// were always available on the broker, but there was nowhere to put them.
+//
+// Local and remote nodes differ in what they permit. The engine manager has
+// remote install, start, stop, and the four model operations, but no remote
+// restart, uninstall, or port change — those need process ownership on the
+// target host — so those verbs are hidden rather than offered and then failed.
+type nodeDetail struct {
+ client *rpc.Client
+ node nodeRow
+
+ engines []engineStatus
+ engineTable table.Model
+ models modelsResult
+ modelTable table.Model
+ modelRows []detailModelRow
+ pane detailPane
+ // proxy carries the client-facing endpoint ports for this machine, so the
+ // port a client connects to sits beside the engine it reaches. Left nil for
+ // a peer, whose proxies we do not configure.
+ proxy *proxyTracker
+
+ // catalog is the open download browser, nil when it is closed. It replaces
+ // the whole detail screen while up: it is a list needing its own search and
+ // selection, which does not fit alongside two other tables.
+ catalog *catalogBrowser
+
+ // telemetry is the node's own hardware readout, polled directly over HTTP
+ // because the broker does not carry it. telemetryOK records whether the last
+ // poll succeeded, so an unreachable node reads as unavailable rather than as
+ // a machine with no hardware.
+ // pending is a destructive action waiting for confirmation. Deleting a model
+ // and uninstalling an engine both throw away gigabytes that have to be
+ // downloaded again, and both keys sit among the harmless ones — d beside
+ // enter, u beside s and x — so a slip is easy and expensive.
+ //
+ // The target is captured here at arm time rather than re-read on confirm,
+ // because this list re-sorts underneath the cursor whenever a download
+ // finishes or a peer republishes its inventory.
+ pending *pendingDestructive
+
+ // settings is the last settings snapshot seen per engine, keyed by engine
+ // name. Fetched when the operator asks to edit rather than for every engine
+ // on open, and refreshed by engine:settings-changed.
+ //
+ // Every write carries the revision from here, which is what makes a
+ // concurrent edit fail loudly instead of overwriting silently.
+ settings map[string]enginesettings.Snapshot
+ // settingsWanted is the edit the operator asked for while its snapshot was
+ // still being fetched, so the right field opens once it arrives.
+ settingsWanted *pendingSettingsEdit
+ // settingsConfirm is an applied change waiting on "y" because the backend
+ // said it would restart the engine.
+ settingsConfirm *enginesettings.Request
+ // settingsAwaited is the engine whose saved state this screen is waiting to
+ // see, so the outcome is reported for a change made here and not for one
+ // another client made.
+ settingsAwaited string
+
+ telemetry nodeTelemetry
+ telemetryOK bool
+ // telemetryGen identifies this screen's polling chain. Bubble Tea cannot
+ // cancel a pending tick, so a chain is retired by no longer matching it.
+ telemetryGen int
+ // telemetryRunning is whether a chain is in flight. A node with no known
+ // address has nothing to poll and so no chain; one can start later.
+ telemetryRunning bool
+ // enginesStale marks the engine list as last-known rather than current,
+ // because the most recent read of it failed. Held as state rather than
+ // announced, since the read repeats on a timer.
+ enginesStale bool
+
+ input textinput.Model
+ mode detailInputMode
+ status toast
+
+ width, height int
+}
+
+// pendingSettingsEdit is an edit waiting for the snapshot it needs.
+//
+// Opening a field requires the current value and the revision to write against,
+// and both arrive with the snapshot. Rather than block the interface on the
+// round trip, the request is remembered and the field opens when it lands.
+type pendingSettingsEdit struct {
+ engine string
+ mode detailInputMode
+}
+
+// detailModelRow is one row of the model list: a model and the engine serving it.
+type detailModelRow struct {
+ engine string
+ model string
+ loaded bool
+}
+
+type detailEnginesMsg struct {
+ engines []engineStatus
+ err error
+}
+
+type detailModelsMsg struct {
+ models modelsResult
+ err error
+}
+
+var (
+ detailBackKey = key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "back"))
+ detailPaneKey = key.NewBinding(key.WithKeys("left", "right", "h", "l"), key.WithHelp("h/l", "pane"))
+ detailInstallKey = key.NewBinding(key.WithKeys("i"), key.WithHelp("i", "install"))
+ detailStartKey = key.NewBinding(key.WithKeys("s"), key.WithHelp("s", "start"))
+ detailStopKey = key.NewBinding(key.WithKeys("x"), key.WithHelp("x", "stop"))
+ detailRestartKey = key.NewBinding(key.WithKeys("r"), key.WithHelp("r", "restart"))
+ detailUninstKey = key.NewBinding(key.WithKeys("u"), key.WithHelp("u", "uninstall"))
+ detailConfirmKey = key.NewBinding(key.WithKeys("y"), key.WithHelp("y", "confirm"))
+ // In the engines pane only, so these do not collide with the models pane's
+ // p (browse) or e (eject). Each is mnemonic where it applies: e for the
+ // engine's own port, p for the proxy fronting it.
+ detailPortKey = key.NewBinding(key.WithKeys("e"), key.WithHelp("e", "engine port"))
+ detailProxyKey = key.NewBinding(key.WithKeys("p"), key.WithHelp("p", "proxy port"))
+ // a for arguments, the word the backend's own notation uses. Free in this
+ // screen: the letter is taken on the Nodes list and the Jobs tab, but those
+ // are other views, and this one is full-screen.
+ detailArgsKey = key.NewBinding(key.WithKeys("a"), key.WithHelp("a", "arguments"))
+ detailPullKey = key.NewBinding(key.WithKeys("p"), key.WithHelp("p", "browse models"))
+ detailPullByNameKey = key.NewBinding(key.WithKeys("n"), key.WithHelp("n", "download by name"))
+ detailLoadKey = key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "load"))
+ detailEjectKey = key.NewBinding(key.WithKeys("e"), key.WithHelp("e", "eject"))
+ detailDeleteKey = key.NewBinding(key.WithKeys("d"), key.WithHelp("d", "delete"))
+)
+
+// telemetryChains numbers polling chains so a screen only continues its own.
+// Package-level rather than per-screen because each detail screen is a new value
+// and the point is to differ from every previous one.
+var telemetryChains int
+
+func newNodeDetail(client *rpc.Client, node nodeRow) *nodeDetail {
+ ti := textinput.New()
+ telemetryChains++
+ d := &nodeDetail{
+ client: client,
+ node: node,
+ engineTable: newTable(detailEngineColumns(defaultTableWidth, !node.self)),
+ modelTable: newTable(detailModelColumns(defaultTableWidth)),
+ input: ti,
+ telemetryGen: telemetryChains,
+ }
+ if node.self {
+ d.proxy = newProxyTracker()
+ }
+ // A remote node's models are already in the discovery snapshot the broker
+ // enriched, so the list is populated before any request completes.
+ d.models = modelsResult{
+ Models: node.models,
+ ModelsByEngine: node.modelsByEngine,
+ LoadedByEngine: node.loadedByEngine,
+ }
+ d.refreshModels()
+ return d
+}
+
+// detailEngineColumns is the engine table's layout.
+//
+// On this machine it carries both ports side by side. They are easy to confuse —
+// ENGINE PORT is where the engine itself listens, PROXY PORT is where the proxy
+// fronting that engine listens, which is the one clients connect to — and having
+// them in two different places was exactly what made the distinction unclear.
+// A peer's proxies are not ours to configure, so the column is local-only.
+func detailEngineColumns(w int, remote bool) []table.Column {
+ cols := []column{
+ flexCol("ENGINE", 10, 1),
+ fixedCol("INSTALLED", 9),
+ fixedCol("RUNNING", 7),
+ fixedCol("HEALTHY", 7),
+ fixedCol("ENGINE PORT", 11),
+ }
+ if !remote {
+ cols = append(cols, fixedCol("PROXY PORT", 10))
+ }
+ return layoutColumns(w, cols)
+}
+
+func detailModelColumns(w int) []table.Column {
+ return layoutColumns(w, []column{
+ flexCol("MODEL", 12, 2),
+ fixedCol("ENGINE", 10),
+ fixedCol("LOADED", 6),
+ })
+}
+
+// remote reports whether this detail screen targets another machine, which
+// decides both the RPC variants used and the verbs offered.
+func (d *nodeDetail) remote() bool { return !d.node.self }
+
+// nodeArg is the node parameter the remote engine methods take, empty for this
+// machine so the local methods are used.
+func (d *nodeDetail) nodeArg() string {
+ if d.remote() {
+ return d.node.key
+ }
+ return ""
+}
+
+func (d *nodeDetail) Init() tea.Cmd {
+ // The telemetry chain is started by this first poll, not by a tick: each
+ // reading schedules the next one when it lands, so there is exactly one
+ // chain and it cannot outrun a slow node.
+ cmds := []tea.Cmd{d.enginesCmd(), d.modelsCmd(), d.telemetryCmd()}
+ if d.proxy != nil {
+ cmds = append(cmds, d.proxy.init(d.client))
+ }
+ if d.remote() {
+ cmds = append(cmds, detailEnginesTickCmd(d.telemetryGen))
+ }
+ return tea.Batch(cmds...)
+}
+
+// remoteEngineRefresh is how often an open remote detail re-reads the peer's
+// engines. Slower than the telemetry poll because each read crosses the cluster
+// to another machine, and engine lifecycle changes in seconds, not milliseconds.
+const remoteEngineRefresh = 5 * time.Second
+
+// detailEnginesTickMsg re-reads a remote node's engines. gen scopes it to one
+// screen, exactly as the telemetry chain does.
+type detailEnginesTickMsg struct{ gen int }
+
+func detailEnginesTickCmd(gen int) tea.Cmd {
+ return tea.Tick(remoteEngineRefresh, func(time.Time) tea.Msg {
+ return detailEnginesTickMsg{gen: gen}
+ })
+}
+
+func (d *nodeDetail) telemetryCmd() tea.Cmd {
+ cmd := pollTelemetryCmd(d.node.key, d.telemetryGen, telemetryHosts(d.node), d.node.port)
+ // Whether a chain is running, so a node whose address is not known yet can
+ // have one started later. Nothing schedules a tick when there is nothing to
+ // poll, and the reply is what continues the chain — so without this a manual
+ // entry opened before its first probe landed showed "unavailable" forever,
+ // even once discovery supplied an address.
+ d.telemetryRunning = cmd != nil
+ return cmd
+}
+
+func (d *nodeDetail) enginesCmd() tea.Cmd {
+ if !d.enginesQueryable() {
+ // Asking would fail every time, and the failure is indistinguishable
+ // from the node being down — which is how an unpaired machine came to
+ // be reported as "not answering" while its hardware readout, which
+ // needs no pairing, updated beside it.
+ return nil
+ }
+ method, params := "engine:get-installed", map[string]any{}
+ if d.remote() {
+ method = "engine:remote-get-installed"
+ params["node"] = d.node.key
+ }
+ return call(d.client, method, params, func(msg *rpc.Message, err error) tea.Msg {
+ if err != nil {
+ return detailEnginesMsg{err: err}
+ }
+ var r struct {
+ Engines []engineStatus `json:"engines"`
+ }
+ _ = decodeParams(msg.Result, &r)
+ return detailEnginesMsg{engines: r.Engines}
+ })
+}
+
+// enginesQueryable reports whether this node's engine list can be fetched at
+// all.
+//
+// A peer answers engine:remote-* over pin-based mTLS, and a pin only exists
+// for a machine in this cluster — so for anything else the question cannot be
+// asked, rather than being asked and going unanswered. The two look identical
+// from here once the call fails, which is why this is decided before the call
+// rather than read out of its error.
+//
+// Hardware telemetry is a separate matter: that endpoint needs no pairing, so
+// an unpaired node can report its GPU and memory while its engines stay out of
+// reach. Anything saying "not answering" has to survive that combination.
+func (d *nodeDetail) enginesQueryable() bool {
+ return !d.remote() || d.node.membership == membershipMember
+}
+
+// modelsCmd asks the engine manager for this machine's inventory. A remote
+// node's models come from discovery instead, so there is nothing to request.
+func (d *nodeDetail) modelsCmd() tea.Cmd {
+ if d.remote() {
+ return nil
+ }
+ return call(d.client, "engine:models", nil, func(msg *rpc.Message, err error) tea.Msg {
+ if err != nil {
+ return detailModelsMsg{err: err}
+ }
+ var r modelsResult
+ _ = decodeParams(msg.Result, &r)
+ return detailModelsMsg{models: r}
+ })
+}
+
+func (d *nodeDetail) SetSize(w, h int) {
+ d.width, d.height = w, h
+ d.engineTable.SetColumns(detailEngineColumns(w, d.remote()))
+ d.engineTable.SetWidth(w)
+ d.modelTable.SetColumns(detailModelColumns(w))
+ d.modelTable.SetWidth(w)
+ // The browser replaces this whole screen, so it has to follow a resize too;
+ // sizing it only when it opens left it at its original dimensions.
+ if d.catalog != nil {
+ d.catalog.SetSize(w, h)
+ }
+
+ d.sizeEngineTable()
+}
+
+// maxEngineRows caps the engine list. There are two engines today, so this is
+// headroom rather than a limit; the models list is what should grow.
+const maxEngineRows = 6
+
+// sizeEngineTable gives the engine list what it needs, bounded by the cap and
+// by leaving the models list a usable minimum.
+//
+// It is budget-aware, not just content-aware. Sized purely from the engine count
+// it was not chrome the models table could shrink against, so on a short
+// terminal the two tables together overran the frame and the shell deleted the
+// status line — the exact failure the render-time sizing was introduced to make
+// impossible.
+func (d *nodeDetail) sizeEngineTable() {
+ want := clampWidth(len(d.engines)+tableHeaderRows, 1+tableHeaderRows)
+ if want > maxEngineRows {
+ want = maxEngineRows
+ }
+ // Everything the engine table must not crowd out: the fixed furniture, the
+ // hardware block, and a minimal models table.
+ reserved := detailChromeRows + d.hardwareHeight() + (1 + tableHeaderRows)
+ if room := d.height - reserved; room < want {
+ want = clampWidth(room, 1+tableHeaderRows)
+ }
+ d.engineTable.SetHeight(want)
+}
+
+// detailChromeRows is the fixed furniture on the detail screen: the identity
+// line, the two section headings, and the blank line between the sections. The
+// editor and status line come and go, so View measures those directly rather
+// than reserving for them here.
+const detailChromeRows = 4
+
+// hardwareHeight is the rows the hardware block occupies, so the model table can
+// be sized without rendering it first.
+func (d *nodeDetail) hardwareHeight() int {
+ if !d.telemetryOK {
+ return 1
+ }
+ // Must agree with hardwareBlock, including its cap — a height that ignored
+ // the cap would over-reserve, and one that ignored the truncation line would
+ // under-reserve by exactly the row that says devices were dropped.
+ lines := clampWidth(len(d.telemetry.summary()), 1)
+ if max := d.hardwareBudget(); lines > max {
+ return max
+ }
+ return lines
+}
+
+func (d *nodeDetail) CapturingInput() bool {
+ if d.catalog != nil {
+ // The browser owns every key while open, not only while its filter is
+ // focused: it has its own navigation, so the shell must not act on any
+ // of it. This is deliberately unconditional rather than delegated —
+ // "is the filter focused" is a weaker question than the one being asked.
+ return true
+ }
+ if d.pending != nil {
+ // An armed destructive action answers the next key, whatever it is.
+ // Without this the shell's own bindings still fired, so tab or a digit
+ // switched away and left the action armed behind an off-screen prompt —
+ // to be confirmed by whatever the operator pressed on returning.
+ return true
+ }
+ return d.mode != detailInputNone
+}
+
+// update handles a message, returning a command and whether the detail screen
+// should stay open.
+func (d *nodeDetail) update(msg tea.Msg) (tea.Cmd, bool) {
+ // The open browser owns the keyboard and its own load reply. Everything else
+ // still reaches the panes underneath so their state is current on return.
+ if d.catalog != nil {
+ _, isKey := msg.(tea.KeyMsg)
+ _, isLoad := msg.(catalogLoadedMsg)
+ if isKey || isLoad {
+ cmd, picked, stayOpen := d.catalog.update(msg)
+ if !stayOpen {
+ engine := d.catalog.engine
+ d.catalog = nil
+ d.SetSize(d.width, d.height)
+ if picked != "" {
+ return d.downloadFromCatalog(engine, picked), true
+ }
+ }
+ return cmd, true
+ }
+ }
+
+ switch msg := msg.(type) {
+ case detailEnginesMsg:
+ if msg.err != nil {
+ // Said once, not once every five seconds. A peer that has gone away
+ // fails this poll on every tick, and re-firing the toast pinned a
+ // raw Go error to the status line and buried every other message
+ // behind it. Recorded instead, and rendered as a note beside the
+ // engine list, the way an unreachable node's telemetry already is.
+ d.enginesStale = true
+ return nil, true
+ }
+ d.enginesStale = false
+ d.engines = msg.engines
+ d.refreshEngines()
+ d.SetSize(d.width, d.height)
+ return nil, true
+
+ case detailModelsMsg:
+ if msg.err != nil {
+ d.status.error("load models failed: %s", msg.err)
+ return nil, true
+ }
+ d.models = msg.models
+ d.refreshModels()
+ return nil, true
+
+ case engineOpMsg:
+ // The engine is named in the outcome as well as in the request. On a
+ // host running two, "start failed" alone does not say which one.
+ what := msg.what
+ if label := d.engineLabel(msg.engine); label != "" {
+ what += " (" + label + ")"
+ }
+ switch {
+ case msg.err != nil:
+ d.status.error("%s failed: %s", what, msg.err)
+ case msg.detached:
+ d.status.info("%s is continuing in the background - watch for progress", what)
+ default:
+ d.status.ok("%s requested", what)
+ }
+ return nil, true
+
+ case engineSettingsMsg:
+ if msg.err != nil {
+ d.settingsWanted = nil
+ d.status.error("read engine settings failed: %s", msg.err)
+ return nil, true
+ }
+ if d.settings == nil {
+ d.settings = map[string]enginesettings.Snapshot{}
+ }
+ d.settings[msg.snapshot.Engine] = msg.snapshot
+ d.reportSettingsOutcome(msg.snapshot)
+ // A snapshot arrives either because a field was asked for or because
+ // the backend pushed a change. Only the first opens an editor, and only
+ // for the engine that was asked about — a push for another engine while
+ // the operator waits must not hijack the field.
+ if w := d.settingsWanted; w != nil && w.engine == msg.snapshot.Engine {
+ d.settingsWanted = nil
+ // The "reading settings..." note is sticky, so it has to be
+ // replaced rather than left to expire behind the open field.
+ d.status.info("editing %s", d.engineLabel(msg.snapshot.Engine))
+ return d.openSettingsField(msg.snapshot, w.mode), true
+ }
+ return nil, true
+
+ case enginePreviewMsg:
+ return d.applySettingsPreview(msg), true
+
+ case engineSettingsAppliedMsg:
+ if msg.err != nil {
+ d.settingsAwaited = ""
+ // The snapshot this write was based on is no longer one the
+ // backend will accept, whoever moved it on. Drop it and re-read,
+ // or every later attempt fails identically against the same stale
+ // revision and the only way out is to leave the screen.
+ delete(d.settings, msg.engine)
+ d.status.error("%s settings: %s", d.engineLabel(msg.engine), settingsFailure(msg.err))
+ return getEngineSettingsCmd(d.client, d.nodeArg(), msg.engine), true
+ }
+ // Deliberately silent on success. This reply says the write was
+ // accepted, not what the engine ended up with, and the difference is
+ // the whole point: the snapshot that follows carries the effective
+ // ports, and reportSettingsOutcome speaks then. Announcing "saved"
+ // here would be the claim that outran the facts.
+ return nil, true
+
+ case proxyStatusMsg:
+ if d.proxy != nil {
+ d.proxy.apply(msg)
+ d.refreshEngines()
+ }
+ return nil, true
+
+ case nodeTelemetryMsg:
+ // Scoped to this screen's own chain, exactly as the tick is. This reply
+ // schedules the next poll, so accepting a superseded one would leave two
+ // chains running against the same node.
+ if msg.nodeKey != d.node.key || msg.gen != d.telemetryGen {
+ return nil, true
+ }
+ // A failed poll is expected for an unreachable node and is recorded
+ // rather than reported: the panel says telemetry is unavailable, and no
+ // error toast fires every two seconds.
+ was := d.hardwareHeight()
+ d.telemetryOK = msg.err == nil
+ if msg.err == nil {
+ d.telemetry = msg.telemetry
+ }
+ // The hardware block's height changes what the engine table may take.
+ // The models table needs no such nudge — View measures the hardware
+ // block directly every frame.
+ if d.hardwareHeight() != was {
+ d.sizeEngineTable()
+ }
+ // Schedule the next poll now that this one is done, so the interval is
+ // the gap between polls rather than the gap between their starts and a
+ // slow node cannot accumulate overlapping requests.
+ return telemetryTickCmd(d.node.key, d.telemetryGen), true
+
+ case detailEnginesTickMsg:
+ // A peer's engine state has no push: engine:state-changed carries a
+ // local snapshot with no node on it, so it cannot be attributed to
+ // another machine and is correctly ignored below. Polling is the only
+ // way an open remote detail reflects an engine starting or stopping
+ // over there; without it the screen showed whatever was true when it
+ // was opened, including right after an action taken from this screen.
+ if msg.gen != d.telemetryGen || !d.remote() {
+ return nil, true
+ }
+ return tea.Batch(d.enginesCmd(), detailEnginesTickCmd(d.telemetryGen)), true
+
+ case nodeTelemetryTickMsg:
+ // Only this screen's own chain continues. A tick from a previous visit
+ // to the same node is dropped rather than extended, so re-opening a
+ // detail screen cannot leave two chains polling in parallel.
+ if msg.nodeKey != d.node.key || msg.gen != d.telemetryGen {
+ return nil, true
+ }
+ // Only the poll. The next tick is scheduled when this one comes back —
+ // see nodeTelemetryMsg — because a poll can take longer than the
+ // interval: it tries each of the node's addresses in turn, so a
+ // multi-homed peer with an unreachable address already exceeds two
+ // seconds. Scheduling the next tick alongside the poll rather than after
+ // it started a new one regardless, so those polls accumulated for as
+ // long as the screen stayed open.
+ return d.telemetryCmd(), true
+
+ case NotificationMsg:
+ return d.handleNotification(msg.Msg), true
+
+ case tea.KeyMsg:
+ return d.handleKey(msg)
+ }
+ return nil, true
+}
+
+func (d *nodeDetail) handleNotification(msg *rpc.Message) tea.Cmd {
+ switch msg.Method {
+ case "engine:state-changed":
+ var e engineStatus
+ _ = decodeParams(msg.Params, &e)
+ if e.Engine == "" || d.remote() {
+ return nil
+ }
+ for i, existing := range d.engines {
+ if existing.Engine == e.Engine {
+ d.engines[i] = e
+ d.refreshEngines()
+ return nil
+ }
+ }
+ d.engines = append(d.engines, e)
+ d.refreshEngines()
+ // A new engine row changes the split between the two tables.
+ d.sizeEngineTable()
+
+ case "engine:settings-changed":
+ // The saved state, from whoever changed it — this screen, the desktop
+ // app, or another operator. Kept current so the next edit writes
+ // against a revision the backend will still accept, and so a field
+ // opened afterwards shows what is actually saved.
+ var snap enginesettings.Snapshot
+ _ = decodeParams(msg.Params, &snap)
+ // Matched on the row's key, which is the node's UUID, not on nodeArg:
+ // that is empty for this machine because the local RPCs take no node,
+ // while the broker stamps every snapshot with the real UUID. Comparing
+ // against it dropped every local push, so the cached revision stopped
+ // advancing and the next save was rejected as stale — by which point
+ // only leaving the screen could clear it.
+ if snap.Engine == "" || snap.NodeID != d.node.key {
+ return nil
+ }
+ if d.settings == nil {
+ d.settings = map[string]enginesettings.Snapshot{}
+ }
+ d.settings[snap.Engine] = snap
+ d.reportSettingsOutcome(snap)
+
+ case "engine:settings-disconnected":
+ // The settings worker behind a peer went away, so every revision held
+ // for it is now unverifiable. Dropping the cache makes the next edit
+ // re-fetch rather than write against a number nobody will honour.
+ clear(d.settings)
+
+ case "engine:models-changed":
+ // The manager polls each running engine's resident set and pushes this
+ // on any change — explicit load/unload, LM Studio's JIT auto-load, and
+ // idle eviction alike. Re-reading is cheaper than merging the delta.
+ if !d.remote() {
+ return d.modelsCmd()
+ }
+
+ case "discovery:nodes-changed":
+ // This is where a peer's model inventory lives: the broker enriches the
+ // discovery snapshot with each node's per-engine models, and there is no
+ // per-node models RPC to ask instead. The detail seeded itself from the
+ // snapshot it was opened with and then never looked again, so a model
+ // pulled or deleted on that peer — including by an action taken from
+ // this very screen — did not appear until the operator backed out and
+ // came back in.
+ if !d.remote() {
+ return nil
+ }
+ var nodes []availableNode
+ _ = decodeParams(msg.Params, &nodes)
+ for _, n := range nodes {
+ if n.HostUUID != d.node.key && n.ID != d.node.key {
+ continue
+ }
+ d.node.models = n.Models
+ d.node.modelsByEngine = n.ModelsByEngine
+ d.node.loadedByEngine = n.LoadedByEngine
+ d.models = modelsResult{
+ Models: n.Models,
+ ModelsByEngine: n.ModelsByEngine,
+ LoadedByEngine: n.LoadedByEngine,
+ }
+ d.refreshModels()
+
+ // Adopt an address learned since the screen opened, and start
+ // polling if there was nothing to poll before. A manual entry is
+ // routinely opened before discovery has resolved it.
+ d.node.address = n.IPAddress
+ d.node.addresses = candidateAddresses(n)
+ if n.Port != 0 {
+ d.node.port = n.Port
+ }
+ if !d.telemetryRunning {
+ return d.telemetryCmd()
+ }
+ break
+ }
+
+ case "engine:install-progress":
+ var p struct {
+ Engine string `json:"engine"`
+ Stage string `json:"stage"`
+ Percent int `json:"percent"`
+ }
+ _ = decodeParams(msg.Params, &p)
+ // Sticky for the same reason the pull feed is: an engine install is a
+ // multi-hundred-megabyte download, and between two frames more than six
+ // seconds apart an expiring line leaves the screen looking idle.
+ d.status.busy("install %s: %s (%d%%)", d.engineLabel(p.Engine), p.Stage, p.Percent)
+
+ case "engine:pull-progress":
+ // Local pulls only. The engine manager emits this for its own downloads
+ // and the payload carries no node, so a peer's screen would attribute
+ // this machine's download to that peer — and now that the note is
+ // sticky, it would sit there for the life of the pull. A peer's
+ // downloads arrive on engine:remote-progress, which does carry a node.
+ if d.remote() {
+ return nil
+ }
+ // An in-progress frame keeps the note sticky, so a download that stalls
+ // leaves its last reported percentage on screen instead of the line
+ // quietly expiring and making a stuck pull look like nothing happened.
+ // Only the terminal frames — done, failed — go back to expiring.
+ kind, format, args := progressToast(msg.Params, d.engineLabel)
+ if kind == toastInfo {
+ d.status.busy(format, args...)
+ return nil
+ }
+ d.status.set(kind, format, args...)
+
+ case "engine:remote-progress":
+ var p struct {
+ Node string `json:"node"`
+ Engine string `json:"engine"`
+ Op string `json:"op"`
+ Stage string `json:"stage"`
+ Percent int `json:"percent"`
+ Message string `json:"message"`
+ }
+ _ = decodeParams(msg.Params, &p)
+ if p.Node == d.node.key {
+ d.status.busy("%s %s: %s (%d%%)", p.Op, d.engineLabel(p.Engine), p.Stage, p.Percent)
+ }
+
+ default:
+ // A proxy rebinding its listener changes the proxy port shown against
+ // the engine it fronts.
+ if d.proxy != nil {
+ d.proxy.handleNotification(msg)
+ d.refreshEngines()
+ }
+ }
+ return nil
+}
+
+// progressToast renders an engine:pull-progress frame. Terminal stages carry no
+// meaningful percent (success is implicitly complete, error uses -1), so they
+// read as outcomes rather than a misleading "success (0%)". A late failure that
+// arrives after the synchronous call timed out still surfaces here.
+func progressToast(params []byte, label func(string) string) (toastKind, string, []any) {
+ var p struct {
+ Engine string `json:"engine"`
+ Stage string `json:"stage"`
+ Percent int `json:"percent"`
+ Message string `json:"message"`
+ }
+ _ = decodeParams(params, &p)
+ switch p.Stage {
+ case "success":
+ return toastOK, "download %s: done", []any{label(p.Engine)}
+ case "error":
+ detail := p.Message
+ if detail == "" {
+ detail = "failed"
+ }
+ return toastError, "download %s failed: %s", []any{label(p.Engine), detail}
+ default:
+ return toastInfo, "download %s: %s (%d%%)", []any{label(p.Engine), p.Stage, p.Percent}
+ }
+}
+
+func (d *nodeDetail) handleKey(msg tea.KeyMsg) (tea.Cmd, bool) {
+ if d.mode != detailInputNone {
+ switch msg.String() {
+ case "enter":
+ return d.submitInput(), true
+ case "esc":
+ d.mode = detailInputNone
+ d.input.Blur()
+ return nil, true
+ }
+ var cmd tea.Cmd
+ d.input, cmd = d.input.Update(msg)
+ return cmd, true
+ }
+
+ // An armed destructive action answers the next key, whatever it is, so the
+ // confirmation cannot be skipped past by a navigation key.
+ if d.pending != nil {
+ return d.resolvePending(msg), true
+ }
+
+ // Same rule for a settings change the backend says will restart the engine.
+ // It is not destructive in the way deleting a model is, but it interrupts
+ // whatever the engine is serving, and an operator who meant to move the
+ // cursor should not discover that by watching requests fail.
+ if d.settingsConfirm != nil {
+ return d.resolveSettingsConfirm(msg), true
+ }
+
+ if key.Matches(msg, detailBackKey) {
+ return nil, false
+ }
+ if key.Matches(msg, detailPaneKey) {
+ if d.pane == detailEngines {
+ d.pane = detailModels
+ } else {
+ d.pane = detailEngines
+ }
+ return nil, true
+ }
+
+ if d.pane == detailEngines {
+ return d.handleEngineKey(msg), true
+ }
+ return d.handleModelKey(msg), true
+}
+
+func (d *nodeDetail) handleEngineKey(msg tea.KeyMsg) tea.Cmd {
+ engine := d.selectedEngine()
+ switch {
+ case key.Matches(msg, detailInstallKey):
+ return d.lifecycle(engine, "install")
+ case key.Matches(msg, detailStartKey):
+ return d.lifecycle(engine, "start")
+ case key.Matches(msg, detailStopKey):
+ return d.lifecycle(engine, "stop")
+ case key.Matches(msg, detailRestartKey):
+ return d.lifecycle(engine, "restart")
+ case key.Matches(msg, detailUninstKey):
+ if d.remote() {
+ // Refused before arming, not after. The footer hides this key on a
+ // peer, but the handler still matched it — so pressing it staged a
+ // gigabyte-destroying confirmation that could only ever answer that
+ // the operation is unavailable. Its siblings all check first.
+ d.status.error("uninstalling an engine is only available on the machine running it")
+ return nil
+ }
+ return d.uninstallEngine(engine)
+ case key.Matches(msg, detailPortKey):
+ return d.editSetting(engine, detailInputEnginePort)
+ case key.Matches(msg, detailProxyKey):
+ return d.editSetting(engine, detailInputProxyPort)
+ case key.Matches(msg, detailArgsKey):
+ return d.editSetting(engine, detailInputLaunchArgs)
+ }
+ var cmd tea.Cmd
+ d.engineTable, cmd = d.engineTable.Update(msg)
+ return cmd
+}
+
+// editSetting opens one of the three settings fields for an engine.
+//
+// All three are one backend operation — the server port, the client-facing
+// proxy port, and the launch arguments are written together against a revision
+// — so they share a path rather than each having its own RPC. Ports used to go
+// through engine:set-port and the proxy's set-port, which meant two writers to
+// the state with no common revision: the last one to finish won, and neither
+// could tell it had lost.
+//
+// Whether an engine can be configured at all is the backend's answer, not a
+// guess from here. It reports Editable with a reason, which covers cases this
+// screen cannot see — an engine PAIR adopted rather than started, a settings
+// worker that is not reachable on a peer — and it covers them without this
+// screen having to keep a second list of when to refuse.
+func (d *nodeDetail) editSetting(engine *engineStatus, mode detailInputMode) tea.Cmd {
+ if engine == nil {
+ d.status.error("no engine selected")
+ return nil
+ }
+ snap, ok := d.settings[engine.Engine]
+ if !ok {
+ // Ask, then open the field when the answer lands. The alternative is
+ // opening it against a guessed value and a revision we do not have,
+ // which is how an edit silently overwrites someone else's.
+ d.settingsWanted = &pendingSettingsEdit{engine: engine.Engine, mode: mode}
+ d.status.busy("reading %s settings...", engine.label())
+ return getEngineSettingsCmd(d.client, d.nodeArg(), engine.Engine)
+ }
+ return d.openSettingsField(snap, mode)
+}
+
+// openSettingsField focuses the field for mode, prefilled from the snapshot.
+func (d *nodeDetail) openSettingsField(snap enginesettings.Snapshot, mode detailInputMode) tea.Cmd {
+ if reason := settingsUnavailableReason(snap); reason != "" {
+ d.status.error("%s", reason)
+ return nil
+ }
+ d.mode = mode
+ switch mode {
+ case detailInputEnginePort:
+ d.input.Placeholder = "port"
+ d.input.CharLimit = 5
+ d.input.SetValue(strconv.Itoa(snap.Settings.ServerPort))
+ case detailInputProxyPort:
+ d.input.Placeholder = "port"
+ d.input.CharLimit = 5
+ d.input.SetValue(strconv.Itoa(snap.Settings.ProxyPort))
+ case detailInputLaunchArgs:
+ // No character limit that this screen invents: the backend bounds the
+ // text at 16 KiB and says so in its own words if that is exceeded.
+ d.input.Placeholder = "NAME=value --flag ..."
+ d.input.CharLimit = 0
+ d.input.SetValue(snap.Settings.LaunchText)
+ default:
+ d.mode = detailInputNone
+ return nil
+ }
+ d.input.Focus()
+ d.input.CursorEnd()
+ return textinput.Blink
+}
+
+// settingsRequest builds a write for one field against the snapshot's revision.
+//
+// The resolution names which side wins when the numeric server port and the
+// port written inside the command text disagree. They are two views of one
+// number, and the answer is simply whichever the operator just edited: a
+// changed port field rewrites the command, and changed command text updates the
+// field. Sending no resolution makes the backend report a conflict instead,
+// which is right for an API and useless to someone who has just typed a value.
+func (d *nodeDetail) settingsRequest(
+ snap enginesettings.Snapshot,
+ mode detailInputMode,
+ value string,
+) (enginesettings.Request, bool) {
+ cfg := snap.Settings
+ resolution := resolutionLaunch
+ switch mode {
+ case detailInputEnginePort:
+ port, ok := parsePort(value)
+ if !ok {
+ d.status.error("invalid port: enter a number between 1 and 65535")
+ return enginesettings.Request{}, false
+ }
+ cfg.ServerPort = port
+ resolution = resolutionServer
+ case detailInputProxyPort:
+ port, ok := parsePort(value)
+ if !ok {
+ d.status.error("invalid port: enter a number between 1 and 65535")
+ return enginesettings.Request{}, false
+ }
+ cfg.ProxyPort = port
+ case detailInputLaunchArgs:
+ // Not trimmed, not rewritten. The notation has its own rules about
+ // quoting and whitespace and the backend normalizes to them; tidying
+ // the text here would only disagree with it.
+ cfg.LaunchText = value
+ default:
+ return enginesettings.Request{}, false
+ }
+ return enginesettings.Request{
+ NodeID: d.nodeArg(),
+ Engine: snap.Engine,
+ ExpectedRevision: snap.Revision,
+ Settings: cfg,
+ Resolution: resolution,
+ }, true
+}
+
+func (d *nodeDetail) handleModelKey(msg tea.KeyMsg) tea.Cmd {
+ switch {
+ case key.Matches(msg, detailPullKey):
+ return d.openCatalog()
+ case key.Matches(msg, detailPullByNameKey):
+ engine := d.pullTargetEngine()
+ if engine == "" {
+ d.status.error("no engine available to download into")
+ return nil
+ }
+ d.mode = detailInputModelName
+ d.input.Placeholder = fmt.Sprintf("model name for %s (e.g. llama3.2)", d.engineLabel(engine))
+ d.input.CharLimit = 0
+ d.input.SetValue("")
+ d.input.Focus()
+ return textinput.Blink
+ case key.Matches(msg, detailLoadKey):
+ return d.modelOp("load")
+ case key.Matches(msg, detailEjectKey):
+ return d.modelOp("unload")
+ case key.Matches(msg, detailDeleteKey):
+ return d.deleteSelectedModel()
+ }
+ var cmd tea.Cmd
+ d.modelTable, cmd = d.modelTable.Update(msg)
+ return cmd
+}
+
+func (d *nodeDetail) submitInput() tea.Cmd {
+ val := strings.TrimSpace(d.input.Value())
+ mode := d.mode
+ d.mode = detailInputNone
+ d.input.Blur()
+
+ switch mode {
+ case detailInputModelName:
+ if val == "" {
+ d.status.error("model name required")
+ return nil
+ }
+ engine := d.pullTargetEngine()
+ if engine == "" {
+ d.status.error("no engine available to download into")
+ return nil
+ }
+ d.status.busy("download %s: %s...", d.engineLabel(engine), val)
+ return modelCmd(d.client, d.nodeArg(), engine, modelActions["pull"], val)
+
+ case detailInputEnginePort, detailInputProxyPort, detailInputLaunchArgs:
+ return d.submitSettings(mode, d.input.Value(), val)
+ }
+ return nil
+}
+
+// submitSettings validates a settings edit before committing it.
+//
+// Nothing is saved by this: it sends the draft to engine:preview-settings,
+// which normalizes the text, reports per-field errors, and says whether
+// applying would restart the engine. The commit happens in the reply handler,
+// against the settings the preview returned rather than the text that was
+// typed, so what lands is exactly what was validated.
+//
+// raw is the field's text as typed and trimmed is the same with surrounding
+// whitespace removed. The ports want the trimmed form; the launch text does
+// not, because whitespace is significant to a tokenizer and normalizing it is
+// the backend's job.
+func (d *nodeDetail) submitSettings(mode detailInputMode, raw, trimmed string) tea.Cmd {
+ engine := d.selectedEngine()
+ if engine == nil {
+ return nil
+ }
+ snap, ok := d.settings[engine.Engine]
+ if !ok {
+ d.status.error("settings for %s are no longer loaded; press the key again", engine.label())
+ return nil
+ }
+ value := trimmed
+ if mode == detailInputLaunchArgs {
+ value = raw
+ }
+ req, ok := d.settingsRequest(snap, mode, value)
+ if !ok {
+ return nil
+ }
+ d.status.busy("checking %s settings...", engine.label())
+ return previewEngineSettingsCmd(d.client, req)
+}
+
+// settingsVerdict is what a preview reply decided about a draft.
+type settingsVerdict int
+
+const (
+ // settingsRefuse: nothing will be written, and problem says why.
+ settingsRefuse settingsVerdict = iota
+ // settingsConfirmFirst: writable, but it restarts the engine.
+ settingsConfirmFirst
+ // settingsWrite: writable as it stands.
+ settingsWrite
+)
+
+// judgeSettingsPreview decides what to do with a preview reply.
+//
+// Separated from the screen so the decision can be tested without a broker:
+// what gets written after a preview is the part worth pinning down, and it is
+// three branches of "no" around one "yes".
+//
+// The returned request carries the *normalized* settings rather than the draft
+// that was sent, and no resolution. Both matter. Applying the raw draft would
+// save text the preview never approved, and re-sending a resolution against
+// settled text invites the backend to rewrite what the operator just confirmed.
+//
+// It also gains the request identifier the commit is required to carry. Minted
+// here, once, rather than at send time: a change held for a restart
+// confirmation keeps the id it was judged with, so confirming twice is a
+// replay the backend recognizes instead of a second write.
+func judgeSettingsPreview(msg enginePreviewMsg) (settingsVerdict, enginesettings.Request, string) {
+ if msg.err != nil {
+ return settingsRefuse, enginesettings.Request{}, msg.err.Error()
+ }
+ // A conflict is the one outcome a resolution was supposed to prevent, so
+ // reaching here means the two ports disagree in a way the backend will not
+ // settle on its own. Name both numbers: the operator is the only one who
+ // knows which was intended.
+ if c := msg.preview.Conflict; c != nil {
+ return settingsRefuse, enginesettings.Request{}, fmt.Sprintf(
+ "the port field says %d and the arguments say %d - make them agree",
+ c.ServerPort, c.LaunchPort)
+ }
+ if len(msg.preview.Errors) != 0 {
+ return settingsRefuse, enginesettings.Request{}, joinSettingsErrors(msg.preview.Errors)
+ }
+ req := msg.request
+ req.Settings = msg.preview.Settings
+ req.Resolution = ""
+ req.RequestID = newSettingsRequestID()
+ if msg.preview.Restart {
+ return settingsConfirmFirst, req, ""
+ }
+ return settingsWrite, req, ""
+}
+
+// applySettingsPreview commits a validated draft, or explains why it cannot.
+func (d *nodeDetail) applySettingsPreview(msg enginePreviewMsg) tea.Cmd {
+ label := d.engineLabel(msg.request.Engine)
+ verdict, req, problem := judgeSettingsPreview(msg)
+ switch verdict {
+ case settingsRefuse:
+ d.status.error("%s: %s", label, problem)
+ return nil
+ case settingsConfirmFirst:
+ // Restarting drops whatever the engine is serving, so it is asked
+ // rather than assumed — the same arm-and-confirm the destructive keys
+ // use, for the same reason.
+ d.settingsConfirm = &req
+ d.status.arm("applying this restarts %s - press y to confirm", label)
+ return nil
+ }
+ d.settingsAwaited = req.Engine
+ d.status.busy("applying %s settings...", label)
+ return applyEngineSettingsCmd(d.client, req)
+}
+
+// settingsFailure puts a failed save in terms of what the operator should do.
+//
+// The backend's own wording for a revision mismatch — "settings changed on
+// this device; reload before applying" — describes a step this screen has
+// already taken by the time it is shown, so on its own it reads as an
+// instruction with nothing to act on.
+func settingsFailure(err error) string {
+ text := err.Error()
+ if strings.Contains(text, "reload before applying") {
+ return "changed somewhere else while you were editing - reloaded, try again"
+ }
+ return text
+}
+
+// joinSettingsErrors renders the backend's per-field errors as one line.
+//
+// Sorted by field so the same failure reads the same way twice; a map's order
+// would otherwise reshuffle the message between attempts.
+func joinSettingsErrors(errs map[string]string) string {
+ fields := make([]string, 0, len(errs))
+ for field := range errs {
+ fields = append(fields, field)
+ }
+ sort.Strings(fields)
+ parts := make([]string, 0, len(fields))
+ for _, field := range fields {
+ parts = append(parts, errs[field])
+ }
+ return strings.Join(parts, "; ")
+}
+
+// parsePort validates a typed TCP port.
+func parsePort(val string) (int, bool) {
+ port, err := strconv.Atoi(val)
+ if err != nil || port <= 0 || port > 65535 {
+ return 0, false
+ }
+ return port, true
+}
+
+func (d *nodeDetail) lifecycle(engine *engineStatus, op string) tea.Cmd {
+ if engine == nil {
+ // Say so. A key that ignores a press is indistinguishable from one the
+ // terminal dropped, and this is reachable whenever a peer's engine read
+ // has failed — exactly when the operator is trying to fix something.
+ d.status.error("no engine selected")
+ return nil
+ }
+ spec, ok := engineOps[op]
+ if !ok {
+ return nil
+ }
+ if spec.localOnly && d.remote() {
+ d.status.error("%s is only available on the machine running the engine", spec.what)
+ return nil
+ }
+ d.status.busy("%s %s...", spec.what, engine.label())
+ return engineCmd(d.client, d.nodeArg(), engine.Engine, spec.method, op, spec.what)
+}
+
+// openCatalog opens the download browser for the engine a download would go
+// into. A stopped engine cannot download, so that is reported here rather than
+// after the operator has chosen a model.
+func (d *nodeDetail) openCatalog() tea.Cmd {
+ engine := d.pullTargetEngine()
+ if engine == "" {
+ d.status.error("start an engine before downloading a model")
+ return nil
+ }
+ label := engine
+ for _, e := range d.engines {
+ if e.Engine == engine {
+ label = e.label()
+ break
+ }
+ }
+ d.catalog = newCatalogBrowser(d.client, engine, label, d.node.name, d.remote())
+ d.catalog.SetSize(d.width, d.height)
+ return d.catalog.Init()
+}
+
+// downloadFromCatalog starts the download the operator picked in the browser.
+func (d *nodeDetail) downloadFromCatalog(engine, model string) tea.Cmd {
+ d.status.busy("download %s: %s...", d.engineLabel(engine), model)
+ return modelCmd(d.client, d.nodeArg(), engine, modelActions["pull"], model)
+}
+
+func (d *nodeDetail) modelOp(op string) tea.Cmd {
+ act, ok := modelActions[op]
+ if !ok {
+ return nil
+ }
+ row := d.selectedModel()
+ if row == nil {
+ d.status.error("no model selected")
+ return nil
+ }
+ if row.engine == "" {
+ // Every model operation is addressed to an engine, and this node did not
+ // say which one serves this model. Guessing would act on the wrong one.
+ d.status.error("%s did not report which engine serves %s", d.node.name, row.model)
+ return nil
+ }
+ d.status.busy("%s %s...", act.what, row.model)
+ return modelCmd(d.client, d.nodeArg(), row.engine, act, row.model)
+}
+
+// pendingDestructive is an armed action bound to the exact target it was armed
+// against, so a list that re-sorts before the confirmation cannot redirect it.
+type pendingDestructive struct {
+ run func() tea.Cmd
+}
+
+// arm holds a destructive action until the operator confirms it.
+//
+// The prompt is pinned rather than left to expire: an armed action outliving
+// the message that explains it turns the next keystroke into a confirmation the
+// operator has no reason to expect.
+func (d *nodeDetail) arm(prompt string, run func() tea.Cmd) tea.Cmd {
+ d.pending = &pendingDestructive{run: run}
+ d.status.arm("%s press y to confirm, any other key to cancel", prompt)
+ return nil
+}
+
+// resolvePending answers an armed action. Anything but the confirmation key
+// cancels, so a stray keystroke never destroys anything.
+// reportSettingsOutcome says what an engine actually got, once, after a change
+// this screen asked for.
+//
+// The verdict is the backend's, carried in the snapshot as a phase and, when it
+// failed, a reason. It is not inferred from comparing the saved ports against
+// the effective ones: the effective engine port is observed at the moment the
+// apply replies, and an engine that restarts onto its new port — LM Studio's
+// server re-launches detached, so the manager loses sight of it — finishes
+// after that. The reading then lags a change behind, and reading failure into
+// it reported a move that had plainly happened as one that had not, naming a
+// port nothing was listening on.
+//
+// The proxy port is compared, because that one is read live from the proxy
+// process rather than observed in passing. A proxy that could not take the
+// port it was given binds elsewhere and says so, and reporting the requested
+// value as though it had been honoured is the lie this exists to prevent.
+//
+// Only after this screen's own write, and only once. These snapshots also
+// arrive unprompted whenever anyone else changes settings, and a note firing on
+// each would be noise about something the operator did not do.
+func (d *nodeDetail) reportSettingsOutcome(snap enginesettings.Snapshot) {
+ if d.settingsAwaited != snap.Engine {
+ return
+ }
+ d.settingsAwaited = ""
+ label := d.engineLabel(snap.Engine)
+ switch {
+ case snap.Phase == settingsPhaseFailed:
+ // The backend's own words. It knows why; this screen would be guessing.
+ if snap.Error != "" {
+ d.status.error("%s settings: %s", label, snap.Error)
+ return
+ }
+ d.status.error("%s settings were not applied", label)
+ case snap.EffectiveProxyPort != 0 && snap.EffectiveProxyPort != snap.Settings.ProxyPort:
+ d.status.error("%s endpoint is on :%d, not the :%d you asked for",
+ label, snap.EffectiveProxyPort, snap.Settings.ProxyPort)
+ default:
+ d.status.ok("%s settings saved", label)
+ }
+}
+
+// settingsPhaseFailed is the snapshot phase for an apply the backend refused or
+// could not complete.
+const settingsPhaseFailed = "failed"
+
+// resolveSettingsConfirm answers the restart prompt raised by a settings
+// change, applying it on "y" and discarding it on anything else.
+func (d *nodeDetail) resolveSettingsConfirm(msg tea.KeyMsg) tea.Cmd {
+ req := d.settingsConfirm
+ d.settingsConfirm = nil
+ if key.Matches(msg, detailConfirmKey) {
+ d.settingsAwaited = req.Engine
+ d.status.busy("applying %s settings...", d.engineLabel(req.Engine))
+ return applyEngineSettingsCmd(d.client, *req)
+ }
+ d.status.info("cancelled")
+ return nil
+}
+
+func (d *nodeDetail) resolvePending(msg tea.KeyMsg) tea.Cmd {
+ act := d.pending
+ d.pending = nil
+ if key.Matches(msg, detailConfirmKey) {
+ return act.run()
+ }
+ // Replaces the pinned prompt, which would otherwise stay on screen.
+ d.status.info("cancelled")
+ return nil
+}
+
+// deleteSelectedModel arms the delete against the model highlighted right now.
+func (d *nodeDetail) deleteSelectedModel() tea.Cmd {
+ row := d.selectedModel()
+ if row == nil {
+ d.status.error("no model selected")
+ return nil
+ }
+ if row.engine == "" {
+ d.status.error("%s did not report which engine serves %s", d.node.name, row.model)
+ return nil
+ }
+ target := *row
+ return d.arm(fmt.Sprintf("delete %s from %s?", target.model, d.engineLabel(target.engine)), func() tea.Cmd {
+ d.status.busy("delete %s...", target.model)
+ return modelCmd(d.client, d.nodeArg(), target.engine, modelActions["delete"], target.model)
+ })
+}
+
+// uninstallEngine arms the uninstall against the engine highlighted right now.
+func (d *nodeDetail) uninstallEngine(engine *engineStatus) tea.Cmd {
+ if engine == nil {
+ d.status.error("no engine selected")
+ return nil
+ }
+ name, label := engine.Engine, engine.label()
+ return d.arm(fmt.Sprintf("uninstall %s and its downloaded models?", label), func() tea.Cmd {
+ return d.lifecycle(&engineStatus{Engine: name, DisplayName: label}, "uninstall")
+ })
+}
+
+// pullTargetEngine is the engine a download goes into: the one highlighted in
+// the engine pane when it can serve, else the only running engine. Downloading
+// requires a running engine, so a stopped one is never chosen silently.
+func (d *nodeDetail) pullTargetEngine() string {
+ if e := d.selectedEngine(); e != nil && e.Running {
+ return e.Engine
+ }
+ for _, e := range d.engines {
+ if e.Running {
+ return e.Engine
+ }
+ }
+ return ""
+}
+
+func (d *nodeDetail) selectedEngine() *engineStatus {
+ idx := d.engineTable.Cursor()
+ if idx < 0 || idx >= len(d.engines) {
+ return nil
+ }
+ return &d.engines[idx]
+}
+
+func (d *nodeDetail) selectedModel() *detailModelRow {
+ idx := d.modelTable.Cursor()
+ if idx < 0 || idx >= len(d.modelRows) {
+ return nil
+ }
+ return &d.modelRows[idx]
+}
+
+func (d *nodeDetail) refreshEngines() {
+ rows := make([]table.Row, 0, len(d.engines))
+ for _, e := range d.engines {
+ port := "-"
+ if e.Port != 0 {
+ port = strconv.Itoa(e.Port)
+ }
+ row := table.Row{e.label(), yesNo(e.Installed), yesNo(e.Running), yesNo(e.Healthy), port}
+ if d.proxy != nil {
+ row = append(row, d.proxyPortCell(e.Engine))
+ }
+ rows = append(rows, row)
+ }
+ d.engineTable.SetRows(rows)
+ // The engine list is briefly empty on a peer whose first read fails, and a
+ // cursor left at -1 disables install, start, stop, and the port editors for
+ // the life of the screen.
+ restoreCursor(&d.engineTable, len(rows))
+}
+
+// proxyPortCell renders the endpoint clients use for an engine. A port with the
+// proxy down is not usable, so that reads differently from a live one, and an
+// engine no proxy fronts says so rather than showing a blank.
+func (d *nodeDetail) proxyPortCell(engine string) string {
+ port, ready := d.proxy.portForEngine(engine)
+ switch {
+ case d.proxy.indexForEngine(engine) < 0:
+ return "-"
+ case port == 0:
+ return "?"
+ case !ready:
+ return strconv.Itoa(port) + " down"
+ default:
+ return strconv.Itoa(port)
+ }
+}
+
+// refreshModels flattens the per-engine inventory into one sorted list, marking
+// those resident in memory.
+func (d *nodeDetail) refreshModels() {
+ // Captured before the rows are rebuilt. Reading it afterwards returns
+ // whatever now sits at the old index — that is, exactly the row the cursor
+ // slid onto — so restoring it would be a no-op that puts the cursor back
+ // where it already was.
+ selected := d.selectedModelKey()
+
+ engines := make([]string, 0, len(d.models.ModelsByEngine))
+ for name := range d.models.ModelsByEngine {
+ engines = append(engines, name)
+ }
+ sort.Strings(engines)
+
+ d.modelRows = d.modelRows[:0]
+ for _, engine := range engines {
+ loaded := make(map[string]bool, len(d.models.LoadedByEngine[engine]))
+ for _, m := range d.models.LoadedByEngine[engine] {
+ loaded[m] = true
+ }
+ models := append([]string(nil), d.models.ModelsByEngine[engine]...)
+ sort.Strings(models)
+ for _, m := range models {
+ d.modelRows = append(d.modelRows, detailModelRow{engine: engine, model: m, loaded: loaded[m]})
+ }
+ }
+
+ // A node can report models without saying which engine serves each one —
+ // noderec documents the unattributed case as live, for a peer that predates
+ // attribution or is running a different version. Building rows only from the
+ // per-engine map showed those nodes as having no models at all, and the
+ // empty-state hint then blamed a stopped engine for what is a data-shape
+ // difference. Fall back to the flat union rather than regressing to nothing.
+ if len(d.modelRows) == 0 && len(d.models.Models) > 0 {
+ models := append([]string(nil), d.models.Models...)
+ sort.Strings(models)
+ for _, m := range models {
+ d.modelRows = append(d.modelRows, detailModelRow{model: m})
+ }
+ }
+
+ rows := make([]table.Row, 0, len(d.modelRows))
+ for _, r := range d.modelRows {
+ engine := d.engineLabel(r.engine)
+ if r.engine == "" {
+ // The node reported the model but not which engine serves it.
+ engine = "unknown"
+ }
+ rows = append(rows, table.Row{r.model, engine, yesNo(r.loaded)})
+ }
+ // Put the cursor back on whatever was highlighted. This list is sorted and
+ // rebuilt wholesale on every engine:models-changed and every discovery
+ // snapshot, so a download finishing elsewhere inserts a row and shifts
+ // everything below it. bubbles only clamps a cursor that has run off the end
+ // — it does not track what the cursor was pointing at — so without this the
+ // selection silently slides onto a different model, and the next d or enter
+ // acts on that one.
+ d.modelTable.SetRows(rows)
+ d.restoreModelSelection(selected)
+ restoreCursor(&d.modelTable, len(rows))
+}
+
+// engineLabel is an engine key in the vocabulary the rest of the screen uses.
+//
+// The engines table shows the display name, so a prompt or a message quoting
+// the wire id names the same thing twice over on one screen — worst on a
+// confirmation, which is the last place to introduce a word the operator has
+// not seen. Falls back to the key for an engine not in the list, which is what
+// a stale selection would produce.
+func (d *nodeDetail) engineLabel(key string) string {
+ for _, e := range d.engines {
+ if e.Engine == key {
+ return e.label()
+ }
+ }
+ // The engine list is not always there to answer. It is fetched per screen
+ // and a peer's can be empty — the manager not running, the read failing, or
+ // a reply that genuinely lists nothing — while the models table still has
+ // rows, because a remote node's models come from discovery instead. Falling
+ // back to the raw wire id made the same engine read "Ollama" on one machine
+ // and "ollama" on another, purely from whether that fetch had landed.
+ //
+ // engineDisplayName answers from the static proxy inventory, so it does not
+ // depend on any fetch. Only a genuinely unknown engine reaches the key.
+ return engineDisplayName(key)
+}
+
+// selectedModelKey identifies the highlighted model across a rebuild. Engine
+// and name together, because the same model can be present under both engines.
+func (d *nodeDetail) selectedModelKey() (key detailModelRow) {
+ if r := d.selectedModel(); r != nil {
+ key = *r
+ }
+ return key
+}
+
+// restoreModelSelection puts the cursor back on a model after the rows changed.
+// A model that is gone — just deleted, or evicted from the peer's inventory —
+// leaves the cursor where bubbles clamped it.
+func (d *nodeDetail) restoreModelSelection(want detailModelRow) {
+ if want.model == "" {
+ return
+ }
+ for i, r := range d.modelRows {
+ if r.engine == want.engine && r.model == want.model {
+ d.modelTable.SetCursor(i)
+ return
+ }
+ }
+}
+
+func (d *nodeDetail) View() string {
+ if d.catalog != nil {
+ return d.catalog.View()
+ }
+
+ scope := "this machine"
+ if d.remote() {
+ scope = "remote node"
+ }
+ identity := titleStyle.Render(d.node.name) + footerStyle.Render(fmt.Sprintf(
+ " %s:%d %s %s %s",
+ d.node.address, d.node.port, d.node.presence, d.node.membership, scope))
+
+ editor := ""
+ if d.mode != detailInputNone {
+ editor = d.inputLabel() + d.input.View()
+ }
+ enginesBody := d.engineTable.View()
+ switch {
+ case len(d.engines) == 0 && d.enginesStale:
+ enginesBody = footerStyle.Render(fmt.Sprintf(
+ " %s is not answering - no engine list available.", d.node.name))
+ case len(d.engines) == 0:
+ // The manager answered, and its answer was nothing. That is a different
+ // fact from the branch above, where it did not answer at all, and the
+ // two must not read alike: this one means the engine list itself is
+ // empty, which on this machine is a broken engine manager rather than
+ // anything the operator did. Bare, "No engines reported" invited the
+ // reading that PAIR had lost track of an engine that was plainly there.
+ enginesBody = footerStyle.Render(d.emptyEnginesHint())
+ case d.enginesStale:
+ enginesBody += "\n" + footerStyle.Render(fmt.Sprintf(
+ " %s is not answering - this list may be out of date.", d.node.name))
+ }
+ modelsEmpty := ""
+ if len(d.modelRows) == 0 {
+ modelsEmpty = footerStyle.Render(d.emptyModelsHint())
+ }
+
+ // A blank line between the sections: two tables stacked flush read as one
+ // table with a stray header in the middle. It is a real row, so it is
+ // measured as one.
+ const separator = " "
+ status := d.status.render()
+ hardware := d.hardwareBlock()
+ enginesHeading := d.paneHeading("Engines", detailEngines)
+ modelsHeading := d.paneHeading("Models", detailModels)
+
+ // The engine table's height comes from the engine count, not the budget, so
+ // on a very short screen it is the piece that will not shrink. Replaced by a
+ // line when there is no room, the same way the models table is — otherwise
+ // it holds three rows it cannot afford and the status line pays for them.
+ if len(d.engines) > 0 {
+ engineRoom := d.height - countLines(identity) - countLines(hardware) -
+ countLines(enginesHeading) - countLines(editor) - countLines(status)
+ // Against what will actually be rendered, not against the table's
+ // minimum. The section is a row taller than the table whenever the list
+ // is stale — the "not answering" note — which is the ordinary state for
+ // a peer that has gone away, so comparing to the minimum let the section
+ // through at exactly the sizes where it did not fit.
+ if engineRoom < countLines(enginesBody) {
+ return joinLines(identity, hardware, enginesHeading,
+ footerStyle.Render(fmt.Sprintf(
+ " (too little room to list %d engine(s))", len(d.engines))),
+ editor, status)
+ }
+ }
+
+ // Everything above the models section is either fixed or already sized, so
+ // what is left decides how much of that section can appear. Measuring the
+ // real strings — including the editor and status line, which come and go —
+ // is what keeps the frame exact whichever of them are on screen.
+ above := countLines(identity) + countLines(hardware) +
+ countLines(enginesHeading) + countLines(enginesBody) +
+ countLines(editor) + countLines(status)
+ room := d.height - above
+
+ // The section costs a separator and a heading before it shows anything, so
+ // below that it is dropped whole rather than rendered as a heading over
+ // nothing. The engine list and the status line are the better use of the
+ // last rows.
+ const sectionChrome = 2
+ if room < sectionChrome+1 {
+ return joinLines(identity, hardware, enginesHeading, enginesBody, editor, status)
+ }
+
+ modelsBody := modelsEmpty
+ if modelsEmpty == "" {
+ if fitTable(&d.modelTable, room-sectionChrome) {
+ modelsBody = d.modelTable.View()
+ } else {
+ modelsBody = footerStyle.Render(" (too little room to list models)")
+ }
+ }
+
+ return joinLines(
+ identity,
+ hardware,
+ enginesHeading,
+ enginesBody,
+ separator,
+ modelsHeading,
+ modelsBody,
+ editor,
+ status,
+ )
+}
+
+// hardwareBlock renders the node's own GPU, CPU, and memory readings.
+func (d *nodeDetail) hardwareBlock() string {
+ if !d.telemetryOK {
+ if d.node.presence != presenceOnline {
+ return footerStyle.Render(" hardware: unavailable (node is not reachable)")
+ }
+ return footerStyle.Render(" hardware: unavailable")
+ }
+ lines := d.telemetry.summary()
+ if len(lines) == 0 {
+ return footerStyle.Render(" hardware: no GPU, CPU, or memory reported")
+ }
+ // Capped, because this is the one block whose height comes from the machine
+ // rather than from the layout: a host reports a line per GPU, and an
+ // eight-GPU box produced ten lines that nothing could shrink. Everything
+ // below it — the engine table, the models list, the status line — was then
+ // pushed off the frame, so the screen showed a hardware readout and nothing
+ // else, with no sign anything was missing.
+ if max := d.hardwareBudget(); len(lines) > max {
+ hidden := len(lines) - (max - 1)
+ lines = append(lines[:max-1],
+ footerStyle.Render(fmt.Sprintf(" ...and %d more device(s)", hidden)))
+ }
+ return strings.Join(lines, "\n")
+}
+
+// hardwareBudget is the most rows the hardware block may take.
+//
+// Half the screen, floored at one line so there is always something. The engine
+// list, the models list, and the status row all sit below it and matter more
+// than an exhaustive device inventory — the operator came here to act on an
+// engine, not to audit GPUs.
+func (d *nodeDetail) hardwareBudget() int {
+ return clampWidth(d.height/2, 1)
+}
+
+// emptyModelsHint explains an empty list in terms of the thing to fix, since
+// "no models" has several quite different causes.
+// emptyEnginesHint explains an engine list that came back empty.
+//
+// Every supported engine is meant to appear here whether or not it is
+// installed — that is how you install one — so an empty list is not "nothing is
+// installed", it is the engine manager failing to enumerate. Saying where to
+// look beats a flat statement the operator cannot act on.
+func (d *nodeDetail) emptyEnginesHint() string {
+ if !d.enginesQueryable() {
+ return " " + d.unpairedEnginesHint()
+ }
+ if d.remote() {
+ return " No engines reported by this node - its engine manager may not be running."
+ }
+ // Named rather than numbered: a hardcoded tab number is the same drift that
+ // left the footer advertising "1-9" against five tabs.
+ return " No engines reported. Every supported engine should be listed here even when not installed, so this points at the engine manager rather than at what you have installed - check the Logs tab."
+}
+
+// unpairedEnginesHint says why a node's engines are out of reach, and what
+// would bring them into it.
+//
+// Each membership needs its own sentence because the next move differs: an
+// unrelated machine can be paired with from the Nodes list, one in another
+// cluster has to leave that cluster first, and one part-way through pairing
+// only needs the handshake to finish.
+//
+// Where the node advertises models, the engines serving them are named. Saying
+// its engines cannot be seen, directly above a model list with an ENGINE
+// column filled in, claims less than the screen is already showing: discovery
+// carries which engine serves each model, and needs no pairing to do it. What
+// pairing buys is their state and their controls, so that is what the sentence
+// promises.
+func (d *nodeDetail) unpairedEnginesHint() string {
+ var advertised string
+ if names := d.advertisedEngines(); len(names) > 0 {
+ advertised = fmt.Sprintf(" It advertises models for %s, listed below.",
+ strings.Join(names, " and "))
+ }
+ switch d.node.membership {
+ case membershipForeign:
+ return fmt.Sprintf(
+ "%s is in another cluster, so its engines cannot be managed from here.%s",
+ d.node.name, advertised)
+ case membershipPending:
+ return fmt.Sprintf(
+ "%s is still pairing - its engines appear once that finishes.%s",
+ d.node.name, advertised)
+ default:
+ return fmt.Sprintf(
+ "%s is not in this cluster.%s Pair with it on the Nodes tab to manage its engines.",
+ d.node.name, advertised)
+ }
+}
+
+// advertisedEngines are the engines this node attributes models to, by display
+// name, in a stable order.
+//
+// Discovery carries the attribution, so this is known for any node on the
+// network whether or not it is a peer. It is what the node says it serves, not
+// what is installed on it: an engine holding no models is not represented.
+func (d *nodeDetail) advertisedEngines() []string {
+ names := make([]string, 0, len(d.models.ModelsByEngine))
+ for engine, models := range d.models.ModelsByEngine {
+ if len(models) == 0 {
+ continue
+ }
+ names = append(names, d.engineLabel(engine))
+ }
+ sort.Strings(names)
+ return names
+}
+
+func (d *nodeDetail) emptyModelsHint() string {
+ switch {
+ case d.node.presence != presenceOnline:
+ return " No models reported - this node is not reachable."
+ case !d.enginesQueryable():
+ // Its models come from what it advertises over discovery, which needs
+ // no pairing — so silence here means it is advertising none, not that
+ // something needs starting. Whether an engine is running is exactly
+ // what cannot be seen from outside the cluster.
+ return " No models advertised by this node."
+ case !d.anyEngineRunning():
+ return " No models reported - start an engine to see its models."
+ case d.remote():
+ return " No models reported by this node's engines."
+ default:
+ return " No models installed. Press p to download one."
+ }
+}
+
+func (d *nodeDetail) anyEngineRunning() bool {
+ for _, e := range d.engines {
+ if e.Running {
+ return true
+ }
+ }
+ return false
+}
+
+func (d *nodeDetail) paneHeading(label string, pane detailPane) string {
+ if d.pane == pane {
+ return titleStyle.Render("▸ " + label)
+ }
+ return footerStyle.Render(" " + label)
+}
+
+func (d *nodeDetail) inputLabel() string {
+ switch d.mode {
+ case detailInputEnginePort:
+ return "engine port: "
+ case detailInputProxyPort:
+ return "proxy port: "
+ case detailInputLaunchArgs:
+ return "arguments: "
+ default:
+ return "download model: "
+ }
+}
+
+// Help lists the verbs for the focused pane, filtered to what this node
+// actually permits, so a remote node never advertises an operation the engine
+// manager cannot perform on it.
+func (d *nodeDetail) Help() []key.Binding {
+ if d.catalog != nil {
+ return d.catalog.Help()
+ }
+ if d.mode != detailInputNone {
+ switch d.mode {
+ case detailInputModelName:
+ return inputHelp("download")
+ case detailInputLaunchArgs:
+ return inputHelp("check and save")
+ }
+ return inputHelp("set port")
+ }
+ if d.pending != nil || d.settingsConfirm != nil {
+ return []key.Binding{detailConfirmKey}
+ }
+ bindings := []key.Binding{detailBackKey, detailPaneKey}
+ if d.pane == detailEngines {
+ bindings = append(bindings, detailInstallKey, detailStartKey, detailStopKey)
+ // The settings keys are offered on a peer too: the engine manager
+ // relays them, and whether a particular engine will accept the write
+ // is the snapshot's answer, given when the key is pressed.
+ bindings = append(bindings, detailPortKey, detailProxyKey, detailArgsKey)
+ if !d.remote() {
+ bindings = append(bindings, detailRestartKey, detailUninstKey)
+ }
+ return bindings
+ }
+ return append(bindings,
+ detailPullKey, detailPullByNameKey, detailLoadKey, detailEjectKey, detailDeleteKey)
+}
+
+func yesNo(b bool) string {
+ if b {
+ return "yes"
+ }
+ return "no"
+}
diff --git a/services/nvpair-tui/ui/nodedetail_test.go b/services/nvpair-tui/ui/nodedetail_test.go
new file mode 100644
index 00000000..6a0c7601
--- /dev/null
+++ b/services/nvpair-tui/ui/nodedetail_test.go
@@ -0,0 +1,1274 @@
+// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+package ui
+
+import (
+ "encoding/json"
+ "errors"
+ "strings"
+ "testing"
+
+ "nvpair-shared/enginesettings"
+ "nvpair-shared/noderec"
+ "nvpair-tui/rpc"
+
+ "github.com/charmbracelet/bubbles/key"
+ tea "github.com/charmbracelet/bubbletea"
+)
+
+func localDetail() *nodeDetail {
+ d := newNodeDetail(nil, nodeRow{key: "self", name: "this-host", self: true, presence: presenceOnline})
+ d.SetSize(100, 30)
+ return d
+}
+
+func remoteDetail() *nodeDetail {
+ d := newNodeDetail(nil, nodeRow{key: "peer", name: "peer-host", presence: presenceOnline})
+ d.SetSize(100, 30)
+ return d
+}
+
+// discoveryPush builds a discovery:nodes-changed notification.
+func discoveryPush(nodes ...availableNode) NotificationMsg {
+ params, _ := json.Marshal(nodes)
+ return NotificationMsg{Msg: &rpc.Message{Method: "discovery:nodes-changed", Params: params}}
+}
+
+// TestRemoteDetailFollowsDiscoveryModels is the regression guard for a peer's
+// detail screen freezing at the moment it was opened.
+//
+// A peer's model inventory arrives only in the enriched discovery snapshot —
+// there is no per-node models RPC — and the screen seeded itself from the
+// snapshot it was constructed with and then ignored every later one. Pulling a
+// model onto that peer, including from this very screen, changed nothing on
+// screen until the operator backed out and re-entered.
+func TestRemoteDetailFollowsDiscoveryModels(t *testing.T) {
+ d := remoteDetail()
+ d.models = modelsResult{Models: []string{"old-model"}}
+ d.refreshModels()
+
+ d.update(discoveryPush(availableNode{
+ HostUUID: "peer",
+ Name: "peer-host",
+ Models: []string{"old-model", "new-model"},
+ ModelsByEngine: map[string][]string{"ollama": {"old-model", "new-model"}},
+ }))
+
+ if len(d.models.Models) != 2 {
+ t.Fatalf("models = %v, want the refreshed pair from discovery", d.models.Models)
+ }
+ if !contains(d.View(), "new-model") {
+ t.Error("a model that appeared on the peer is not on screen")
+ }
+}
+
+// TestRemoteDetailIgnoresOtherNodesDiscovery checks the screen only takes the
+// entry for its own node, so a busy cluster cannot overwrite it.
+func TestRemoteDetailIgnoresOtherNodesDiscovery(t *testing.T) {
+ d := remoteDetail()
+ d.models = modelsResult{Models: []string{"mine"}}
+ d.refreshModels()
+
+ d.update(discoveryPush(availableNode{
+ HostUUID: "somebody-else",
+ Name: "other-host",
+ Models: []string{"theirs"},
+ }))
+
+ if len(d.models.Models) != 1 || d.models.Models[0] != "mine" {
+ t.Errorf("another node's discovery entry overwrote this one: %v", d.models.Models)
+ }
+}
+
+// TestLocalDetailIgnoresDiscoveryModels checks this machine keeps using the
+// authoritative engine:models RPC rather than the discovery summary.
+func TestLocalDetailIgnoresDiscoveryModels(t *testing.T) {
+ d := localDetail()
+ d.models = modelsResult{Models: []string{"authoritative"}}
+ d.refreshModels()
+
+ d.update(discoveryPush(availableNode{
+ HostUUID: "self",
+ Name: "this-host",
+ Models: []string{"stale-summary"},
+ }))
+
+ if len(d.models.Models) != 1 || d.models.Models[0] != "authoritative" {
+ t.Errorf("local detail took models from discovery: %v", d.models.Models)
+ }
+}
+
+// TestRemoteDetailPollsEngines checks a peer's engine state is re-read on a
+// tick. engine:state-changed carries a local snapshot with no node on it, so it
+// cannot be attributed to a peer — polling is the only way an open remote
+// screen notices an engine starting or stopping over there.
+func TestRemoteDetailPollsEngines(t *testing.T) {
+ d := remoteDetail()
+ if cmd, _ := d.update(detailEnginesTickMsg{gen: d.telemetryGen}); cmd == nil {
+ t.Error("remote detail did not re-read engines on its tick")
+ }
+
+ // A superseded screen's tick is dropped, matching the telemetry chain.
+ if cmd, _ := d.update(detailEnginesTickMsg{gen: d.telemetryGen + 1}); cmd != nil {
+ t.Error("a stale chain's tick was extended")
+ }
+
+ // This machine has real pushes, so it must not poll.
+ local := localDetail()
+ if cmd, _ := local.update(detailEnginesTickMsg{gen: local.telemetryGen}); cmd != nil {
+ t.Error("local detail polls engines despite receiving engine:state-changed")
+ }
+}
+
+// TestDetailSectionsAreSeparated is the guard for the two tables reading as one
+// with a stray header in the middle: there must be a blank line between them.
+func TestDetailSectionsAreSeparated(t *testing.T) {
+ d := localDetail()
+ d.engines = []engineStatus{{Engine: "ollama", Installed: true, Running: true, Port: 11434}}
+ d.refreshEngines()
+
+ lines := strings.Split(d.View(), "\n")
+ modelsAt := -1
+ for i, l := range lines {
+ if strings.Contains(l, "Models") {
+ modelsAt = i
+ break
+ }
+ }
+ if modelsAt <= 0 {
+ t.Fatalf("no Models heading found in:\n%s", d.View())
+ }
+ if strings.TrimSpace(lines[modelsAt-1]) != "" {
+ t.Errorf("no blank line before the Models heading; previous line was %q", lines[modelsAt-1])
+ }
+}
+
+// TestLocalDetailShowsBothPorts is the guard for the two ports being managed in
+// different places: on this machine they sit side by side on the engine's row.
+func TestLocalDetailShowsBothPorts(t *testing.T) {
+ d := localDetail()
+ if d.proxy == nil {
+ t.Fatal("no proxy tracker on the local node")
+ }
+ d.proxy.apply(proxyStatusMsg{idx: 0, ready: true, port: 11435})
+ d.engines = []engineStatus{{Engine: "ollama", Installed: true, Running: true, Port: 11434}}
+ d.refreshEngines()
+
+ cols := detailEngineColumns(100, false)
+ titles := make([]string, 0, len(cols))
+ for _, c := range cols {
+ titles = append(titles, c.Title)
+ }
+ joined := strings.Join(titles, " ")
+ if !strings.Contains(joined, "ENGINE PORT") || !strings.Contains(joined, "PROXY PORT") {
+ t.Fatalf("local engine columns = %q, want both ports", joined)
+ }
+
+ row := d.engineTable.Rows()[0]
+ if row[4] != "11434" {
+ t.Errorf("engine port cell = %q, want 11434", row[4])
+ }
+ if row[5] != "11435" {
+ t.Errorf("proxy port cell = %q, want 11435", row[5])
+ }
+}
+
+// TestRemoteDetailHidesProxyPort checks a peer's endpoints are not presented as
+// ours to configure.
+func TestRemoteDetailHidesProxyPort(t *testing.T) {
+ d := remoteDetail()
+ if d.proxy != nil {
+ t.Error("a remote node should carry no proxy tracker")
+ }
+ for _, c := range detailEngineColumns(100, true) {
+ if c.Title == "PROXY PORT" {
+ t.Error("remote engine table offers a proxy port column")
+ }
+ }
+
+ d.engines = []engineStatus{{Engine: "ollama", Port: 11434}}
+ d.refreshEngines()
+ if got := len(d.engineTable.Rows()[0]); got != 5 {
+ t.Errorf("remote row has %d cells, want 5", got)
+ }
+}
+
+// TestProxyPortCellStates checks the cell distinguishes a live endpoint from a
+// port whose proxy is down, and from an engine no proxy fronts.
+func TestProxyPortCellStates(t *testing.T) {
+ d := localDetail()
+
+ d.proxy.apply(proxyStatusMsg{idx: 0, ready: true, port: 11435})
+ if got := d.proxyPortCell("ollama"); got != "11435" {
+ t.Errorf("live endpoint = %q", got)
+ }
+
+ d.proxy.engines[0].ready = false
+ if got := d.proxyPortCell("ollama"); !strings.Contains(got, "down") {
+ t.Errorf("endpoint with the proxy down = %q, want it marked down", got)
+ }
+
+ if got := d.proxyPortCell("not-an-engine"); got != "-" {
+ t.Errorf("engine with no proxy = %q, want %q", got, "-")
+ }
+}
+
+// TestProxyIndexForEngine pins the engine-to-proxy pairing the proxy port
+// column depends on.
+func TestProxyIndexForEngine(t *testing.T) {
+ p := newProxyTracker()
+ if got := p.indexForEngine("ollama"); got != 0 {
+ t.Errorf("ollama -> %d, want 0", got)
+ }
+ if got := p.indexForEngine("lmstudio"); got != 1 {
+ t.Errorf("lmstudio -> %d, want 1", got)
+ }
+ // Case and padding must not decide whether a port renders.
+ if got := p.indexForEngine(" OLLAMA "); got != 0 {
+ t.Errorf("normalisation failed: %d", got)
+ }
+ if got := p.indexForEngine("vllm"); got != -1 {
+ t.Errorf("unknown engine -> %d, want -1", got)
+ }
+}
+
+// seedSettings puts a settings snapshot in the cache, as a fetch or a push
+// would, so a test can press an edit key without a broker behind it.
+func seedSettings(d *nodeDetail, snap enginesettings.Snapshot) {
+ if d.settings == nil {
+ d.settings = map[string]enginesettings.Snapshot{}
+ }
+ d.settings[snap.Engine] = snap
+}
+
+// ollamaSettings is an editable snapshot for the engine the detail tests use.
+func ollamaSettings() enginesettings.Snapshot {
+ return enginesettings.Snapshot{
+ Engine: "ollama",
+ Revision: 7,
+ Editable: true,
+ Settings: enginesettings.Config{
+ ServerPort: 11434,
+ ProxyPort: 11435,
+ LaunchText: "OLLAMA_KEEP_ALIVE=5m",
+ },
+ }
+}
+
+// TestSettingsKeysAskTheBackendBeforeOpening checks an edit key fetches the
+// snapshot rather than opening a field against values this screen guessed.
+//
+// The revision is the point. A field opened without one has nothing to write
+// against, and the backend's whole defence against two clients overwriting each
+// other is that every write carries the revision it was based on.
+func TestSettingsKeysAskTheBackendBeforeOpening(t *testing.T) {
+ d := localDetail()
+ d.engines = []engineStatus{{Engine: "ollama", Installed: true, Port: 11434}}
+ d.refreshEngines()
+
+ cmd := d.handleEngineKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("e")})
+ if d.mode != detailInputNone {
+ t.Errorf("opened a field before the snapshot arrived (mode %v)", d.mode)
+ }
+ if cmd == nil {
+ t.Fatal("no settings request was issued")
+ }
+ if d.settingsWanted == nil || d.settingsWanted.mode != detailInputEnginePort {
+ t.Fatalf("the requested edit was not remembered: %+v", d.settingsWanted)
+ }
+}
+
+// TestSettingsEditorsAreDistinct checks the three fields are told apart, so a
+// typed value cannot be applied to the wrong one.
+func TestSettingsEditorsAreDistinct(t *testing.T) {
+ d := localDetail()
+ d.engines = []engineStatus{{Engine: "ollama", Installed: true, Port: 11434}}
+ d.refreshEngines()
+ seedSettings(d, ollamaSettings())
+
+ cases := []struct {
+ key string
+ mode detailInputMode
+ value string
+ label string
+ }{
+ {"e", detailInputEnginePort, "11434", "engine"},
+ {"p", detailInputProxyPort, "11435", "proxy"},
+ {"a", detailInputLaunchArgs, "OLLAMA_KEEP_ALIVE=5m", "arguments"},
+ }
+ for _, tc := range cases {
+ d.mode = detailInputNone
+ d.handleEngineKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(tc.key)})
+ if d.mode != tc.mode {
+ t.Fatalf("%q opened mode %v, want %v", tc.key, d.mode, tc.mode)
+ }
+ if got := d.input.Value(); got != tc.value {
+ t.Errorf("%q seeded with %q, want %q", tc.key, got, tc.value)
+ }
+ if !strings.Contains(d.inputLabel(), tc.label) {
+ t.Errorf("label %q does not say which field", d.inputLabel())
+ }
+ }
+}
+
+// TestSettingsRefusalComesFromTheBackend checks an engine the backend will not
+// let us configure is refused in the backend's own words.
+//
+// This screen used to decide for itself, refusing every port change on a peer
+// because the old RPC had no remote form. The settings path does, so the
+// judgement belongs to the side that knows why — an adopted engine, an
+// unreachable settings worker — rather than to a rule here that would drift.
+func TestSettingsRefusalComesFromTheBackend(t *testing.T) {
+ d := remoteDetail()
+ d.engines = []engineStatus{{Engine: "ollama", Port: 11434}}
+ d.refreshEngines()
+
+ snap := ollamaSettings()
+ snap.Editable = false
+ snap.Adopted = true
+ snap.Reason = "this engine was started outside PAIR"
+ seedSettings(d, snap)
+
+ d.handleEngineKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("a")})
+ if d.mode != detailInputNone {
+ t.Error("opened an editor for an engine the backend said is not editable")
+ }
+ if got := d.status.render(); !strings.Contains(got, "started outside PAIR") {
+ t.Errorf("status %q does not carry the backend's reason", got)
+ }
+}
+
+// TestSettingsWriteCarriesTheRevisionAndResolution checks the two things a
+// settings write cannot be wrong about.
+//
+// The revision is what makes a concurrent edit fail instead of silently
+// winning. The resolution decides which side gives way when the numeric port
+// field and the port inside the command text disagree: editing the field
+// should rewrite the command, and editing the command should move the field.
+// Send the wrong one and the backend quietly rewrites what the operator typed.
+func TestSettingsWriteCarriesTheRevisionAndResolution(t *testing.T) {
+ d := localDetail()
+ d.engines = []engineStatus{{Engine: "ollama", Installed: true, Port: 11434}}
+ d.refreshEngines()
+ snap := ollamaSettings()
+ seedSettings(d, snap)
+
+ cases := []struct {
+ name string
+ mode detailInputMode
+ value string
+ resolution string
+ check func(enginesettings.Config) error
+ }{
+ {
+ name: "the port field wins over the command",
+ mode: detailInputEnginePort,
+ value: "11500",
+ resolution: resolutionServer,
+ },
+ {
+ name: "the command wins over the port field",
+ mode: detailInputLaunchArgs,
+ value: "OLLAMA_HOST=127.0.0.1:11500",
+ resolution: resolutionLaunch,
+ },
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ req, ok := d.settingsRequest(snap, tc.mode, tc.value)
+ if !ok {
+ t.Fatal("the request was rejected")
+ }
+ if req.ExpectedRevision != snap.Revision {
+ t.Errorf("revision %d, want the snapshot's %d",
+ req.ExpectedRevision, snap.Revision)
+ }
+ if req.Resolution != tc.resolution {
+ t.Errorf("resolution %q, want %q", req.Resolution, tc.resolution)
+ }
+ })
+ }
+}
+
+// TestLaunchTextIsSentAsTyped checks the arguments field is not tidied here.
+//
+// Whitespace is significant to a tokenizer, and the backend normalizes to rules
+// this screen does not carry. Trimming on the way out would disagree with it
+// and, worse, would disagree invisibly.
+func TestLaunchTextIsSentAsTyped(t *testing.T) {
+ d := localDetail()
+ snap := ollamaSettings()
+ const typed = ` OLLAMA_ORIGINS="https://example.com" `
+
+ req, ok := d.settingsRequest(snap, detailInputLaunchArgs, typed)
+ if !ok {
+ t.Fatal("the request was rejected")
+ }
+ if req.Settings.LaunchText != typed {
+ t.Errorf("sent %q, want the text exactly as typed", req.Settings.LaunchText)
+ }
+}
+
+// TestSettingsApplyUsesTheNormalizedDraft checks the commit sends what the
+// backend validated, not what was typed.
+//
+// The preview returns normalized settings — quoting settled, ports reconciled
+// — and applying the raw draft instead would save text that was never checked,
+// with the preview's approval standing behind it.
+func TestSettingsApplyUsesTheNormalizedDraft(t *testing.T) {
+ d := localDetail()
+ d.engines = []engineStatus{{Engine: "ollama", Installed: true}}
+ d.refreshEngines()
+
+ typed := enginesettings.Config{ServerPort: 11434, ProxyPort: 11435, LaunchText: "--flag x"}
+ normalized := enginesettings.Config{ServerPort: 11434, ProxyPort: 11435, LaunchText: "--flag x"}
+
+ verdict, sent, problem := judgeSettingsPreview(enginePreviewMsg{
+ request: enginesettings.Request{
+ Engine: "ollama",
+ ExpectedRevision: 7,
+ Settings: typed,
+ Resolution: resolutionLaunch,
+ },
+ preview: enginesettings.Preview{Settings: normalized},
+ })
+
+ if verdict != settingsWrite {
+ t.Fatalf("a clean preview did not write (verdict %v, problem %q)", verdict, problem)
+ }
+ if sent.Settings.LaunchText != normalized.LaunchText {
+ t.Errorf("applied %q, want the normalized %q",
+ sent.Settings.LaunchText, normalized.LaunchText)
+ }
+ if sent.Resolution != "" {
+ t.Errorf("resolution %q survived into the commit; the draft is already settled",
+ sent.Resolution)
+ }
+ if sent.ExpectedRevision != 7 {
+ t.Errorf("revision %d, want the one the draft was based on", sent.ExpectedRevision)
+ }
+ _ = d
+}
+
+// settingsPush builds an engine:settings-changed notification.
+func settingsPush(snap enginesettings.Snapshot) NotificationMsg {
+ params, _ := json.Marshal(snap)
+ return NotificationMsg{Msg: &rpc.Message{Method: "engine:settings-changed", Params: params}}
+}
+
+// TestLocalSettingsPushIsNotDiscarded is the regression guard for a second
+// save that could never succeed.
+//
+// The broker stamps every snapshot with this node's UUID. Matching that
+// against the node argument the local RPCs take — which is empty, precisely
+// because they are local — discarded every push for this machine. The cached
+// revision then stayed at whatever the first read returned, so the save after
+// a successful one was rejected as stale, and stayed rejected until the screen
+// was closed and reopened.
+func TestLocalSettingsPushIsNotDiscarded(t *testing.T) {
+ const uuid = "33983c39-c0a6-41d2-9488-455b5e61e25f"
+ d := newNodeDetail(nil, nodeRow{key: uuid, name: "this-host", self: true, presence: presenceOnline})
+ d.SetSize(100, 30)
+ seedSettings(d, ollamaSettings()) // revision 7
+
+ moved := ollamaSettings()
+ moved.NodeID = uuid
+ moved.Revision = 8
+ d.handleNotification(settingsPush(moved).Msg)
+
+ if got := d.settings["ollama"].Revision; got != 8 {
+ t.Fatalf("cached revision is %d after a push for this machine, want 8", got)
+ }
+
+ // And a push for a different machine is still ignored.
+ other := ollamaSettings()
+ other.NodeID = "some-other-node"
+ other.Revision = 99
+ d.handleNotification(settingsPush(other).Msg)
+ if got := d.settings["ollama"].Revision; got != 8 {
+ t.Errorf("a peer's snapshot overwrote this machine's: revision %d", got)
+ }
+}
+
+// TestFailedSaveReloadsTheSnapshot checks a rejected write leaves the screen
+// able to try again.
+//
+// A revision the backend will not accept is not recoverable by repeating the
+// same write: without dropping it, every later attempt fails identically and
+// the only way out is to leave the screen.
+func TestFailedSaveReloadsTheSnapshot(t *testing.T) {
+ d := localDetail()
+ d.engines = []engineStatus{{Engine: "ollama", Installed: true}}
+ d.refreshEngines()
+ seedSettings(d, ollamaSettings())
+ d.settingsAwaited = "ollama"
+
+ cmd, _ := d.update(engineSettingsAppliedMsg{
+ engine: "ollama",
+ err: errors.New("settings changed on this device; reload before applying"),
+ })
+
+ if _, still := d.settings["ollama"]; still {
+ t.Error("the rejected snapshot is still cached, so the next attempt repeats the failure")
+ }
+ if cmd == nil {
+ t.Error("nothing re-read the settings, so the next edit has nothing to write against")
+ }
+ if got := d.status.render(); !strings.Contains(got, "try again") {
+ t.Errorf("status %q does not tell the operator what to do: %q", got, "try again")
+ }
+}
+
+// TestSettingsCommitCarriesARequestIdentifier is the regression guard for a
+// save that failed after the check had passed.
+//
+// The identifier is the backend's idempotency key: it records a receipt against
+// it, returns the original outcome when one is replayed, and refuses a replay
+// carrying different settings. A commit without one is rejected outright — and
+// because only the commit needs it, the preview succeeded first, so the
+// interface reported the settings as valid and then refused to save them.
+func TestSettingsCommitCarriesARequestIdentifier(t *testing.T) {
+ previewOf := func(port int) enginePreviewMsg {
+ return enginePreviewMsg{
+ request: enginesettings.Request{Engine: "ollama", ExpectedRevision: 7},
+ preview: enginesettings.Preview{
+ Settings: enginesettings.Config{ServerPort: port, ProxyPort: 11434},
+ },
+ }
+ }
+
+ _, first, _ := judgeSettingsPreview(previewOf(11500))
+ if first.RequestID == "" {
+ t.Fatal("the commit carries no request identifier; the backend will refuse it")
+ }
+ if len(first.RequestID) > 128 {
+ t.Errorf("identifier is %d characters; the broker allows 128", len(first.RequestID))
+ }
+
+ // Asserted on the wire form, not the Go field. `requestId` is omitempty, so
+ // an unset one does not travel as an empty string — it disappears from the
+ // object altogether, which is precisely how this shipped: the struct had
+ // the field, the JSON did not, and the broker refused the call.
+ wire, err := json.Marshal(first)
+ if err != nil {
+ t.Fatalf("marshal: %v", err)
+ }
+ if !strings.Contains(string(wire), `"requestId"`) {
+ t.Errorf("the request sent to the broker has no requestId: %s", wire)
+ }
+
+ // A different change must not reuse it: the backend refuses an identifier
+ // replayed with settings that do not match its receipt.
+ _, second, _ := judgeSettingsPreview(previewOf(11501))
+ if second.RequestID == first.RequestID {
+ t.Error("two different changes share one identifier; the second would be refused")
+ }
+}
+
+// TestSettingsRestartIsConfirmed checks a change that restarts the engine asks
+// first, and that the confirmation applies the same request it armed.
+func TestSettingsRestartIsConfirmed(t *testing.T) {
+ d := localDetail()
+ d.engines = []engineStatus{{Engine: "ollama", Installed: true}}
+ d.refreshEngines()
+
+ normalized := enginesettings.Config{ServerPort: 11500, ProxyPort: 11435}
+ restarting := enginePreviewMsg{
+ request: enginesettings.Request{Engine: "ollama", ExpectedRevision: 7},
+ preview: enginesettings.Preview{Settings: normalized, Restart: true},
+ }
+
+ if verdict, _, _ := judgeSettingsPreview(restarting); verdict != settingsConfirmFirst {
+ t.Fatalf("a restarting change was not held for confirmation (verdict %v)", verdict)
+ }
+
+ if cmd := d.applySettingsPreview(restarting); cmd != nil {
+ t.Fatal("a restarting change was sent without asking")
+ }
+ if d.settingsConfirm == nil {
+ t.Fatal("no confirmation was armed")
+ }
+ if got := d.status.render(); !strings.Contains(got, "restart") {
+ t.Errorf("prompt %q does not say the engine will restart", got)
+ }
+
+ // Anything other than y walks away, and the armed request goes with it.
+ if cmd := d.resolveSettingsConfirm(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("n")}); cmd != nil {
+ t.Error("a non-confirming key still applied the change")
+ }
+ if d.settingsConfirm != nil {
+ t.Error("the armed request outlived the cancellation")
+ }
+
+ d.applySettingsPreview(restarting)
+ armed := d.settingsConfirm
+ if armed == nil || armed.Settings.ServerPort != normalized.ServerPort {
+ t.Fatalf("armed the wrong request: %+v", armed)
+ }
+ // The identifier is minted when the change is judged, not when it is sent,
+ // so confirming is a replay of the arming rather than a second write.
+ if armed.RequestID == "" {
+ t.Error("the armed request has no identifier, so confirming it would be refused")
+ }
+ if cmd := d.resolveSettingsConfirm(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("y")}); cmd == nil {
+ t.Error("y did not apply the armed change")
+ }
+}
+
+// TestSettingsPreviewFailuresAreExplained checks a rejected draft says why and
+// saves nothing.
+func TestSettingsPreviewFailuresAreExplained(t *testing.T) {
+ cases := []struct {
+ name string
+ preview enginesettings.Preview
+ err error
+ want string
+ }{
+ {
+ name: "a field the backend rejected",
+ preview: enginesettings.Preview{Errors: map[string]string{"launchText": "unbalanced quote"}},
+ want: "unbalanced quote",
+ },
+ {
+ name: "the two ports disagree",
+ preview: enginesettings.Preview{Conflict: &enginesettings.Conflict{ServerPort: 11434, LaunchPort: 11500}},
+ want: "11500",
+ },
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ d := localDetail()
+ d.engines = []engineStatus{{Engine: "ollama", Installed: true}}
+ d.refreshEngines()
+
+ cmd := d.applySettingsPreview(enginePreviewMsg{
+ request: enginesettings.Request{Engine: "ollama"},
+ preview: tc.preview,
+ err: tc.err,
+ })
+ if cmd != nil {
+ t.Error("a rejected draft was sent anyway")
+ }
+ if d.settingsConfirm != nil {
+ t.Error("a rejected draft was armed for confirmation")
+ }
+ if got := d.status.render(); !strings.Contains(got, tc.want) {
+ t.Errorf("status %q does not mention %q", got, tc.want)
+ }
+ })
+ }
+}
+
+// TestSettingsErrorsReadTheSameEveryTime checks the per-field errors are
+// ordered, since a map would reshuffle the same failure between attempts.
+func TestSettingsErrorsReadTheSameEveryTime(t *testing.T) {
+ errs := map[string]string{
+ "serverPort": "port in use",
+ "launchText": "unbalanced quote",
+ "proxyPort": "port in use",
+ }
+ first := joinSettingsErrors(errs)
+ for i := 0; i < 20; i++ {
+ if got := joinSettingsErrors(errs); got != first {
+ t.Fatalf("attempt %d rendered %q, want the stable %q", i, got, first)
+ }
+ }
+ if !strings.Contains(first, "unbalanced quote") {
+ t.Errorf("rendered %q, want every field's message", first)
+ }
+}
+
+// TestNoBindingRequiresShift is the guard for the mixed-case keyboard: needing
+// shift for some keys and not others makes every press a guess. Named keys like
+// shift+tab are exempt; this is about letters.
+func TestNoBindingRequiresShift(t *testing.T) {
+ bindings := map[string][]key.Binding{
+ "global": globalBindings(),
+ "nodes": newNodesView(nil).Help(),
+ "jobs": newJobsView(nil).Help(),
+ "service": newServiceView(nil).Help(),
+ "logs": newLogsView(nil).Help(),
+ "errors overlay": newErrorsView(nil).Help(),
+ "catalog": newCatalogBrowser(nil, "ollama", "Ollama", "this-host", false).Help(),
+ "detail engines": localDetail().Help(),
+ }
+
+ models := localDetail()
+ models.pane = detailModels
+ bindings["detail models"] = models.Help()
+
+ for where, set := range bindings {
+ for _, b := range set {
+ for _, k := range b.Keys() {
+ // A single upper-case letter is the shift-dependent case; the
+ // named keys (esc, enter, shift+tab) are longer than one rune.
+ if len(k) == 1 && k >= "A" && k <= "Z" {
+ t.Errorf("%s: binding %q uses shift-dependent key %q", where, b.Help().Desc, k)
+ }
+ }
+ }
+ }
+}
+
+// TestSettingsOutcomeReportsTheBoundPort is the regression guard for a change
+// that was refused and reported as done.
+//
+// A running engine outranks the proxy for a port, so the backend binds
+// elsewhere and reports the difference as the effective port. Treating the
+// absence of an error as success meant asking for a port an engine held
+// produced "updated" while the table went on showing the old one.
+func TestSettingsOutcomeReportsTheBoundPort(t *testing.T) {
+ cases := []struct {
+ name string
+ snapshot enginesettings.Snapshot
+ wantKind toastKind
+ wantHas []string
+ wantNotIn []string
+ }{
+ {
+ // The proxy's port is read live from the proxy process, not
+ // observed in passing, so a difference here is real.
+ name: "the endpoint could not take the port",
+ snapshot: enginesettings.Snapshot{
+ Engine: "lmstudio",
+ Settings: enginesettings.Config{ProxyPort: 1235, ServerPort: 1236},
+ EffectiveProxyPort: 1234,
+ EffectiveServerPort: 1236,
+ },
+ wantKind: toastError,
+ // Both numbers: which port it is on, and which one it could not have.
+ wantHas: []string{"1234", "1235"},
+ wantNotIn: []string{"saved"},
+ },
+ {
+ // The backend refused it, and says why. Its words, not a guess
+ // assembled from the ports.
+ name: "the backend refused the change",
+ snapshot: enginesettings.Snapshot{
+ Engine: "ollama",
+ Phase: settingsPhaseFailed,
+ Error: "port 11500 is reserved by another service",
+ Settings: enginesettings.Config{ProxyPort: 11434, ServerPort: 11500},
+ },
+ wantKind: toastError,
+ wantHas: []string{"reserved by another service"},
+ wantNotIn: []string{"saved"},
+ },
+ {
+ // The engine's effective port is observed when the apply replies,
+ // and an engine that restarts onto the new port finishes after
+ // that -- LM Studio's server re-launches detached. Reading failure
+ // into the lag reported a move that had happened as one that had
+ // not, naming a port nothing was listening on.
+ name: "the engine port reading lags a successful move",
+ snapshot: enginesettings.Snapshot{
+ Engine: "ollama",
+ Settings: enginesettings.Config{ProxyPort: 11434, ServerPort: 11500},
+ EffectiveProxyPort: 11434,
+ EffectiveServerPort: 11435,
+ },
+ wantKind: toastOK,
+ wantHas: []string{"saved"},
+ wantNotIn: []string{"11435"},
+ },
+ {
+ name: "honoured",
+ snapshot: enginesettings.Snapshot{
+ Engine: "ollama",
+ Settings: enginesettings.Config{ProxyPort: 11434, ServerPort: 11500},
+ EffectiveProxyPort: 11434,
+ EffectiveServerPort: 11500,
+ },
+ wantKind: toastOK,
+ wantHas: []string{"saved"},
+ },
+ {
+ name: "nothing bound yet",
+ snapshot: enginesettings.Snapshot{
+ Engine: "ollama",
+ Settings: enginesettings.Config{ProxyPort: 11434, ServerPort: 11500},
+ },
+ // A stopped engine has no port in force. Saying it "stayed on :0"
+ // would be worse than not saying where it landed.
+ wantKind: toastOK,
+ wantNotIn: []string{":0"},
+ },
+ }
+
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ d := localDetail()
+ d.settingsAwaited = tc.snapshot.Engine
+ d.reportSettingsOutcome(tc.snapshot)
+
+ if d.status.kind != tc.wantKind {
+ t.Errorf("toast kind = %v, want %v", d.status.kind, tc.wantKind)
+ }
+ got := d.status.render()
+ for _, want := range tc.wantHas {
+ if !strings.Contains(got, want) {
+ t.Errorf("message %q does not mention %q", got, want)
+ }
+ }
+ for _, unwanted := range tc.wantNotIn {
+ if strings.Contains(got, unwanted) {
+ t.Errorf("message %q should not contain %q", got, unwanted)
+ }
+ }
+ })
+ }
+}
+
+// TestSettingsOutcomeIsSilentForSomeoneElsesChange checks the report is scoped
+// to a change this screen made.
+//
+// These snapshots also arrive whenever the desktop app or another operator
+// saves something, and a note on each would be noise about work the person at
+// this terminal did not do.
+func TestSettingsOutcomeIsSilentForSomeoneElsesChange(t *testing.T) {
+ d := localDetail()
+ snap := enginesettings.Snapshot{
+ Engine: "ollama",
+ Settings: enginesettings.Config{ProxyPort: 11434},
+ EffectiveProxyPort: 11999,
+ }
+
+ d.reportSettingsOutcome(snap)
+ if got := d.status.render(); got != "" {
+ t.Errorf("reported %q for a change this screen did not make", got)
+ }
+
+ // And it speaks exactly once for a change it did make.
+ d.settingsAwaited = "ollama"
+ d.reportSettingsOutcome(snap)
+ if d.status.render() == "" {
+ t.Fatal("said nothing about this screen's own change")
+ }
+ d.status = toast{}
+ d.reportSettingsOutcome(snap)
+ if got := d.status.render(); got != "" {
+ t.Errorf("repeated the outcome as %q on a later snapshot", got)
+ }
+}
+
+// TestEngineNameDoesNotDependOnTheEngineFetch is the regression guard for the
+// same engine reading "Ollama" on one machine and "ollama" on another.
+//
+// A remote node's models come from discovery while its engine list comes from a
+// separate call, so the models table routinely has rows while the engine list is
+// still empty — the manager not running, the read failing, or a reply that
+// genuinely lists nothing. Resolving the name only against that list meant the
+// spelling depended on whether an unrelated fetch had landed.
+func TestEngineNameDoesNotDependOnTheEngineFetch(t *testing.T) {
+ withList := localDetail()
+ withList.engines = []engineStatus{
+ {Engine: "ollama", DisplayName: "Ollama"},
+ {Engine: "lmstudio", DisplayName: "LM Studio"},
+ }
+ empty := localDetail() // no engine list at all
+
+ for _, engine := range []string{"ollama", "lmstudio"} {
+ want := withList.engineLabel(engine)
+ if got := empty.engineLabel(engine); got != want {
+ t.Errorf("engine %q reads %q with no engine list but %q with one",
+ engine, got, want)
+ }
+ if want == engine {
+ t.Errorf("engine %q resolved to its own wire id, not a display name", engine)
+ }
+ }
+
+ // A genuinely unknown engine still has to render as something.
+ if got := empty.engineLabel("some-new-engine"); got != "some-new-engine" {
+ t.Errorf("unknown engine rendered %q, want the id passed through", got)
+ }
+}
+
+// TestEmptyEngineListExplainsItself checks the empty states read differently.
+// The manager answering with nothing, the manager not answering, and the node
+// being one we cannot ask at all have different causes, and the operator's
+// next move differs for each.
+func TestEmptyEngineListExplainsItself(t *testing.T) {
+ local := localDetail()
+ if got := local.emptyEnginesHint(); !strings.Contains(got, "engine manager") {
+ t.Errorf("local hint does not point at the engine manager: %q", got)
+ }
+
+ member := remoteDetail()
+ member.node.membership = membershipMember
+ if got := member.emptyEnginesHint(); !strings.Contains(got, "this node") {
+ t.Errorf("remote member hint does not attribute the gap to the peer: %q", got)
+ }
+
+ if local.emptyEnginesHint() == member.emptyEnginesHint() {
+ t.Error("local and remote read identically; the causes are different")
+ }
+}
+
+// TestUnpairedNodeIsNotCalledSilent is the regression guard for a machine that
+// was answering perfectly well being reported as unresponsive.
+//
+// A peer's engines are fetched over pin-based mTLS, so a node outside the
+// cluster cannot be asked at all. That call was made anyway, and its failure
+// rendered as "not answering" — beside a hardware readout, polled over an
+// endpoint that needs no pairing, visibly updating for the same machine.
+func TestUnpairedNodeIsNotCalledSilent(t *testing.T) {
+ cases := map[nodeMembership]string{
+ membershipNone: "not in this cluster",
+ membershipForeign: "another cluster",
+ membershipPending: "still pairing",
+ }
+ for membership, want := range cases {
+ d := remoteDetail()
+ d.node.membership = membership
+
+ if d.enginesCmd() != nil {
+ t.Errorf("%v: asked for engines over a link that cannot carry the question", membership)
+ }
+ got := d.emptyEnginesHint()
+ if !strings.Contains(got, want) {
+ t.Errorf("%v: hint %q does not say %q", membership, got, want)
+ }
+ if strings.Contains(got, "not answering") {
+ t.Errorf("%v: hint %q calls a reachable node silent", membership, got)
+ }
+ // Models come from what the node advertises over discovery, which
+ // needs no pairing, so the models pane must not blame a stopped engine
+ // for what it cannot see either way.
+ d.node.presence = presenceOnline
+ if m := d.emptyModelsHint(); strings.Contains(m, "start an engine") {
+ t.Errorf("%v: models hint %q claims an engine needs starting", membership, m)
+ }
+ }
+
+ // A member is still asked, and is still allowed to be silent.
+ member := remoteDetail()
+ member.node.membership = membershipMember
+ if member.enginesCmd() == nil {
+ t.Error("a cluster member was not asked for its engines")
+ }
+}
+
+// TestUnpairedHintAgreesWithTheModelList checks the engines pane does not deny
+// what the pane below it is already showing.
+//
+// Discovery carries which engine serves each model and needs no pairing to do
+// it, so an unpaired node's model list arrives with its ENGINE column filled
+// in. "Its engines are not visible from here", printed directly above a list
+// naming Ollama, claimed less than the screen displayed. What pairing actually
+// buys is their state and their controls.
+func TestUnpairedHintAgreesWithTheModelList(t *testing.T) {
+ d := remoteDetail()
+ d.node.membership = membershipNone
+ d.models = modelsResult{ModelsByEngine: map[string][]string{
+ "ollama": {"gemma3:4b", "gemma4:12b"},
+ }}
+ d.refreshModels()
+
+ hint := d.emptyEnginesHint()
+ if !strings.Contains(hint, "Ollama") {
+ t.Errorf("hint %q does not name the engine the model list attributes rows to", hint)
+ }
+ if !strings.Contains(hint, "manage") {
+ t.Errorf("hint %q does not say what pairing would actually add", hint)
+ }
+
+ // With nothing advertised there is nothing to name, and the sentence must
+ // not trail off into an empty list.
+ bare := remoteDetail()
+ bare.node.membership = membershipNone
+ if got := bare.emptyEnginesHint(); strings.Contains(got, "advertises") {
+ t.Errorf("hint %q claims advertised engines for a node reporting none", got)
+ }
+}
+
+// TestNodeKeysDoNotCollide checks no two verbs on the Nodes tab claim the same
+// key, and that none of them shadows a shell binding.
+//
+// A collision is silent: whichever case the switch reaches first wins and the
+// other verb simply stops working, with the footer still advertising it. The
+// risk is concentrated here because this tab has eleven verbs competing for one
+// letter each, and they have been renamed more than once — pair moved from i to
+// p and accept from p to a, which is exactly the edit that lands two verbs on
+// one key if the whole set is not considered at once.
+func TestNodeKeysDoNotCollide(t *testing.T) {
+ nodeKeys := map[string]key.Binding{
+ "details": nodeDetailKey,
+ "pair": nodeInviteKey,
+ "pair by address": nodeInviteAddrKey,
+ "find by address": nodeAddKey,
+ "remove": nodeRemoveKey,
+ "accept pairing": nodePairKey,
+ "decline": nodeDeclineKey,
+ "leave cluster": nodeLeaveKey,
+ "cancel invite": nodeCancelKey,
+ "filter": nodeFilterKey,
+ "confirm": nodeConfirmKey,
+ }
+
+ // esc is deliberately excluded: nodeClearKey shares it with the universal
+ // "get out of here" gesture, and both mean the same thing.
+ seen := map[string]string{}
+ for verb, binding := range nodeKeys {
+ for _, k := range binding.Keys() {
+ if other, dup := seen[k]; dup {
+ t.Errorf("key %q is bound to both %q and %q", k, other, verb)
+ }
+ seen[k] = verb
+ }
+ }
+
+ shell := newGlobalKeyMap(len(defaultViews(nil)))
+ for _, g := range []key.Binding{shell.NextTab, shell.PrevTab, shell.JumpTab, shell.Help, shell.Quit} {
+ for _, k := range g.Keys() {
+ if verb, clash := seen[k]; clash {
+ t.Errorf("node verb %q claims %q, which the shell uses for %q",
+ verb, k, g.Help().Desc)
+ }
+ }
+ }
+}
+
+// globalBindings is the shell's own key set, for the shift audit above, built
+// for the real tab count so the digit binding matches what ships.
+func globalBindings() []key.Binding {
+ k := newGlobalKeyMap(len(defaultViews(nil)))
+ return []key.Binding{k.NextTab, k.PrevTab, k.JumpTab, k.Help, k.Quit}
+}
+
+// TestDetailResizesWhenHardwareArrives is the regression guard for the status
+// line falling off the frame. The model table is sized against the hardware
+// block's height, and that height changes when a telemetry reading lands.
+func TestDetailResizesWhenHardwareArrives(t *testing.T) {
+ const budget = 20
+ d := localDetail()
+ d.engines = []engineStatus{{Engine: "ollama", Installed: true, Running: true}}
+ models := make([]string, 30)
+ for i := range models {
+ models[i] = "model-" + string(rune('a'+i%26))
+ }
+ d.models = modelsResult{Models: models, ModelsByEngine: map[string][]string{"ollama": models}}
+ d.SetSize(100, budget)
+ d.refreshEngines()
+ d.refreshModels()
+ d.status.error("something to push off the bottom")
+
+ if got := renderedRows(d.View()); got > budget {
+ t.Fatalf("setup already overflows: %d rows into %d", got, budget)
+ }
+
+ // A dual-GPU reading is four hardware lines instead of the unavailable one.
+ d.update(nodeTelemetryMsg{nodeKey: d.node.key, gen: d.telemetryGen, telemetry: nodeTelemetry{
+ TelemetryValid: true,
+ GPUs: []noderec.GPUInfo{
+ {Name: "GPU 0", VramBytes: 1 << 30},
+ {Name: "GPU 1", VramBytes: 1 << 30},
+ },
+ CPU: &noderec.CPUInfo{Name: "CPU", Cores: 8},
+ Memory: &noderec.MemoryInfo{TotalBytes: 1 << 34, UsedBytes: 1 << 33},
+ }})
+
+ if d.hardwareHeight() < 4 {
+ t.Errorf("hardwareHeight = %d, want one row per reading", d.hardwareHeight())
+ }
+ // The frame is what matters, not any particular table's height. Asserting on
+ // the height instead measured a value SetSize wrote and the renderer then
+ // overwrote — so it passed whether or not the frame actually fit.
+ if got := renderedRows(d.View()); got > budget {
+ t.Errorf("rendered %d rows into %d after the hardware block grew; "+
+ "the shell will delete the status line", got, budget)
+ }
+ if !contains(d.View(), "something to push off the bottom") {
+ t.Error("the status line was squeezed out by the hardware block")
+ }
+}
+
+// TestDetailResizesCatalogBrowser checks the browser follows a terminal resize
+// rather than keeping the size it was opened at.
+func TestDetailResizesCatalogBrowser(t *testing.T) {
+ d := localDetail()
+ d.engines = []engineStatus{{Engine: "ollama", Installed: true, Running: true}}
+ d.refreshEngines()
+ d.openCatalog()
+ if d.catalog == nil {
+ t.Fatal("catalog did not open")
+ }
+
+ d.SetSize(140, 40)
+ if d.catalog.width != 140 || d.catalog.height != 40 {
+ t.Errorf("catalog is %dx%d after resize, want 140x40", d.catalog.width, d.catalog.height)
+ }
+}
+
+// TestModelSelectionSurvivesARebuild is the regression guard for a delete
+// landing on the wrong model.
+//
+// The list is sorted and rebuilt wholesale whenever a download finishes or a
+// peer republishes its inventory, so a row inserted above the cursor shifts
+// everything below it. An earlier attempt at this captured the selection after
+// the rebuild, which reads back whatever now sits at the old index — the very
+// row the cursor slid onto — so it restored nothing.
+func TestModelSelectionSurvivesARebuild(t *testing.T) {
+ d := localDetail()
+ d.models = modelsResult{
+ ModelsByEngine: map[string][]string{"ollama": {"bravo", "charlie", "delta"}},
+ }
+ d.refreshModels()
+ d.modelTable.SetCursor(1)
+ if got := d.selectedModel(); got == nil || got.model != "charlie" {
+ t.Fatalf("setup: selected %v", got)
+ }
+
+ // A background pull lands "alpha", which sorts first.
+ d.models = modelsResult{
+ ModelsByEngine: map[string][]string{"ollama": {"alpha", "bravo", "charlie", "delta"}},
+ }
+ d.refreshModels()
+
+ got := d.selectedModel()
+ if got == nil {
+ t.Fatal("nothing selected after the rebuild")
+ }
+ if got.model != "charlie" {
+ t.Errorf("selection slid to %q; a delete would now destroy the wrong model", got.model)
+ }
+}
+
+// TestActionsWorkAfterAnEmptyRefresh is the regression guard for keys that went
+// dead on the normal startup path.
+//
+// bubbles clamps an out-of-range cursor to len-1, so a table handed zero rows
+// lands on -1 and stays there — refilling it never moves a cursor already below
+// the range. A local node's inventory is empty until engine:models replies, so
+// every model action reported "no model selected" for the life of the screen.
+func TestActionsWorkAfterAnEmptyRefresh(t *testing.T) {
+ d := localDetail() // constructed with no inventory, as at startup
+
+ d.models = modelsResult{ModelsByEngine: map[string][]string{"ollama": {"a", "b"}}}
+ d.refreshModels()
+ if d.selectedModel() == nil {
+ t.Errorf("no model selected after the inventory arrived (cursor %d); "+
+ "load, eject, and delete are all dead", d.modelTable.Cursor())
+ }
+
+ // Same for the engines pane, which gates install/start/stop.
+ d.engines = []engineStatus{{Engine: "ollama", Installed: true}}
+ d.refreshEngines()
+ if d.selectedEngine() == nil {
+ t.Errorf("no engine selected after engines arrived (cursor %d)",
+ d.engineTable.Cursor())
+ }
+}
+
+// TestArmedActionOwnsTheKeyboard checks a pending confirmation cannot be
+// escaped by a global key, which would leave it armed behind an off-screen
+// prompt for whatever the operator pressed on returning.
+func TestArmedActionOwnsTheKeyboard(t *testing.T) {
+ d := localDetail()
+ d.models = modelsResult{ModelsByEngine: map[string][]string{"ollama": {"victim"}}}
+ d.refreshModels()
+ d.pane = detailModels
+
+ d.deleteSelectedModel()
+ if d.pending == nil {
+ t.Fatal("delete did not arm")
+ }
+ if !d.CapturingInput() {
+ t.Error("an armed action does not capture input, so tab or a digit escapes it")
+ }
+ if !contains(d.status.render(), "press y to confirm") {
+ t.Error("the confirmation prompt is not on screen")
+ }
+ // The prompt must not expire out from under the armed state.
+ if d.status.expired() {
+ t.Error("the confirmation prompt expires while the action stays armed")
+ }
+}
+
+// TestArmedActionRunsOnConfirmAndNotOtherwise covers the half of the gate that
+// was never tested: that `y` actually runs the captured action and that any
+// other key abandons it. Arming was covered; confirming was not, on the one
+// screen whose arming implementation the others were copied from.
+func TestArmedActionRunsOnConfirmAndNotOtherwise(t *testing.T) {
+ build := func() *nodeDetail {
+ d := localDetail()
+ d.models = modelsResult{ModelsByEngine: map[string][]string{"ollama": {"victim"}}}
+ d.refreshModels()
+ d.pane = detailModels
+ d.deleteSelectedModel()
+ return d
+ }
+
+ // Any other key cancels, and says so.
+ d := build()
+ if cmd := d.handleKeyForTest(t, "n"); cmd != nil {
+ t.Error("a non-confirming key ran the destructive action")
+ }
+ if d.pending != nil {
+ t.Error("the action stayed armed after being cancelled")
+ }
+ if !contains(d.status.render(), "cancelled") {
+ t.Errorf("cancelling said nothing: %q", d.status.render())
+ }
+
+ // y runs it and disarms.
+ d = build()
+ if cmd := d.handleKeyForTest(t, "y"); cmd == nil {
+ t.Error("confirming produced no command; the delete never ran")
+ }
+ if d.pending != nil {
+ t.Error("the action stayed armed after being confirmed")
+ }
+}
+
+// TestUninstallRefusedOnAPeerBeforeArming checks the refusal comes before the
+// confirmation, not after it. Staging a gigabyte-destroying prompt and then
+// answering that the operation is unavailable is worse than refusing outright.
+func TestUninstallRefusedOnAPeerBeforeArming(t *testing.T) {
+ d := remoteDetail()
+ d.engines = []engineStatus{{Engine: "ollama", Installed: true}}
+ d.refreshEngines()
+ d.pane = detailEngines
+
+ d.handleKeyForTest(t, "u")
+
+ if d.pending != nil {
+ t.Error("armed a confirmation for an uninstall that cannot run on a peer")
+ }
+ if !contains(d.status.render(), "machine running it") {
+ t.Errorf("no explanation given: %q", d.status.render())
+ }
+}
+
+// handleKeyForTest presses one key through the detail screen's key handling.
+func (d *nodeDetail) handleKeyForTest(t *testing.T, k string) tea.Cmd {
+ t.Helper()
+ cmd, _ := d.handleKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(k)})
+ return cmd
+}
+
+func TestParsePort(t *testing.T) {
+ valid := map[string]int{"1": 1, "11434": 11434, "65535": 65535}
+ for in, want := range valid {
+ if got, ok := parsePort(in); !ok || got != want {
+ t.Errorf("parsePort(%q) = %d, %v", in, got, ok)
+ }
+ }
+ for _, in := range []string{"", "0", "-1", "65536", "abc", "80x"} {
+ if _, ok := parsePort(in); ok {
+ t.Errorf("parsePort(%q) accepted an invalid port", in)
+ }
+ }
+}
+
+// TestServiceTabNoLongerOffersPorts checks the ports are configured in exactly
+// one place, not two.
+func TestServiceTabNoLongerOffersPorts(t *testing.T) {
+ v := newServiceView(nil)
+ for _, it := range v.items {
+ if strings.Contains(strings.ToLower(it.label), "port") {
+ t.Errorf("Service tab still offers %q; ports belong on the node", it.label)
+ }
+ }
+}
diff --git a/services/nvpair-tui/ui/nodenames.go b/services/nvpair-tui/ui/nodenames.go
new file mode 100644
index 00000000..f655afb9
--- /dev/null
+++ b/services/nvpair-tui/ui/nodenames.go
@@ -0,0 +1,96 @@
+// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+package ui
+
+import (
+ "nvpair-tui/rpc"
+
+ tea "github.com/charmbracelet/bubbletea"
+)
+
+// unknownNodeLabel stands in for a node reference with no value at all.
+const unknownNodeLabel = "-"
+
+// shortNodeIDLen is how much of an unresolved UUID to show. Enough to tell two
+// nodes apart and to match against a full id elsewhere, without letting one
+// column eat the row.
+const shortNodeIDLen = 8
+
+// nodeNamer resolves the stable node UUIDs the backend stamps onto records into
+// names an operator recognises.
+//
+// Several payloads identify a node by UUID rather than by name: a workload's
+// originatedFrom and scheduledOn (the broker stamps its resolveLocalNodeID,
+// which is nodeid.Resolve), and a service error's nodeId. Rendering those raw
+// shows the operator a random-looking string. Discovery already carries the
+// mapping — hostUuid alongside name — so nothing extra has to be fetched, only
+// remembered.
+//
+// Names are retained once learned rather than dropped when a node leaves the
+// discovery snapshot: a completed job outlives the reachability of the machine
+// that ran it, and "the node formerly known as 3f2a…" is not an improvement.
+type nodeNamer struct {
+ names map[string]string
+ selfUUID string
+}
+
+func newNodeNamer() *nodeNamer {
+ return &nodeNamer{names: map[string]string{}}
+}
+
+// identityCmd resolves this machine's own UUID and name, which discovery does
+// not necessarily report for the local host.
+func nodeIdentityCmd(client *rpc.Client, finish func(clusterIdentity, error) tea.Msg) tea.Cmd {
+ return call(client, "cluster:get-node-id", nil, func(msg *rpc.Message, err error) tea.Msg {
+ if err != nil {
+ return finish(clusterIdentity{}, err)
+ }
+ var id clusterIdentity
+ _ = decodeParams(msg.Result, &id)
+ return finish(id, nil)
+ })
+}
+
+func (n *nodeNamer) learnDiscovered(nodes []availableNode) {
+ for _, d := range nodes {
+ n.learn(d.HostUUID, d.Name)
+ }
+}
+
+func (n *nodeNamer) learnMembers(nodes []clusterNode) {
+ for _, m := range nodes {
+ n.learn(m.NodeUUID, m.Name)
+ }
+}
+
+// setSelf records this machine, so its own jobs read as a name rather than as
+// the one UUID the operator is guaranteed to see most often.
+func (n *nodeNamer) setSelf(id clusterIdentity) {
+ n.selfUUID = id.NodeUUID
+ name := id.Name
+ if name == "" {
+ name = id.NodeID
+ }
+ n.learn(id.NodeUUID, name)
+}
+
+func (n *nodeNamer) learn(uuid, name string) {
+ if uuid == "" || name == "" {
+ return
+ }
+ n.names[uuid] = name
+}
+
+// name renders a node reference for display.
+func (n *nodeNamer) name(uuid string) string {
+ if uuid == "" {
+ return unknownNodeLabel
+ }
+ if known, ok := n.names[uuid]; ok {
+ return known
+ }
+ // An unresolved id still has to be distinguishable, and truncating marks it
+ // as an id rather than passing a fragment off as a name.
+ return truncate(uuid, shortNodeIDLen)
+}
diff --git a/services/nvpair-tui/ui/nodenames_test.go b/services/nvpair-tui/ui/nodenames_test.go
new file mode 100644
index 00000000..8ff566b6
--- /dev/null
+++ b/services/nvpair-tui/ui/nodenames_test.go
@@ -0,0 +1,209 @@
+// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+package ui
+
+import (
+ "encoding/json"
+ "strings"
+ "testing"
+ "unicode/utf8"
+
+ "nvpair-tui/rpc"
+)
+
+// identityChanged builds the cluster:identity-changed push the manager emits.
+func identityChanged(id, friendly string) *rpc.Message {
+ params, _ := json.Marshal(map[string]string{
+ "clusterId": id,
+ "clusterFriendlyName": friendly,
+ })
+ return &rpc.Message{Method: "cluster:identity-changed", Params: params}
+}
+
+// A realistic value: the broker stamps its resolveLocalNodeID, a nodeid UUID.
+const sampleNodeUUID = "3f2a91c4-7b1e-4d55-9a02-8c6f1e2b7d40"
+
+// TestNamerResolvesFromDiscovery is the regression guard for the jobs list
+// showing a random-looking string: discovery carries hostUuid alongside name, so
+// the id the workload manager reports is resolvable without any extra request.
+func TestNamerResolvesFromDiscovery(t *testing.T) {
+ n := newNodeNamer()
+ if got := n.name(sampleNodeUUID); got == "workstation-01" {
+ t.Fatal("resolved before learning anything")
+ }
+
+ n.learnDiscovered([]availableNode{{HostUUID: sampleNodeUUID, Name: "workstation-01"}})
+ if got := n.name(sampleNodeUUID); got != "workstation-01" {
+ t.Errorf("name = %q, want the discovered name", got)
+ }
+}
+
+// TestNamerResolvesFromMembership covers a peer known from the cluster roster
+// but absent from the current discovery snapshot.
+func TestNamerResolvesFromMembership(t *testing.T) {
+ n := newNodeNamer()
+ n.learnMembers([]clusterNode{{NodeUUID: sampleNodeUUID, Name: "peer-a"}})
+ if got := n.name(sampleNodeUUID); got != "peer-a" {
+ t.Errorf("name = %q, want the member name", got)
+ }
+}
+
+// TestNamerFallsBackToShortID checks an unresolved id is shortened rather than
+// printed in full, and is still distinguishable from a real name.
+func TestNamerFallsBackToShortID(t *testing.T) {
+ n := newNodeNamer()
+ got := n.name(sampleNodeUUID)
+
+ if got == sampleNodeUUID {
+ t.Error("unresolved id rendered in full")
+ }
+ // Counted in runes: the truncation marker is multi-byte, and the budget is
+ // about how many columns the cell occupies.
+ if width := utf8.RuneCountInString(got); width > shortNodeIDLen {
+ t.Errorf("fallback %q is %d runes, over the %d-column budget", got, width, shortNodeIDLen)
+ }
+ if !strings.HasPrefix(sampleNodeUUID, strings.TrimSuffix(got, "…")) {
+ t.Errorf("fallback %q is not a prefix of the id", got)
+ }
+}
+
+func TestNamerHandlesEmptyReference(t *testing.T) {
+ n := newNodeNamer()
+ if got := n.name(""); got != unknownNodeLabel {
+ t.Errorf("empty reference = %q, want %q", got, unknownNodeLabel)
+ }
+}
+
+// TestNamerRetainsNamesAfterNodeLeaves checks a completed job keeps a readable
+// origin after the machine that ran it drops out of discovery.
+func TestNamerRetainsNamesAfterNodeLeaves(t *testing.T) {
+ n := newNodeNamer()
+ n.learnDiscovered([]availableNode{{HostUUID: sampleNodeUUID, Name: "workstation-01"}})
+ n.learnDiscovered(nil) // node gone from the snapshot
+
+ if got := n.name(sampleNodeUUID); got != "workstation-01" {
+ t.Errorf("name = %q; a finished job outlives its node's reachability", got)
+ }
+}
+
+// TestNamerIgnoresBlankLearnings checks a payload missing either half does not
+// poison the map with an empty name.
+func TestNamerIgnoresBlankLearnings(t *testing.T) {
+ n := newNodeNamer()
+ n.learnDiscovered([]availableNode{
+ {HostUUID: sampleNodeUUID, Name: ""},
+ {HostUUID: "", Name: "nameless"},
+ })
+ if got := n.name(sampleNodeUUID); got == "" {
+ t.Error("resolved to an empty name")
+ }
+}
+
+func TestNamerSelf(t *testing.T) {
+ n := newNodeNamer()
+ n.setSelf(clusterIdentity{NodeUUID: sampleNodeUUID, Name: "this-host"})
+
+ if got := n.name(sampleNodeUUID); got != "this-host" {
+ t.Errorf("self name = %q", got)
+ }
+ // Falls back to nodeId when the manager reports no friendly name.
+ n2 := newNodeNamer()
+ n2.setSelf(clusterIdentity{NodeUUID: "u", NodeID: "host-b"})
+ if got := n2.name("u"); got != "host-b" {
+ t.Errorf("name = %q, want the nodeId fallback", got)
+ }
+}
+
+// TestJobsRendersNodeNames is the end-to-end guard: a job's origin and target
+// must reach the table as names, not as the UUIDs the backend stamps.
+func TestJobsRendersNodeNames(t *testing.T) {
+ v := newJobsView(nil)
+ v.namer.learnDiscovered([]availableNode{
+ {HostUUID: "origin-uuid", Name: "laptop"},
+ {HostUUID: "target-uuid", Name: "gpu-box"},
+ })
+ v.upsert(workload{
+ ID: "w1",
+ Model: "llama3.2",
+ Engine: "ollama",
+ State: "running",
+ OriginatedFrom: "origin-uuid",
+ ScheduledOn: "target-uuid",
+ })
+
+ rows := v.table.Rows()
+ if len(rows) != 1 {
+ t.Fatalf("got %d rows", len(rows))
+ }
+ if rows[0][3] != "laptop" {
+ t.Errorf("FROM = %q, want laptop", rows[0][3])
+ }
+ if rows[0][4] != "gpu-box" {
+ t.Errorf("RAN ON = %q, want gpu-box", rows[0][4])
+ }
+}
+
+// TestJobsUnplacedWorkShowsPending checks an active job with no target yet says
+// so, instead of rendering a blank that reads as "ran nowhere".
+func TestJobsUnplacedWorkShowsPending(t *testing.T) {
+ v := newJobsView(nil)
+
+ v.upsert(workload{ID: "w1", State: "queued", OriginatedFrom: "o"})
+ if got := v.ranOn(v.byKey[workloadKey("o", "w1")]); got == unknownNodeLabel {
+ t.Error("an active unplaced job should say a node is being chosen")
+ }
+
+ v.upsert(workload{ID: "w2", State: "completed", OriginatedFrom: "o"})
+ if got := v.ranOn(v.byKey[workloadKey("o", "w2")]); got != unknownNodeLabel {
+ t.Errorf("a finished job with no target = %q, want %q", got, unknownNodeLabel)
+ }
+}
+
+// TestClusterLabelIsShown is the guard for a write-only setting: the cluster
+// name must appear somewhere once set, or naming a cluster has no visible
+// effect anywhere in the interface.
+func TestClusterLabelIsShown(t *testing.T) {
+ v := newNodesView(nil)
+ v.identity = clusterIdentity{ClusterID: "abcdef0123456789", Name: "host-a"}
+
+ // With no label, the id stands in — it is what anything operational uses.
+ if got := v.clusterLine(); !contains(got, "abcdef") {
+ t.Errorf("cluster line %q shows neither a label nor the id", got)
+ }
+
+ v.clusterName = "Lab 3 desks"
+ got := v.clusterLine()
+ if !contains(got, "Lab 3 desks") {
+ t.Errorf("cluster line %q omits the label that was set", got)
+ }
+}
+
+// TestClusterLabelFromIdentityPush checks the label follows the notification, so
+// renaming on one machine is reflected without a restart.
+func TestClusterLabelFromIdentityPush(t *testing.T) {
+ v := newNodesView(nil)
+ v.Update(NotificationMsg{Msg: identityChanged("cid-1", "Lab 3 desks")})
+
+ if v.clusterName != "Lab 3 desks" {
+ t.Errorf("clusterName = %q after the push", v.clusterName)
+ }
+ if v.identity.ClusterID != "cid-1" {
+ t.Errorf("clusterId = %q after the push", v.identity.ClusterID)
+ }
+}
+
+// TestServiceHidesUnusedSettings checks the two settings nothing acts on are not
+// offered, so the list does not imply an effect they do not have.
+func TestServiceHidesUnusedSettings(t *testing.T) {
+ v := newServiceView(nil)
+ for _, it := range v.items {
+ switch it.suffix {
+ case "force-ports", "cluster-auto-sync":
+ t.Errorf("%q is shown but nothing acts on it", it.label)
+ }
+ }
+ if len(v.items) == 0 {
+ t.Fatal("no configuration rows at all")
+ }
+}
diff --git a/services/nvpair-tui/ui/nodes.go b/services/nvpair-tui/ui/nodes.go
index 8c8db2e7..7964938a 100644
--- a/services/nvpair-tui/ui/nodes.go
+++ b/services/nvpair-tui/ui/nodes.go
@@ -5,247 +5,1277 @@ package ui
import (
"fmt"
+ "net"
+ "sort"
"strconv"
+ "strings"
"time"
"nvpair-tui/rpc"
"github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/bubbles/table"
+ "github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
)
-// availableNode mirrors the broker's discovery boundary shape (the
-// discovery:get-nodes element and discovery:nodes-changed payload entry).
-type availableNode struct {
- ID string `json:"id"`
- HostUUID string `json:"hostUuid"`
- Name string `json:"name"`
- IPAddress string `json:"ipAddress"`
- Port int `json:"port"`
- LastSeen int64 `json:"lastSeen"` // Unix seconds
- // Trusted: this node is a paired cluster peer of ours. Clustered: it belongs
- // to some cluster (advertises a cluster-uuid), whether or not we're paired
- // with it. Either one makes it non-invitable — an already-clustered peer
- // rejects a fresh pairing (it must leave/be removed first).
- Trusted bool `json:"trusted"`
- Clustered bool `json:"clustered"`
-}
-
-// key is the node's stable identity (hostUuid) for cluster actions; the name
-// column is display only. Every discovered node carries a hostUuid, so there is
-// no id fallback.
-func (n availableNode) key() string {
- return n.HostUUID
-}
-
-// nodesView shows mDNS-discovered Ollama nodes. It subscribes to the
-// discovery stream on start; the broker replays a baseline snapshot on
-// subscribe and then pushes discovery:nodes-changed (a full list) on
-// every change.
+// manualRefreshInterval re-lists manual nodes so the probe-driven reachability
+// columns stay current (nvpair-manual-nodes re-probes every 10s).
+const manualRefreshInterval = 10 * time.Second
+
+// nodesInputMode is which text field, if any, is currently capturing keys.
+type nodesInputMode int
+
+const (
+ nodesInputNone nodesInputMode = iota
+ nodesInputManualAddress
+ nodesInputInviteAddress
+ nodesInputPin
+ nodesInputFilter
+)
+
+// nodesView is the machine-centric surface: every node PAIR knows about,
+// whether discovered, added by hand, or paired into our cluster, in one table
+// with a detail pane for the selected row.
+//
+// It replaces the separate Nodes, Manual, and Cluster tabs. Those split one
+// machine's story across three places — its address in one, its membership in
+// another, its reachability in a third — and gave pairing two entry points with
+// different guards. A node is the unit an operator thinks in, so it is the unit
+// the tab is built around.
type nodesView struct {
- client *rpc.Client
- table table.Model
- nodes []availableNode
- status string
+ client *rpc.Client
+ table table.Model
+
+ feeds nodeFeeds
+ rows []nodeRow
+
+ // selectedKey tracks the highlighted node by identity, not row index. The
+ // list re-sorts as nodes come and go, and an index would silently move the
+ // operator's selection onto a different machine between keypresses.
+ selectedKey string
+ // detail is the open drill-down for one node, nil when the list is showing.
+ detail *nodeDetail
+
+ identity clusterIdentity
+ // clusterName is the cluster's display label. It is a separate field because
+ // cluster:get-node-id does not return it: it is a node setting, and arrives
+ // either from settings/get-cluster-friendly-name or on the
+ // cluster:identity-changed push. Without showing it here the setting was
+ // write-only — you could name a cluster and never see the name again.
+ clusterName string
+ // inbound is the most recent pairing request awaiting our answer.
+ inbound *clusterInvite
+ // invitedKey is the node an outbound invite is pending against, held so the
+ // pinned PIN can be retired once that node turns up trusted. Empty for an
+ // invite sent by address, which has no node identity to key on — see
+ // invitedAddress.
+ invitedKey string
+ // invitedAddress is the host an invite-by-address is pending against. That
+ // path has no UUID, so the joined peer is recognised by its address instead;
+ // without it the PIN it pinned was never retired.
+ invitedAddress string
+ // outboundInviteID is the invite our pending PIN belongs to. Terminal
+ // notifications carry an inviteId and the manager supports concurrent
+ // pairings, so an event is only ours if the ids match — otherwise an
+ // unrelated invite's decline cleared this one's PIN.
+ outboundInviteID string
+ // confirmLeave and confirmRemove gate the two trust teardowns behind a
+ // second keystroke. Both keys are lowercase and sit beside the navigation
+ // keys, so a single press is too easy to hit by accident — and removing a
+ // member acts on someone else's row, which makes a misfire worse rather
+ // than better.
+ confirmLeave bool
+ // confirmRemove holds the node key awaiting confirmation, so the row cannot
+ // change underneath the confirmation.
+ confirmRemove string
+ // all is every node the merge produced; rows is the subset on screen. They
+ // differ only when a filter is set, and the distinction matters: the cluster
+ // summary and the pairing-completion check are about the cluster, not about
+ // what the operator is currently looking at.
+ all []nodeRow
+ // filter narrows the list by name or address. Empty shows everything.
+ filter string
+ // feedFailures maps a feed name to why it last failed. An empty table is
+ // ambiguous — nothing discovered yet, or nothing could be read — and the
+ // difference decides whether the operator waits or goes looking at the
+ // service, so it has to be on screen.
+ feedFailures map[string]string
+
+ input textinput.Model
+ mode nodesInputMode
+ status toast
+
width, height int
}
+// The feeds that populate this tab. Each can fail independently, and a failure
+// is reported by name because the consequences differ: no identity means this
+// machine cannot be told apart from its peers, while no manual list only hides
+// hand-added entries.
+const (
+ feedIdentity = "cluster identity"
+ feedMembers = "cluster members"
+ feedClusterName = "cluster name"
+ feedManual = "manual nodes"
+ feedDiscovery = "discovery"
+)
+
+// noteFeed records or clears a feed's failure.
+//
+// Held as state rather than announced as a toast because these are conditions,
+// not events: the manual list re-reads on a tick, so a toast per failure would
+// bury every other message while a worker is down, and a toast that expires
+// would leave the tab looking merely empty again.
+func (v *nodesView) noteFeed(name string, err error) {
+ if err == nil {
+ delete(v.feedFailures, name)
+ return
+ }
+ if v.feedFailures == nil {
+ v.feedFailures = map[string]string{}
+ }
+ v.feedFailures[name] = err.Error()
+}
+
+// feedWarning is the one-line summary of what could not be read, or "" when
+// everything is current.
+func (v *nodesView) feedWarning() string {
+ if len(v.feedFailures) == 0 {
+ return ""
+ }
+ names := make([]string, 0, len(v.feedFailures))
+ for name := range v.feedFailures {
+ names = append(names, name)
+ }
+ sort.Strings(names)
+ // One representative reason: the failures almost always share a cause (the
+ // worker behind them is down), and repeating it per feed would push the
+ // table off a short terminal.
+ return fmt.Sprintf("unavailable: %s (%s)",
+ strings.Join(names, ", "), v.feedFailures[names[0]])
+}
+
type discoverySubscribedMsg struct{ err error }
-// nodeInviteMsg carries the outcome of a cluster:invite-node fired from the
-// Nodes tab: the PIN to read to the joining node, an explicit rejection from an
-// already-clustered peer, or the failure to surface.
+// engineSubscribedMsg is the ack for the engine push stream. A failure is
+// surfaced because everything on a node's detail screen goes stale without it.
+type engineSubscribedMsg struct{ err error }
+
+type clusterIdentityMsg struct {
+ id clusterIdentity
+ err error
+}
+
+type clusterMembersMsg struct {
+ nodes []clusterNode
+ err error
+}
+
+// clusterNameMsg carries the cluster's display label.
+type clusterNameMsg struct {
+ name string
+ err error
+}
+
+type manualNodesMsg struct {
+ nodes []manualNode
+ err error
+}
+
+type manualTickMsg struct{}
+
+// nodeActionMsg is the outcome of any single-shot node command.
+type nodeActionMsg struct {
+ what string
+ err error
+}
+
+// nodeInviteMsg carries the outcome of a cluster:invite-node: the PIN to read
+// to the joining node, an explicit rejection, or the failure to surface.
type nodeInviteMsg struct {
- name string
+ name string
+ // inviteID identifies the session, so a later terminal notification can be
+ // matched to the PIN this result pinned.
+ inviteID string
+ // address is set when the invite went out by address rather than to a
+ // discovered node, which is how the joined peer is recognised later.
+ address string
pin string
rejected bool
reason string
err error
}
-var niInviteKey = key.NewBinding(key.WithKeys("i"), key.WithHelp("i", "invite to cluster"))
+// Key labels distinguish the two things an address can be used for, which are
+// easily confused: "pair" establishes mutual trust and needs the other side to
+// accept a PIN, while "find" only teaches this node an address so a machine mDNS
+// cannot see becomes visible. Neither implies the other.
+var (
+ nodeDetailKey = key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "details"))
+ // p pairs and a accepts, matching the words on screen. The verb everywhere
+ // in this UI is "pair", so the key that starts one is p; i only ever made
+ // sense against "invite", which nothing says any more.
+ nodeInviteKey = key.NewBinding(key.WithKeys("p"), key.WithHelp("p", "pair"))
+ nodeInviteAddrKey = key.NewBinding(key.WithKeys("n"), key.WithHelp("n", "pair by address"))
+ // f rather than a, which accepting took. The bubbles table binds f to
+ // page-down, so this is the third verb on this tab to win a key from the
+ // table's paging — d and l already do — and the paging key still works on
+ // every row action that does not apply.
+ nodeAddKey = key.NewBinding(key.WithKeys("f"), key.WithHelp("f", "find by address"))
+ nodeRemoveKey = key.NewBinding(key.WithKeys("r"), key.WithHelp("r", "remove"))
+ nodePairKey = key.NewBinding(key.WithKeys("a"), key.WithHelp("a", "accept pairing"))
+ nodeDeclineKey = key.NewBinding(key.WithKeys("d"), key.WithHelp("d", "decline"))
+ nodeLeaveKey = key.NewBinding(key.WithKeys("l"), key.WithHelp("l", "leave cluster"))
+ nodeCancelKey = key.NewBinding(key.WithKeys("c"), key.WithHelp("c", "cancel invite"))
+ nodeFilterKey = key.NewBinding(key.WithKeys("/"), key.WithHelp("/", "filter"))
+ nodeClearKey = key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "clear filter"))
+ nodeConfirmKey = key.NewBinding(key.WithKeys("y"), key.WithHelp("y", "confirm"))
+)
func newNodesView(client *rpc.Client) *nodesView {
- v := &nodesView{client: client}
- v.table = newTable(nil)
+ ti := textinput.New()
+ v := &nodesView{client: client, input: ti}
+ v.table = newTable(nodesColumns(defaultTableWidth))
return v
}
+// nodesColumns is the node table's layout, shared by construction and resize so
+// the two cannot drift. STATUS and CLUSTER are separate on purpose: reachability
+// and membership are independent facts, and merging them made a departed member
+// read as connected.
+//
+// There is deliberately no "last seen" column. The only timestamp discovery
+// carries is the moment a record was last written, which nvpair-node-scanner
+// documents as explicitly not a liveness clock: the browser reports a node only
+// when its record changes, so a healthy peer's timestamp freezes at first
+// discovery, and the local node's advances only when it republishes. Rendered as
+// an age it invited exactly the wrong reading — a steadily climbing number
+// beside "this machine", whose reachability is never in question. STATUS is the
+// reachability verdict, and it has better evidence behind it.
+func nodesColumns(w int) []table.Column {
+ return layoutColumns(w, []column{
+ flexCol("NAME", 10, 2),
+ flexCol("ADDRESS", 10, 2),
+ fixedCol("STATUS", 7),
+ fixedCol("CLUSTER", 13),
+ fixedCol("MODELS", 6),
+ })
+}
+
func (v *nodesView) Title() string { return "Nodes" }
func (v *nodesView) Init() tea.Cmd {
- return call(v.client, "discovery:subscribe", nil, func(_ *rpc.Message, err error) tea.Msg {
- return discoverySubscribedMsg{err: err}
+ return tea.Batch(
+ call(v.client, "discovery:subscribe", nil, func(_ *rpc.Message, err error) tea.Msg {
+ return discoverySubscribedMsg{err: err}
+ }),
+ // The engine push stream is opt-in and off by default: without this the
+ // broker discards every engine:state-changed, engine:models-changed,
+ // and install/pull/remote progress notification, so a node's detail
+ // screen would show a one-shot snapshot that never updates and no
+ // progress would ever appear. Subscribed here, once, because this tab
+ // owns the detail screens that consume those pushes.
+ call(v.client, "engine:subscribe", nil, func(_ *rpc.Message, err error) tea.Msg {
+ return engineSubscribedMsg{err: err}
+ }),
+ v.identityCmd(),
+ v.clusterNameCmd(),
+ v.membersCmd(),
+ v.manualCmd(),
+ v.manualTickCmd(),
+ )
+}
+
+func (v *nodesView) clusterNameCmd() tea.Cmd {
+ return call(v.client, "settings/get-cluster-friendly-name", nil,
+ func(msg *rpc.Message, err error) tea.Msg {
+ if err != nil {
+ return clusterNameMsg{err: err}
+ }
+ var r struct {
+ Value string `json:"value"`
+ }
+ _ = decodeParams(msg.Result, &r)
+ return clusterNameMsg{name: r.Value}
+ })
+}
+
+func (v *nodesView) identityCmd() tea.Cmd {
+ return call(v.client, "cluster:get-node-id", nil, func(msg *rpc.Message, err error) tea.Msg {
+ if err != nil {
+ return clusterIdentityMsg{err: err}
+ }
+ var id clusterIdentity
+ _ = decodeParams(msg.Result, &id)
+ return clusterIdentityMsg{id: id}
+ })
+}
+
+func (v *nodesView) membersCmd() tea.Cmd {
+ return call(v.client, "nodes:get-initial", nil, func(msg *rpc.Message, err error) tea.Msg {
+ if err != nil {
+ return clusterMembersMsg{err: err}
+ }
+ var r struct {
+ Nodes []clusterNode `json:"nodes"`
+ }
+ _ = decodeParams(msg.Result, &r)
+ return clusterMembersMsg{nodes: r.Nodes}
})
}
+func (v *nodesView) manualCmd() tea.Cmd {
+ return call(v.client, "nodes/list", nil, func(msg *rpc.Message, err error) tea.Msg {
+ if err != nil {
+ return manualNodesMsg{err: err}
+ }
+ var r struct {
+ Nodes []manualNode `json:"nodes"`
+ }
+ _ = decodeParams(msg.Result, &r)
+ return manualNodesMsg{nodes: r.Nodes}
+ })
+}
+
+func (v *nodesView) manualTickCmd() tea.Cmd {
+ return tea.Tick(manualRefreshInterval, func(time.Time) tea.Msg { return manualTickMsg{} })
+}
+
+// SetSize records the budget and fixes the table's width. Its height is set in
+// View, from the chrome actually being rendered — see fitTable.
func (v *nodesView) SetSize(w, h int) {
v.width, v.height = w, h
- const port, age, status = 7, 8, 11
- name := clampWidth((w-port-age-status-2)/2, 10)
- addr := clampWidth(w-port-age-status-name-2, 10)
- v.table.SetColumns([]table.Column{
- {Title: "NAME", Width: name},
- {Title: "ADDRESS", Width: addr},
- {Title: "PORT", Width: port},
- {Title: "SEEN", Width: age},
- {Title: "STATUS", Width: status},
- })
+ v.table.SetColumns(nodesColumns(w))
v.table.SetWidth(w)
- v.table.SetHeight(clampWidth(h-1, 1))
+ if v.detail != nil {
+ v.detail.SetSize(w, h)
+ }
+}
+
+// CapturingInput reports a text field having the keyboard, in the list or in an
+// open detail screen, so the shell stops applying its global bindings.
+func (v *nodesView) CapturingInput() bool {
+ if v.detail != nil {
+ return v.detail.CapturingInput()
+ }
+ // An armed teardown answers the next key too. Without this the shell's own
+ // bindings still fired, so tab or a digit switched away and left the action
+ // armed behind a prompt no longer on screen — to be confirmed by whatever
+ // the operator pressed on returning to the tab.
+ return v.mode != nodesInputNone || v.confirmLeave || v.confirmRemove != ""
}
func (v *nodesView) Update(msg tea.Msg) tea.Cmd {
+ // An open detail screen owns the keyboard. Everything else still reaches
+ // the list underneath so its state is current when the operator returns,
+ // and reaches the detail too so engine and model pushes land there.
+ if v.detail != nil {
+ if _, isKey := msg.(tea.KeyMsg); isKey {
+ cmd, stayOpen := v.detail.update(msg)
+ if !stayOpen {
+ v.detail = nil
+ v.SetSize(v.width, v.height)
+ }
+ return cmd
+ }
+ detailCmd, _ := v.detail.update(msg)
+ if detailCmd != nil {
+ return tea.Batch(detailCmd, v.updateList(msg))
+ }
+ }
+ return v.updateList(msg)
+}
+
+func (v *nodesView) updateList(msg tea.Msg) tea.Cmd {
switch msg := msg.(type) {
case discoverySubscribedMsg:
+ // Recorded as well as announced: without discovery the list simply stops
+ // filling, and by the time the operator wonders why, the toast is gone.
+ v.noteFeed(feedDiscovery, msg.err)
if msg.err != nil {
- v.status = "discovery subscribe failed: " + msg.err.Error()
+ v.status.error("discovery subscribe failed: %s", msg.err)
}
return nil
- case NotificationMsg:
- if msg.Msg.Method == "discovery:nodes-changed" {
- var nodes []availableNode
- _ = decodeParams(msg.Msg.Params, &nodes)
- v.setNodes(nodes)
+ case engineSubscribedMsg:
+ if msg.err != nil {
+ v.status.error("engine updates unavailable: %s", msg.err)
}
return nil
- case nodeInviteMsg:
+ case clusterIdentityMsg:
+ v.noteFeed(feedIdentity, msg.err)
+ if msg.err == nil {
+ v.identity = msg.id
+ v.feeds.selfUUID = msg.id.NodeUUID
+ v.rebuild()
+ }
+ return nil
+
+ case clusterMembersMsg:
+ v.noteFeed(feedMembers, msg.err)
+ if msg.err == nil {
+ v.feeds.members = msg.nodes
+ v.rebuild()
+ }
+ return nil
+
+ case clusterNameMsg:
+ v.noteFeed(feedClusterName, msg.err)
+ if msg.err == nil {
+ v.clusterName = msg.name
+ }
+ return nil
+
+ case manualNodesMsg:
+ v.noteFeed(feedManual, msg.err)
+ if msg.err == nil {
+ v.feeds.manual = msg.nodes
+ v.rebuild()
+ }
+ return nil
+
+ case manualTickMsg:
+ return tea.Batch(v.manualCmd(), v.manualTickCmd())
+
+ case TickMsg:
+ // Relative ages and presence both derive from the clock, so a node
+ // going quiet has to re-grade without waiting for a broker push.
+ v.rebuild()
+ return nil
+
+ case nodeActionMsg:
if msg.err != nil {
- v.status = "invite failed: " + msg.err.Error()
- } else if msg.rejected {
- v.status = fmt.Sprintf("%s rejected the invite (%s) - remove the existing relationship first", msg.name, rejectReason(msg.reason))
- } else if msg.pin != "" {
- v.status = fmt.Sprintf("invite sent to %s - PIN %s (read it to that node)", msg.name, msg.pin)
+ v.status.error("%s failed: %s", msg.what, msg.err)
} else {
- v.status = "invite sent to " + msg.name
+ v.status.ok("%s ok", msg.what)
}
- return nil
+ return tea.Batch(v.membersCmd(), v.manualCmd())
+
+ case nodeInviteMsg:
+ return v.handleInviteResult(msg)
+
+ case pairingResultMsg:
+ return v.handlePairingResult(msg)
+
+ case NotificationMsg:
+ return v.handleNotification(msg.Msg)
case tea.KeyMsg:
- if key.Matches(msg, niInviteKey) {
- return v.inviteSelected()
+ return v.handleKey(msg)
+ }
+ return nil
+}
+
+func (v *nodesView) handleInviteResult(msg nodeInviteMsg) tea.Cmd {
+ switch {
+ case msg.err != nil:
+ v.clearOutboundInvite()
+ v.status.error("invite failed: %s", msg.err)
+ case msg.rejected:
+ v.clearOutboundInvite()
+ v.status.error("%s rejected the invite (%s) - remove the existing relationship first",
+ msg.name, rejectReason(msg.reason))
+ case msg.pin != "":
+ // Pinned, not expiring: the operator reads this PIN to someone at the
+ // other machine. It clears when the invite resolves.
+ //
+ // The id is recorded so a terminal notification can be matched to this
+ // session, and the address so an invite sent by address — which has no
+ // node identity — can still recognise the peer once it joins.
+ v.outboundInviteID = msg.inviteID
+ v.invitedAddress = msg.address
+ v.status.pin("invite sent to %s - PIN %s (read it to that node)", msg.name, msg.pin)
+ default:
+ v.status.ok("invite sent to %s", msg.name)
+ }
+ return nil
+}
+
+// clearOutboundInvite forgets the pending outbound pairing session.
+func (v *nodesView) clearOutboundInvite() {
+ v.invitedKey = ""
+ v.invitedAddress = ""
+ v.outboundInviteID = ""
+}
+
+func (v *nodesView) handleNotification(msg *rpc.Message) tea.Cmd {
+ switch msg.Method {
+ case "discovery:nodes-changed":
+ var nodes []availableNode
+ _ = decodeParams(msg.Params, &nodes)
+ v.feeds.discovered = nodes
+ v.rebuild()
+
+ case "nodes:changed":
+ var r struct {
+ Nodes []clusterNode `json:"nodes"`
+ }
+ _ = decodeParams(msg.Params, &r)
+ v.feeds.members = r.Nodes
+ v.rebuild()
+
+ case "cluster:identity-changed":
+ var r struct {
+ ClusterID string `json:"clusterId"`
+ ClusterFriendlyName string `json:"clusterFriendlyName"`
+ }
+ _ = decodeParams(msg.Params, &r)
+ v.identity.ClusterID = r.ClusterID
+ v.clusterName = r.ClusterFriendlyName
+
+ case "cluster:invite-received":
+ var inv clusterInvite
+ _ = decodeParams(msg.Params, &inv)
+ v.inbound = &inv
+ // The prompt is a row of the frame, not a status line — see
+ // inboundPrompt. Pinning it here as well said the same thing twice, in
+ // two wordings, and left the status line unable to report what
+ // happened next because the pin outranked it.
+ v.SetSize(v.width, v.height)
+
+ default:
+ // Terminal invite events retire a pinned PIN or an inbound prompt that
+ // can no longer be acted on. Without them a declined or expired invite
+ // stayed on screen looking live.
+ if outcome, ok := inviteOutcome(msg.Method); ok {
+ v.retireInvite(msg.Params, outcome)
+ }
+ }
+ return nil
+}
+
+// retireInvite clears whichever pairing session a terminal event belongs to.
+//
+// Matched on inviteId, because concurrent pairings are supported: an outbound
+// decline arriving while an inbound request is on screen must not clear the
+// inbound prompt, and vice versa. Every terminal notification the cluster
+// manager emits carries the invite it refers to, so an event without one
+// belongs to no session this view is tracking and is ignored rather than
+// applied to both.
+// inboundPrompt is the standing line for a pairing request someone sent us,
+// or empty when there is none.
+//
+// It follows the request through its two states rather than describing only
+// the first. Pressing "a" does not finish anything — it opens the PIN field —
+// so a prompt that went on offering "a to accept" after the field was already
+// up told the operator to do the thing they had just done, while the answer it
+// actually wanted was on the line below.
+func (v *nodesView) inboundPrompt() string {
+ if v.inbound == nil {
+ return ""
+ }
+ if v.mode == nodesInputPin {
+ return statusOKStyle.Render(fmt.Sprintf(
+ "accepting %s - enter the PIN shown on that machine",
+ v.inbound.FromNodeName))
+ }
+ return statusOKStyle.Render(fmt.Sprintf(
+ "pairing request from %s - %s to accept, %s to decline",
+ v.inbound.FromNodeName, nodePairKey.Help().Key, nodeDeclineKey.Help().Key))
+}
+
+func (v *nodesView) retireInvite(params []byte, outcome inviteResolution) {
+ var ref inviteRef
+ _ = decodeParams(params, &ref)
+ if ref.InviteID == "" {
+ return
+ }
+
+ if ref.InviteID == v.outboundInviteID {
+ v.clearOutboundInvite()
+ v.status.set(outcome.kind, "invite %s", outcome.label)
+ }
+ if v.inbound != nil && ref.InviteID == v.inbound.InviteID {
+ v.inbound = nil
+ v.status.set(outcome.kind, "pairing request %s", outcome.label)
+ }
+ v.SetSize(v.width, v.height)
+}
+
+func (v *nodesView) handleKey(msg tea.KeyMsg) tea.Cmd {
+ if v.mode != nodesInputNone {
+ switch msg.String() {
+ case "enter":
+ return v.submitInput()
+ case "esc":
+ v.cancelInput()
+ return nil
}
var cmd tea.Cmd
- v.table, cmd = v.table.Update(msg)
+ v.input, cmd = v.input.Update(msg)
return cmd
}
+
+ // Trust teardown is armed, not done: anything other than the confirmation
+ // cancels, so a stray key never removes a peer or leaves a cluster.
+ if v.confirmLeave {
+ v.confirmLeave = false
+ if key.Matches(msg, nodeConfirmKey) {
+ return v.leaveCluster()
+ }
+ v.status.info("cancelled")
+ return nil
+ }
+ if v.confirmRemove != "" {
+ target := v.confirmRemove
+ v.confirmRemove = ""
+ if key.Matches(msg, nodeConfirmKey) {
+ return v.removeMember(target)
+ }
+ v.status.info("cancelled")
+ return nil
+ }
+
+ switch {
+ case key.Matches(msg, nodeDetailKey):
+ return v.openDetail()
+ case key.Matches(msg, nodeInviteKey):
+ return v.inviteSelected()
+ case key.Matches(msg, nodeInviteAddrKey):
+ v.beginInput(nodesInputInviteAddress, "host (or host:port; default 14321)")
+ return textinput.Blink
+ case key.Matches(msg, nodeAddKey):
+ v.beginInput(nodesInputManualAddress, "host")
+ return textinput.Blink
+ case key.Matches(msg, nodeRemoveKey):
+ return v.removeSelected()
+ case key.Matches(msg, nodePairKey):
+ if v.inbound == nil {
+ v.status.info("no pairing request to accept")
+ return nil
+ }
+ v.beginInput(nodesInputPin, "PIN from the inviting node")
+ return textinput.Blink
+ case key.Matches(msg, nodeDeclineKey) && v.inbound != nil:
+ // Gated on there being something to decline, so that with no pairing
+ // request pending the key falls through to the table, where d is the
+ // standard half-page-down. Unconditionally intercepting it meant paging
+ // a long node list answered with a message about pairing.
+ return v.respondToInvite(false, "")
+ case key.Matches(msg, nodeCancelKey):
+ return v.cancelInvite()
+ case key.Matches(msg, nodeFilterKey):
+ v.beginInput(nodesInputFilter, "filter by name or address")
+ v.input.SetValue(v.filter)
+ return textinput.Blink
+ case key.Matches(msg, nodeClearKey) && v.filter != "":
+ v.filter = ""
+ v.rebuild()
+ return nil
+ case key.Matches(msg, nodeLeaveKey):
+ if v.identity.ClusterID == "" {
+ v.status.error("not in a cluster")
+ return nil
+ }
+ v.confirmLeave = true
+ v.status.arm("leave the cluster? press y to confirm, any other key to cancel")
+ return nil
+ }
+
+ var cmd tea.Cmd
+ v.table, cmd = v.table.Update(msg)
+ // Moving the cursor re-anchors the selection so a later refresh keeps it.
+ if row := v.rowAt(v.table.Cursor()); row != nil {
+ v.selectedKey = row.key
+ }
+ return cmd
+}
+
+// reset closes an open detail screen so the tab shows the node list again.
+//
+// Called when the operator leaves this tab, not when they press esc — esc has
+// its own path through Update. The list underneath has been kept current the
+// whole time the detail was up, so there is nothing to reload.
+func (v *nodesView) reset() {
+ if v.detail == nil {
+ return
+ }
+ v.detail = nil
+ // The list was sized for the space the detail screen was using.
+ v.SetSize(v.width, v.height)
+}
+
+// openDetail drills into the selected node. The detail screen is built from the
+// merged row, so a remote node's models are on screen immediately from the
+// discovery snapshot while its engine list is being fetched.
+func (v *nodesView) openDetail() tea.Cmd {
+ row := v.selectedRow()
+ if row == nil {
+ v.status.error("no node selected")
+ return nil
+ }
+ v.detail = newNodeDetail(v.client, *row)
+ v.detail.SetSize(v.width, v.height)
+ return v.detail.Init()
+}
+
+func (v *nodesView) beginInput(mode nodesInputMode, placeholder string) {
+ v.mode = mode
+ v.input.SetValue("")
+ v.input.Placeholder = placeholder
+ v.input.Focus()
+ v.SetSize(v.width, v.height)
+}
+
+func (v *nodesView) cancelInput() {
+ v.mode = nodesInputNone
+ v.input.Blur()
+ v.SetSize(v.width, v.height)
+}
+
+func (v *nodesView) submitInput() tea.Cmd {
+ val := strings.TrimSpace(v.input.Value())
+ mode := v.mode
+ v.cancelInput()
+
+ switch mode {
+ case nodesInputFilter:
+ // Applied on submit rather than per keystroke: the list re-sorts as
+ // nodes come and go, and narrowing it under the cursor while the
+ // operator is still typing moves the selection out from under them.
+ v.filter = val
+ v.rebuild()
+ return nil
+
+ case nodesInputManualAddress:
+ if val == "" {
+ v.status.error("address required")
+ return nil
+ }
+ v.status.busy("looking for %s...", val)
+ return call(v.client, "node/add", map[string]string{"address": val},
+ func(_ *rpc.Message, err error) tea.Msg {
+ return nodeActionMsg{what: "add " + val, err: err}
+ })
+
+ case nodesInputInviteAddress:
+ if val == "" {
+ v.status.error("address required")
+ return nil
+ }
+ return v.inviteAddress(val)
+
+ case nodesInputPin:
+ return v.respondToInvite(true, val)
+ }
return nil
}
-// inviteSelected sends a cluster invite to the highlighted node. The node's
-// discovery IP is the dial target; the manager appends the fixed cluster-manager
-// port (14321), so we deliberately do NOT pass the row's Port (that's the
-// discovery/Ollama port). The nodeId is passed too so the manager stamps it on
-// the invite as the target identity. A PIN comes back to read to the joiner.
+// inviteAddress pairs with a host discovery has not found, for networks that
+// filter multicast.
+//
+// nvpair-cluster-manager treats "address" as a bare host and appends the port
+// itself (default 14321). If the operator typed host:port, split it so the port
+// lands in the manager's separate field instead of being glued onto the host.
+func (v *nodesView) inviteAddress(val string) tea.Cmd {
+ // The same guard the discovered-node path applies. A node the table already
+ // shows as belonging to another cluster cannot accept, and typing its
+ // address instead of selecting its row should not get a different answer —
+ // the operator would otherwise read out a PIN for an invite that is already
+ // doomed.
+ for _, n := range v.all {
+ if addressMatches(n, val) && !n.membership.invitable() {
+ v.status.error("%s is already %s - remove that relationship before pairing",
+ n.name, n.membership.relationship())
+ return nil
+ }
+ }
+
+ params := map[string]any{"address": val}
+ if host, portStr, err := net.SplitHostPort(val); err == nil {
+ // A malformed or out-of-range port is rejected rather than folded back
+ // into the host. Passing "host:notaport" through as an address sends a
+ // string no dialer can use, and the failure surfaces much later as an
+ // unreachable peer; "host:99999" was forwarded to the manager verbatim.
+ port, ok := parsePort(portStr)
+ if !ok {
+ v.status.error("%q is not a port between 1 and 65535", portStr)
+ return nil
+ }
+ params["address"] = host
+ params["port"] = port
+ }
+ v.status.busy("inviting %s...", val)
+ return inviteNodeCmd(v.client, params, func(res inviteNodeResult, err error) tea.Msg {
+ return inviteResultMsg(val, val, res, err)
+ })
+}
+
+// inviteSelected sends a cluster invite to the highlighted node.
+//
+// The node's discovery IP is the dial target; the manager appends the fixed
+// cluster-manager port, so the row's own port (the node-info port) is
+// deliberately not passed. The nodeId travels too so the manager stamps it as
+// the invite's target identity.
func (v *nodesView) inviteSelected() tea.Cmd {
- idx := v.table.Cursor()
- if idx < 0 || idx >= len(v.nodes) {
- v.status = "no node selected"
+ row := v.selectedRow()
+ if row == nil {
+ v.status.error("no node selected")
+ return nil
+ }
+ if row.self {
+ v.status.error("cannot invite this machine to its own cluster")
return nil
}
- n := v.nodes[idx]
- // A paired or otherwise-clustered node can't be invited again: the peer
- // rejects a fresh pairing until the existing relationship is removed. Guard
- // here so we don't fire a doomed invite (the cluster-manager rejects it too).
- if n.Trusted {
- v.status = n.Name + " is already paired (Connected) - remove it first to re-pair"
+ // One guard for every path into pairing. Previously the discovered-node
+ // path checked this and the invite-by-address path did not, so the same
+ // doomed invite could still be sent from the other tab.
+ if !row.membership.invitable() {
+ v.status.error("%s is already %s - remove that relationship before pairing",
+ row.name, row.membership.relationship())
return nil
}
- if n.Clustered {
- v.status = n.Name + " is already in a cluster - it must leave before it can pair"
+ if row.address == "" {
+ v.status.error("%s has no known address - use %s to pair by address",
+ row.name, nodeInviteAddrKey.Help().Key)
return nil
}
- // Identify the invite target by its stable UUID (the address is still the
- // dial target); the manager stamps this as the invite's target identity.
- params := map[string]any{"address": n.IPAddress, "nodeId": n.key()}
- name := n.Name
- v.status = "inviting " + name + "..."
+
+ params := map[string]any{"address": row.address, "nodeId": row.key}
+ name := row.name
+ v.invitedKey = row.key
+ v.status.busy("inviting %s...", name)
return inviteNodeCmd(v.client, params, func(res inviteNodeResult, err error) tea.Msg {
- if err != nil {
- return nodeInviteMsg{name: name, err: err}
+ return inviteResultMsg(name, "", res, err)
+ })
+}
+
+// inviteResultMsg maps a cluster:invite-node outcome onto the view message.
+// address is empty for an invite aimed at a discovered node.
+func inviteResultMsg(name, address string, res inviteNodeResult, err error) tea.Msg {
+ if err != nil {
+ return nodeInviteMsg{name: name, address: address, err: err}
+ }
+ if res.State == "rejected" {
+ return nodeInviteMsg{
+ name: name, address: address, rejected: true, reason: res.Reason,
}
- if res.State == "rejected" {
- return nodeInviteMsg{name: name, rejected: true, reason: res.Reason}
+ }
+ pin := ""
+ if res.Pin != nil {
+ pin = *res.Pin
+ }
+ return nodeInviteMsg{name: name, address: address, inviteID: res.InviteID, pin: pin}
+}
+
+func (v *nodesView) respondToInvite(accept bool, pin string) tea.Cmd {
+ if v.inbound == nil {
+ // Say so rather than doing nothing. A key that silently ignores a press
+ // is indistinguishable from one the terminal dropped, and the accept
+ // path already answers.
+ v.status.info("no pairing request to answer")
+ return nil
+ }
+ params := map[string]any{"inviteId": v.inbound.InviteID, "accept": accept}
+ if accept && pin != "" {
+ params["pin"] = pin
+ }
+ from := v.inbound.FromNodeName
+ v.inbound = nil
+ v.SetSize(v.width, v.height)
+
+ if !accept {
+ v.status.busy("declining the pairing request...")
+ return call(v.client, "cluster:respond-to-invite", params,
+ func(_ *rpc.Message, err error) tea.Msg {
+ return nodeActionMsg{what: "decline pairing", err: err}
+ })
+ }
+ // The handshake crosses to another machine and is the slowest call this tab
+ // makes; the inbound prompt has already been cleared, so without this the
+ // screen is blank until it answers.
+ v.status.busy("pairing with %s...", from)
+ return call(v.client, "cluster:respond-to-invite", params,
+ func(msg *rpc.Message, err error) tea.Msg {
+ if err != nil {
+ return pairingResultMsg{from: from, err: err}
+ }
+ // A wrong PIN is NOT a JSON-RPC error. The cluster manager tears the
+ // session down and replies successfully with the invite, whose state
+ // is "failed" and whose reason says why. Reading only the transport
+ // error reported a green "accept pairing ok" for a pairing that had
+ // just been rejected — and since the prompt is already gone by then,
+ // nothing later corrected it.
+ var res inviteNodeResult
+ _ = decodeParams(msg.Result, &res)
+ return pairingResultMsg{from: from, state: res.State, reason: res.Reason}
+ })
+}
+
+// pairingResultMsg is the outcome of answering an inbound pairing request.
+type pairingResultMsg struct {
+ from string
+ state string
+ reason string
+ err error
+}
+
+// handlePairingResult reports whether this machine actually joined.
+func (v *nodesView) handlePairingResult(msg pairingResultMsg) tea.Cmd {
+ switch {
+ case msg.err != nil:
+ v.status.error("could not answer the pairing request: %s", msg.err)
+ case msg.state == "paired":
+ v.status.ok("paired with %s", msg.from)
+ case msg.reason == reasonIncorrectPIN:
+ // The specific case worth naming: it is the operator's typo, and the
+ // remedy is a fresh invite because the PIN is single-use.
+ v.status.error("wrong PIN - ask %s to send a new invite, then try again", msg.from)
+ case msg.state == "declined":
+ v.status.info("pairing request declined")
+ default:
+ v.status.error("pairing with %s failed (%s) - ask for a new invite",
+ msg.from, rejectReason(msg.reason))
+ }
+ // Membership is what actually changed, so re-read it rather than trusting
+ // this reply.
+ return tea.Batch(v.membersCmd(), v.identityCmd())
+}
+
+// removeSelected drops the selected node's strongest relationship: cluster
+// membership if it is a member, otherwise the manual entry that added it.
+func (v *nodesView) removeSelected() tea.Cmd {
+ row := v.selectedRow()
+ if row == nil {
+ v.status.error("no node selected")
+ return nil
+ }
+ switch {
+ case row.membership == membershipMember || row.membership == membershipPending:
+ if row.self {
+ v.status.error("use %s to leave the cluster from this machine",
+ nodeLeaveKey.Help().Key)
+ return nil
}
- pin := ""
- if res.Pin != nil {
- pin = *res.Pin
+ // Arm, do not act. Un-pairing a peer tears down mutual trust and is not
+ // something a single keystroke on a moving list should do.
+ v.confirmRemove = row.key
+ v.status.arm("remove %s from the cluster? press y to confirm, any other key to cancel",
+ row.name)
+ return nil
+
+ case row.manualID != "":
+ return call(v.client, "node/remove", map[string]string{"id": row.manualID},
+ func(_ *rpc.Message, err error) tea.Msg {
+ return nodeActionMsg{what: "remove manual entry " + row.name, err: err}
+ })
+
+ default:
+ v.status.info("%s is only discovered - nothing to remove", row.name)
+ return nil
+ }
+}
+
+// cancelInvite aborts an outbound invite the operator no longer wants to
+// complete.
+//
+// This is the inviter's half of decline, and without it a PIN read out to the
+// wrong person could only be retired by waiting for it to expire — the invite
+// stayed live and answerable the whole time. The manager evicts the pairing
+// session, which invalidates the PIN immediately, and best-effort tells the
+// other side so its prompt disappears too.
+//
+// No confirmation: this is the safe direction. Cancelling an invite in flight
+// costs one keystroke to redo, while the thing being prevented is a stranger
+// completing a join.
+func (v *nodesView) cancelInvite() tea.Cmd {
+ if v.outboundInviteID == "" {
+ v.status.info("no invite is waiting")
+ return nil
+ }
+ id := v.outboundInviteID
+ // Cleared optimistically: the PIN must stop being displayed the moment the
+ // operator asks, not when the round trip finishes. A failure restores
+ // nothing because the invite is either already gone or about to expire.
+ v.clearOutboundInvite()
+ v.status.busy("cancelling invite...")
+ return call(v.client, "cluster:cancel-invite", map[string]string{"inviteId": id},
+ func(_ *rpc.Message, err error) tea.Msg {
+ return nodeActionMsg{what: "cancel invite", err: err}
+ })
+}
+
+// removeMember un-pairs a confirmed peer.
+//
+// Keyed by the stable nodeUuid rather than the display name: a member that
+// renamed its PC keeps its UUID, so matching on a possibly stale name would
+// silently fail. The row is looked up again by key so a list that re-sorted
+// between arming and confirming cannot redirect the removal at another node.
+func (v *nodesView) removeMember(key string) tea.Cmd {
+ name := key
+ for _, n := range v.all {
+ if n.key == key {
+ name = n.name
+ break
}
- return nodeInviteMsg{name: name, pin: pin}
+ }
+ // Replaces the armed prompt, which is sticky: without this the screen went
+ // on asking whether to remove the peer while the removal was under way.
+ v.status.busy("removing %s from the cluster...", name)
+ return call(v.client, "nodes:remove", map[string]string{"nodeUuid": key},
+ func(_ *rpc.Message, err error) tea.Msg {
+ return nodeActionMsg{what: "remove " + name, err: err}
+ })
+}
+
+// leaveCluster unjoins this node. The cluster-manager tears down local trust and
+// pushes cluster:identity-changed and nodes:changed, which refresh the view.
+func (v *nodesView) leaveCluster() tea.Cmd {
+ if v.identity.ClusterID == "" {
+ v.status.error("not in a cluster")
+ return nil
+ }
+ // Same as removeMember: the armed prompt does not expire, and this is the
+ // slowest relay in the client, so it has to be replaced rather than left
+ // asking a question that has already been answered.
+ v.status.busy("leaving the cluster...")
+ return call(v.client, "cluster:leave", nil, func(_ *rpc.Message, err error) tea.Msg {
+ return nodeActionMsg{what: "leave cluster", err: err}
})
}
-func (v *nodesView) setNodes(nodes []availableNode) {
- v.nodes = nodes
- rows := make([]table.Row, 0, len(nodes))
- for _, n := range nodes {
+// rebuild re-merges the feeds and repaints the table, preserving the operator's
+// selection by key across the re-sort.
+func (v *nodesView) rebuild() {
+ v.all = mergeNodes(v.feeds)
+ v.rows = filterNodeRows(v.all, v.filter)
+ v.retirePendingInvite()
+
+ rows := make([]table.Row, 0, len(v.rows))
+ for _, n := range v.rows {
+ name := n.name
+ if n.self {
+ name += " (this machine)"
+ }
+ models := "-"
+ if c := n.modelCount(); c > 0 {
+ models = strconv.Itoa(c)
+ }
rows = append(rows, table.Row{
- n.Name,
- n.IPAddress,
- strconv.Itoa(n.Port),
- ageUnix(n.LastSeen),
- nodeStatus(n),
+ name,
+ n.address,
+ n.presence.String(),
+ n.membership.String(),
+ models,
})
}
v.table.SetRows(rows)
+ v.restoreSelection()
}
-// nodeStatus is the STATUS cell for a discovered node: "Connected" for a paired
-// peer of ours, "In cluster" for a node clustered elsewhere (both non-invitable),
-// empty for an invitable standalone node.
-func nodeStatus(n availableNode) string {
- switch {
- case n.Trusted:
- return "Connected"
- case n.Clustered:
- return "In cluster"
- default:
- return ""
+// restoreSelection puts the cursor back on the node it was on before the merge
+// re-ordered the rows, falling back to the first row when that node is gone.
+func (v *nodesView) restoreSelection() {
+ if v.selectedKey == "" && len(v.rows) > 0 {
+ v.selectedKey = v.rows[0].key
+ }
+ for i, n := range v.rows {
+ if n.key == v.selectedKey {
+ v.table.SetCursor(i)
+ return
+ }
+ }
+ if len(v.rows) > 0 {
+ v.selectedKey = v.rows[0].key
+ v.table.SetCursor(0)
}
}
-// rejectReason renders a machine reason from a rejected invite as human text.
-func rejectReason(reason string) string {
- switch reason {
- case "already-clustered":
- return "already in a cluster"
- case "":
- return "rejected by peer"
- default:
- return reason
+// retirePendingInvite replaces the pinned PIN with a success note once the node
+// we invited shows up as a member. Pairing completing is the one outcome with no
+// terminal notification of its own, so it is detected from the merged state.
+func (v *nodesView) retirePendingInvite() {
+ if v.invitedKey == "" && v.invitedAddress == "" {
+ return
+ }
+ // The full set, not the filtered view: a peer that joined while hidden by a
+ // filter still completes the pairing, and its PIN still has to stop showing.
+ for _, n := range v.all {
+ if n.membership != membershipMember {
+ continue
+ }
+ // Either identity works: a discovered node was invited by UUID, while an
+ // invite by address has none, so that peer is recognised by the address
+ // it was invited at. Without the address arm, a PIN pinned by the
+ // by-address path was never retired at all.
+ if (v.invitedKey != "" && n.key == v.invitedKey) ||
+ (v.invitedAddress != "" && addressMatches(n, v.invitedAddress)) {
+ v.clearOutboundInvite()
+ v.status.ok("%s joined the cluster", n.name)
+ return
+ }
}
}
-func (v *nodesView) View() string {
- var body string
- if len(v.nodes) == 0 {
- body = footerStyle.Render("No nodes discovered yet. Browsing for _nvpair-node-info._tcp ...")
- } else {
- body = v.table.View()
+// addressMatches reports whether a node answers to the given address. The
+// operator may have typed a host:port form, and a node publishes several
+// addresses, so the host part is compared against every candidate.
+func addressMatches(n nodeRow, address string) bool {
+ host := normalizeHost(address)
+ if host == "" {
+ return false
}
- if v.status != "" {
- body += "\n" + footerStyle.Render(v.status)
+ if normalizeHost(n.address) == host {
+ return true
}
- return body
+ for _, candidate := range n.addresses {
+ if normalizeHost(candidate) == host {
+ return true
+ }
+ }
+ return strings.EqualFold(strings.TrimSpace(n.name), host)
}
-func (v *nodesView) Help() []key.Binding { return []key.Binding{niInviteKey} }
+func (v *nodesView) rowAt(idx int) *nodeRow {
+ if idx < 0 || idx >= len(v.rows) {
+ return nil
+ }
+ return &v.rows[idx]
+}
-// ageUnix renders a Unix-seconds timestamp as a compact relative age.
-func ageUnix(sec int64) string {
- if sec == 0 {
- return "-"
+func (v *nodesView) selectedRow() *nodeRow {
+ for i, n := range v.rows {
+ if n.key == v.selectedKey {
+ return &v.rows[i]
+ }
}
- d := time.Since(time.Unix(sec, 0))
- switch {
- case d < time.Minute:
- return fmt.Sprintf("%ds", int(d.Seconds()))
- case d < time.Hour:
- return fmt.Sprintf("%dm", int(d.Minutes()))
+ return v.rowAt(v.table.Cursor())
+}
+
+func (v *nodesView) View() string {
+ if v.detail != nil {
+ return v.detail.View()
+ }
+
+ // Everything that is not the table, gathered before the table is sized so
+ // its height can be whatever is left. Empty entries cost nothing.
+ above := v.clusterLine()
+
+ filterNote := ""
+ if v.filter != "" && len(v.rows) > 0 {
+ // A filtered table looks like the whole cluster, so it has to say it is
+ // not. Without this an operator can conclude a node has vanished when
+ // they are simply still filtered.
+ filterNote = footerStyle.Render(fmt.Sprintf(
+ "showing %d of %d - filter %q, esc to clear",
+ len(v.rows), len(v.all), v.filter))
+ }
+ feedNote := ""
+ if warning := v.feedWarning(); warning != "" && len(v.rows) > 0 {
+ // A populated table can still be missing a feed, and then it is worse
+ // than an empty one: it looks complete.
+ feedNote = statusErrStyle.Render("Some node data is " + warning)
+ }
+ inboundNote := v.inboundPrompt()
+ editor := ""
+ if v.mode != nodesInputNone {
+ editor = v.inputLabel() + v.input.View()
+ }
+
+ body := ""
+ if len(v.rows) == 0 {
+ // An empty table means one of three very different things, and the
+ // operator's next move depends on which: clear the filter, wait, or go
+ // look at the service. Say which one this is.
+ switch {
+ case v.filter != "":
+ body = footerStyle.Render(fmt.Sprintf(
+ "No node matches %q. %d known - press esc to clear the filter.",
+ v.filter, len(v.all)))
+ case v.feedWarning() != "":
+ body = statusErrStyle.Render("Cannot read the node list - " + v.feedWarning())
+ default:
+ body = footerStyle.Render(fmt.Sprintf(
+ "No nodes yet. Discovery is browsing the network; press %s to add one by address.",
+ nodeAddKey.Help().Key))
+ }
+ }
+
+ status := v.status.render()
+ if len(v.rows) > 0 {
+ if fitTable(&v.table, v.height, above, filterNote, feedNote, inboundNote, editor, status) {
+ body = v.table.View()
+ } else {
+ body = footerStyle.Render(fmt.Sprintf(
+ " (too little room to list %d nodes)", len(v.rows)))
+ }
+ }
+ return joinLines(above, body, filterNote, feedNote, inboundNote, editor, status)
+}
+
+// clusterLine is the one-line summary of this machine's cluster standing.
+func (v *nodesView) clusterLine() string {
+ if v.identity.ClusterID == "" {
+ return footerStyle.Render("Not in a cluster - inviting a node forms one automatically")
+ }
+ members := 0
+ // Counted over every known node: a filter narrows what is on screen, not
+ // what the cluster contains, and a shrinking member count would be alarming.
+ for _, n := range v.all {
+ if n.membership == membershipMember {
+ members++
+ }
+ }
+ name := v.identity.Name
+ if name == "" {
+ name = v.identity.NodeID
+ }
+ // The label if one is set, the id otherwise: the id is what anything
+ // operational keys off, so it is the honest fallback rather than "unnamed".
+ label := v.clusterName
+ if label == "" {
+ label = truncate(v.identity.ClusterID, 12)
+ }
+ return titleStyle.Render(fmt.Sprintf("Cluster %s - %d member(s), this machine is %s",
+ label, members, name))
+}
+
+func (v *nodesView) inputLabel() string {
+ switch v.mode {
+ case nodesInputManualAddress:
+ return "find node at host: "
+ case nodesInputInviteAddress:
+ return "pair with host: "
+ case nodesInputPin:
+ return "PIN: "
default:
- return fmt.Sprintf("%dh", int(d.Hours()))
+ return ""
+ }
+}
+
+func (v *nodesView) Help() []key.Binding {
+ if v.detail != nil {
+ return v.detail.Help()
+ }
+ if v.mode != nodesInputNone {
+ switch v.mode {
+ case nodesInputFilter:
+ return inputHelp("apply filter")
+ case nodesInputPin:
+ return inputHelp("submit PIN")
+ default:
+ return inputHelp("submit address")
+ }
+ }
+ if v.confirmLeave || v.confirmRemove != "" {
+ return []key.Binding{nodeConfirmKey}
+ }
+ bindings := []key.Binding{nodeDetailKey, nodeInviteKey, nodeInviteAddrKey, nodeAddKey, nodeRemoveKey, nodeFilterKey}
+ if v.filter != "" {
+ bindings = append(bindings, nodeClearKey)
+ }
+ if v.inbound != nil {
+ bindings = append(bindings, nodePairKey, nodeDeclineKey)
+ }
+ // Only while there is something to cancel: a key offered with nothing
+ // pending invites a press that can only answer "nothing is waiting".
+ if v.outboundInviteID != "" {
+ bindings = append(bindings, nodeCancelKey)
+ }
+ if v.identity.ClusterID != "" {
+ bindings = append(bindings, nodeLeaveKey)
}
+ return bindings
}
diff --git a/services/nvpair-tui/ui/nodesmodel.go b/services/nvpair-tui/ui/nodesmodel.go
new file mode 100644
index 00000000..a843c6a9
--- /dev/null
+++ b/services/nvpair-tui/ui/nodesmodel.go
@@ -0,0 +1,385 @@
+// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+package ui
+
+import (
+ "net"
+ "sort"
+ "strings"
+)
+
+// Being listed and being reachable are different facts: a cluster member that
+// has gone away stays in the roster, and showing it without that distinction is
+// what made departed peers look present. What decides reachability is the
+// backend's own eviction — see discoveredPresence, which deliberately keeps no
+// timer of its own.
+
+// nodePresence is whether PAIR can currently reach a node.
+type nodePresence int
+
+const (
+ // presenceUnknown is a node we have no liveness evidence for either way —
+ // a roster member that discovery has never reported and no probe covers.
+ presenceUnknown nodePresence = iota
+ presenceOnline
+ presenceOffline
+)
+
+func (p nodePresence) String() string {
+ switch p {
+ case presenceOnline:
+ return "Online"
+ case presenceOffline:
+ return "Offline"
+ default:
+ return "Unknown"
+ }
+}
+
+// nodeMembership is a node's relationship to our cluster. It is deliberately
+// separate from presence: the old single STATUS column conflated "we trust
+// this node" with "this node is up", so a departed member read as Connected.
+type nodeMembership int
+
+const (
+ // membershipNone is a standalone node — the only kind that can be invited.
+ membershipNone nodeMembership = iota
+ membershipMember
+ membershipPending
+ // membershipForeign is a node that belongs to some other cluster. It must
+ // leave that one before it can pair with us.
+ membershipForeign
+)
+
+func (m nodeMembership) String() string {
+ switch m {
+ case membershipMember:
+ return "Member"
+ case membershipPending:
+ return "Pending"
+ case membershipForeign:
+ return "Other cluster"
+ default:
+ return "-"
+ }
+}
+
+// invitable reports whether an invite to this node could succeed. An existing
+// relationship — ours or another cluster's — must be removed first, and the
+// peer rejects the attempt either way, so the UI declines to send it.
+func (m nodeMembership) invitable() bool {
+ return m == membershipNone
+}
+
+// relationship describes a membership in a sentence.
+//
+// String is a table cell — "Member", "Other cluster" — and lowercasing it into
+// prose produced "peer is already other cluster". A column label and a clause
+// are not the same text.
+func (m nodeMembership) relationship() string {
+ switch m {
+ case membershipMember:
+ return "a member of this cluster"
+ case membershipPending:
+ return "part-way through pairing"
+ case membershipForeign:
+ return "in another cluster"
+ default:
+ return "unrelated to this cluster"
+ }
+}
+
+// nodeRow is one machine as the Nodes tab presents it, merged from the three
+// feeds that each hold part of the picture:
+//
+// - discovery — liveness, address, and model inventory (AvailableNode)
+// - cluster — membership state for peers we have paired with
+// - manual — user-added entries discovery cannot see, plus their probes
+//
+// One machine can appear in all three. Merging on a stable key is what stops it
+// rendering as three separate rows.
+type nodeRow struct {
+ key string
+ name string
+ address string
+ // addresses is every address the node published, ranked by the node itself
+ // with address first. Anything that dials the node walks this rather than
+ // assuming the first one is reachable from here.
+ addresses []string
+ port int
+
+ presence nodePresence
+ membership nodeMembership
+ self bool
+
+ // manualID is the handle node/remove needs. Empty unless the entry was
+ // added by hand.
+ manualID string
+
+ models []string
+ modelsByEngine map[string][]string
+ loadedByEngine map[string][]string
+}
+
+// modelCount is the number of distinct models the node advertises.
+func (n nodeRow) modelCount() int { return len(n.models) }
+
+// nodeFeeds is the raw input to a merge: one snapshot from each source.
+type nodeFeeds struct {
+ discovered []availableNode
+ members []clusterNode
+ manual []manualNode
+ selfUUID string
+}
+
+// mergeNodes folds the three feeds into the unified list the Nodes tab renders.
+//
+// Discovery is the base layer because it carries the richest record. The
+// cluster roster then contributes membership and, critically, keeps a member
+// listed even when discovery has dropped it — that node is offline, not gone.
+// Manual entries are matched by address so a hand-added host that discovery
+// later finds does not appear twice.
+func mergeNodes(in nodeFeeds) []nodeRow {
+ byKey := make(map[string]*nodeRow, len(in.discovered)+len(in.members)+len(in.manual))
+ order := make([]string, 0, len(byKey))
+
+ get := func(key string) *nodeRow {
+ if row, ok := byKey[key]; ok {
+ return row
+ }
+ row := &nodeRow{key: key}
+ byKey[key] = row
+ order = append(order, key)
+ return row
+ }
+
+ for _, d := range in.discovered {
+ key := d.HostUUID
+ if key == "" {
+ key = "name:" + d.Name
+ }
+ row := get(key)
+ row.name = d.Name
+ row.address = d.IPAddress
+ row.addresses = candidateAddresses(d)
+ row.port = d.Port
+ row.models = d.Models
+ row.modelsByEngine = d.ModelsByEngine
+ row.loadedByEngine = d.LoadedByEngine
+ row.presence = discoveredPresence(d)
+ switch {
+ case d.Trusted:
+ row.membership = membershipMember
+ case d.Clustered:
+ row.membership = membershipForeign
+ }
+ }
+
+ for _, m := range in.members {
+ key := m.NodeUUID
+ if key == "" {
+ key = m.ID
+ }
+ row := get(key)
+ if row.name == "" {
+ row.name = m.Name
+ }
+ if row.address == "" {
+ row.address = m.IPAddress
+ row.port = m.Port
+ }
+ row.membership = memberMembership(m.State)
+ // A member discovery has not reported is listed but unreachable; one it
+ // did report keeps the presence computed above. Unknown is precisely
+ // "no feed has graded this row", since every discovered node gets a
+ // verdict and so does every probed manual entry.
+ if row.presence == presenceUnknown {
+ row.presence = presenceOffline
+ }
+ }
+
+ for _, m := range in.manual {
+ row := matchManual(byKey, order, m)
+ if row == nil {
+ row = get("manual:" + m.ID)
+ row.name = m.Name
+ row.address = m.Address
+ }
+ row.manualID = m.ID
+ if row.name == "" {
+ row.name = m.Address
+ }
+ // A probe is direct evidence and outranks discovery silence: a node on
+ // a network that filters multicast is reachable but never announced.
+ if m.NodeInfoUp || m.OllamaUp {
+ row.presence = presenceOnline
+ } else if row.presence == presenceUnknown {
+ row.presence = presenceOffline
+ }
+ }
+
+ if in.selfUUID != "" {
+ if row, ok := byKey[in.selfUUID]; ok {
+ row.self = true
+ row.presence = presenceOnline
+ }
+ }
+
+ rows := make([]nodeRow, 0, len(order))
+ for _, key := range order {
+ rows = append(rows, *byKey[key])
+ }
+ sortNodeRows(rows)
+ return rows
+}
+
+// filterNodeRows narrows a node list to those matching a case-insensitive
+// substring of the name or any of the node's addresses.
+//
+// Addresses are included because half of what an operator knows a machine by is
+// its IP — especially the ones added by address in the first place, which may
+// carry a name they have never seen.
+func filterNodeRows(rows []nodeRow, filter string) []nodeRow {
+ needle := strings.ToLower(strings.TrimSpace(filter))
+ if needle == "" {
+ return rows
+ }
+ out := make([]nodeRow, 0, len(rows))
+ for _, n := range rows {
+ if nodeMatchesFilter(n, needle) {
+ out = append(out, n)
+ }
+ }
+ return out
+}
+
+// nodeMatchesFilter reports whether one row matches an already-lowercased needle.
+func nodeMatchesFilter(n nodeRow, needle string) bool {
+ if strings.Contains(strings.ToLower(n.name), needle) {
+ return true
+ }
+ if strings.Contains(strings.ToLower(n.address), needle) {
+ return true
+ }
+ for _, a := range n.addresses {
+ if strings.Contains(strings.ToLower(a), needle) {
+ return true
+ }
+ }
+ return false
+}
+
+// candidateAddresses is the node's addresses in the order it ranked them, with
+// its primary first and duplicates removed. The broker omits the list entirely
+// when a node published a single address, so that case falls back to it.
+func candidateAddresses(d availableNode) []string {
+ out := make([]string, 0, len(d.IPAddresses)+1)
+ seen := make(map[string]bool, len(d.IPAddresses)+1)
+ for _, a := range append([]string{d.IPAddress}, d.IPAddresses...) {
+ a = strings.TrimSpace(a)
+ if a == "" || seen[a] {
+ continue
+ }
+ seen[a] = true
+ out = append(out, a)
+ }
+ return out
+}
+
+// matchManual finds the existing row a manual entry describes. Manual entries
+// carry no host UUID, so address is the only join available.
+// The comparison is normalised, and considers every address a node published.
+// The manual address was typed by an operator while the discovered one comes off
+// the wire, so an exact string match on the primary address missed a host typed
+// with different case or with its port, and missed a multi-homed node entirely
+// when the operator used its second address — listing the same machine twice,
+// once discovered and once manual.
+func matchManual(byKey map[string]*nodeRow, order []string, m manualNode) *nodeRow {
+ want := normalizeHost(m.Address)
+ if want == "" {
+ return nil
+ }
+ for _, key := range order {
+ row := byKey[key]
+ if normalizeHost(row.address) == want {
+ return row
+ }
+ for _, candidate := range row.addresses {
+ if normalizeHost(candidate) == want {
+ return row
+ }
+ }
+ }
+ return nil
+}
+
+// normalizeHost reduces an address to a comparable host: trimmed, lowercased,
+// and without a port.
+func normalizeHost(address string) string {
+ host := strings.TrimSpace(address)
+ if h, _, err := net.SplitHostPort(host); err == nil {
+ host = h
+ }
+ return strings.ToLower(host)
+}
+
+// discoveredPresence grades a node that appears in the broker's discovery
+// snapshot: reachable if it has somewhere to be reached.
+//
+// Deliberately no clock. Eviction is the backend's job and it does it properly —
+// roughly sixty seconds of unbroken mDNS silence, with a TCP probe to stop a
+// node flapping out on one missed announcement, and inference traffic from the
+// node counting as evidence too. A node that survives all that is still in the
+// snapshot, and a client second-guessing it with a timer can only be wrong.
+//
+// It used to be wrong. This graded the record's age against a 45-second
+// threshold, on the theory that the scanner re-stamps every peer every fifteen
+// seconds. It does not: nvpair-node-scanner says outright that the timestamp is
+// not a liveness clock, because the mDNS browser reports a node only when its
+// record CHANGES — so a healthy peer with a stable advertisement stops producing
+// events and its timestamp freezes at first discovery. Three "missed refreshes"
+// that were never going to arrive marked a perfectly reachable peer Offline.
+//
+// This is also what the desktop app does, which matters because the two should
+// not disagree about whether a machine is up. Its rule is
+// `anyEngineUp(node) || node.nodeInfoUp`, where nodeInfoUp begins as
+// `Boolean(ipAddress) && port > 0` from the same snapshot and goes false only
+// when the broker drops the node. Its own /v1/node-info poll never demotes a
+// node — a failed poll keeps the last metrics and backs off.
+func discoveredPresence(n availableNode) nodePresence {
+ if n.IPAddress == "" || n.Port <= 0 {
+ return presenceOffline
+ }
+ return presenceOnline
+}
+
+// memberMembership maps a cluster roster state onto the membership shown. The
+// manager reports intermediate states while a join is settling; anything that
+// is not clearly established reads as pending rather than as a full member.
+func memberMembership(state string) nodeMembership {
+ switch strings.ToLower(state) {
+ case "joined", "active", "connected", "trusted", "member":
+ return membershipMember
+ case "":
+ return membershipMember
+ default:
+ return membershipPending
+ }
+}
+
+// sortNodeRows orders the table: this machine first, then reachable nodes, then
+// by name. Rows are keyed rather than indexed by the view, so re-ordering as
+// state changes does not move the operator's selection.
+func sortNodeRows(rows []nodeRow) {
+ sort.SliceStable(rows, func(i, j int) bool {
+ a, b := rows[i], rows[j]
+ if a.self != b.self {
+ return a.self
+ }
+ if (a.presence == presenceOnline) != (b.presence == presenceOnline) {
+ return a.presence == presenceOnline
+ }
+ return strings.ToLower(a.name) < strings.ToLower(b.name)
+ })
+}
diff --git a/services/nvpair-tui/ui/nodesmodel_test.go b/services/nvpair-tui/ui/nodesmodel_test.go
new file mode 100644
index 00000000..b3ce493d
--- /dev/null
+++ b/services/nvpair-tui/ui/nodesmodel_test.go
@@ -0,0 +1,278 @@
+// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+package ui
+
+import (
+ "testing"
+ "time"
+)
+
+// mergeReference is a fixed instant for building lastSeen timestamps.
+//
+// The merge itself no longer reads a clock — presence comes from whether the
+// backend still lists a node, not from how old its record is — so this exists
+// only to construct records of a given age and prove they are ignored.
+var mergeReference = time.Unix(1_700_000_000, 0)
+
+// seenAgo builds a lastSeen timestamp d before the reference instant.
+func seenAgo(d time.Duration) int64 { return mergeReference.Add(-d).Unix() }
+
+func findRow(t *testing.T, rows []nodeRow, name string) nodeRow {
+ t.Helper()
+ for _, r := range rows {
+ if r.name == name {
+ return r
+ }
+ }
+ t.Fatalf("no row named %q in %d rows", name, len(rows))
+ return nodeRow{}
+}
+
+// TestMergeKeepsOfflineMembersListed is the regression guard for the reported
+// asymmetry: a cluster member that goes away must stay listed and be marked
+// offline, rather than either vanishing or continuing to look present.
+// TestFilterNodeRowsMatchesNameAndAddress checks both of the things an operator
+// knows a machine by. Addresses matter especially for nodes added by address,
+// whose reported name they may never have seen.
+func TestFilterNodeRowsMatchesNameAndAddress(t *testing.T) {
+ rows := []nodeRow{
+ {key: "a", name: "workstation", address: "10.0.0.5", addresses: []string{"10.0.0.5", "192.168.1.9"}},
+ {key: "b", name: "laptop", address: "10.0.0.6"},
+ {key: "c", name: "Server-01", address: "10.0.1.7"},
+ }
+
+ cases := map[string][]string{
+ "work": {"a"}, // name substring
+ "10.0.0.": {"a", "b"}, // shared address prefix
+ "192.168.1.9": {"a"}, // a secondary address
+ "SERVER": {"c"}, // case-insensitive
+ " laptop ": {"b"}, // surrounding whitespace ignored
+ "nothing": {},
+ }
+ for needle, want := range cases {
+ got := filterNodeRows(rows, needle)
+ if len(got) != len(want) {
+ t.Errorf("filter %q matched %d rows, want %d", needle, len(got), len(want))
+ continue
+ }
+ for i, key := range want {
+ if got[i].key != key {
+ t.Errorf("filter %q row %d = %q, want %q", needle, i, got[i].key, key)
+ }
+ }
+ }
+
+ // An empty filter is not a filter.
+ if got := filterNodeRows(rows, " "); len(got) != len(rows) {
+ t.Errorf("blank filter dropped rows: %d of %d", len(got), len(rows))
+ }
+}
+
+func TestMergeKeepsOfflineMembersListed(t *testing.T) {
+ rows := mergeNodes(nodeFeeds{
+ discovered: []availableNode{
+ {HostUUID: "up", Name: "up-host", IPAddress: "10.0.0.1", Port: 14318,
+ LastSeen: seenAgo(2 * time.Second)},
+ },
+ members: []clusterNode{
+ {NodeUUID: "up", Name: "up-host", State: "joined"},
+ {NodeUUID: "gone", Name: "gone-host", State: "joined", IPAddress: "10.0.0.9"},
+ },
+ })
+
+ if len(rows) != 2 {
+ t.Fatalf("got %d rows, want both members listed", len(rows))
+ }
+
+ gone := findRow(t, rows, "gone-host")
+ if gone.presence != presenceOffline {
+ t.Errorf("departed member presence = %v, want Offline", gone.presence)
+ }
+ if gone.membership != membershipMember {
+ t.Errorf("departed member membership = %v, want Member", gone.membership)
+ }
+
+ up := findRow(t, rows, "up-host")
+ if up.presence != presenceOnline {
+ t.Errorf("live member presence = %v, want Online", up.presence)
+ }
+}
+
+// TestMergeDoesNotAgeOutDiscoveryOnItsOwnClock is the regression guard for a
+// healthy peer being marked Offline for going quiet.
+//
+// The scanner reports a node only when its record changes, so a peer with a
+// stable advertisement stops producing events and its timestamp stops advancing
+// — while the peer is perfectly reachable. Grading that age marked it Offline
+// after 45 seconds. Eviction belongs to the backend, which has real evidence
+// (mDNS silence plus a TCP probe plus inference traffic); a node still in the
+// snapshot has survived that and must be shown as reachable.
+//
+// An hour-old timestamp is used deliberately: under the previous rule it was
+// forty-eight thresholds stale.
+func TestMergeDoesNotAgeOutDiscoveryOnItsOwnClock(t *testing.T) {
+ rows := mergeNodes(nodeFeeds{
+ discovered: []availableNode{
+ {HostUUID: "fresh", Name: "fresh", IPAddress: "10.0.0.1", Port: 14318,
+ LastSeen: seenAgo(5 * time.Second)},
+ {HostUUID: "quiet", Name: "quiet", IPAddress: "10.0.0.2", Port: 14318,
+ LastSeen: seenAgo(time.Hour)},
+ {HostUUID: "never", Name: "never", IPAddress: "10.0.0.3", Port: 14318},
+ },
+ })
+
+ for _, name := range []string{"fresh", "quiet", "never"} {
+ if got := findRow(t, rows, name).presence; got != presenceOnline {
+ t.Errorf("%s presence = %v, want Online: it is in the snapshot with an address",
+ name, got)
+ }
+ }
+}
+
+// TestMergeNeedsSomewhereToReachANode mirrors the desktop app's rule, whose
+// nodeInfoUp starts as `Boolean(ipAddress) && port > 0`. A snapshot entry with
+// nowhere to connect is not a reachable node.
+func TestMergeNeedsSomewhereToReachANode(t *testing.T) {
+ rows := mergeNodes(nodeFeeds{
+ discovered: []availableNode{
+ {HostUUID: "noaddr", Name: "noaddr", Port: 14318},
+ {HostUUID: "noport", Name: "noport", IPAddress: "10.0.0.4"},
+ },
+ })
+
+ for _, name := range []string{"noaddr", "noport"} {
+ if got := findRow(t, rows, name).presence; got != presenceOffline {
+ t.Errorf("%s presence = %v, want Offline", name, got)
+ }
+ }
+}
+
+// TestMergeDeduplicatesAcrossFeeds checks one machine appearing in all three
+// feeds renders as a single row carrying every feed's contribution.
+func TestMergeDeduplicatesAcrossFeeds(t *testing.T) {
+ rows := mergeNodes(nodeFeeds{
+ discovered: []availableNode{{
+ HostUUID: "u1",
+ Name: "host",
+ IPAddress: "10.0.0.5",
+ LastSeen: seenAgo(time.Second),
+ Trusted: true,
+ Models: []string{"llama3.2", "qwen3"},
+ }},
+ members: []clusterNode{{NodeUUID: "u1", Name: "host", State: "joined"}},
+ manual: []manualNode{{ID: "m1", Address: "10.0.0.5", NodeInfoUp: true}},
+ })
+
+ if len(rows) != 1 {
+ t.Fatalf("one machine produced %d rows", len(rows))
+ }
+ row := rows[0]
+ if row.manualID != "m1" {
+ t.Errorf("manual handle lost in merge: %q", row.manualID)
+ }
+ if row.membership != membershipMember {
+ t.Errorf("membership = %v, want Member", row.membership)
+ }
+ if row.modelCount() != 2 {
+ t.Errorf("model count = %d, want 2", row.modelCount())
+ }
+}
+
+// TestMergeManualProbeBeatsDiscoverySilence covers a host on a network that
+// filters multicast: it never announces, but a successful probe is direct
+// evidence that it is up.
+func TestMergeManualProbeBeatsDiscoverySilence(t *testing.T) {
+ rows := mergeNodes(nodeFeeds{
+ manual: []manualNode{
+ {ID: "m1", Name: "reachable", Address: "10.0.0.7", NodeInfoUp: true},
+ {ID: "m2", Name: "dead", Address: "10.0.0.8"},
+ },
+ })
+
+ if got := findRow(t, rows, "reachable").presence; got != presenceOnline {
+ t.Errorf("probed-up manual node presence = %v, want Online", got)
+ }
+ if got := findRow(t, rows, "dead").presence; got != presenceOffline {
+ t.Errorf("unreachable manual node presence = %v, want Offline", got)
+ }
+}
+
+// TestMembershipGovernsInvitability is the guard for re-inviting a node that
+// already has a relationship. Only a standalone node may be invited.
+func TestMembershipGovernsInvitability(t *testing.T) {
+ cases := map[nodeMembership]bool{
+ membershipNone: true,
+ membershipMember: false,
+ membershipForeign: false,
+ membershipPending: false,
+ }
+ for membership, want := range cases {
+ if got := membership.invitable(); got != want {
+ t.Errorf("%v invitable = %v, want %v", membership, got, want)
+ }
+ }
+}
+
+// TestMergeMarksSelf checks this machine is identified and always reads online.
+func TestMergeMarksSelf(t *testing.T) {
+ rows := mergeNodes(nodeFeeds{
+ discovered: []availableNode{
+ {HostUUID: "me", Name: "this-host", LastSeen: seenAgo(time.Hour)},
+ {HostUUID: "other", Name: "other-host", LastSeen: seenAgo(time.Second)},
+ },
+ selfUUID: "me",
+ })
+
+ self := findRow(t, rows, "this-host")
+ if !self.self {
+ t.Error("self node not marked")
+ }
+ if self.presence != presenceOnline {
+ t.Errorf("self presence = %v; this machine is by definition reachable", self.presence)
+ }
+ if rows[0].name != "this-host" {
+ t.Errorf("self sorted to position of %q, want first", rows[0].name)
+ }
+}
+
+// TestMergeSortsOnlineBeforeOffline checks reachable nodes lead the list. The
+// offline one here is a cluster member discovery has never reported, which is
+// what an unreachable node now looks like.
+func TestMergeSortsOnlineBeforeOffline(t *testing.T) {
+ rows := mergeNodes(nodeFeeds{
+ discovered: []availableNode{
+ {HostUUID: "z", Name: "zzz", IPAddress: "10.0.0.2", Port: 14318,
+ LastSeen: seenAgo(time.Second)},
+ },
+ members: []clusterNode{{ID: "a", NodeUUID: "a", Name: "aaa", State: "joined"}},
+ })
+
+ if rows[0].name != "zzz" {
+ t.Errorf("first row = %q, want the online node despite its later name", rows[0].name)
+ }
+}
+
+// TestMergeForeignClusterNotInvitable checks a node clustered elsewhere is
+// distinguished from one of ours.
+func TestMergeForeignClusterNotInvitable(t *testing.T) {
+ rows := mergeNodes(nodeFeeds{
+ discovered: []availableNode{
+ {HostUUID: "f", Name: "foreign", Clustered: true, LastSeen: seenAgo(time.Second)},
+ {HostUUID: "s", Name: "standalone", LastSeen: seenAgo(time.Second)},
+ },
+ })
+
+ if got := findRow(t, rows, "foreign").membership; got != membershipForeign {
+ t.Errorf("foreign membership = %v", got)
+ }
+ if got := findRow(t, rows, "standalone").membership; !got.invitable() {
+ t.Errorf("standalone node reported not invitable (%v)", got)
+ }
+}
+
+func TestMergeEmptyFeeds(t *testing.T) {
+ if rows := mergeNodes(nodeFeeds{}); len(rows) != 0 {
+ t.Errorf("empty feeds produced %d rows", len(rows))
+ }
+}
diff --git a/services/nvpair-tui/ui/nodeswire.go b/services/nvpair-tui/ui/nodeswire.go
new file mode 100644
index 00000000..c29abaa5
--- /dev/null
+++ b/services/nvpair-tui/ui/nodeswire.go
@@ -0,0 +1,104 @@
+// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+package ui
+
+// The wire shapes behind the Nodes tab. One machine is described by three
+// different services, so their payloads are collected here and folded into the
+// single nodeRow the view renders (see mergeNodes).
+
+// availableNode mirrors the broker's discovery boundary shape (the
+// discovery:get-nodes element and discovery:nodes-changed payload entry).
+type availableNode struct {
+ ID string `json:"id"`
+ HostUUID string `json:"hostUuid"`
+ Name string `json:"name"`
+ IPAddress string `json:"ipAddress"`
+ // IPAddresses is every address the node published, in its own ranked order
+ // with IPAddress first. A multi-homed node has no single address every peer
+ // can reach — a direct-connect link only works from the machine on its far
+ // end — so a client that dials the node must walk this list rather than
+ // treating IPAddress as the only answer. Omitted when there is just one.
+ IPAddresses []string `json:"ipAddresses"`
+ Port int `json:"port"`
+ // LastSeen is when the scanner last WROTE this record — not when anything
+ // last confirmed the node was alive. nvpair-node-scanner is explicit that it
+ // is not a liveness clock: the mDNS browser reports a node only when its
+ // record changes, so a healthy peer's value freezes at first discovery, and
+ // the local node's advances only when it republishes.
+ //
+ // Decoded because it is on the wire, and deliberately unused. Presence comes
+ // from whether the backend still lists the node (see discoveredPresence);
+ // grading this as an age marked reachable peers Offline and made the local
+ // row count upward forever. Do not reintroduce a threshold on it.
+ LastSeen int64 `json:"lastSeen"` // Unix seconds
+ // Trusted: this node is a paired cluster peer of ours. Clustered: it belongs
+ // to some cluster (advertises a cluster-uuid), whether or not we're paired
+ // with it. Either one makes it non-invitable — an already-clustered peer
+ // rejects a fresh pairing (it must leave/be removed first).
+ Trusted bool `json:"trusted"`
+ Clustered bool `json:"clustered"`
+
+ // The broker enriches each discovered node with the model inventory it
+ // learned from that peer's engine-manager, so a remote node's models need
+ // no extra call: Models is the flat de-duplicated union, ModelsByEngine
+ // attributes each to the engine serving it, and LoadedByEngine names those
+ // currently resident in memory.
+ Models []string `json:"models"`
+ ModelsByEngine map[string][]string `json:"modelsByEngine"`
+ LoadedByEngine map[string][]string `json:"loadedByEngine"`
+}
+
+// clusterIdentity is this node's principal, from cluster:get-node-id.
+type clusterIdentity struct {
+ NodeUUID string `json:"nodeUuid"`
+ NodeID string `json:"nodeId"`
+ Name string `json:"name"`
+ ClusterID string `json:"clusterId"`
+}
+
+// clusterNode mirrors nvpair-cluster-manager's ClusterNode (a member or
+// pending invitee), the element of nodes:get-initial / nodes:changed.
+type clusterNode struct {
+ ID string `json:"id"`
+ NodeUUID string `json:"nodeUuid"`
+ Name string `json:"name"`
+ IPAddress string `json:"ipAddress"`
+ Port int `json:"port"`
+ State string `json:"state"`
+}
+
+// clusterInvite is the broker-facing view of a pairing session
+// (cluster:invite-received push / cluster:invite-node result).
+type clusterInvite struct {
+ InviteID string `json:"inviteId"`
+ FromNodeName string `json:"fromNodeName"`
+ Pin *string `json:"pin"`
+ State string `json:"state"`
+}
+
+// manualNode is the subset of nvpair-manual-nodes' ManualNodeStatus shown.
+type manualNode struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ Address string `json:"address"`
+ OllamaUp bool `json:"ollama_up"`
+ NodeInfoUp bool `json:"node_info_up"`
+}
+
+// rejectReason renders a machine reason from a rejected invite as human text.
+// reasonIncorrectPIN is the cluster manager's reason code for a PIN that failed
+// verification, as opposed to a transport or protocol failure. It is the one
+// outcome with a specific remedy, so it gets specific copy.
+const reasonIncorrectPIN = "incorrect-pin"
+
+func rejectReason(reason string) string {
+ switch reason {
+ case "already-clustered":
+ return "already in a cluster"
+ case "":
+ return "rejected by peer"
+ default:
+ return reason
+ }
+}
diff --git a/services/nvpair-tui/ui/nodetelemetry.go b/services/nvpair-tui/ui/nodetelemetry.go
new file mode 100644
index 00000000..74839879
--- /dev/null
+++ b/services/nvpair-tui/ui/nodetelemetry.go
@@ -0,0 +1,240 @@
+// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+package ui
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net"
+ "net/http"
+ "strconv"
+ "strings"
+ "time"
+
+ "nvpair-shared/noderec"
+
+ tea "github.com/charmbracelet/bubbletea"
+)
+
+// The node-info HTTP contract. Hardware telemetry is the one thing the broker's
+// JSON-RPC surface does not carry: it keeps GPU, CPU, and memory readings
+// internally but does not put them on the discovery wire, so a client that wants
+// them has to ask each node directly. The desktop app does the same thing, and
+// these values match its cadence so the two behave alike.
+const (
+ nodeInfoPath = "/v1/node-info"
+ nodeInfoDefaultPort = 14318
+ nodeInfoPollInterval = 2 * time.Second
+ nodeInfoPollTimeout = 1500 * time.Millisecond
+ nodeInfoSelfHost = "127.0.0.1"
+)
+
+// nodeTelemetry is one node-info reading. The GPU, CPU, and memory shapes are
+// the canonical ones from nvpair-shared/noderec, which node-info's response uses
+// field-for-field.
+type nodeTelemetry struct {
+ GPUs []noderec.GPUInfo `json:"GPUs"`
+ CPU *noderec.CPUInfo `json:"cpu"`
+ Memory *noderec.MemoryInfo `json:"memory"`
+ // TelemetryValid reports whether the dynamic sample is usable at all; a node
+ // with no GPU telemetry source still answers, with this false.
+ TelemetryValid bool `json:"telemetryValid"`
+ MSSince int64 `json:"msSince"`
+}
+
+// nodeTelemetryMsg carries a poll result. A failure is not surfaced as an error
+// toast: an unreachable node is an ordinary, expected state here, and the detail
+// screen simply says telemetry is unavailable.
+type nodeTelemetryMsg struct {
+ nodeKey string
+ // gen identifies the polling chain this reading belongs to. It matters
+ // because the reply is what schedules the next tick: a reading from a
+ // previous visit to the same node would otherwise be accepted and start a
+ // second chain alongside the current one, doubling the poll rate on every
+ // close-and-reopen. Matching on the node alone is not enough — it is the
+ // same node.
+ gen int
+ telemetry nodeTelemetry
+ err error
+}
+
+// nodeTelemetryTickMsg schedules the next poll.
+//
+// gen identifies the polling chain that scheduled it. Bubble Tea has no way to
+// cancel a pending tea.Tick, so closing and re-opening the same node's detail
+// screen left the previous chain's tick in flight; matching on the node key
+// alone accepted it, and each re-open added another self-sustaining chain
+// polling the same endpoint.
+type nodeTelemetryTickMsg struct {
+ nodeKey string
+ gen int
+}
+
+// maxTelemetryBody bounds a node-info reply. The real payload is a few hundred
+// bytes; this is generous while still refusing to buffer whatever a peer feels
+// like sending. Without a bound, a hostile or broken node on the LAN could make
+// this client allocate until it died — the request timeout limits how long a
+// read takes, not how much it yields.
+const maxTelemetryBody = 256 << 10 // 256 KiB
+
+// telemetryClient is shared so connections are pooled across polls rather than
+// opening a socket every two seconds.
+//
+// Redirects are refused. The address comes from mDNS or a typed manual entry, so
+// following one would let a peer point this poll at an arbitrary host — loopback
+// or a metadata endpoint included — and a node-info endpoint has no legitimate
+// reason to redirect.
+var telemetryClient = &http.Client{
+ Timeout: nodeInfoPollTimeout,
+ CheckRedirect: func(*http.Request, []*http.Request) error {
+ return http.ErrUseLastResponse
+ },
+}
+
+// pollTelemetryCmd fetches one node's readings.
+//
+// Only the node whose detail screen is open is polled, unlike the desktop's
+// sweep of every known node. The terminal shows one machine's detail at a time,
+// so a sweep would put N requests on the network to render one panel — and this
+// runs on the headless hosts least able to spare that.
+// addresses is tried in the order the node ranked them, since a multi-homed
+// node's first address may be a link only some peers can reach.
+func pollTelemetryCmd(key string, gen int, addresses []string, port int) tea.Cmd {
+ if len(addresses) == 0 {
+ return nil
+ }
+ return func() tea.Msg {
+ var lastErr error
+ for _, address := range addresses {
+ t, err := fetchTelemetry(nodeInfoURL(address, port))
+ if err == nil {
+ return nodeTelemetryMsg{nodeKey: key, gen: gen, telemetry: t}
+ }
+ lastErr = err
+ }
+ return nodeTelemetryMsg{nodeKey: key, gen: gen, err: lastErr}
+ }
+}
+
+// fetchTelemetry reads one node-info endpoint.
+func fetchTelemetry(url string) (nodeTelemetry, error) {
+ ctx, cancel := context.WithTimeout(context.Background(), nodeInfoPollTimeout)
+ defer cancel()
+
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
+ if err != nil {
+ return nodeTelemetry{}, err
+ }
+ resp, err := telemetryClient.Do(req)
+ if err != nil {
+ return nodeTelemetry{}, err
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ return nodeTelemetry{}, fmt.Errorf("node-info returned %s", resp.Status)
+ }
+ var t nodeTelemetry
+ if err := json.NewDecoder(io.LimitReader(resp.Body, maxTelemetryBody)).Decode(&t); err != nil {
+ return nodeTelemetry{}, err
+ }
+ return t, nil
+}
+
+// telemetryTickCmd schedules the next poll for a node's polling chain.
+func telemetryTickCmd(key string, gen int) tea.Cmd {
+ return tea.Tick(nodeInfoPollInterval, func(time.Time) tea.Msg {
+ return nodeTelemetryTickMsg{nodeKey: key, gen: gen}
+ })
+}
+
+// nodeInfoURL builds the endpoint for a node. Discovery reports the node-info
+// port per node; the constant is only the fallback for an entry that has none
+// yet, such as a manual node whose first probe has not landed.
+func nodeInfoURL(address string, port int) string {
+ if port <= 0 {
+ port = nodeInfoDefaultPort
+ }
+ return "http://" + net.JoinHostPort(address, strconv.Itoa(port)) + nodeInfoPath
+}
+
+// summary renders the readings as compact lines for the detail screen. Returns
+// nil when the node reported nothing worth showing.
+func (t nodeTelemetry) summary() []string {
+ lines := make([]string, 0, len(t.GPUs)+2)
+ for _, g := range t.GPUs {
+ lines = append(lines, " "+gpuLine(g, t.TelemetryValid))
+ }
+ if t.CPU != nil {
+ name := t.CPU.Name
+ if name == "" {
+ name = "CPU"
+ }
+ detail := fmt.Sprintf(" %-28s %3d%%", truncate(name, 28), t.CPU.UtilizationPercent)
+ if t.CPU.Cores > 0 {
+ detail += fmt.Sprintf(" %d cores", t.CPU.Cores)
+ }
+ lines = append(lines, detail)
+ }
+ if t.Memory != nil && t.Memory.TotalBytes > 0 {
+ lines = append(lines, fmt.Sprintf(" %-28s %s / %s", "RAM",
+ humanBytes(t.Memory.UsedBytes), humanBytes(t.Memory.TotalBytes)))
+ }
+ return lines
+}
+
+// gpuLine renders one GPU. Utilization is omitted when the node reports its
+// dynamic sample as unusable, rather than printing a zero that reads as idle.
+func gpuLine(g noderec.GPUInfo, valid bool) string {
+ name := g.Name
+ if name == "" {
+ name = "GPU"
+ }
+ util := " --%"
+ if valid {
+ util = fmt.Sprintf("%3d%%", g.UtilizationPercent)
+ }
+ line := fmt.Sprintf("%-28s %s", truncate(name, 28), util)
+ if g.VramBytes > 0 {
+ line += fmt.Sprintf(" %s / %s", humanBytes(g.VramUsedBytes), humanBytes(g.VramBytes))
+ }
+ return line
+}
+
+// humanBytes renders a byte count in the largest unit that keeps it readable.
+func humanBytes(b uint64) string {
+ const unit = 1024
+ if b < unit {
+ return strconv.FormatUint(b, 10) + " B"
+ }
+ value := float64(b)
+ units := []string{"KiB", "MiB", "GiB", "TiB"}
+ idx := -1
+ for value >= unit && idx < len(units)-1 {
+ value /= unit
+ idx++
+ }
+ if value >= 100 {
+ return fmt.Sprintf("%.0f %s", value, units[idx])
+ }
+ return fmt.Sprintf("%.1f %s", value, units[idx])
+}
+
+// telemetryHosts is the addresses to poll, in preference order. This machine is
+// reached over loopback: its own advertised address may be a link a peer uses to
+// reach it rather than one it can usefully dial itself.
+func telemetryHosts(node nodeRow) []string {
+ if node.self {
+ return []string{nodeInfoSelfHost}
+ }
+ if len(node.addresses) > 0 {
+ return node.addresses
+ }
+ if a := strings.TrimSpace(node.address); a != "" {
+ return []string{a}
+ }
+ return nil
+}
diff --git a/services/nvpair-tui/ui/nodetelemetry_test.go b/services/nvpair-tui/ui/nodetelemetry_test.go
new file mode 100644
index 00000000..e0b27e66
--- /dev/null
+++ b/services/nvpair-tui/ui/nodetelemetry_test.go
@@ -0,0 +1,329 @@
+// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+package ui
+
+import (
+ "encoding/json"
+ "net"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "strconv"
+ "strings"
+ "testing"
+
+ "nvpair-shared/noderec"
+ "nvpair-tui/rpc"
+)
+
+// TestTelemetryReplyIsGenerationScoped is the companion guard: the reply, not
+// the tick, is what schedules the next poll, so a reply from a previous visit
+// to the same node would start a second chain alongside the current one and
+// double the poll rate on every close-and-reopen.
+func TestTelemetryReplyIsGenerationScoped(t *testing.T) {
+ node := nodeRow{key: "n1", name: "n1", address: "10.0.0.4", port: 14318}
+ first := newNodeDetail(nil, node)
+ second := newNodeDetail(nil, node)
+ second.SetSize(100, 30)
+
+ // The older screen's in-flight poll lands on the newer screen.
+ cmd, _ := second.update(nodeTelemetryMsg{
+ nodeKey: node.key, gen: first.telemetryGen,
+ telemetry: nodeTelemetry{TelemetryValid: true},
+ })
+ if cmd != nil {
+ t.Error("a superseded chain's reply scheduled another poll; the chain will double")
+ }
+
+ // Its own reply does continue the chain.
+ cmd, _ = second.update(nodeTelemetryMsg{
+ nodeKey: node.key, gen: second.telemetryGen,
+ telemetry: nodeTelemetry{TelemetryValid: true},
+ })
+ if cmd == nil {
+ t.Error("the screen's own reply did not schedule the next poll; telemetry stops")
+ }
+}
+
+// TestTelemetryStartsWhenAnAddressArrivesLate checks a node opened before
+// discovery resolved it still gets telemetry. Nothing schedules a tick when
+// there is nothing to poll, and the reply is what continues the chain, so
+// without an explicit restart the panel read "unavailable" for the whole visit.
+func TestTelemetryStartsWhenAnAddressArrivesLate(t *testing.T) {
+ d := newNodeDetail(nil, nodeRow{key: "peer", name: "peer"}) // no address yet
+ d.SetSize(100, 30)
+
+ if cmd := d.telemetryCmd(); cmd != nil {
+ t.Fatal("polled a node with no address")
+ }
+ if d.telemetryRunning {
+ t.Fatal("claims a chain is running with nothing to poll")
+ }
+
+ params, _ := json.Marshal([]availableNode{{
+ HostUUID: "peer", Name: "peer", IPAddress: "10.0.0.9", Port: 14318,
+ }})
+ cmd := d.handleNotification(&rpc.Message{
+ Method: "discovery:nodes-changed", Params: params,
+ })
+
+ if cmd == nil {
+ t.Error("an address arriving did not start the telemetry chain")
+ }
+ if d.node.address != "10.0.0.9" {
+ t.Errorf("address = %q, want the one discovery reported", d.node.address)
+ }
+}
+
+// TestTelemetryChainsAreGenerationScoped is the regression guard for polling
+// chains piling up. Bubble Tea cannot cancel a pending tick, so closing and
+// re-opening a node's detail screen left the old chain's tick in flight; keyed
+// on the node alone it was accepted and extended, and every re-open added
+// another chain polling the same endpoint forever.
+func TestTelemetryChainsAreGenerationScoped(t *testing.T) {
+ node := nodeRow{key: "n1", name: "n1", address: "10.0.0.4", port: 14318}
+
+ first := newNodeDetail(nil, node)
+ second := newNodeDetail(nil, node)
+ if first.telemetryGen == second.telemetryGen {
+ t.Fatal("two detail screens share a chain id, so neither can retire the other's ticks")
+ }
+
+ // The newer screen ignores the older chain's tick.
+ if cmd, _ := second.update(nodeTelemetryTickMsg{
+ nodeKey: node.key, gen: first.telemetryGen,
+ }); cmd != nil {
+ t.Error("a superseded chain's tick was extended; polling chains will accumulate")
+ }
+
+ // And still continues its own.
+ if cmd, _ := second.update(nodeTelemetryTickMsg{
+ nodeKey: node.key, gen: second.telemetryGen,
+ }); cmd == nil {
+ t.Error("the screen's own tick did not continue its chain")
+ }
+}
+
+// TestPollTelemetryWalksEveryAddress checks the poll tries a node's other
+// published addresses. A multi-homed node's first address may be a link this
+// machine cannot reach, and giving up on it reported the node as having no
+// telemetry at all.
+func TestPollTelemetryWalksEveryAddress(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ _, _ = w.Write([]byte(`{"telemetryValid":true}`))
+ }))
+ defer srv.Close()
+
+ host, port, err := net.SplitHostPort(strings.TrimPrefix(srv.URL, "http://"))
+ if err != nil {
+ t.Fatalf("split test server address: %v", err)
+ }
+ p, _ := strconv.Atoi(port)
+
+ // An unreachable address first — the reserved TEST-NET-1 block — then the
+ // one that answers.
+ cmd := pollTelemetryCmd("n1", 1, []string{"192.0.2.1", host}, p)
+ msg, ok := cmd().(nodeTelemetryMsg)
+ if !ok {
+ t.Fatalf("got %T, want nodeTelemetryMsg", cmd())
+ }
+ if msg.err != nil {
+ t.Errorf("poll failed despite a reachable second address: %v", msg.err)
+ }
+ if !msg.telemetry.TelemetryValid {
+ t.Error("no telemetry decoded from the address that answered")
+ }
+}
+
+// TestNodeInfoURL pins the endpoint shape, including the fallback for an entry
+// whose node-info port is not known yet.
+func TestNodeInfoURL(t *testing.T) {
+ if got := nodeInfoURL("10.0.0.5", 14318); got != "http://10.0.0.5:14318/v1/node-info" {
+ t.Errorf("url = %q", got)
+ }
+ if got := nodeInfoURL("10.0.0.5", 0); !strings.Contains(got, strconv.Itoa(nodeInfoDefaultPort)) {
+ t.Errorf("url = %q, want the default port when none is known", got)
+ }
+ // An IPv6 literal has to be bracketed or the port parses as part of the host.
+ if got := nodeInfoURL("fe80::1", 14318); !strings.Contains(got, "[fe80::1]:14318") {
+ t.Errorf("url = %q, want a bracketed IPv6 host", got)
+ }
+}
+
+// TestTelemetryHostUsesLoopbackForSelf checks this machine is polled over
+// loopback: its advertised address may be a link only peers can reach.
+func TestTelemetryHostsUseLoopbackForSelf(t *testing.T) {
+ if got := telemetryHosts(nodeRow{self: true, address: "10.0.0.5"}); len(got) != 1 || got[0] != nodeInfoSelfHost {
+ t.Errorf("self hosts = %v, want just %q", got, nodeInfoSelfHost)
+ }
+ if got := telemetryHosts(nodeRow{address: "10.0.0.5"}); len(got) != 1 || got[0] != "10.0.0.5" {
+ t.Errorf("remote hosts = %v", got)
+ }
+}
+
+// TestPollTelemetryDecodesResponse exercises the real HTTP path against a stub
+// serving the node-info contract, so the JSON tags stay pinned to the producer's
+// (notably the capitalised "GPUs" key).
+func TestPollTelemetryDecodesResponse(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != nodeInfoPath {
+ t.Errorf("polled %q, want %q", r.URL.Path, nodeInfoPath)
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(`{
+ "GPUs":[{"name":"Test GPU","vram_bytes":8589934592,"vram_used_bytes":1073741824,"utilization_percent":42}],
+ "cpu":{"name":"Test CPU","cores":8,"utilization_percent":13},
+ "memory":{"total_bytes":34359738368,"used_bytes":8589934592},
+ "telemetryValid":true,
+ "msSince":120
+ }`))
+ }))
+ defer srv.Close()
+
+ host, port := splitTestServer(t, srv.URL)
+ msg, ok := pollTelemetryCmd("key", 1, []string{host}, port)().(nodeTelemetryMsg)
+ if !ok {
+ t.Fatal("poll produced the wrong message type")
+ }
+ if msg.err != nil {
+ t.Fatalf("poll failed: %v", msg.err)
+ }
+ if msg.nodeKey != "key" {
+ t.Errorf("nodeKey = %q, want the key it was asked for", msg.nodeKey)
+ }
+ if len(msg.telemetry.GPUs) != 1 {
+ t.Fatalf("decoded %d GPUs, want 1 — check the \"GPUs\" JSON key", len(msg.telemetry.GPUs))
+ }
+ if got := msg.telemetry.GPUs[0].UtilizationPercent; got != 42 {
+ t.Errorf("GPU utilization = %d, want 42", got)
+ }
+ if msg.telemetry.CPU == nil || msg.telemetry.CPU.Cores != 8 {
+ t.Error("CPU block did not decode")
+ }
+ if msg.telemetry.Memory == nil || msg.telemetry.Memory.TotalBytes == 0 {
+ t.Error("memory block did not decode")
+ }
+ if !msg.telemetry.TelemetryValid {
+ t.Error("telemetryValid did not decode")
+ }
+}
+
+// TestPollTelemetryReportsFailure checks a non-200 is an error rather than being
+// decoded as an empty reading, which would render as a machine with no hardware.
+func TestPollTelemetryReportsFailure(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(http.StatusForbidden)
+ }))
+ defer srv.Close()
+
+ host, port := splitTestServer(t, srv.URL)
+ msg := pollTelemetryCmd("key", 1, []string{host}, port)().(nodeTelemetryMsg)
+ if msg.err == nil {
+ t.Error("a 403 was treated as a successful reading")
+ }
+}
+
+// TestPollTelemetrySkipsUnknownAddress checks a node with no address issues no
+// request at all.
+func TestPollTelemetrySkipsUnknownAddress(t *testing.T) {
+ if cmd := pollTelemetryCmd("key", 1, nil, 14318); cmd != nil {
+ t.Error("polled a node with no known address")
+ }
+}
+
+// TestTelemetrySummaryOmitsUtilizationWhenStale checks an unusable sample does
+// not print 0%, which would read as an idle GPU.
+func TestTelemetrySummaryOmitsUtilizationWhenStale(t *testing.T) {
+ tel := nodeTelemetry{
+ GPUs: []noderec.GPUInfo{{Name: "GPU", VramBytes: 1 << 30, UtilizationPercent: 0}},
+ TelemetryValid: false,
+ }
+ line := strings.Join(tel.summary(), "\n")
+ if strings.Contains(line, "0%") {
+ t.Errorf("stale sample rendered as 0%% utilization: %q", line)
+ }
+ if !strings.Contains(line, "--") {
+ t.Errorf("stale sample should read as unknown: %q", line)
+ }
+
+ tel.TelemetryValid = true
+ tel.GPUs[0].UtilizationPercent = 55
+ if got := strings.Join(tel.summary(), "\n"); !strings.Contains(got, "55%") {
+ t.Errorf("valid sample did not render utilization: %q", got)
+ }
+}
+
+func TestTelemetrySummaryEmpty(t *testing.T) {
+ if lines := (nodeTelemetry{}).summary(); len(lines) != 0 {
+ t.Errorf("empty telemetry produced %d lines", len(lines))
+ }
+}
+
+func TestHumanBytes(t *testing.T) {
+ cases := map[uint64]string{
+ 0: "0 B",
+ 512: "512 B",
+ 1024: "1.0 KiB",
+ 1 << 30: "1.0 GiB",
+ 8 * (1 << 30): "8.0 GiB",
+ 32 * (1 << 30): "32.0 GiB",
+ // At three digits the decimal stops earning its place.
+ 128 * (1 << 30): "128 GiB",
+ 4 * (1 << 40): "4.0 TiB",
+ }
+ for in, want := range cases {
+ if got := humanBytes(in); got != want {
+ t.Errorf("humanBytes(%d) = %q, want %q", in, got, want)
+ }
+ }
+}
+
+// TestDetailHardwareUnavailableWhenPollFails checks the panel is explicit rather
+// than silently blank when a node cannot be reached.
+func TestDetailHardwareUnavailableWhenPollFails(t *testing.T) {
+ d := newNodeDetail(nil, nodeRow{key: "k", name: "peer", presence: presenceOffline})
+ if got := d.hardwareBlock(); !strings.Contains(got, "not reachable") {
+ t.Errorf("hardware block = %q, want an explanation", got)
+ }
+
+ d.node.presence = presenceOnline
+ if got := d.hardwareBlock(); !strings.Contains(got, "unavailable") {
+ t.Errorf("hardware block = %q", got)
+ }
+
+ d.telemetryOK = true
+ d.telemetry = nodeTelemetry{
+ GPUs: []noderec.GPUInfo{{Name: "Test GPU", VramBytes: 1 << 30, UtilizationPercent: 7}},
+ TelemetryValid: true,
+ }
+ if got := d.hardwareBlock(); !strings.Contains(got, "Test GPU") {
+ t.Errorf("hardware block = %q, want the GPU name", got)
+ }
+}
+
+// TestDetailIgnoresTelemetryForOtherNodes checks a late reply for a node the
+// operator has navigated away from does not overwrite the current one.
+func TestDetailIgnoresTelemetryForOtherNodes(t *testing.T) {
+ d := newNodeDetail(nil, nodeRow{key: "current", self: true})
+ d.update(nodeTelemetryMsg{
+ nodeKey: "stale", gen: d.telemetryGen,
+ telemetry: nodeTelemetry{TelemetryValid: true},
+ })
+ if d.telemetryOK {
+ t.Error("accepted a reading addressed to a different node")
+ }
+}
+
+func splitTestServer(t *testing.T, raw string) (string, int) {
+ t.Helper()
+ u, err := url.Parse(raw)
+ if err != nil {
+ t.Fatalf("parse test server url: %v", err)
+ }
+ port, err := strconv.Atoi(u.Port())
+ if err != nil {
+ t.Fatalf("parse test server port: %v", err)
+ }
+ return u.Hostname(), port
+}
diff --git a/services/nvpair-tui/ui/proxies.go b/services/nvpair-tui/ui/proxies.go
deleted file mode 100644
index 42c36dca..00000000
--- a/services/nvpair-tui/ui/proxies.go
+++ /dev/null
@@ -1,374 +0,0 @@
-// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
-// SPDX-License-Identifier: Apache-2.0
-
-package ui
-
-import (
- "fmt"
- "strconv"
- "strings"
-
- "nvpair-shared/engines"
- "nvpair-tui/rpc"
-
- "github.com/charmbracelet/bubbles/key"
- "github.com/charmbracelet/bubbles/table"
- "github.com/charmbracelet/bubbles/textinput"
- tea "github.com/charmbracelet/bubbletea"
-)
-
-// proxyNode mirrors a discovered upstream as reported by the proxy's
-// nodes/list (nvpair-proxy/discovery.go Node).
-type proxyNode struct {
- ID string `json:"id"`
- Host string `json:"host"`
- Port int `json:"port"`
-}
-
-// buildProxyEngines makes one tab per engine, in the shared table's order, so
-// an engine added there appears here rather than being silently absent from
-// this view.
-func buildProxyEngines() []*proxyEngine {
- all := engines.All()
- out := make([]*proxyEngine, 0, len(all))
- for _, e := range all {
- out = append(out, &proxyEngine{label: e.DisplayName, prefix: e.ComponentName(), table: newTable(nil)})
- }
- return out
-}
-
-// proxyEngine is one reverse proxy the broker fronts. They all speak the same
-// routing/failover contract; only the JSON-RPC prefix and label differ.
-type proxyEngine struct {
- label string // "Ollama" / "LM Studio"
- prefix string // "ollama-proxy" / "lmstudio-proxy"
- ready bool
- port int
- selected string
- nodes []proxyNode
- table table.Model
-}
-
-// proxiesView shows both reverse proxies: per-engine status (ready/port/
-// selected node) and the focused engine's discovered upstreams, with
-// actions to select a node and set the listen port.
-type proxiesView struct {
- client *rpc.Client
- engines []*proxyEngine
- focus int
- portInput textinput.Model
- editingPort bool
- status string
-
- width, height int
-}
-
-type proxyStatusMsg struct {
- idx int
- ready bool
- port int
- err error
-}
-
-type proxyNodesMsg struct {
- idx int
- nodes []proxyNode
- err error
-}
-
-type proxySelectedMsg struct {
- idx int
- id string
- err error
-}
-
-type proxyActionMsg struct {
- what string
- err error
-}
-
-var (
- proxyFocusKey = key.NewBinding(key.WithKeys("g"), key.WithHelp("g", "toggle engine"))
- proxySelectKey = key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "select node"))
- proxyPortKey = key.NewBinding(key.WithKeys("p"), key.WithHelp("p", "set port"))
- proxyAutoKey = key.NewBinding(key.WithKeys("a"), key.WithHelp("a", "auto-select"))
-)
-
-func newProxiesView(client *rpc.Client) *proxiesView {
- ti := textinput.New()
- ti.Placeholder = "port"
- ti.CharLimit = 5
- v := &proxiesView{
- client: client,
- portInput: ti,
- engines: buildProxyEngines(),
- }
- return v
-}
-
-func (v *proxiesView) Title() string { return "Proxies" }
-
-func (v *proxiesView) Init() tea.Cmd {
- var cmds []tea.Cmd
- for i, e := range v.engines {
- cmds = append(cmds,
- call(v.client, e.prefix+":subscribe", nil, func(_ *rpc.Message, _ error) tea.Msg { return nil }),
- v.statusCmd(i),
- v.nodesCmd(i),
- v.selectedCmd(i),
- )
- }
- return tea.Batch(cmds...)
-}
-
-func (v *proxiesView) statusCmd(idx int) tea.Cmd {
- e := v.engines[idx]
- return call(v.client, e.prefix+":get-status", nil, func(msg *rpc.Message, err error) tea.Msg {
- if err != nil {
- return proxyStatusMsg{idx: idx, err: err}
- }
- var r struct {
- Ready bool `json:"ready"`
- Port int `json:"port"`
- }
- _ = decodeParams(msg.Result, &r)
- return proxyStatusMsg{idx: idx, ready: r.Ready, port: r.Port}
- })
-}
-
-func (v *proxiesView) nodesCmd(idx int) tea.Cmd {
- e := v.engines[idx]
- return call(v.client, e.prefix+":nodes/list", nil, func(msg *rpc.Message, err error) tea.Msg {
- if err != nil {
- return proxyNodesMsg{idx: idx, err: err}
- }
- var r struct {
- Nodes []proxyNode `json:"nodes"`
- }
- _ = decodeParams(msg.Result, &r)
- return proxyNodesMsg{idx: idx, nodes: r.Nodes}
- })
-}
-
-func (v *proxiesView) selectedCmd(idx int) tea.Cmd {
- e := v.engines[idx]
- return call(v.client, e.prefix+":node/selected", nil, func(msg *rpc.Message, err error) tea.Msg {
- if err != nil {
- return proxySelectedMsg{idx: idx, err: err}
- }
- var r struct {
- ID string `json:"id"`
- }
- _ = decodeParams(msg.Result, &r)
- return proxySelectedMsg{idx: idx, id: r.ID}
- })
-}
-
-func (v *proxiesView) SetSize(w, h int) {
- v.width, v.height = w, h
- const sel, port = 3, 7
- id := clampWidth((w-sel-port-2)/2, 10)
- host := clampWidth(w-sel-port-id-2, 10)
- cols := []table.Column{
- {Title: "SEL", Width: sel},
- {Title: "ID", Width: id},
- {Title: "HOST", Width: host},
- {Title: "PORT", Width: port},
- }
- for _, e := range v.engines {
- e.table.SetColumns(cols)
- e.table.SetWidth(w)
- e.table.SetHeight(clampWidth(h-6, 1))
- }
-}
-
-func (v *proxiesView) CapturingInput() bool { return v.editingPort }
-
-func (v *proxiesView) Update(msg tea.Msg) tea.Cmd {
- switch msg := msg.(type) {
- case proxyStatusMsg:
- if msg.err == nil {
- v.engines[msg.idx].ready = msg.ready
- v.engines[msg.idx].port = msg.port
- }
- return nil
- case proxyNodesMsg:
- if msg.err == nil {
- v.engines[msg.idx].nodes = msg.nodes
- v.refreshRows(msg.idx)
- }
- return nil
- case proxySelectedMsg:
- if msg.err == nil {
- v.engines[msg.idx].selected = msg.id
- v.refreshRows(msg.idx)
- }
- return nil
- case proxyActionMsg:
- if msg.err != nil {
- v.status = msg.what + " failed: " + msg.err.Error()
- } else {
- v.status = msg.what + " ok"
- }
- return nil
- case NotificationMsg:
- return v.handleNotification(msg.Msg)
- case tea.KeyMsg:
- return v.handleKey(msg)
- }
- return nil
-}
-
-func (v *proxiesView) handleNotification(msg *rpc.Message) tea.Cmd {
- // Match against the prefixes the tabs were built from rather than a fixed
- // switch, so this follows the engine table instead of hardcoding both the
- // names and their positions.
- idx := -1
- for i, e := range v.engines {
- if strings.HasPrefix(msg.Method, e.prefix+":") {
- idx = i
- break
- }
- }
- if idx < 0 {
- return nil
- }
- if strings.HasSuffix(msg.Method, ":ready") {
- var r struct {
- Port int `json:"port"`
- }
- _ = decodeParams(msg.Params, &r)
- v.engines[idx].ready = true
- if r.Port != 0 {
- v.engines[idx].port = r.Port
- }
- return nil
- }
- // Any node lifecycle / selection event: refresh that engine's list
- // and current selection rather than tracking deltas by hand.
- if strings.Contains(msg.Method, ":node/") {
- return tea.Batch(v.nodesCmd(idx), v.selectedCmd(idx))
- }
- return nil
-}
-
-func (v *proxiesView) handleKey(msg tea.KeyMsg) tea.Cmd {
- if v.editingPort {
- switch msg.String() {
- case "enter":
- return v.submitPort()
- case "esc":
- v.editingPort = false
- v.portInput.Blur()
- return nil
- }
- var cmd tea.Cmd
- v.portInput, cmd = v.portInput.Update(msg)
- return cmd
- }
-
- switch {
- case key.Matches(msg, proxyFocusKey):
- v.focus = (v.focus + 1) % len(v.engines)
- return nil
- case key.Matches(msg, proxyPortKey):
- v.editingPort = true
- v.portInput.SetValue(strconv.Itoa(v.engines[v.focus].port))
- v.portInput.Focus()
- return textinput.Blink
- case key.Matches(msg, proxySelectKey):
- return v.selectHighlighted()
- case key.Matches(msg, proxyAutoKey):
- return v.selectNode("")
- }
- var cmd tea.Cmd
- v.engines[v.focus].table, cmd = v.engines[v.focus].table.Update(msg)
- return cmd
-}
-
-func (v *proxiesView) selectHighlighted() tea.Cmd {
- e := v.engines[v.focus]
- idx := e.table.Cursor()
- if idx < 0 || idx >= len(e.nodes) {
- return nil
- }
- return v.selectNode(e.nodes[idx].ID)
-}
-
-func (v *proxiesView) selectNode(id string) tea.Cmd {
- e := v.engines[v.focus]
- return call(v.client, e.prefix+":node/select", map[string]string{"id": id}, func(_ *rpc.Message, err error) tea.Msg {
- return proxyActionMsg{what: "select", err: err}
- })
-}
-
-func (v *proxiesView) submitPort() tea.Cmd {
- v.editingPort = false
- v.portInput.Blur()
- port, err := strconv.Atoi(strings.TrimSpace(v.portInput.Value()))
- if err != nil || port <= 0 || port > 65535 {
- v.status = "invalid port"
- return nil
- }
- e := v.engines[v.focus]
- return call(v.client, e.prefix+":set-port", map[string]int{"port": port}, func(_ *rpc.Message, err error) tea.Msg {
- return proxyActionMsg{what: "set-port", err: err}
- })
-}
-
-func (v *proxiesView) refreshRows(idx int) {
- e := v.engines[idx]
- rows := make([]table.Row, 0, len(e.nodes))
- for _, n := range e.nodes {
- marker := ""
- if n.ID != "" && n.ID == e.selected {
- marker = "*"
- }
- rows = append(rows, table.Row{marker, n.ID, n.Host, strconv.Itoa(n.Port)})
- }
- e.table.SetRows(rows)
-}
-
-func (v *proxiesView) View() string {
- var b strings.Builder
- for i, e := range v.engines {
- b.WriteString(v.engineStatusLine(i, e))
- b.WriteByte('\n')
- }
- b.WriteByte('\n')
- focused := v.engines[v.focus]
- b.WriteString(titleStyle.Render(focused.label + " upstreams"))
- b.WriteByte('\n')
- if len(focused.nodes) == 0 {
- b.WriteString(footerStyle.Render("No upstreams discovered."))
- } else {
- b.WriteString(focused.table.View())
- }
- if v.editingPort {
- b.WriteString("\nset " + focused.label + " port: " + v.portInput.View())
- }
- if v.status != "" {
- b.WriteString("\n" + footerStyle.Render(v.status))
- }
- return b.String()
-}
-
-func (v *proxiesView) engineStatusLine(i int, e *proxyEngine) string {
- state := statusErrStyle.Render("down")
- if e.ready {
- state = statusOKStyle.Render(fmt.Sprintf("ready :%d", e.port))
- }
- sel := e.selected
- if sel == "" {
- sel = "auto"
- }
- marker := " "
- if i == v.focus {
- marker = "> "
- }
- return fmt.Sprintf("%s%-10s %s selected=%s", marker, e.label, state, sel)
-}
-
-func (v *proxiesView) Help() []key.Binding {
- return []key.Binding{proxyFocusKey, proxySelectKey, proxyAutoKey, proxyPortKey}
-}
diff --git a/services/nvpair-tui/ui/proxystatus.go b/services/nvpair-tui/ui/proxystatus.go
new file mode 100644
index 00000000..453ea038
--- /dev/null
+++ b/services/nvpair-tui/ui/proxystatus.go
@@ -0,0 +1,216 @@
+// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+package ui
+
+import (
+ "fmt"
+ "strings"
+
+ "nvpair-shared/engines"
+ "nvpair-tui/rpc"
+
+ tea "github.com/charmbracelet/bubbletea"
+)
+
+// proxyEngine is one engine's facade on the proxy the broker fronts. Every
+// facade speaks the same control contract; only the JSON-RPC prefix and the
+// label differ.
+//
+// A facade, not a process: one nvpair-proxy serves them all, so readiness and
+// port are per-engine here while a crash is reported once against the process
+// (see serviceWorkers). The prefix is the per-engine ComponentName, because
+// addressing "ollama-proxy:nodes/list" means that engine regardless of which
+// process happens to serve it.
+type proxyEngine struct {
+ label string // "Ollama" / "LM Studio"
+ prefix string // "ollama-proxy" / "lmstudio-proxy"
+ // engine is the engine-manager engine name this facade fronts, used to line
+ // a facade up with the engine it serves.
+ engine string
+ ready bool
+ port int
+}
+
+// proxyTracker keeps every facade's readiness and listen port current from the
+// broker. It is a shared component rather than a tab: where a request is served
+// belongs with the jobs, while which port to listen on belongs with the rest of
+// the service configuration.
+//
+// It deliberately does not track upstreams. The proxies' node lists are a
+// second, staler view of the machines the Nodes tab already owns — and one that
+// kept showing peers from a cluster this node had left.
+type proxyTracker struct {
+ engines []*proxyEngine
+}
+
+type proxyStatusMsg struct {
+ idx int
+ ready bool
+ port int
+ err error
+}
+
+// engineDisplayName is an engine's wire id rendered the way the operator sees
+// it elsewhere.
+//
+// The Jobs and Errors tabs name engines too, and neither has an engine list to
+// resolve a label from — they receive the id on the wire and nothing else. The
+// pairing is already fixed here, in the proxy inventory, so this reads it from
+// the same place rather than repeating it. An unknown id passes through, which
+// is what a new engine would produce until this list caught up.
+func engineDisplayName(engine string) string {
+ want := strings.ToLower(strings.TrimSpace(engine))
+ for _, e := range newProxyTracker().engines {
+ if e.engine == want {
+ return e.label
+ }
+ }
+ return engine
+}
+
+// newProxyTracker builds one entry per engine, in the shared table's order, so
+// an engine added there appears here rather than being silently absent from
+// every screen that reads a facade's port.
+func newProxyTracker() *proxyTracker {
+ all := engines.All()
+ out := make([]*proxyEngine, 0, len(all))
+ for _, e := range all {
+ out = append(out, &proxyEngine{
+ label: e.DisplayName,
+ prefix: e.ComponentName(),
+ engine: e.Name,
+ })
+ }
+ return &proxyTracker{engines: out}
+}
+
+// indexForEngine finds the proxy fronting an engine-manager engine, or -1. Each
+// proxy serves exactly one engine type, which is what lets a node's engine list
+// show the client-facing port beside each engine's own.
+func (p *proxyTracker) indexForEngine(engine string) int {
+ want := strings.ToLower(strings.TrimSpace(engine))
+ for i, e := range p.engines {
+ if e.engine == want {
+ return i
+ }
+ }
+ return -1
+}
+
+// portForEngine is the listen port of the proxy fronting an engine, and whether
+// that proxy is up. A port with the proxy down is not an endpoint a client can
+// use, so callers distinguish the two.
+func (p *proxyTracker) portForEngine(engine string) (int, bool) {
+ idx := p.indexForEngine(engine)
+ if idx < 0 {
+ return 0, false
+ }
+ return p.engines[idx].port, p.engines[idx].ready
+}
+
+// init subscribes to every engine's facade and fetches its current status.
+// Subscribing is idempotent at the broker, so several consumers may each hold a
+// tracker.
+func (p *proxyTracker) init(client *rpc.Client) tea.Cmd {
+ cmds := make([]tea.Cmd, 0, len(p.engines)*2)
+ for i, e := range p.engines {
+ cmds = append(cmds,
+ call(client, e.prefix+":subscribe", nil, func(_ *rpc.Message, _ error) tea.Msg { return nil }),
+ p.statusCmd(client, i),
+ )
+ }
+ return tea.Batch(cmds...)
+}
+
+func (p *proxyTracker) statusCmd(client *rpc.Client, idx int) tea.Cmd {
+ e := p.engines[idx]
+ return call(client, e.prefix+":get-status", nil, func(msg *rpc.Message, err error) tea.Msg {
+ if err != nil {
+ return proxyStatusMsg{idx: idx, err: err}
+ }
+ var r struct {
+ Ready bool `json:"ready"`
+ Port int `json:"port"`
+ }
+ _ = decodeParams(msg.Result, &r)
+ return proxyStatusMsg{idx: idx, ready: r.Ready, port: r.Port}
+ })
+}
+
+// A proxy's listen port is not changed from here. It is one of the three
+// fields engine:apply-settings writes together against a revision, so it goes
+// through the node detail's settings editor with the server port and the launch
+// arguments. This tracker only reads: readiness, and the port in force.
+
+// apply folds a status reply into the tracker.
+func (p *proxyTracker) apply(msg proxyStatusMsg) {
+ if msg.err != nil || msg.idx < 0 || msg.idx >= len(p.engines) {
+ return
+ }
+ p.engines[msg.idx].ready = msg.ready
+ p.engines[msg.idx].port = msg.port
+}
+
+// handleNotification consumes a proxy push. A ready frame carries the port
+// directly; an error frame takes the proxy down.
+//
+// Both directions are handled deliberately. Readiness only ever being set meant
+// a proxy that died stayed green with its old port on screen, pointing clients
+// at an endpoint that had stopped listening — the one thing this strip exists to
+// tell them.
+func (p *proxyTracker) handleNotification(msg *rpc.Message) {
+ idx := -1
+ switch {
+ case strings.HasPrefix(msg.Method, "lmstudio-proxy:"):
+ idx = 1
+ case strings.HasPrefix(msg.Method, "proxy:"):
+ idx = 0
+ default:
+ return
+ }
+ switch {
+ case strings.HasSuffix(msg.Method, ":ready"):
+ var r struct {
+ Port int `json:"port"`
+ }
+ _ = decodeParams(msg.Params, &r)
+ p.engines[idx].ready = true
+ if r.Port != 0 {
+ p.engines[idx].port = r.Port
+ }
+ case strings.HasSuffix(msg.Method, ":error"):
+ // The port is kept: it is still the configured value, and showing the
+ // proxy as down at a known port is more useful than blanking it.
+ p.engines[idx].ready = false
+ }
+}
+
+// refreshCmd re-reads every facade's status.
+//
+// Polled on the view's tick as well as pushed, because a proxy going away does
+// not always announce it — a crash, or a broker restart, produces no error frame
+// — and a readiness strip that can only be corrected by a push stays wrong
+// indefinitely.
+func (p *proxyTracker) refreshCmd(client *rpc.Client) tea.Cmd {
+ cmds := make([]tea.Cmd, 0, len(p.engines))
+ for i := range p.engines {
+ cmds = append(cmds, p.statusCmd(client, i))
+ }
+ return tea.Batch(cmds...)
+}
+
+// strip is the one-line summary of where local clients should point, and whether
+// anything is listening. Routing mode is stated because it is not adjustable:
+// the scheduler and proxies own placement.
+func (p *proxyTracker) strip() string {
+ parts := make([]string, 0, len(p.engines))
+ for _, e := range p.engines {
+ if e.ready {
+ parts = append(parts, fmt.Sprintf("%s %s", e.label, statusOKStyle.Render(fmt.Sprintf(":%d", e.port))))
+ continue
+ }
+ parts = append(parts, fmt.Sprintf("%s %s", e.label, statusErrStyle.Render("down")))
+ }
+ return strings.Join(parts, " ") + footerStyle.Render(" routing=automatic")
+}
diff --git a/services/nvpair-tui/ui/proxystatus_test.go b/services/nvpair-tui/ui/proxystatus_test.go
new file mode 100644
index 00000000..a4ccbb80
--- /dev/null
+++ b/services/nvpair-tui/ui/proxystatus_test.go
@@ -0,0 +1,74 @@
+// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+package ui
+
+import (
+ "testing"
+
+ "nvpair-tui/rpc"
+)
+
+// TestProxyErrorTakesProxyDown is the regression guard for a strip that could
+// only go green. Readiness was set on :ready and never cleared, so a proxy that
+// failed kept advertising its port — pointing local clients at something that
+// had stopped listening, which is the one thing this strip exists to tell them.
+func TestProxyErrorTakesProxyDown(t *testing.T) {
+ p := newProxyTracker()
+ p.handleNotification(&rpc.Message{
+ Method: "proxy:ready",
+ Params: []byte(`{"port":11434}`),
+ })
+ if port, ready := p.portForEngine("ollama"); !ready || port != 11434 {
+ t.Fatalf("after ready: port=%d ready=%v", port, ready)
+ }
+
+ p.handleNotification(&rpc.Message{Method: "proxy:error", Params: []byte(`{}`)})
+ port, ready := p.portForEngine("ollama")
+ if ready {
+ t.Error("proxy still reads ready after an error frame")
+ }
+ if port != 11434 {
+ t.Errorf("port = %d; the configured port should survive so the strip can name "+
+ "which endpoint is down", port)
+ }
+ if !contains(p.strip(), "down") {
+ t.Errorf("strip does not report the proxy down: %s", p.strip())
+ }
+
+ // The other proxy is untouched.
+ if _, ready := p.portForEngine("lmstudio"); ready {
+ t.Error("an ollama-proxy error changed the LM Studio proxy")
+ }
+}
+
+// TestProxyNotificationsAreScopedByPrefix checks the two proxies are told apart.
+// Their methods share a suffix and "lmstudio-proxy:" would match a naive
+// "proxy:" test, so a mix-up would report one proxy's state against the other.
+func TestProxyNotificationsAreScopedByPrefix(t *testing.T) {
+ p := newProxyTracker()
+ p.handleNotification(&rpc.Message{
+ Method: "lmstudio-proxy:ready",
+ Params: []byte(`{"port":1234}`),
+ })
+
+ if port, ready := p.portForEngine("lmstudio"); !ready || port != 1234 {
+ t.Errorf("lmstudio proxy: port=%d ready=%v, want 1234/true", port, ready)
+ }
+ if _, ready := p.portForEngine("ollama"); ready {
+ t.Error("an lmstudio-proxy frame marked the ollama proxy ready")
+ }
+}
+
+// TestPortForEngineDistinguishesDownFromUnknown checks a port is not treated as
+// usable just because it is known: an engine with no proxy and a proxy that is
+// down both have to read as unusable.
+func TestPortForEngineDistinguishesDownFromUnknown(t *testing.T) {
+ p := newProxyTracker()
+ if _, ready := p.portForEngine("ollama"); ready {
+ t.Error("a proxy that has never reported reads as ready")
+ }
+ if port, ready := p.portForEngine("vllm"); ready || port != 0 {
+ t.Errorf("unknown engine: port=%d ready=%v, want 0/false", port, ready)
+ }
+}
diff --git a/services/nvpair-tui/ui/repaint_test.go b/services/nvpair-tui/ui/repaint_test.go
new file mode 100644
index 00000000..6fc46703
--- /dev/null
+++ b/services/nvpair-tui/ui/repaint_test.go
@@ -0,0 +1,107 @@
+// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+package ui
+
+import (
+ "strings"
+ "testing"
+
+ tea "github.com/charmbracelet/bubbletea"
+)
+
+// The shell sizes the active view inside View, so SetSize runs on every frame
+// rather than only on a resize. That is what keeps the content budget and the
+// frame in agreement, but it makes idempotence a requirement: anything SetSize
+// touches is touched again on every tick and every keystroke.
+//
+// These pin the two pieces of state a repaint must not disturb. Both are easy
+// to break — logsView.SetSize calls render, which calls the viewport's
+// SetContent — and neither would show up in a frame-size test.
+//
+// Cost measured at the time of writing: about 0.5ms per frame with a full
+// 5000-line log buffer, which is why the simplicity is worth the repeated work.
+func TestScrollSurvivesEveryFrame(t *testing.T) {
+ m := newTestModel(defaultViews(nil)...)
+ m.width, m.height = 120, 40
+ m.resizeViews()
+ m.selectTab(4)
+
+ logs, ok := m.views[4].(*logsView)
+ if !ok {
+ t.Fatal("view 4 is not the logs tab")
+ }
+ for i := range 500 {
+ logs.Update(LogLineMsg{Line: "line " + strings.Repeat("x", i%20)})
+ }
+ _ = m.View()
+
+ // Scroll up, which also releases follow.
+ for range 10 {
+ updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("k")})
+ m = updated.(Model)
+ }
+ afterScroll := logs.vp.YOffset
+ if afterScroll == 0 {
+ t.Fatal("scrolling up did not move the viewport")
+ }
+ if logs.follow {
+ t.Error("scrolling up did not release follow")
+ }
+
+ // Repaint several times, as a tick would.
+ for range 5 {
+ _ = m.View()
+ }
+ if got := logs.vp.YOffset; got != afterScroll {
+ t.Errorf("repainting moved the viewport from %d to %d; scrolling back is impossible",
+ afterScroll, got)
+ }
+
+ // A new line arriving must not yank a scrolled-back operator to the bottom.
+ logs.Update(LogLineMsg{Line: "a new line"})
+ _ = m.View()
+ if got := logs.vp.YOffset; got != afterScroll {
+ t.Errorf("a new log line moved the viewport from %d to %d while follow was off",
+ afterScroll, got)
+ }
+}
+
+// The same question for the tables: the cursor must survive a repaint.
+func TestTableCursorSurvivesEveryFrame(t *testing.T) {
+ m := newTestModel(defaultViews(nil)...)
+ m.width, m.height = 120, 40
+ m.resizeViews()
+
+ nodes, ok := m.views[0].(*nodesView)
+ if !ok {
+ t.Fatal("first view is not the nodes tab")
+ }
+ discovered := make([]availableNode, 20)
+ for i := range discovered {
+ discovered[i] = availableNode{
+ HostUUID: string(rune('a' + i)),
+ Name: "node-" + string(rune('a'+i)),
+ IPAddress: "10.0.0.1",
+ }
+ }
+ nodes.feeds.discovered = discovered
+ nodes.rebuild()
+ _ = m.View()
+
+ for range 5 {
+ updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("j")})
+ m = updated.(Model)
+ }
+ after := nodes.table.Cursor()
+ if after == 0 {
+ t.Fatal("moving down did not move the cursor")
+ }
+
+ for range 5 {
+ _ = m.View()
+ }
+ if got := nodes.table.Cursor(); got != after {
+ t.Errorf("repainting moved the cursor from %d to %d", after, got)
+ }
+}
diff --git a/services/nvpair-tui/ui/rpccmd.go b/services/nvpair-tui/ui/rpccmd.go
index 007f56dd..64d09946 100644
--- a/services/nvpair-tui/ui/rpccmd.go
+++ b/services/nvpair-tui/ui/rpccmd.go
@@ -17,6 +17,22 @@ import (
// headroom, so a healthy call never times out under us.
const callTimeout = 35 * time.Second
+// uiTickInterval drives everything that ages on screen: the relative age
+// columns ("12s", "5m") and the expiry of transient status messages. Bubble Tea
+// only re-renders in response to a message, so without this the SEEN column sat
+// at whatever it read when the last notification happened to arrive.
+const uiTickInterval = time.Second
+
+// TickMsg is the shell's periodic re-render pulse, broadcast to every view.
+// Views that render relative times need no state to handle it; the redraw alone
+// refreshes them.
+type TickMsg struct{ Now time.Time }
+
+// uiTick schedules the next pulse. The root model re-arms it on each TickMsg.
+func uiTick() tea.Cmd {
+ return tea.Tick(uiTickInterval, func(t time.Time) tea.Msg { return TickMsg{Now: t} })
+}
+
// NotificationMsg carries one broker server-push frame into the Bubble
// Tea update loop. Every view receives it.
type NotificationMsg struct{ Msg *rpc.Message }
diff --git a/services/nvpair-tui/ui/service.go b/services/nvpair-tui/ui/service.go
new file mode 100644
index 00000000..8cf13e37
--- /dev/null
+++ b/services/nvpair-tui/ui/service.go
@@ -0,0 +1,795 @@
+// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+package ui
+
+import (
+ "fmt"
+ "strings"
+ "time"
+
+ "nvpair-shared/applog"
+ "nvpair-shared/engines"
+ svcerrors "nvpair-shared/errors"
+ "nvpair-tui/rpc"
+
+ "github.com/charmbracelet/bubbles/key"
+ "github.com/charmbracelet/bubbles/table"
+ "github.com/charmbracelet/bubbles/textinput"
+ tea "github.com/charmbracelet/bubbletea"
+)
+
+// crashPrefix is the id prefix the broker stamps on its sticky "subprocess X
+// exited unexpectedly" errors. Per-worker liveness is derived from the presence
+// of these in the errors:update snapshot, because the broker exposes no
+// dedicated worker-status RPC.
+const crashPrefix = "supervisor:subprocess-crashed:"
+
+// servicePollInterval is how often the broker is re-pinged for liveness+uptime.
+const servicePollInterval = 5 * time.Second
+
+// serviceWorkers are the workers the broker supervises, in the order the table
+// lists them. The names are the supervisor names the broker builds its crash
+// ids from, so every entry here must match one.
+//
+// The proxy appears once, as engines.ProxyComponent, because one nvpair-proxy
+// process hosts every engine's facade under one supervisor — the broker reports
+// one crash for the process, not one per engine. Keying a row on the per-engine
+// ComponentName would be silent in both directions: the real crash entry would
+// match no row, and the per-engine rows could never leave "ok". See the identity
+// split in nvpair-shared/engines, and the test that enforces it below.
+//
+// "errors" is listed even though its own crash cannot be observed this way:
+// nvpair-errors is the sink the crash reports are written to, so it cannot
+// report its own death. It reads "ok" whether alive or dead, and the row says
+// so — better than omitting a worker the operator never sees at all.
+var serviceWorkers = []string{
+ "scanner",
+ "node-info",
+ engines.ProxyComponent,
+ "workload-manager",
+ "engine-manager",
+ "manual-nodes",
+ "settings",
+ "cluster-manager",
+ "scheduler",
+ "errors",
+}
+
+// errorSinkWorker is the one worker the crash feed cannot describe, called out
+// in the table so "ok" is not read as confirmed liveness.
+const errorSinkWorker = "errors"
+
+// logLevels are the fleet-wide log levels, ordered least to most severe.
+var logLevels = []string{"debug", "info", "warn", "error"}
+
+// serviceItemKind is what activating a row does.
+type serviceItemKind int
+
+const (
+ // itemText opens an inline editor.
+ itemText serviceItemKind = iota
+ // itemChoice opens a picker over a fixed set of values.
+ itemChoice
+ // itemAction runs a command, after a confirmation when destructive.
+ itemAction
+)
+
+// serviceItem is one row of the configuration list.
+type serviceItem struct {
+ kind serviceItemKind
+ label string
+ help string
+ // suffix is the settings/get-*/set-* method pair for a persisted node
+ // setting. Empty for rows backed by something else.
+ suffix string
+ // destructive rows require an explicit confirmation keystroke.
+ destructive bool
+ // options are the values an itemChoice row offers, in the order the picker
+ // presents them.
+ options []string
+
+ strV string
+}
+
+// serviceView is the service-wide surface: is the service healthy, where are its
+// endpoints, and the settings and maintenance actions that apply to the whole
+// node.
+//
+// It absorbs the former Overview, Proxies port control, and Settings tabs. Those
+// were three tabs describing one thing — the state of the service on this
+// machine, and splitting them meant worker health lived nowhere near the log
+// level you would raise to diagnose it.
+//
+// Proxy ports are the exception: they moved on to each machine's node detail
+// screen, beside the engine each one fronts.
+type serviceView struct {
+ client *rpc.Client
+
+ workers table.Model
+ items []serviceItem
+ cursor int
+
+ brokerVersion string
+ uptime time.Duration
+ pingErr error
+ logLevel string
+
+ crashed map[string]svcerrors.ServiceError
+ // localNodeUUID is this host's stable UUID. The broker stamps local-origin
+ // reports with it, so a crash entry carrying a different node belongs to a
+ // peer and must be ignored — errors:update is the full cross-node snapshot
+ // when nvpair-errors runs with --peer-sync.
+ localNodeUUID string
+ // lastErrs is the most recent snapshot, retained so the crash table can be
+ // re-filtered once the local UUID resolves.
+ lastErrs []svcerrors.ServiceError
+
+ input textinput.Model
+ editing bool
+ // choosing is set while a picker is open over an itemChoice row, with
+ // choiceIdx the highlighted option. A picker rather than a cycle: cycling
+ // makes the operator guess what comes next, gives no way to back out once
+ // started, and hides the full set of values from someone who has not
+ // memorised it.
+ choosing bool
+ choiceIdx int
+ // confirming is the index of a destructive row awaiting its confirmation
+ // keystroke, or -1.
+ confirming int
+ status toast
+
+ width, height int
+}
+
+type serviceTickMsg struct{}
+
+type servicePingMsg struct {
+ version string
+ uptime time.Duration
+ err error
+}
+
+type serviceNodeIDMsg struct {
+ nodeUUID string
+ err error
+}
+
+type settingLoadedMsg struct {
+ idx int
+ strV string
+ err error
+}
+
+type settingSavedMsg struct {
+ idx int
+ err error
+}
+
+type logLevelSetMsg struct {
+ level string
+ err error
+}
+
+// wipeDataMsg asks the shell to quit and delete the per-user data directory once
+// the service tree is down. The deletion cannot happen here: the workers still
+// hold those files, and one shutting down afterwards would recreate what was
+// just removed.
+type wipeDataMsg struct{}
+
+var (
+ serviceUpKey = key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("up/k", "up"))
+ serviceDownKey = key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("down/j", "down"))
+ serviceActivateKey = key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "change"))
+ serviceConfirmKey = key.NewBinding(key.WithKeys("y"), key.WithHelp("y", "confirm"))
+
+ // The picker is laid out horizontally, so both axes move the highlight —
+ // whichever the operator reaches for.
+ choicePrevKey = key.NewBinding(key.WithKeys("left", "h", "up", "k"), key.WithHelp("←/→", "choose"))
+ choiceNextKey = key.NewBinding(key.WithKeys("right", "l", "down", "j"), key.WithHelp("→", "next"))
+ choiceApplyKey = key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "apply"))
+ choiceCancelKey = key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "cancel"))
+)
+
+func newServiceView(client *rpc.Client) *serviceView {
+ v := &serviceView{
+ client: client,
+ crashed: map[string]svcerrors.ServiceError{},
+ input: textinput.New(),
+ confirming: -1,
+ logLevel: "info",
+ items: []serviceItem{
+ // The proxy ports are deliberately not here. They belong
+ // beside the engine each one fronts, on that machine's node detail
+ // screen — keeping them here meant the two ports an operator has to
+ // tell apart were configured in different places.
+ {kind: itemChoice, label: "Service log level", options: logLevels,
+ help: "applies to the whole service fleet"},
+ // force-ports and cluster-auto-sync are deliberately absent. Both
+ // are persisted by nvpair-node-settings but nothing currently acts
+ // on them — the desktop app does not read them either — so offering
+ // them would imply an effect they do not have.
+ // Says "this machine" because it is stored per node and never
+ // propagates: nvpair-node-settings holds it as display-only sugar
+ // with no push and no peer sync, so each machine keeps its own
+ // label. Sitting unqualified under a setting that announces it
+ // "applies to the whole service fleet", it read as cluster-wide and
+ // silently was not.
+ {kind: itemText, label: "Cluster name", suffix: "cluster-friendly-name",
+ help: "this machine's own label for the cluster - not shared with peers"},
+ {kind: itemAction, label: "Reset all data and quit", destructive: true,
+ help: "deletes settings, cluster identity, and pairing"},
+ },
+ }
+ // Static: there is no per-worker operation, so a movable highlight would
+ // promise a selection that does nothing.
+ v.workers = newStaticTable(serviceWorkerColumns(defaultTableWidth))
+ return v
+}
+
+// serviceWorkerColumns is the worker table's layout, shared by construction and
+// resize so the two cannot drift.
+func serviceWorkerColumns(w int) []table.Column {
+ return layoutColumns(w, []column{
+ fixedCol("WORKER", 18),
+ fixedCol("STATUS", 6),
+ flexCol("DETAIL", 10, 1),
+ })
+}
+
+func (v *serviceView) Title() string { return "Service" }
+
+func (v *serviceView) Init() tea.Cmd {
+ cmds := []tea.Cmd{v.pingCmd(), v.tickCmd(), v.nodeIDCmd()}
+ for i := range v.items {
+ if c := v.loadCmd(i); c != nil {
+ cmds = append(cmds, c)
+ }
+ }
+ return tea.Batch(cmds...)
+}
+
+func (v *serviceView) pingCmd() tea.Cmd {
+ return call(v.client, "ping", nil, func(msg *rpc.Message, err error) tea.Msg {
+ if err != nil {
+ return servicePingMsg{err: err}
+ }
+ var r struct {
+ Version string `json:"version"`
+ UptimeMS int64 `json:"uptime_ms"`
+ }
+ _ = decodeParams(msg.Result, &r)
+ return servicePingMsg{version: r.Version, uptime: time.Duration(r.UptimeMS) * time.Millisecond}
+ })
+}
+
+// nodeIDCmd resolves this host's UUID so the crash table can drop peer-origin
+// entries from the cross-node errors:update snapshot.
+func (v *serviceView) nodeIDCmd() tea.Cmd {
+ return call(v.client, "cluster:get-node-id", nil, func(msg *rpc.Message, err error) tea.Msg {
+ if err != nil {
+ return serviceNodeIDMsg{err: err}
+ }
+ var id clusterIdentity
+ _ = decodeParams(msg.Result, &id)
+ return serviceNodeIDMsg{nodeUUID: id.NodeUUID}
+ })
+}
+
+func (v *serviceView) tickCmd() tea.Cmd {
+ return tea.Tick(servicePollInterval, func(time.Time) tea.Msg { return serviceTickMsg{} })
+}
+
+// logLevelCmd reads the current level when level is empty, otherwise sets it.
+// The broker fans a set out to every worker.
+func (v *serviceView) logLevelCmd(level string) tea.Cmd {
+ if level == "" {
+ // There is no getter in the applog contract, so the displayed level is
+ // whatever this session last set, seeded from the service default.
+ return nil
+ }
+ return call(v.client, applog.SetLevelMethod, applog.SetLevelParams{Level: level},
+ func(msg *rpc.Message, err error) tea.Msg {
+ if err != nil {
+ return logLevelSetMsg{err: err}
+ }
+ var r struct {
+ Level string `json:"level"`
+ }
+ _ = decodeParams(msg.Result, &r)
+ return logLevelSetMsg{level: r.Level}
+ })
+}
+
+func (v *serviceView) loadCmd(idx int) tea.Cmd {
+ it := v.items[idx]
+ if it.suffix == "" {
+ return nil
+ }
+ return call(v.client, "settings/get-"+it.suffix, nil, func(msg *rpc.Message, err error) tea.Msg {
+ if err != nil {
+ return settingLoadedMsg{idx: idx, err: err}
+ }
+ var r struct {
+ Value string `json:"value"`
+ }
+ _ = decodeParams(msg.Result, &r)
+ return settingLoadedMsg{idx: idx, strV: r.Value}
+ })
+}
+
+// SetSize records the budget and fixes the table's width. Its height is set in
+// View, from the chrome actually being rendered — see fitTable.
+func (v *serviceView) SetSize(w, h int) {
+ v.width, v.height = w, h
+ v.workers.SetColumns(serviceWorkerColumns(w))
+ v.workers.SetWidth(w)
+}
+
+// CapturingInput covers the picker and an armed confirmation as well as the text
+// editor. The picker navigates with keys the shell also uses (the digits jump
+// tabs), and an armed reset must answer the next key rather than let a tab
+// switch leave it armed behind a prompt that is no longer on screen.
+func (v *serviceView) CapturingInput() bool {
+ return v.editing || v.choosing || v.confirming >= 0
+}
+
+func (v *serviceView) Update(msg tea.Msg) tea.Cmd {
+ switch msg := msg.(type) {
+ case serviceTickMsg:
+ return tea.Batch(v.pingCmd(), v.tickCmd())
+
+ case servicePingMsg:
+ // Worker status is derived from reachability, so the table has to be
+ // rebuilt whenever that changes in either direction.
+ if (v.pingErr == nil) != (msg.err == nil) {
+ defer v.refreshWorkers()
+ }
+ v.pingErr = msg.err
+ if msg.err == nil {
+ v.brokerVersion = msg.version
+ v.uptime = msg.uptime
+ }
+ return nil
+
+ case serviceNodeIDMsg:
+ if msg.err == nil && msg.nodeUUID != "" {
+ v.localNodeUUID = msg.nodeUUID
+ v.rebuildCrashes(v.lastErrs)
+ }
+ return nil
+
+ case settingLoadedMsg:
+ if msg.err == nil {
+ v.items[msg.idx].strV = msg.strV
+ }
+ return nil
+
+ case settingSavedMsg:
+ if msg.err != nil {
+ v.status.error("save failed: %s", msg.err)
+ return nil
+ }
+ v.status.ok("%s saved", v.items[msg.idx].label)
+ return v.loadCmd(msg.idx)
+
+ case logLevelSetMsg:
+ if msg.err != nil {
+ v.status.error("log level change failed: %s", msg.err)
+ return nil
+ }
+ v.logLevel = msg.level
+ v.status.ok("log level set to %s for every service", msg.level)
+ return nil
+
+ case NotificationMsg:
+ if msg.Msg.Method == "errors:update" {
+ var errs []svcerrors.ServiceError
+ _ = decodeParams(msg.Msg.Params, &errs)
+ v.rebuildCrashes(errs)
+ }
+ return nil
+
+ case tea.KeyMsg:
+ return v.handleKey(msg)
+ }
+ return nil
+}
+
+func (v *serviceView) handleKey(msg tea.KeyMsg) tea.Cmd {
+ if v.editing {
+ switch msg.String() {
+ case "enter":
+ return v.submitEdit()
+ case "esc":
+ v.editing = false
+ v.input.Blur()
+ return nil
+ }
+ var cmd tea.Cmd
+ v.input, cmd = v.input.Update(msg)
+ return cmd
+ }
+
+ if v.choosing {
+ switch {
+ case key.Matches(msg, choicePrevKey):
+ v.moveChoice(-1)
+ case key.Matches(msg, choiceNextKey):
+ v.moveChoice(1)
+ case key.Matches(msg, choiceApplyKey):
+ return v.commitChoice()
+ case key.Matches(msg, choiceCancelKey):
+ v.closeChoice()
+ }
+ return nil
+ }
+
+ // A destructive row asks for one more keystroke. Anything other than the
+ // confirmation cancels, so a stray key never triggers it.
+ if v.confirming >= 0 {
+ idx := v.confirming
+ v.confirming = -1
+ if key.Matches(msg, serviceConfirmKey) {
+ return v.runAction(idx)
+ }
+ v.status.info("cancelled")
+ return nil
+ }
+
+ switch {
+ case key.Matches(msg, serviceUpKey):
+ if v.cursor > 0 {
+ v.cursor--
+ }
+ case key.Matches(msg, serviceDownKey):
+ if v.cursor < len(v.items)-1 {
+ v.cursor++
+ }
+ case key.Matches(msg, serviceActivateKey):
+ return v.activate()
+ }
+ return nil
+}
+
+func (v *serviceView) activate() tea.Cmd {
+ it := &v.items[v.cursor]
+ switch it.kind {
+ case itemChoice:
+ v.openChoice(it)
+ return nil
+
+ case itemText:
+ v.beginEdit(it.strV, it.label, 0)
+ return textinput.Blink
+
+ case itemAction:
+ if it.destructive {
+ v.confirming = v.cursor
+ v.status.arm("%s - press y to confirm, any other key to cancel", it.label)
+ return nil
+ }
+ return v.runAction(v.cursor)
+ }
+ return nil
+}
+
+func (v *serviceView) beginEdit(value, placeholder string, limit int) {
+ v.editing = true
+ v.input.SetValue(value)
+ v.input.Placeholder = placeholder
+ v.input.CharLimit = limit
+ v.input.Focus()
+}
+
+func (v *serviceView) submitEdit() tea.Cmd {
+ v.editing = false
+ v.input.Blur()
+ idx := v.cursor
+ it := v.items[idx]
+ val := strings.TrimSpace(v.input.Value())
+
+ return call(v.client, "settings/set-"+it.suffix, map[string]string{"value": val},
+ func(_ *rpc.Message, err error) tea.Msg {
+ return settingSavedMsg{idx: idx, err: err}
+ })
+}
+
+// runAction performs a confirmed action row.
+func (v *serviceView) runAction(idx int) tea.Cmd {
+ if v.items[idx].destructive {
+ return func() tea.Msg { return wipeDataMsg{} }
+ }
+ return nil
+}
+
+// openChoice opens the picker on the row's current value, so the highlighted
+// option is the one in force rather than always the first.
+func (v *serviceView) openChoice(it *serviceItem) {
+ if len(it.options) == 0 {
+ return
+ }
+ v.choosing = true
+ v.choiceIdx = indexOf(it.options, v.itemValue(*it))
+ v.SetSize(v.width, v.height)
+}
+
+func (v *serviceView) closeChoice() {
+ v.choosing = false
+ v.SetSize(v.width, v.height)
+}
+
+// commitChoice applies the highlighted option. Only the log level is a choice
+// row today, and it is applied through the broker's fleet-wide fan-out.
+func (v *serviceView) commitChoice() tea.Cmd {
+ it := v.items[v.cursor]
+ v.closeChoice()
+ if v.choiceIdx < 0 || v.choiceIdx >= len(it.options) {
+ return nil
+ }
+ chosen := it.options[v.choiceIdx]
+ if chosen == v.itemValue(it) {
+ v.status.info("%s unchanged", it.label)
+ return nil
+ }
+ return v.logLevelCmd(chosen)
+}
+
+// moveChoice steps the highlight by delta, clamping rather than wrapping so the
+// ends of the list are felt.
+func (v *serviceView) moveChoice(delta int) {
+ next := v.choiceIdx + delta
+ if next < 0 || next >= len(v.items[v.cursor].options) {
+ return
+ }
+ v.choiceIdx = next
+}
+
+// indexOf finds value in options, returning 0 when it is absent so the picker
+// always opens on a valid row.
+func indexOf(options []string, value string) int {
+ for i, o := range options {
+ if o == value {
+ return i
+ }
+ }
+ return 0
+}
+
+func (v *serviceView) rebuildCrashes(errs []svcerrors.ServiceError) {
+ v.lastErrs = errs
+ crashed := map[string]svcerrors.ServiceError{}
+ for _, e := range errs {
+ // errors:update is the full cross-node snapshot in a cluster, so a
+ // peer's crashed worker would otherwise paint this host's same-named
+ // worker DOWN. An empty NodeID is treated as local.
+ if v.localNodeUUID != "" && e.NodeID != "" && e.NodeID != v.localNodeUUID {
+ continue
+ }
+ if strings.HasPrefix(e.ID, crashPrefix) {
+ crashed[strings.TrimPrefix(e.ID, crashPrefix)] = e
+ }
+ }
+ v.crashed = crashed
+ v.refreshWorkers()
+}
+
+// refreshWorkers rebuilds the worker table, crashed workers first.
+//
+// The order matters because the table cannot be scrolled: on a short terminal
+// the tail is clipped and unreachable. Putting failures at the top means the
+// rows that are ever hidden are the ones reading "ok", which carry no
+// information anyway.
+func (v *serviceView) refreshWorkers() {
+ down := make([]table.Row, 0, len(v.crashed))
+ up := make([]table.Row, 0, len(serviceWorkers))
+ for _, w := range serviceWorkers {
+ if e, crashed := v.crashed[w]; crashed {
+ down = append(down, table.Row{workerLabel(w), "DOWN", e.Message})
+ continue
+ }
+ // A worker is only known good because the crash stream says nothing
+ // about it — and that stream comes through the broker. With the broker
+ // unreachable there is no such evidence, so claiming "ok" would be a
+ // green wall directly under a red "service not responding", which reads
+ // as the answer and is worse than admitting ignorance.
+ if v.pingErr != nil {
+ up = append(up, table.Row{workerLabel(w), "?", "unknown - the service is not responding"})
+ continue
+ }
+ detail := ""
+ if w == errorSinkWorker {
+ detail = "reports other crashes; cannot report its own"
+ }
+ up = append(up, table.Row{workerLabel(w), "ok", detail})
+ }
+ v.workers.SetRows(append(down, up...))
+}
+
+// workerLabel is a worker's name in the vocabulary the rest of the interface
+// uses.
+//
+// The identifiers are supervisor process names, and they collided with how the
+// same components are named elsewhere: the proxy process serves the endpoints
+// the Jobs tab calls "Ollama" and "LM Studio", so an operator reading
+// "nvpair-proxy DOWN" here had no way to connect it to the endpoint they had
+// just seen reported down there. The crash lookup still keys on the process
+// name; only the display changes.
+//
+// One row, named for what its death costs, because one process hosts every
+// engine's facade: when it dies, every endpoint goes with it.
+func workerLabel(worker string) string {
+ switch worker {
+ case "scanner":
+ return "node discovery"
+ case "node-info":
+ return "node telemetry"
+ case engines.ProxyComponent:
+ return "engine endpoints"
+ case "workload-manager":
+ return "job tracking"
+ case "engine-manager":
+ return "engines"
+ case "manual-nodes":
+ return "manual nodes"
+ case "settings":
+ return "node settings"
+ case "cluster-manager":
+ return "cluster"
+ case "scheduler":
+ return "scheduling"
+ case errorSinkWorker:
+ return "error reporting"
+ }
+ return worker
+}
+
+// hiddenWorkers is how many worker rows do not fit, for a note under the table.
+//
+// Measured against the visible data rows, not the table's total height: the
+// header occupies two of them, so treating the height as a row count reported
+// zero hidden while two workers were clipped off a table that cannot scroll.
+func (v *serviceView) hiddenWorkers() int {
+ // bubbles is asymmetric here: SetHeight takes the table's total height and
+ // subtracts the header internally, while Height returns what is left — the
+ // data rows. So this compares against Height directly; running it through
+ // visibleTableRows would subtract the header a second time.
+ hidden := len(v.workers.Rows()) - v.workers.Height()
+ if hidden < 0 {
+ return 0
+ }
+ return hidden
+}
+
+func (v *serviceView) View() string {
+ summary := statusOKStyle.Render(fmt.Sprintf("service v%s up %s",
+ v.brokerVersion, v.uptime.Round(time.Second)))
+ if v.pingErr != nil {
+ summary = statusErrStyle.Render(
+ "service not responding: " + v.pingErr.Error() +
+ " - press 5 for Logs, or q to quit and restart nvpair")
+ }
+
+ if len(v.workers.Rows()) == 0 {
+ v.refreshWorkers()
+ }
+
+ editor := ""
+ if v.editing {
+ editor = v.items[v.cursor].label + ": " + v.input.View()
+ }
+ if v.choosing {
+ editor = v.choiceRow()
+ }
+ heading := titleStyle.Render("Configuration")
+ items := v.itemList()
+ status := v.status.render()
+
+ // Sized like every other view: from the chrome actually being rendered.
+ // This was the last one still subtracting a hand-maintained constant, and
+ // on a short terminal it overran the budget — which cost the status line,
+ // the row that carries "press y to confirm" for the data reset.
+ //
+ // The note's row is reserved before the table is sized, unconditionally, and
+ // only its text is decided afterwards.
+ //
+ // It cannot be decided first: whether any worker is hidden depends on the
+ // height fitTable is about to choose. An earlier version guessed, guessed
+ // wrong between budgets 13 and 18 — the guess ignored the chrome above the
+ // table — and then substituted the real note after sizing, so one unbudgeted
+ // row appeared and the shell deleted the last line. That line is the status
+ // row, which carries "press y to confirm" for the data reset: the operator
+ // pressed enter on the one irreversible action, saw nothing change, and was
+ // left armed with no prompt.
+ //
+ // Reserving a row that turns out to be unused costs nothing, because the
+ // shell pads the frame. Adding one after sizing costs the bottom line.
+ const noteRow = " "
+
+ body := footerStyle.Render(" (too little room to list workers)")
+ hiddenNote := ""
+ if fitTable(&v.workers, v.height, summary, noteRow, heading, items, editor, status) {
+ body = v.workers.View()
+ if hidden := v.hiddenWorkers(); hidden > 0 {
+ hiddenNote = footerStyle.Render(fmt.Sprintf(
+ " %d more worker(s) not shown - any that had crashed would be listed first",
+ hidden))
+ }
+ }
+ if hiddenNote == "" {
+ // Keep the reserved row rather than reflowing: the reservation is what
+ // makes the arithmetic hold.
+ hiddenNote = noteRow
+ }
+
+ return joinLines(summary, body, hiddenNote, heading, items, editor, status)
+}
+
+// choiceRow renders the open picker: every option on one line with the
+// highlighted one marked, so the full set is visible while choosing.
+func (v *serviceView) choiceRow() string {
+ it := v.items[v.cursor]
+ cells := make([]string, 0, len(it.options))
+ for i, o := range it.options {
+ if i == v.choiceIdx {
+ cells = append(cells, tabActiveStyle.Render(o))
+ continue
+ }
+ cells = append(cells, tabInactiveStyle.Render(o))
+ }
+ return it.label + ": " + strings.Join(cells, " ")
+}
+
+func (v *serviceView) itemList() string {
+ lines := make([]string, 0, len(v.items))
+ for i, it := range v.items {
+ cursor := " "
+ if i == v.cursor {
+ cursor = "> "
+ }
+ line := fmt.Sprintf("%s%-26s %s", cursor, it.label, v.itemValue(it))
+ if it.help != "" && i == v.cursor {
+ line += footerStyle.Render(" " + it.help)
+ }
+ if i == v.cursor {
+ line = titleStyle.Render(line)
+ }
+ lines = append(lines, line)
+ }
+ return strings.Join(lines, "\n")
+}
+
+func (v *serviceView) itemValue(it serviceItem) string {
+ switch it.kind {
+ case itemChoice:
+ return v.logLevel
+ case itemAction:
+ return ""
+ default:
+ if it.strV == "" {
+ return footerStyle.Render("(unset)")
+ }
+ return it.strV
+ }
+}
+
+func (v *serviceView) Help() []key.Binding {
+ switch {
+ case v.editing:
+ // While the field has the keyboard, j and k type letters. The other
+ // four text-field views already branch here; this was the last one
+ // still advertising keys that no longer do what they say.
+ return inputHelp("save")
+ case v.confirming >= 0:
+ return []key.Binding{serviceConfirmKey}
+ case v.choosing:
+ return []key.Binding{choicePrevKey, choiceApplyKey, choiceCancelKey}
+ default:
+ // Only the action, not the movement. Every other tab leaves arrow and
+ // j/k navigation unadvertised — the four table tabs all do — and this
+ // one listing it was the sole inconsistency, spending two footer slots
+ // on the keys a user is least likely to need told. What is worth stating
+ // is enter, because "this row does something" is not guessable.
+ return []key.Binding{serviceActivateKey}
+ }
+}
diff --git a/services/nvpair-tui/ui/service_test.go b/services/nvpair-tui/ui/service_test.go
new file mode 100644
index 00000000..6b44b615
--- /dev/null
+++ b/services/nvpair-tui/ui/service_test.go
@@ -0,0 +1,505 @@
+// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+package ui
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/charmbracelet/bubbles/table"
+ tea "github.com/charmbracelet/bubbletea"
+ "nvpair-shared/engines"
+ svcerrors "nvpair-shared/errors"
+)
+
+// The Service tab's proxy row must key on the identity the broker actually
+// stamps on a proxy crash.
+//
+// One nvpair-proxy process hosts every engine's facade under one supervisor, so
+// the broker reports one crash for the process. Keying on the per-engine
+// ComponentName is silent in both directions: the real crash entry matches no
+// row, so a dead proxy is invisible in the one view meant to show worker
+// liveness, and the per-engine rows can never leave "ok". Nothing else in the
+// build catches that — the mismatch compiles and every other test passes — so
+// this assertion is what makes the identity rule in nvpair-shared/engines
+// enforceable rather than advisory.
+func TestServiceProxyRowMatchesTheBrokerCrashIdentity(t *testing.T) {
+ found := false
+ for _, w := range serviceWorkers {
+ if w == engines.ProxyComponent {
+ found = true
+ }
+ for _, e := range engines.All() {
+ if w == e.ComponentName() {
+ t.Errorf("worker row %q keys on a per-facade identity; the broker reports proxy crashes against %q",
+ w, engines.ProxyComponent)
+ }
+ }
+ }
+ if !found {
+ t.Fatalf("no worker row keys on %q, so a proxy crash would have no row at all: %v",
+ engines.ProxyComponent, serviceWorkers)
+ }
+
+ // End to end through the real matcher, with the id the broker builds.
+ v := newServiceView(nil)
+ v.localNodeUUID = "self-uuid"
+ v.rebuildCrashes([]svcerrors.ServiceError{{
+ ID: crashPrefix + engines.ProxyComponent,
+ Message: "proxy crashed",
+ NodeID: "self-uuid",
+ }})
+ if _, down := v.crashed[engines.ProxyComponent]; !down {
+ t.Fatalf("a proxy crash did not register: %v", v.crashed)
+ }
+}
+
+// TestRebuildCrashesFiltersByUUID: the worker table keeps only local-origin
+// crashes, keyed on this host's stable UUID (the value the broker stamps on
+// local reports). A peer's crash must be dropped, and a local UUID-stamped
+// crash must NOT be misclassified as remote.
+func TestRebuildCrashesFiltersByUUID(t *testing.T) {
+ v := newServiceView(nil)
+ v.localNodeUUID = "self-uuid"
+
+ crash := func(worker, nodeID string) svcerrors.ServiceError {
+ return svcerrors.ServiceError{ID: crashPrefix + worker, Message: worker + " crashed", NodeID: nodeID}
+ }
+ v.rebuildCrashes([]svcerrors.ServiceError{
+ crash("scanner", "self-uuid"), // local crash — keep
+ crash("proxy", "peer-uuid"), // a peer's crash — drop
+ })
+
+ if _, down := v.crashed["scanner"]; !down {
+ t.Fatal("local UUID-stamped crash should be surfaced, not filtered as remote")
+ }
+ if _, down := v.crashed["proxy"]; down {
+ t.Fatal("a peer's crash must be filtered out of the local service view")
+ }
+}
+
+// TestRebuildCrashesBeforeIdentity: before the local UUID resolves, all crashes
+// are kept (fail-open) so the view isn't blank during startup.
+func TestRebuildCrashesBeforeIdentity(t *testing.T) {
+ v := newServiceView(nil)
+ v.rebuildCrashes([]svcerrors.ServiceError{
+ {ID: crashPrefix + "scanner", Message: "x", NodeID: "whatever-uuid"},
+ })
+ if _, down := v.crashed["scanner"]; !down {
+ t.Fatal("crashes should be kept until the local UUID is known")
+ }
+}
+
+// TestServiceListsEverySupervisedWorker is the regression guard for the reported
+// gap: the table was missing the errors worker, and the scheduler too.
+//
+// The list is written out rather than derived from serviceWorkers, which would
+// only compare that slice with itself. It is the second opinion: a worker the
+// broker supervises and this table forgot is exactly the failure an operator
+// cannot see, so the names are restated here deliberately.
+func TestServiceListsEverySupervisedWorker(t *testing.T) {
+ // The broker's supervisor names, which its crash ids are built from. The
+ // proxy appears once, by process name: one nvpair-proxy hosts every
+ // engine's facade, so there is one supervisor entry and one crash id.
+ supervised := []string{
+ "scanner", "node-info", engines.ProxyComponent, "workload-manager",
+ "engine-manager", "manual-nodes", "settings", "cluster-manager",
+ "scheduler", "errors",
+ }
+ listed := make(map[string]bool, len(serviceWorkers))
+ for _, w := range serviceWorkers {
+ listed[w] = true
+ }
+ for _, w := range supervised {
+ if !listed[w] {
+ t.Errorf("supervised worker %q is not shown in the service table", w)
+ }
+ }
+ if len(serviceWorkers) != len(supervised) {
+ t.Errorf("table lists %d workers, broker supervises %d", len(serviceWorkers), len(supervised))
+ }
+}
+
+// TestErrorSinkRowExplainsItself checks the errors worker carries a caveat, so
+// its unconditional "ok" is not read as confirmed liveness.
+func TestErrorSinkRowExplainsItself(t *testing.T) {
+ v := newServiceView(nil)
+ v.refreshWorkers()
+
+ for i, w := range serviceWorkers {
+ if w != errorSinkWorker {
+ continue
+ }
+ row := v.workers.Rows()[i]
+ if row[2] == "" {
+ t.Error("the errors worker reads ok with no explanation that it cannot report its own crash")
+ }
+ return
+ }
+ t.Fatalf("%q not present in the worker list", errorSinkWorker)
+}
+
+// logLevelRow finds the log level row and puts the cursor on it.
+func logLevelRow(t *testing.T, v *serviceView) int {
+ t.Helper()
+ for i, it := range v.items {
+ if it.kind == itemChoice {
+ v.cursor = i
+ return i
+ }
+ }
+ t.Fatal("no choice row found")
+ return -1
+}
+
+func press(v *serviceView, k string) tea.Cmd {
+ if k == "enter" {
+ return v.handleKey(tea.KeyMsg{Type: tea.KeyEnter})
+ }
+ if k == "esc" {
+ return v.handleKey(tea.KeyMsg{Type: tea.KeyEsc})
+ }
+ return v.handleKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(k)})
+}
+
+// TestLogLevelOpensPicker checks enter presents the options rather than silently
+// stepping to the next one.
+func TestLogLevelOpensPicker(t *testing.T) {
+ v := newServiceView(nil)
+ logLevelRow(t, v)
+
+ if cmd := press(v, "enter"); cmd != nil {
+ t.Error("enter applied a change immediately instead of opening the picker")
+ }
+ if !v.choosing {
+ t.Fatal("picker did not open")
+ }
+ if !v.CapturingInput() {
+ t.Error("picker does not own the keyboard; a digit would jump tabs mid-choice")
+ }
+
+ // Every option must be visible while choosing.
+ row := v.choiceRow()
+ for _, level := range logLevels {
+ if !contains(row, level) {
+ t.Errorf("picker row %q omits %q", row, level)
+ }
+ }
+}
+
+// TestLogLevelPickerOpensOnCurrentValue checks the highlight starts on the level
+// in force, not always at the first option.
+func TestLogLevelPickerOpensOnCurrentValue(t *testing.T) {
+ v := newServiceView(nil)
+ logLevelRow(t, v)
+ v.logLevel = "warn"
+
+ press(v, "enter")
+ if got := logLevels[v.choiceIdx]; got != "warn" {
+ t.Errorf("picker opened on %q, want the current level warn", got)
+ }
+}
+
+// TestLogLevelPickerNavigationClamps checks the highlight moves on both axes and
+// stops at the ends rather than wrapping.
+func TestLogLevelPickerNavigationClamps(t *testing.T) {
+ v := newServiceView(nil)
+ logLevelRow(t, v)
+ v.logLevel = logLevels[0]
+ press(v, "enter")
+
+ press(v, "h")
+ if v.choiceIdx != 0 {
+ t.Errorf("moved before the first option to %d", v.choiceIdx)
+ }
+ press(v, "l")
+ if got := logLevels[v.choiceIdx]; got != logLevels[1] {
+ t.Errorf("after one step right = %q, want %q", got, logLevels[1])
+ }
+ // Walk past the end.
+ for range logLevels {
+ press(v, "j")
+ }
+ if v.choiceIdx != len(logLevels)-1 {
+ t.Errorf("walked past the last option to %d", v.choiceIdx)
+ }
+}
+
+// TestLogLevelPickerCancels checks esc closes without applying anything.
+func TestLogLevelPickerCancels(t *testing.T) {
+ v := newServiceView(nil)
+ logLevelRow(t, v)
+ v.logLevel = "info"
+ press(v, "enter")
+ press(v, "l") // highlight a different level
+
+ if cmd := press(v, "esc"); cmd != nil {
+ t.Error("esc issued a command")
+ }
+ if v.choosing {
+ t.Error("esc left the picker open")
+ }
+ if v.logLevel != "info" {
+ t.Errorf("level changed to %q despite cancelling", v.logLevel)
+ }
+}
+
+// TestLogLevelPickerAppliesSelection checks committing a different option issues
+// the change, and that re-picking the current one does not.
+func TestLogLevelPickerAppliesSelection(t *testing.T) {
+ v := newServiceView(nil)
+ logLevelRow(t, v)
+ v.logLevel = "info"
+
+ press(v, "enter")
+ press(v, "l")
+ cmd := press(v, "enter")
+ if cmd == nil {
+ t.Error("committing a different level issued no command")
+ }
+ if v.choosing {
+ t.Error("picker stayed open after applying")
+ }
+
+ // Re-selecting the level already in force is a no-op, not a redundant RPC.
+ press(v, "enter")
+ if cmd := press(v, "enter"); cmd != nil {
+ t.Error("re-selecting the current level issued a command")
+ }
+}
+
+// TestResetRequiresConfirmation is the guard on the one irreversible action in
+// the TUI: activating the row must only arm it, and only the confirmation key
+// may fire it.
+func TestResetRequiresConfirmation(t *testing.T) {
+ resetIdx := -1
+ v := newServiceView(nil)
+ for i, it := range v.items {
+ if it.destructive {
+ resetIdx = i
+ break
+ }
+ }
+ if resetIdx < 0 {
+ t.Fatal("no destructive row found")
+ }
+
+ v.cursor = resetIdx
+ if cmd := v.activate(); cmd != nil {
+ t.Error("activating the reset row acted immediately instead of asking to confirm")
+ }
+ if v.confirming != resetIdx {
+ t.Fatalf("confirming = %d, want %d", v.confirming, resetIdx)
+ }
+
+ // Any other key cancels.
+ v.handleKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("n")})
+ if v.confirming != -1 {
+ t.Error("a non-confirming key left the action armed")
+ }
+
+ // Re-arm, then confirm.
+ v.cursor = resetIdx
+ v.activate()
+ cmd := v.handleKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("y")})
+ if cmd == nil {
+ t.Fatal("confirmation produced no command")
+ }
+ if _, ok := cmd().(wipeDataMsg); !ok {
+ t.Error("confirmation did not request a data wipe")
+ }
+}
+
+// TestWipeRequestQuitsAndRecordsIntent checks the shell records the request and
+// exits, leaving the deletion to the caller once the broker has stopped.
+func TestWipeRequestQuitsAndRecordsIntent(t *testing.T) {
+ m := newTestModel(&stubView{title: "T", rows: 1})
+ updated, cmd := m.Update(wipeDataMsg{})
+ got, ok := updated.(Model)
+ if !ok {
+ t.Fatal("model type changed")
+ }
+ if !got.wipeOnExit {
+ t.Error("wipe intent not recorded")
+ }
+ if cmd == nil {
+ t.Fatal("no quit command issued")
+ }
+ if _, isQuit := cmd().(tea.QuitMsg); !isQuit {
+ t.Error("wipe request did not quit the program")
+ }
+}
+
+// TestWorkerTableIsStatic is the guard for a highlight nothing can move.
+//
+// bubbles highlights its cursor row regardless of focus, so a read-only table
+// built the normal way advertises a selection that does not exist — and the
+// worker table has no per-worker operation to select for.
+func TestWorkerTableIsStatic(t *testing.T) {
+ v := newServiceView(nil)
+ if v.workers.Focused() {
+ t.Error("worker table is focused but its keys are never routed to it")
+ }
+
+ rows := []table.Row{{"scanner", "ok", ""}, {"proxy", "ok", ""}, {"errors", "ok", ""}}
+
+ // The behavioural claim: a static table's cursor cannot be moved, so the
+ // row it sits on is not a selection the operator can act on.
+ static := newStaticTable(serviceWorkerColumns(60))
+ static.SetHeight(4)
+ static.SetRows(rows)
+ before := static.Cursor()
+ static, _ = static.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("j")})
+ static, _ = static.Update(tea.KeyMsg{Type: tea.KeyDown})
+ if static.Cursor() != before {
+ t.Errorf("static table cursor moved from %d to %d", before, static.Cursor())
+ }
+
+ // An interactive table does move, which is what makes the distinction real
+ // rather than a property every table happens to have.
+ interactive := newTable(serviceWorkerColumns(60))
+ interactive.SetHeight(4)
+ interactive.SetRows(rows)
+ interactive, _ = interactive.Update(tea.KeyMsg{Type: tea.KeyDown})
+ if interactive.Cursor() == 0 {
+ t.Error("interactive table did not move; the test proves nothing")
+ }
+}
+
+// TestStaticTableRowsAlignExactly is the guard for the cursor row sitting one
+// column right of the others.
+//
+// bubbles applies the Selected style to the already-assembled row, so giving it
+// anything with padding indents row 0 relative to its neighbours — which looked
+// like the first worker being mysteriously offset.
+func TestStaticTableRowsAlignExactly(t *testing.T) {
+ tbl := newStaticTable(serviceWorkerColumns(70))
+ tbl.SetHeight(4)
+ tbl.SetRows([]table.Row{
+ {"scanner", "ok", ""},
+ {"node-info", "ok", ""},
+ {"proxy", "ok", ""},
+ })
+
+ var indents []int
+ for _, line := range strings.Split(tbl.View(), "\n") {
+ // Data rows only: skip the header and its border rule.
+ if !strings.Contains(line, "ok") {
+ continue
+ }
+ indents = append(indents, len(line)-len(strings.TrimLeft(line, " ")))
+ }
+ if len(indents) < 2 {
+ t.Fatalf("found %d data rows, want at least 2", len(indents))
+ }
+ for i, got := range indents[1:] {
+ if got != indents[0] {
+ t.Errorf("row %d is indented %d columns, row 0 is indented %d; rows must align",
+ i+1, got, indents[0])
+ }
+ }
+}
+
+// TestWorkerTableOrdersCrashesFirst is the guard for the clipping bug behind the
+// highlight: the table cannot be scrolled, so on a short terminal the tail is
+// unreachable. Failures must never be the rows that get cut.
+func TestWorkerTableOrdersCrashesFirst(t *testing.T) {
+ v := newServiceView(nil)
+ // Pick a worker deliberately late in the declared order.
+ lastWorker := serviceWorkers[len(serviceWorkers)-1]
+ v.rebuildCrashes([]svcerrors.ServiceError{
+ {ID: crashPrefix + lastWorker, Message: "it died"},
+ })
+
+ rows := v.workers.Rows()
+ if len(rows) != len(serviceWorkers) {
+ t.Fatalf("table has %d rows, want %d", len(rows), len(serviceWorkers))
+ }
+ // The row shows the display label; the crash lookup keys on the process name.
+ if rows[0][0] != workerLabel(lastWorker) {
+ t.Errorf("first row is %q, want the crashed %q", rows[0][0], workerLabel(lastWorker))
+ }
+ if rows[0][1] != "DOWN" {
+ t.Errorf("first row status = %q", rows[0][1])
+ }
+ for _, r := range rows[1:] {
+ if r[1] == "DOWN" {
+ t.Errorf("a crashed worker (%q) sorted below a healthy one", r[0])
+ }
+ }
+}
+
+// TestWorkerTableReportsHiddenRows checks a terminal too short to show every
+// worker says so, rather than silently dropping the tail of a table that cannot
+// be scrolled.
+func TestWorkerTableReportsHiddenRows(t *testing.T) {
+ v := newServiceView(nil)
+ v.refreshWorkers()
+
+ // Roomy: nothing hidden, no note.
+ v.SetSize(80, 40)
+ if got := v.hiddenWorkers(); got != 0 {
+ t.Errorf("tall terminal hides %d workers", got)
+ }
+ if strings.Contains(v.View(), "not shown") {
+ t.Error("roomy layout still claims workers are hidden")
+ }
+
+ // Cramped: too short for eleven workers plus the configuration list, so
+ // some rows are unreachable and the view must admit it.
+ v.SetSize(80, 15)
+ _ = v.View() // the table is sized at render time
+ if v.hiddenWorkers() == 0 {
+ t.Fatalf("terminal with %d worker rows reports nothing hidden despite %d workers",
+ visibleTableRows(v.workers.Height()), len(serviceWorkers))
+ }
+ if !strings.Contains(v.View(), "not shown") {
+ t.Errorf("short layout hides workers without saying so:\n%s", v.View())
+ }
+}
+
+// TestHiddenWorkersAccountsForTheHeader is the regression guard for a count that
+// read zero while rows were being clipped.
+//
+// bubbles' SetHeight sets the height of the whole table, header included, so
+// only height-2 data rows are visible. Treating the height as a row count made
+// the view claim everything fit while two workers were cut off a table that
+// cannot be scrolled.
+func TestHiddenWorkersAccountsForTheHeader(t *testing.T) {
+ v := newServiceView(nil)
+ v.refreshWorkers()
+
+ // Exactly enough total height for the header plus every worker.
+ v.workers.SetHeight(len(serviceWorkers) + tableHeaderRows)
+ if got := v.hiddenWorkers(); got != 0 {
+ t.Errorf("with room for all %d workers plus the header, hidden = %d",
+ len(serviceWorkers), got)
+ }
+
+ // One row short: exactly one worker must be reported hidden.
+ v.workers.SetHeight(len(serviceWorkers) + tableHeaderRows - 1)
+ if got := v.hiddenWorkers(); got != 1 {
+ t.Errorf("one row short reports %d hidden, want 1", got)
+ }
+
+ // The old arithmetic ignored the header and so reported 0 here.
+ v.workers.SetHeight(len(serviceWorkers))
+ if got := v.hiddenWorkers(); got != tableHeaderRows {
+ t.Errorf("height equal to the worker count reports %d hidden, want %d "+
+ "(the header occupies %d rows)", got, tableHeaderRows, tableHeaderRows)
+ }
+}
+
+// TestVisibleTableRows pins the header accounting the layout budgets depend on.
+func TestVisibleTableRows(t *testing.T) {
+ cases := map[int]int{0: 0, 1: 0, 2: 0, 3: 1, 5: 3, 13: 11}
+ for h, want := range cases {
+ if got := visibleTableRows(h); got != want {
+ t.Errorf("visibleTableRows(%d) = %d, want %d", h, got, want)
+ }
+ }
+}
+
+var _ View = (*serviceView)(nil)
+var _ inputCapturer = (*serviceView)(nil)
diff --git a/services/nvpair-tui/ui/settings.go b/services/nvpair-tui/ui/settings.go
deleted file mode 100644
index b157cd19..00000000
--- a/services/nvpair-tui/ui/settings.go
+++ /dev/null
@@ -1,221 +0,0 @@
-// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
-// SPDX-License-Identifier: Apache-2.0
-
-package ui
-
-import (
- "fmt"
- "strconv"
- "strings"
-
- "nvpair-tui/rpc"
-
- "github.com/charmbracelet/bubbles/key"
- "github.com/charmbracelet/bubbles/textinput"
- tea "github.com/charmbracelet/bubbletea"
-)
-
-// settingItem describes one persisted per-node preference and its
-// settings/get-*/set-* method pair.
-type settingItem struct {
- suffix string // e.g. "force-ports" -> settings/get-force-ports
- label string
- isBool bool
- boolV bool
- strV string
-}
-
-// settingsView edits the node-settings store: boolean prefs toggle in
-// place, string prefs open an inline editor. Values are read on start and
-// re-read after each successful write.
-type settingsView struct {
- client *rpc.Client
- items []settingItem
- cursor int
- input textinput.Model
- editing bool
- status string
- width, height int
-}
-
-type settingLoadedMsg struct {
- idx int
- isB bool
- boolV bool
- strV string
- err error
-}
-
-type settingSavedMsg struct {
- idx int
- err error
-}
-
-var (
- settingsUpKey = key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("up/k", "up"))
- settingsDownKey = key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("down/j", "down"))
- settingsEditKey = key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "toggle/edit"))
-)
-
-func newSettingsView(client *rpc.Client) *settingsView {
- ti := textinput.New()
- return &settingsView{
- client: client,
- input: ti,
- items: []settingItem{
- {suffix: "force-ports", label: "Force ports", isBool: true},
- {suffix: "cluster-auto-sync", label: "Cluster auto-sync", isBool: true},
- {suffix: "cluster-id", label: "Cluster ID"},
- {suffix: "cluster-friendly-name", label: "Cluster friendly name"},
- },
- }
-}
-
-func (v *settingsView) Title() string { return "Settings" }
-
-func (v *settingsView) Init() tea.Cmd {
- cmds := make([]tea.Cmd, len(v.items))
- for i := range v.items {
- cmds[i] = v.loadCmd(i)
- }
- return tea.Batch(cmds...)
-}
-
-func (v *settingsView) loadCmd(idx int) tea.Cmd {
- it := v.items[idx]
- return call(v.client, "settings/get-"+it.suffix, nil, func(msg *rpc.Message, err error) tea.Msg {
- if err != nil {
- return settingLoadedMsg{idx: idx, err: err}
- }
- if it.isBool {
- var r struct {
- Value bool `json:"value"`
- }
- _ = decodeParams(msg.Result, &r)
- return settingLoadedMsg{idx: idx, isB: true, boolV: r.Value}
- }
- var r struct {
- Value string `json:"value"`
- }
- _ = decodeParams(msg.Result, &r)
- return settingLoadedMsg{idx: idx, strV: r.Value}
- })
-}
-
-func (v *settingsView) SetSize(w, h int) { v.width, v.height = w, h }
-
-func (v *settingsView) CapturingInput() bool { return v.editing }
-
-func (v *settingsView) Update(msg tea.Msg) tea.Cmd {
- switch msg := msg.(type) {
- case settingLoadedMsg:
- if msg.err == nil {
- v.items[msg.idx].boolV = msg.boolV
- v.items[msg.idx].strV = msg.strV
- }
- return nil
- case settingSavedMsg:
- if msg.err != nil {
- v.status = "save failed: " + msg.err.Error()
- return nil
- }
- v.status = v.items[msg.idx].label + " saved"
- return v.loadCmd(msg.idx)
- case tea.KeyMsg:
- return v.handleKey(msg)
- }
- return nil
-}
-
-func (v *settingsView) handleKey(msg tea.KeyMsg) tea.Cmd {
- if v.editing {
- switch msg.String() {
- case "enter":
- return v.submitString()
- case "esc":
- v.editing = false
- v.input.Blur()
- return nil
- }
- var cmd tea.Cmd
- v.input, cmd = v.input.Update(msg)
- return cmd
- }
- switch {
- case key.Matches(msg, settingsUpKey):
- if v.cursor > 0 {
- v.cursor--
- }
- case key.Matches(msg, settingsDownKey):
- if v.cursor < len(v.items)-1 {
- v.cursor++
- }
- case key.Matches(msg, settingsEditKey):
- return v.activate()
- }
- return nil
-}
-
-func (v *settingsView) activate() tea.Cmd {
- it := &v.items[v.cursor]
- if it.isBool {
- return v.saveBool(v.cursor, !it.boolV)
- }
- v.editing = true
- v.input.SetValue(it.strV)
- v.input.Focus()
- return textinput.Blink
-}
-
-func (v *settingsView) saveBool(idx int, val bool) tea.Cmd {
- it := v.items[idx]
- return call(v.client, "settings/set-"+it.suffix, map[string]bool{"value": val}, func(_ *rpc.Message, err error) tea.Msg {
- return settingSavedMsg{idx: idx, err: err}
- })
-}
-
-func (v *settingsView) submitString() tea.Cmd {
- v.editing = false
- v.input.Blur()
- idx := v.cursor
- it := v.items[idx]
- val := strings.TrimSpace(v.input.Value())
- return call(v.client, "settings/set-"+it.suffix, map[string]string{"value": val}, func(_ *rpc.Message, err error) tea.Msg {
- return settingSavedMsg{idx: idx, err: err}
- })
-}
-
-func (v *settingsView) View() string {
- var b strings.Builder
- for i, it := range v.items {
- cursor := " "
- if i == v.cursor {
- cursor = "> "
- }
- var val string
- if it.isBool {
- val = strconv.FormatBool(it.boolV)
- } else if it.strV == "" {
- val = footerStyle.Render("(unset)")
- } else {
- val = it.strV
- }
- line := fmt.Sprintf("%s%-24s %s", cursor, it.label, val)
- if i == v.cursor {
- line = titleStyle.Render(line)
- }
- b.WriteString(line)
- b.WriteByte('\n')
- }
- if v.editing {
- b.WriteString("\n" + v.items[v.cursor].label + ": " + v.input.View())
- }
- if v.status != "" {
- b.WriteString("\n" + footerStyle.Render(v.status))
- }
- return b.String()
-}
-
-func (v *settingsView) Help() []key.Binding {
- return []key.Binding{settingsUpKey, settingsDownKey, settingsEditKey}
-}
diff --git a/services/nvpair-tui/ui/styles.go b/services/nvpair-tui/ui/styles.go
index 6ffdf5db..30aaee42 100644
--- a/services/nvpair-tui/ui/styles.go
+++ b/services/nvpair-tui/ui/styles.go
@@ -3,7 +3,12 @@
package ui
-import "github.com/charmbracelet/lipgloss"
+import (
+ "strings"
+
+ "github.com/charmbracelet/bubbles/help"
+ "github.com/charmbracelet/lipgloss"
+)
// Styles are intentionally restrained: adaptive colors that degrade
// gracefully on a bare SSH terminal, no background fills that depend on
@@ -14,11 +19,24 @@ var (
colorErr = lipgloss.AdaptiveColor{Light: "#b91c1c", Dark: "#f87171"}
colorOK = lipgloss.AdaptiveColor{Light: "#15803d", Dark: "#86efac"}
+ // colorOnAccent is text drawn on top of colorAccent, and it has to flip
+ // with it rather than be a fixed colour.
+ //
+ // The accent is a dark blue on a light terminal and a pale blue on a dark
+ // one, so one foreground cannot serve both: this was black, which is
+ // correct on the pale blue and unreadable on the dark. It applied to the
+ // selected table row — the row the operator is looking at, on every tab.
+ //
+ // The pairing is the point. A fixed foreground over an adaptive background
+ // is only ever right for one of the two terminals, and which one it is
+ // depends on a detection the program does not control.
+ colorOnAccent = lipgloss.AdaptiveColor{Light: "#ffffff", Dark: "#000000"}
+
titleStyle = lipgloss.NewStyle().Bold(true).Foreground(colorAccent)
tabActiveStyle = lipgloss.NewStyle().
Bold(true).
- Foreground(lipgloss.Color("0")).
+ Foreground(colorOnAccent).
Background(colorAccent).
Padding(0, 1)
@@ -30,4 +48,117 @@ var (
statusOKStyle = lipgloss.NewStyle().Foreground(colorOK)
statusErrStyle = lipgloss.NewStyle().Foreground(colorErr)
+
+ // helpKeyStyle and helpDescStyle separate the key you press from what it
+ // does. Undifferentiated, the footer reads as one run of words — "enter
+ // details p pair n pair by address" — and the reader has to know the
+ // convention to parse it. Bold marks the keys; the descriptions take the
+ // same muted tone as the rest of the chrome.
+ //
+ // Bold rather than a color because this has to survive a bare SSH terminal:
+ // lipgloss downsamples color, but bold is an SGR attribute that terminals
+ // honour even at two colors, and it stays legible on any background.
+ helpKeyStyle = lipgloss.NewStyle().Bold(true)
+ helpDescStyle = lipgloss.NewStyle().Foreground(colorMuted)
+)
+
+// Appearance is how the interface should colour itself, when the operator has
+// to say so rather than let the terminal be asked.
+type Appearance string
+
+const (
+ // AppearanceAuto asks the terminal for its background colour.
+ AppearanceAuto Appearance = "auto"
+ // AppearanceLight and AppearanceDark state it instead.
+ AppearanceLight Appearance = "light"
+ AppearanceDark Appearance = "dark"
)
+
+// SetAppearance fixes the terminal background, by detecting it now or by being
+// told.
+//
+// Detection asks the terminal for its background colour and reads the reply
+// from stdin. lipgloss does that once, lazily, the first time an adaptive
+// colour is resolved — and that first resolution happens while rendering,
+// which is after Bubble Tea has put the terminal in raw mode and started its
+// own reader on stdin. The terminal answers, Bubble Tea's reader takes the
+// reply, and the query times out having learned nothing.
+//
+// So detection is forced here instead, before the program starts, while stdin
+// is still ours to read. The result is cached behind lipgloss's sync.Once, so
+// every later render uses what was measured rather than re-asking at a moment
+// when asking cannot work.
+//
+// Light and dark skip the question. They are for the terminal that does not
+// answer at all — over SSH, inside tmux, in CI — where lipgloss would fall back
+// to assuming dark and get a light terminal wrong.
+func SetAppearance(a Appearance) {
+ switch a {
+ case AppearanceLight:
+ lipgloss.SetHasDarkBackground(false)
+ case AppearanceDark:
+ lipgloss.SetHasDarkBackground(true)
+ default:
+ // The return value is deliberately unused: the point is to run the
+ // query now and let the sync.Once keep the answer.
+ _ = lipgloss.HasDarkBackground()
+ }
+}
+
+// StartAppearance settles the terminal background off the startup path,
+// returning a function that waits for it.
+//
+// The query costs nothing on a terminal that answers, and five seconds on one
+// that does not: termenv's timeout is a constant, so it cannot be shortened.
+// Rather than spend that before anything else happens, it runs while the
+// broker starts — work the program has to do regardless — and is joined just
+// before the first render, which is the first moment the answer is needed.
+//
+// Terminals that cannot answer are recognised without waiting at all. termenv
+// refuses the query outright under screen, tmux, and TERM=dumb, because those
+// can be attached to several terminals at once and there is no single
+// background to report. Those sessions fall back to assuming dark, which is
+// what --appearance is for.
+func StartAppearance(a Appearance) (wait func()) {
+ done := make(chan struct{})
+ go func() {
+ defer close(done)
+ SetAppearance(a)
+ }()
+ return func() { <-done }
+}
+
+// DetectedAppearance reports the background in force, for a log line that
+// explains a colour scheme the operator did not expect.
+func DetectedAppearance() Appearance {
+ if lipgloss.HasDarkBackground() {
+ return AppearanceDark
+ }
+ return AppearanceLight
+}
+
+// ParseAppearance narrows a flag value, reporting whether it is one of the
+// three accepted words.
+func ParseAppearance(v string) (Appearance, bool) {
+ switch Appearance(strings.ToLower(strings.TrimSpace(v))) {
+ case AppearanceAuto, "":
+ return AppearanceAuto, true
+ case AppearanceLight:
+ return AppearanceLight, true
+ case AppearanceDark:
+ return AppearanceDark, true
+ }
+ return AppearanceAuto, false
+}
+
+// styleHelp applies the key/description split to a help model.
+//
+// Done once, on the shell's single help model, so every view's footer and the
+// full-help overlay share it. bubbles keeps separate styles for the short and
+// full renderings and defaults both to the same faint tone.
+func styleHelp(m *help.Model) {
+ m.Styles.ShortKey = helpKeyStyle
+ m.Styles.FullKey = helpKeyStyle
+ m.Styles.ShortDesc = helpDescStyle
+ m.Styles.FullDesc = helpDescStyle
+}
diff --git a/services/nvpair-tui/ui/styles_test.go b/services/nvpair-tui/ui/styles_test.go
new file mode 100644
index 00000000..4309b16e
--- /dev/null
+++ b/services/nvpair-tui/ui/styles_test.go
@@ -0,0 +1,123 @@
+// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+package ui
+
+import (
+ "testing"
+
+ "github.com/charmbracelet/lipgloss"
+)
+
+// TestTextOnAnAdaptiveBackgroundAdaptsToo is the regression guard for a light
+// terminal rendering the selected row unreadable.
+//
+// A style whose background changes with the terminal but whose foreground does
+// not is only legible in one of the two. Both of these paired a fixed black
+// with the accent, which is a pale blue on a dark terminal — correct — and a
+// dark blue on a light one, where black on dark blue is what the operator was
+// left reading on every tab.
+//
+// Asserted structurally rather than by rendering, because the colour profile in
+// a test process is unset and lipgloss then drops colour from the output
+// entirely: the rendered strings for a right and a wrong pairing are identical.
+func TestTextOnAnAdaptiveBackgroundAdaptsToo(t *testing.T) {
+ cases := map[string]lipgloss.Style{
+ "active tab": tabActiveStyle,
+ "selected table row": tableStyles().Selected,
+ }
+ for name, style := range cases {
+ t.Run(name, func(t *testing.T) {
+ bg, bgAdaptive := style.GetBackground().(lipgloss.AdaptiveColor)
+ if !bgAdaptive {
+ // Not a failure in itself: a fixed background with a fixed
+ // foreground is a deliberate pair. There is just nothing here
+ // for this test to check.
+ t.Skipf("background is %T, not adaptive", style.GetBackground())
+ }
+ fg, fgAdaptive := style.GetForeground().(lipgloss.AdaptiveColor)
+ if !fgAdaptive {
+ t.Fatalf("background adapts (%+v) but the foreground is fixed (%v); "+
+ "one of the two terminals gets unreadable text", bg, style.GetForeground())
+ }
+ if fg.Light == fg.Dark {
+ t.Errorf("foreground is the same colour either way (%q), so it cannot "+
+ "suit both the light and dark backgrounds", fg.Light)
+ }
+ })
+ }
+}
+
+// TestAppearanceOverrideIsExplicit checks the flag accepts exactly the three
+// words it documents, and that anything else is refused rather than quietly
+// treated as auto.
+//
+// A typo that silently means "auto" is the worst outcome: the operator who
+// reached for this flag is the one whose terminal was already detected wrongly,
+// so falling back to detection leaves them exactly where they started with no
+// indication why.
+func TestAppearanceOverrideIsExplicit(t *testing.T) {
+ good := map[string]Appearance{
+ "auto": AppearanceAuto,
+ "": AppearanceAuto,
+ "light": AppearanceLight,
+ "dark": AppearanceDark,
+ " Dark ": AppearanceDark,
+ "LIGHT": AppearanceLight,
+ }
+ for in, want := range good {
+ got, ok := ParseAppearance(in)
+ if !ok || got != want {
+ t.Errorf("ParseAppearance(%q) = %q, %v; want %q, true", in, got, ok, want)
+ }
+ }
+
+ for _, in := range []string{"lite", "black", "white", "true", "1", "no"} {
+ if _, ok := ParseAppearance(in); ok {
+ t.Errorf("ParseAppearance(%q) was accepted; it should be refused", in)
+ }
+ }
+}
+
+// TestStartAppearanceSettlesBeforeTheFirstFrame checks the background is
+// decided by the time the waiter returns.
+//
+// The whole point of resolving it off the startup path is that the answer is
+// ready before anything renders. A waiter that returned early would put the
+// query back where it was — resolved during the first frame, when Bubble Tea
+// owns stdin and the terminal's reply goes to its reader instead.
+func TestStartAppearanceSettlesBeforeTheFirstFrame(t *testing.T) {
+ before := lipgloss.HasDarkBackground()
+ t.Cleanup(func() { lipgloss.SetHasDarkBackground(before) })
+
+ wait := StartAppearance(AppearanceLight)
+ wait()
+ if lipgloss.HasDarkBackground() {
+ t.Error("the waiter returned before the appearance was settled")
+ }
+
+ // Waiting twice is not an error: the caller joins it on one path, and a
+ // second join must not block forever on a closed channel.
+ wait()
+}
+
+// TestSetAppearanceLeavesDetectionAloneOnAuto checks auto does not assert a
+// background of its own.
+func TestSetAppearanceLeavesDetectionAloneOnAuto(t *testing.T) {
+ before := lipgloss.HasDarkBackground()
+ t.Cleanup(func() { lipgloss.SetHasDarkBackground(before) })
+
+ SetAppearance(AppearanceAuto)
+ if got := lipgloss.HasDarkBackground(); got != before {
+ t.Errorf("auto changed the background assumption from %v to %v", before, got)
+ }
+
+ SetAppearance(AppearanceLight)
+ if lipgloss.HasDarkBackground() {
+ t.Error("light did not take effect")
+ }
+ SetAppearance(AppearanceDark)
+ if !lipgloss.HasDarkBackground() {
+ t.Error("dark did not take effect")
+ }
+}
diff --git a/services/nvpair-tui/ui/table.go b/services/nvpair-tui/ui/table.go
index 3d2d025c..16aaa0d0 100644
--- a/services/nvpair-tui/ui/table.go
+++ b/services/nvpair-tui/ui/table.go
@@ -4,17 +4,203 @@
package ui
import (
+ "strings"
+
"github.com/charmbracelet/bubbles/table"
"github.com/charmbracelet/lipgloss"
)
-// newTable builds a focused table with the shell's shared styling. Views
-// pass their columns and then drive rows/size via the returned model.
-func newTable(cols []table.Column) table.Model {
- t := table.New(
- table.WithColumns(cols),
- table.WithFocused(true),
- )
+// cellPadding is the horizontal padding bubbles' table adds to every cell.
+// Its default Header and Cell styles both carry Padding(0, 1), and the content
+// is rendered with Width(col.Width) *inside* that padding, so a column occupies
+// col.Width+2 terminal columns. Layout that reserves less overflows the table
+// and the terminal clips the rightmost columns — which is why a 7-wide PORT
+// column used to render its header as "po".
+const cellPadding = 2
+
+// minCellWidth keeps a column wide enough to show something on a very narrow
+// terminal. Below this, content is unreadable anyway and clipping is preferable
+// to a zero/negative width bubbles would panic on.
+const minCellWidth = 3
+
+// countLines is how many terminal rows a rendered fragment occupies. An empty
+// fragment occupies none, which is what lets a caller pass optional chrome
+// straight through without branching on whether it is present.
+func countLines(s string) int {
+ if s == "" {
+ return 0
+ }
+ return strings.Count(s, "\n") + 1
+}
+
+// fitTable sizes a table to whatever is left of a row budget once the given
+// chrome has taken its share, and reports whether it fits at all.
+//
+// Sizing happens here, at render time, rather than in SetSize, because half of
+// a view's chrome is conditional: a status toast, an inline editor, a filter
+// note, a warning. Sizing against a fixed guess of how many of those are
+// present means the view renders more rows than the shell allotted whenever the
+// guess is low, and the shell's frame clamp then deletes the last line — which
+// is always the newest, most urgent one, since the optional rows are the
+// messages. Deriving the height from the chrome actually being rendered makes
+// that overflow impossible rather than merely unlikely.
+//
+// This measures and nothing else. It deliberately does not assemble the view:
+// an earlier version returned the non-empty chrome for the caller to splice the
+// table into by index, and because it dropped the empty entries, an index the
+// caller had counted for could point past the end — which panicked and took the
+// whole program down. Callers now build their own line list in their own order,
+// where that order is written out in the code and no index is kept in step.
+//
+// A false return means the terminal is too short for both. The chrome wins: it
+// is the status message and the reason the list looks the way it does, while a
+// table clamped to a header and no rows conveys nothing. Losing the table is a
+// visible, explicable outcome; losing the bottom line is a silent one.
+func fitTable(t *table.Model, budget int, chrome ...string) bool {
+ used := 0
+ for _, c := range chrome {
+ used += countLines(c)
+ }
+
+ // A header with no data rows is not a table, so that is the floor.
+ const minTable = 1 + tableHeaderRows
+ if budget-used < minTable {
+ return false
+ }
+ t.SetHeight(budget - used)
+ return true
+}
+
+// joinLines renders the non-empty fragments as consecutive rows.
+//
+// Empty fragments are the absent optional chrome. Dropping them here, at the
+// point of assembly where the order is written out, is what lets a view list
+// every possible line unconditionally and still render only what applies.
+func joinLines(parts ...string) string {
+ present := make([]string, 0, len(parts))
+ for _, p := range parts {
+ if p != "" {
+ present = append(present, p)
+ }
+ }
+ return strings.Join(present, "\n")
+}
+
+// restoreCursor puts a table's cursor back in range after its contents changed.
+//
+// bubbles clamps a cursor that has run past the end, but it clamps to len-1 —
+// so a table momentarily handed zero rows lands on -1 and stays there, because
+// refilling it never moves a cursor already below the range. Every action keyed
+// off the highlighted row then reports nothing selected, for the life of the
+// screen, until an arrow key happens to rescue it. Views are handed an empty
+// list routinely: before the first reply lands, or while a peer is unreachable.
+func restoreCursor(t *table.Model, rows int) {
+ if rows > 0 && t.Cursor() < 0 {
+ t.SetCursor(0)
+ }
+}
+
+// tableHeaderRows is how much of a bubbles table's height goes to its header:
+// the titles plus the border rule beneath them. SetHeight sets the height of the
+// whole table, so only height-tableHeaderRows data rows are visible — a caller
+// that treats the height it *passed in* as a row count over-counts by exactly
+// this much.
+//
+// Note the asymmetry, which is easy to get wrong in both directions: SetHeight
+// takes a total and subtracts the header itself, while Height returns the
+// remainder, i.e. the data rows. Convert a budget with visibleTableRows before
+// comparing it to a row count; never apply it to Height, which is already
+// converted.
+const tableHeaderRows = 2
+
+// visibleTableRows is how many data rows a table of total height h can show.
+func visibleTableRows(h int) int {
+ if h <= tableHeaderRows {
+ return 0
+ }
+ return h - tableHeaderRows
+}
+
+// defaultTableWidth is the nominal width a view lays its table out at before the
+// first WindowSizeMsg arrives.
+//
+// Every table must have its columns from construction. The broker replays a
+// baseline snapshot as soon as a view subscribes, which can land before the
+// terminal size does, and bubbles' renderRow indexes its column slice per row
+// cell — so rows against a zero-column table panic rather than render empty.
+const defaultTableWidth = 80
+
+// column is a caller's request for one table column. Fixed columns keep their
+// width; flex columns share whatever is left over in proportion to their weight
+// and never shrink below width, which acts as their minimum.
+type column struct {
+ title string
+ width int
+ flex int
+}
+
+// fixedCol is a column that always renders at exactly w content columns.
+func fixedCol(title string, w int) column {
+ return column{title: title, width: w}
+}
+
+// flexCol is a column that absorbs leftover width, never going below min.
+// Weight distributes the remainder when several columns flex: two weight-1
+// columns split it evenly, weight 2 against weight 1 takes two thirds.
+func flexCol(title string, min, weight int) column {
+ if weight < 1 {
+ weight = 1
+ }
+ return column{title: title, width: min, flex: weight}
+}
+
+// layoutColumns fits cols into total terminal columns, accounting for the
+// per-cell padding bubbles adds. Fixed columns are honoured first; whatever
+// remains is shared among the flex columns by weight. When even the minimums do
+// not fit, every column falls back to its minimum and the table clips — the
+// terminal is simply too narrow, and a readable left edge beats evenly
+// unreadable columns.
+func layoutColumns(total int, cols []column) []table.Column {
+ out := make([]table.Column, len(cols))
+ for i, c := range cols {
+ out[i] = table.Column{Title: c.title, Width: clampWidth(c.width, minCellWidth)}
+ }
+
+ budget := total - cellPadding*len(cols)
+ var fixed, flexMin, weight int
+ for _, c := range cols {
+ if c.flex > 0 {
+ flexMin += clampWidth(c.width, minCellWidth)
+ weight += c.flex
+ } else {
+ fixed += clampWidth(c.width, minCellWidth)
+ }
+ }
+ if weight == 0 || budget <= fixed+flexMin {
+ return out
+ }
+
+ // Hand out the surplus by weight, giving the last flex column the
+ // rounding remainder so the row fills the width exactly.
+ surplus := budget - fixed - flexMin
+ granted, lastFlex := 0, -1
+ for i, c := range cols {
+ if c.flex == 0 {
+ continue
+ }
+ lastFlex = i
+ share := surplus * c.flex / weight
+ out[i].Width += share
+ granted += share
+ }
+ if lastFlex >= 0 {
+ out[lastFlex].Width += surplus - granted
+ }
+ return out
+}
+
+// tableStyles is the shell's shared table styling.
+func tableStyles() table.Styles {
s := table.DefaultStyles()
s.Header = s.Header.
Bold(true).
@@ -23,9 +209,40 @@ func newTable(cols []table.Column) table.Model {
BorderBottom(true)
s.Selected = s.Selected.
Bold(true).
- Foreground(lipgloss.Color("0")).
+ Foreground(colorOnAccent).
Background(colorAccent)
+ return s
+}
+
+// newTable builds a focused table with the shell's shared styling. Views
+// pass their columns and then drive rows/size via the returned model.
+func newTable(cols []table.Column) table.Model {
+ t := table.New(
+ table.WithColumns(cols),
+ table.WithFocused(true),
+ )
+ t.SetStyles(tableStyles())
+ return t
+}
+
+// newStaticTable builds a table for rows nothing can be done to: no highlighted
+// row, and no key handling.
+//
+// Blurring alone is not enough. bubbles highlights whatever row its cursor sits
+// on regardless of focus — focus only gates Update — so a read-only table built
+// with newTable renders a selection the operator cannot move and that means
+// nothing. Neutralising the selected style is the only way to make a table look
+// as inert as it is.
+func newStaticTable(cols []table.Column) table.Model {
+ t := table.New(table.WithColumns(cols))
+ s := tableStyles()
+ // An empty style, not the cell style. bubbles applies Selected to the
+ // already-assembled row, so a style carrying padding indents the cursor row
+ // by one column relative to every other row. Only a style that renders its
+ // input unchanged leaves the row truly identical to its neighbours.
+ s.Selected = lipgloss.NewStyle()
t.SetStyles(s)
+ t.Blur()
return t
}
diff --git a/services/nvpair-tui/ui/table_test.go b/services/nvpair-tui/ui/table_test.go
new file mode 100644
index 00000000..dd01cba3
--- /dev/null
+++ b/services/nvpair-tui/ui/table_test.go
@@ -0,0 +1,253 @@
+// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+package ui
+
+import (
+ "testing"
+
+ svcerrors "nvpair-shared/errors"
+
+ "github.com/charmbracelet/bubbles/table"
+)
+
+// rendered is the terminal width a laid-out row actually consumes: every column
+// costs its declared width plus the padding bubbles wraps each cell in.
+func rendered(cols []column, total int) int {
+ got := layoutColumns(total, cols)
+ sum := 0
+ for _, c := range got {
+ sum += c.Width + cellPadding
+ }
+ return sum
+}
+
+// TestLayoutColumnsFillsWidthExactly is the regression guard for the clipped
+// headers ("po" for PORT, "SEE" for SEEN): a laid-out row must consume the
+// width it was given, never more. Sizing that ignores cellPadding overflows and
+// the terminal drops the rightmost columns.
+func TestLayoutColumnsFillsWidthExactly(t *testing.T) {
+ cases := []struct {
+ name string
+ total int
+ cols []column
+ }{
+ {
+ name: "proxies upstream table",
+ total: 80,
+ cols: []column{
+ flexCol("ID", 10, 1),
+ flexCol("HOST", 10, 1),
+ fixedCol("PORT", 7),
+ },
+ },
+ {
+ name: "nodes table",
+ total: 100,
+ cols: []column{
+ flexCol("NAME", 10, 1),
+ flexCol("ADDRESS", 10, 1),
+ fixedCol("PORT", 7),
+ fixedCol("LAST SEEN", 10),
+ fixedCol("STATUS", 11),
+ },
+ },
+ {
+ name: "single flex column takes the remainder",
+ total: 60,
+ cols: []column{
+ fixedCol("SEV", 9),
+ flexCol("MESSAGE", 10, 1),
+ },
+ },
+ {
+ name: "uneven division leaves no gap",
+ total: 77,
+ cols: []column{
+ flexCol("A", 5, 1),
+ flexCol("B", 5, 1),
+ flexCol("C", 5, 1),
+ },
+ },
+ }
+
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ if got := rendered(tc.cols, tc.total); got != tc.total {
+ t.Errorf("row consumes %d columns, terminal is %d", got, tc.total)
+ }
+ })
+ }
+}
+
+// TestLayoutColumnsWeightedFlex checks a heavier column takes proportionally
+// more of the surplus, so a message column can dominate a narrow id column.
+func TestLayoutColumnsWeightedFlex(t *testing.T) {
+ cols := []column{flexCol("ID", 10, 1), flexCol("MESSAGE", 10, 3)}
+ got := layoutColumns(60, cols)
+
+ // budget 60-4=56, minimums 20, surplus 36 split 1:3 -> +9 / +27.
+ if got[0].Width != 19 {
+ t.Errorf("ID width = %d, want 19", got[0].Width)
+ }
+ if got[1].Width != 37 {
+ t.Errorf("MESSAGE width = %d, want 37", got[1].Width)
+ }
+}
+
+// TestLayoutColumnsNarrowTerminal checks a terminal too narrow for the
+// minimums degrades to those minimums rather than producing widths bubbles
+// would render as zero-width or negative.
+func TestLayoutColumnsNarrowTerminal(t *testing.T) {
+ cols := []column{fixedCol("SEV", 9), flexCol("MESSAGE", 10, 1)}
+ for _, total := range []int{0, 1, 10, 20} {
+ got := layoutColumns(total, cols)
+ if got[0].Width != 9 {
+ t.Errorf("total=%d: SEV width = %d, want the declared 9", total, got[0].Width)
+ }
+ if got[1].Width != 10 {
+ t.Errorf("total=%d: MESSAGE width = %d, want the 10 minimum", total, got[1].Width)
+ }
+ }
+}
+
+// TestLayoutColumnsHonoursMinimum checks a flex column never shrinks below its
+// stated minimum even when fixed columns consume the whole width.
+func TestLayoutColumnsHonoursMinimum(t *testing.T) {
+ cols := []column{fixedCol("WIDE", 50), flexCol("REST", 12, 1)}
+ got := layoutColumns(40, cols)
+ if got[1].Width < 12 {
+ t.Errorf("REST width = %d, want at least the 12 minimum", got[1].Width)
+ }
+}
+
+// TestViewsAcceptRowsBeforeResize guards a panic every table view was exposed
+// to: the broker replays a baseline snapshot as soon as a view subscribes, which
+// can arrive before the first WindowSizeMsg. bubbles' renderRow indexes its
+// column slice per row cell, so a table built with no columns panics on the
+// first row rather than rendering empty.
+func TestViewsAcceptRowsBeforeResize(t *testing.T) {
+ t.Run("nodes from discovery", func(t *testing.T) {
+ v := newNodesView(nil)
+ v.feeds.discovered = []availableNode{{HostUUID: "u", Name: "n", IPAddress: "10.0.0.1", Port: 1}}
+ v.rebuild()
+ })
+ t.Run("nodes from cluster roster", func(t *testing.T) {
+ v := newNodesView(nil)
+ v.feeds.members = []clusterNode{{ID: "id", NodeUUID: "u", Name: "n", State: "joined", Port: 1}}
+ v.rebuild()
+ })
+ t.Run("nodes from manual list", func(t *testing.T) {
+ v := newNodesView(nil)
+ v.feeds.manual = []manualNode{{ID: "id", Name: "n", Address: "10.0.0.1"}}
+ v.rebuild()
+ })
+ t.Run("jobs", func(t *testing.T) {
+ newJobsView(nil).upsert(workload{ID: "w", Model: "m", Engine: "ollama", State: "running"})
+ })
+ t.Run("node detail engines and models", func(t *testing.T) {
+ d := newNodeDetail(nil, nodeRow{
+ key: "u",
+ name: "n",
+ self: true,
+ modelsByEngine: map[string][]string{"ollama": {"llama3.2"}},
+ loadedByEngine: map[string][]string{"ollama": {"llama3.2"}},
+ })
+ d.engines = []engineStatus{{Engine: "ollama", Installed: true}}
+ d.refreshEngines()
+ d.refreshModels()
+ })
+ t.Run("errors", func(t *testing.T) {
+ newErrorsView(nil).setErrors([]svcerrors.ServiceError{{ID: "e", Message: "boom"}})
+ })
+ t.Run("service workers", func(t *testing.T) {
+ newServiceView(nil).refreshWorkers()
+ })
+}
+
+// TestEveryTableViewHasColumnsAtConstruction is the direct invariant behind the
+// panic above, stated per view so a new view cannot regress it silently.
+func TestEveryTableViewHasColumnsAtConstruction(t *testing.T) {
+ widths := map[string][]table.Column{
+ "nodes": nodesColumns(defaultTableWidth),
+ "jobs": workloadColumns(defaultTableWidth),
+ "detail engines local": detailEngineColumns(defaultTableWidth, false),
+ "detail engines remote": detailEngineColumns(defaultTableWidth, true),
+ "detail models": detailModelColumns(defaultTableWidth),
+ "service workers": serviceWorkerColumns(defaultTableWidth),
+ }
+ for name, cols := range widths {
+ if len(cols) == 0 {
+ t.Errorf("%s: no columns", name)
+ }
+ for _, c := range cols {
+ if c.Width < minCellWidth {
+ t.Errorf("%s: column %q width %d below minimum", name, c.Title, c.Width)
+ }
+ }
+ }
+}
+
+// TestRowsMatchTheirColumns pins the two halves of a table together.
+//
+// bubbles' renderRow walks the column slice and indexes the row per column, so a
+// row with fewer cells than columns panics and one with more silently drops the
+// extras. Nothing else catches it: both halves compile independently, and a view
+// that builds its rows in one function and its columns in another can lose a
+// cell without a single type error — which is exactly the shape of the edit that
+// removed the node table's timestamp column.
+func TestRowsMatchTheirColumns(t *testing.T) {
+ t.Run("nodes", func(t *testing.T) {
+ v := newNodesView(nil)
+ v.feeds.discovered = []availableNode{
+ {HostUUID: "u", Name: "n", IPAddress: "10.0.0.1", Port: 1},
+ }
+ v.rebuild()
+ assertRowWidths(t, v.table)
+ })
+ t.Run("jobs", func(t *testing.T) {
+ v := newJobsView(nil)
+ v.upsert(workload{ID: "w", Model: "m", Engine: "ollama", State: "running"})
+ assertRowWidths(t, v.table)
+ })
+ t.Run("errors", func(t *testing.T) {
+ v := newErrorsView(nil)
+ v.setErrors([]svcerrors.ServiceError{{ID: "e", Message: "boom"}})
+ assertRowWidths(t, v.table)
+ })
+ t.Run("service workers", func(t *testing.T) {
+ v := newServiceView(nil)
+ v.refreshWorkers()
+ assertRowWidths(t, v.workers)
+ })
+ t.Run("node detail engines and models", func(t *testing.T) {
+ for _, remote := range []bool{false, true} {
+ d := newNodeDetail(nil, nodeRow{
+ key: "u",
+ name: "n",
+ self: !remote,
+ modelsByEngine: map[string][]string{"ollama": {"llama3.2"}},
+ loadedByEngine: map[string][]string{"ollama": {"llama3.2"}},
+ })
+ d.engines = []engineStatus{{Engine: "ollama", Installed: true}}
+ d.refreshEngines()
+ d.refreshModels()
+ assertRowWidths(t, d.engineTable)
+ assertRowWidths(t, d.modelTable)
+ }
+ })
+}
+
+func assertRowWidths(t *testing.T, m table.Model) {
+ t.Helper()
+ cols := len(m.Columns())
+ rows := m.Rows()
+ if len(rows) == 0 {
+ t.Fatal("no rows to check; the fixture did not populate the table")
+ }
+ for i, row := range rows {
+ if len(row) != cols {
+ t.Errorf("row %d has %d cell(s), table has %d column(s)", i, len(row), cols)
+ }
+ }
+}
diff --git a/services/nvpair-tui/ui/toast.go b/services/nvpair-tui/ui/toast.go
new file mode 100644
index 00000000..e932fadd
--- /dev/null
+++ b/services/nvpair-tui/ui/toast.go
@@ -0,0 +1,122 @@
+// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+package ui
+
+import (
+ "fmt"
+ "time"
+)
+
+// Transient status messages expire on their own. Failures stay longer than
+// successes: an operator needs time to read why something did not work, while
+// "invite sent" has served its purpose within a few seconds.
+const (
+ toastTTL = 6 * time.Second
+ toastErrorTTL = 20 * time.Second
+)
+
+type toastKind int
+
+const (
+ toastInfo toastKind = iota
+ toastOK
+ toastError
+)
+
+// toast is a view's transient status line. Expiry is evaluated at render time
+// rather than scheduled, so setting a new message implicitly replaces any
+// pending one and no timer can fire against a stale message. The shell's
+// one-second tick guarantees the frame refreshes within a tick of expiry.
+//
+// This exists because per-view status strings were only ever overwritten by the
+// next action, so an outcome like "invite sent - PIN 123456" stayed on screen
+// indefinitely and read as current long after the invite had been answered.
+type toast struct {
+ text string
+ kind toastKind
+ at time.Time
+ // sticky suppresses expiry for a message whose content the user is still
+ // acting on — the pairing PIN, which they read aloud to someone standing
+ // at another machine. A timer must not take that away mid-sentence, so it
+ // persists until the operation resolves and the owner clears it.
+ sticky bool
+}
+
+func (t *toast) info(format string, args ...any) { t.set(toastInfo, format, args...) }
+
+// busy announces an operation that is under way, and does not expire.
+//
+// An RPC here is allowed 35 seconds and an engine start or a model download
+// routinely uses them, while an ordinary note expires in 6 — so "start
+// Ollama..." vanished long before the outcome arrived, leaving a screen
+// indistinguishable from a keypress the terminal had dropped. This is bounded
+// by the call instead of by a timer: the reply's ok or error replaces it.
+func (t *toast) busy(format string, args ...any) {
+ t.set(toastInfo, format, args...)
+ t.sticky = true
+}
+
+// arm sets a destructive confirmation prompt: styled as a warning, and not
+// expiring.
+//
+// Both halves matter. An armed action whose prompt has timed out turns the next
+// keystroke into a confirmation the operator has no reason to expect; and a
+// prompt asking whether to destroy something should not render in the same
+// green as "invite sent", which is what pin would have given it.
+func (t *toast) arm(format string, args ...any) {
+ t.set(toastError, format, args...)
+ t.sticky = true
+}
+
+func (t *toast) ok(format string, args ...any) { t.set(toastOK, format, args...) }
+func (t *toast) error(format string, args ...any) { t.set(toastError, format, args...) }
+
+func (t *toast) set(kind toastKind, format string, args ...any) {
+ t.text = fmt.Sprintf(format, args...)
+ t.kind = kind
+ t.at = time.Now()
+ t.sticky = false
+}
+
+// pin sets a message that stays until explicitly cleared. Reserved for content
+// the user must transcribe; everything else expires.
+func (t *toast) pin(format string, args ...any) {
+ t.set(toastOK, format, args...)
+ t.sticky = true
+}
+
+func (t toast) ttl() time.Duration {
+ if t.kind == toastError {
+ return toastErrorTTL
+ }
+ return toastTTL
+}
+
+// expired reports whether the message has outlived its kind's TTL. A sticky
+// message never expires on its own.
+func (t toast) expired() bool {
+ if t.text == "" {
+ return true
+ }
+ if t.sticky {
+ return false
+ }
+ return time.Since(t.at) >= t.ttl()
+}
+
+// render returns the styled status line, or "" when there is nothing current to
+// show. Callers treat "" as "render no status row".
+func (t toast) render() string {
+ if t.expired() {
+ return ""
+ }
+ switch t.kind {
+ case toastError:
+ return statusErrStyle.Render(t.text)
+ case toastOK:
+ return statusOKStyle.Render(t.text)
+ default:
+ return footerStyle.Render(t.text)
+ }
+}
diff --git a/services/nvpair-tui/ui/toast_test.go b/services/nvpair-tui/ui/toast_test.go
new file mode 100644
index 00000000..88d6ce68
--- /dev/null
+++ b/services/nvpair-tui/ui/toast_test.go
@@ -0,0 +1,726 @@
+// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+package ui
+
+import (
+ "encoding/json"
+ "errors"
+ "strings"
+ "testing"
+ "time"
+ "unicode/utf8"
+
+ "nvpair-tui/rpc"
+
+ tea "github.com/charmbracelet/bubbletea"
+)
+
+// TestToastExpires is the regression guard for status lines that never went
+// away: an outcome must stop rendering once its TTL has passed.
+func TestToastExpires(t *testing.T) {
+ var s toast
+ s.ok("accept invite ok")
+ if s.render() == "" {
+ t.Fatal("a message just set should render")
+ }
+
+ s.at = time.Now().Add(-toastTTL - time.Second)
+ if s.render() != "" {
+ t.Error("message outlived its TTL and is still rendering")
+ }
+}
+
+// TestToastErrorsOutliveSuccesses checks a failure stays readable for longer
+// than a success, since the operator needs time to act on it.
+func TestToastErrorsOutliveSuccesses(t *testing.T) {
+ var ok, bad toast
+ ok.ok("done")
+ bad.error("it broke")
+
+ aged := time.Now().Add(-toastTTL - time.Second)
+ ok.at, bad.at = aged, aged
+
+ if ok.render() != "" {
+ t.Error("success should have expired by now")
+ }
+ if bad.render() == "" {
+ t.Error("error expired at the success TTL; it should last longer")
+ }
+
+ bad.at = time.Now().Add(-toastErrorTTL - time.Second)
+ if bad.render() != "" {
+ t.Error("error outlived even the error TTL")
+ }
+}
+
+// TestPinnedToastNeverExpires covers the pairing PIN. The operator reads it
+// aloud to someone at another machine, so a timer must not remove it.
+func TestPinnedToastNeverExpires(t *testing.T) {
+ var s toast
+ s.pin("invite sent - PIN 123456")
+ s.at = time.Now().Add(-24 * time.Hour)
+
+ if s.render() == "" {
+ t.Error("pinned message expired; the PIN must stay until the invite resolves")
+ }
+ // It is replaced by the outcome, which is how a pinned message ends: the
+ // views set a new one rather than clearing to nothing.
+ s.ok("peer joined the cluster")
+ if contains(s.render(), "123456") {
+ t.Error("the PIN survived the message that replaced it")
+ }
+}
+
+// TestSetReplacesPinned checks a later ordinary message drops the sticky flag,
+// so a pinned PIN cannot make every subsequent message permanent.
+func TestSetReplacesPinned(t *testing.T) {
+ var s toast
+ s.pin("invite sent - PIN 123456")
+ s.info("inviting other-host...")
+
+ s.at = time.Now().Add(-toastTTL - time.Second)
+ if s.render() != "" {
+ t.Error("message set after a pin inherited its stickiness")
+ }
+}
+
+// TestTruncateIsRuneSafe is the regression guard for a byte-based cut. GPU and
+// CPU model names reach truncate, and a slice landing inside a multi-byte
+// sequence emits an invalid rune; the callers also pair it with %-Ns, which pads
+// by rune count, so a byte cut narrowed the column too.
+func TestTruncateIsRuneSafe(t *testing.T) {
+ // 10 runes, 20 bytes.
+ const wide = "ααααααααα™"
+ got := truncate(wide, 5)
+
+ if !utf8.ValidString(got) {
+ t.Errorf("truncate produced invalid UTF-8: %q", got)
+ }
+ if n := utf8.RuneCountInString(got); n != 5 {
+ t.Errorf("truncate(%q, 5) is %d runes, want 5", wide, n)
+ }
+
+ // Short input is returned untouched even when its byte length exceeds max.
+ if got := truncate("ααα", 5); got != "ααα" {
+ t.Errorf("truncate shortened a string that already fits: %q", got)
+ }
+ // Degenerate widths must not panic.
+ if got := truncate("abc", 1); got != "…" {
+ t.Errorf("truncate(_, 1) = %q", got)
+ }
+ if got := truncate("abc", 0); got != "" {
+ t.Errorf("truncate(_, 0) = %q", got)
+ }
+ if got := truncate("abc", -1); got != "" {
+ t.Errorf("truncate(_, -1) = %q", got)
+ }
+}
+
+func TestInviteOutcome(t *testing.T) {
+ resolved := []string{
+ "cluster:invite-declined",
+ "cluster:invite-expired",
+ "cluster:invite-canceled",
+ "cluster:invite-failed",
+ }
+ for _, method := range resolved {
+ outcome, ok := inviteOutcome(method)
+ if !ok {
+ t.Errorf("%s is not recognised as a terminal invite event", method)
+ continue
+ }
+ if outcome.label == "" {
+ t.Errorf("%s has no operator-facing label", method)
+ }
+ }
+
+ // An invite arriving is not an invite resolving.
+ if _, ok := inviteOutcome("cluster:invite-received"); ok {
+ t.Error("invite-received treated as terminal")
+ }
+ if _, ok := inviteOutcome("discovery:nodes-changed"); ok {
+ t.Error("unrelated notification treated as a terminal invite event")
+ }
+}
+
+// notify builds the broker push a view would receive for method, with no params.
+func notify(method string) NotificationMsg {
+ return NotificationMsg{Msg: &rpc.Message{Method: method}}
+}
+
+// TestNodesViewRetiresPinOnInviteDeclined is the regression guard for the
+// pairing dead end: a PIN pinned on the Nodes tab must be replaced once the
+// cluster manager reports the invite is no longer pending.
+func TestNodesViewRetiresPinOnInviteDeclined(t *testing.T) {
+ v := newNodesView(nil)
+ v.invitedKey = "peer-uuid"
+ v.outboundInviteID = "inv-1"
+ v.status.pin("invite sent to peer - PIN 123456")
+
+ v.Update(inviteEvent("cluster:invite-declined", "inv-1"))
+
+ if v.invitedKey != "" {
+ t.Error("pending invite still tracked after it was declined")
+ }
+ rendered := v.status.render()
+ if rendered == "" {
+ t.Fatal("declined invite produced no status at all")
+ }
+ if contains(rendered, "123456") {
+ t.Errorf("PIN still on screen after the invite was declined: %q", rendered)
+ }
+}
+
+// TestNodesViewRetiresPinOnPairingSuccess covers the one outcome with no
+// terminal notification: success shows up as the invited node becoming a member.
+func TestNodesViewRetiresPinOnPairingSuccess(t *testing.T) {
+ v := newNodesView(nil)
+ v.invitedKey = "peer-uuid"
+ v.status.pin("invite sent to peer - PIN 123456")
+
+ v.feeds.discovered = []availableNode{{HostUUID: "peer-uuid", Name: "peer", Trusted: true}}
+ v.rebuild()
+
+ if v.invitedKey != "" {
+ t.Error("pending invite still tracked after the peer joined")
+ }
+ if rendered := v.status.render(); contains(rendered, "123456") {
+ t.Errorf("PIN still on screen after pairing completed: %q", rendered)
+ }
+}
+
+// TestNodesViewKeepsPinWhileInvitePending checks an unrelated snapshot does not
+// retire a PIN that is still live.
+func TestNodesViewKeepsPinWhileInvitePending(t *testing.T) {
+ v := newNodesView(nil)
+ v.invitedKey = "peer-uuid"
+ v.status.pin("invite sent to peer - PIN 123456")
+
+ v.feeds.discovered = []availableNode{
+ {HostUUID: "peer-uuid", Name: "peer", Trusted: false},
+ {HostUUID: "other-uuid", Name: "other", Trusted: true},
+ }
+ v.rebuild()
+
+ if v.invitedKey != "peer-uuid" {
+ t.Error("pending invite dropped while still unanswered")
+ }
+ if !contains(v.status.render(), "123456") {
+ t.Error("PIN removed while the invite was still pending")
+ }
+}
+
+// inviteEvent builds a terminal invite notification carrying an inviteId.
+func inviteEvent(method, inviteID string) NotificationMsg {
+ params, _ := json.Marshal(map[string]string{"inviteId": inviteID})
+ return NotificationMsg{Msg: &rpc.Message{Method: method, Params: params}}
+}
+
+// inviteReceived is an inbound pairing request, as the cluster manager pushes it.
+func inviteReceived(inviteID, from string) NotificationMsg {
+ params, _ := json.Marshal(map[string]string{
+ "inviteId": inviteID, "fromNodeName": from,
+ })
+ return NotificationMsg{Msg: &rpc.Message{
+ Method: "cluster:invite-received", Params: params,
+ }}
+}
+
+// TestInboundPairingIsPromptedOnce is the regression guard for the same
+// request being announced twice, in two wordings.
+//
+// It was both a pinned status line and a row of the frame. Two prompts for one
+// fact read as two requests, and the pinned one also outranked the status
+// line, so nothing that happened next could be reported there.
+func TestInboundPairingIsPromptedOnce(t *testing.T) {
+ v := newNodesView(nil)
+ v.SetSize(120, 30)
+ v.Update(inviteReceived("inv-1", "M2GT9CR405"))
+
+ prompt := v.inboundPrompt()
+ if !contains(prompt, "M2GT9CR405") {
+ t.Fatalf("the prompt does not name the machine asking: %q", prompt)
+ }
+ if got := v.status.render(); contains(got, "pairing request") {
+ t.Errorf("the request is announced on the status line as well: %q", got)
+ }
+ // And once in the rendered frame, not twice.
+ if n := strings.Count(v.View(), "pairing request from"); n != 1 {
+ t.Errorf("the frame carries the prompt %d times, want 1", n)
+ }
+}
+
+// TestAcceptingPairingChangesThePrompt checks the prompt follows the request
+// into its second state.
+//
+// Pressing accept does not finish anything — it opens the PIN field — so a
+// prompt still offering "a to accept" told the operator to do what they had
+// just done, while the PIN it actually wanted sat on the line below.
+func TestAcceptingPairingChangesThePrompt(t *testing.T) {
+ v := newNodesView(nil)
+ v.SetSize(120, 30)
+ v.Update(inviteReceived("inv-2", "M2GT9CR405"))
+ v.handleKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(nodePairKey.Help().Key)})
+
+ if v.mode != nodesInputPin {
+ t.Fatalf("accept did not open the PIN field (mode %v)", v.mode)
+ }
+ prompt := v.inboundPrompt()
+ if contains(prompt, "to accept") {
+ t.Errorf("still offering accept after it was pressed: %q", prompt)
+ }
+ if !contains(prompt, "PIN") {
+ t.Errorf("prompt %q does not say what is wanted now", prompt)
+ }
+ // The request is still pending until the PIN is submitted, so the machine
+ // it came from stays named.
+ if !contains(prompt, "M2GT9CR405") {
+ t.Errorf("prompt %q lost track of who is pairing", prompt)
+ }
+}
+
+// TestInviteOutcomeMatchesBySession is the regression guard for concurrent
+// pairings clobbering each other. The manager supports several at once and
+// stamps an inviteId on every terminal event, so an unrelated invite's decline
+// must not clear the PIN or prompt belonging to a different session.
+func TestInviteOutcomeMatchesBySession(t *testing.T) {
+ v := newNodesView(nil)
+ v.outboundInviteID = "mine"
+ v.invitedKey = "peer-uuid"
+ v.status.pin("invite sent to peer - PIN 123456")
+ v.inbound = &clusterInvite{InviteID: "theirs", FromNodeName: "other"}
+
+ // An event carrying no invite at all belongs to no session and is ignored.
+ // Every terminal notification the manager emits carries one, so this is a
+ // malformed frame rather than an older sender to be accommodated.
+ v.Update(notify("cluster:invite-declined"))
+ if v.outboundInviteID != "mine" || v.inbound == nil {
+ t.Error("an unattributable event cleared a live pairing session")
+ }
+
+ // A decline for a third, unrelated session touches neither.
+ v.Update(inviteEvent("cluster:invite-declined", "somebody-else"))
+ if v.outboundInviteID != "mine" || v.invitedKey == "" {
+ t.Error("an unrelated invite's decline cleared our outbound session")
+ }
+ if v.inbound == nil {
+ t.Error("an unrelated invite's decline cleared the inbound prompt")
+ }
+
+ // A decline for our outbound invite clears that, and leaves the inbound
+ // request alone.
+ v.Update(inviteEvent("cluster:invite-declined", "mine"))
+ if v.outboundInviteID != "" || v.invitedKey != "" {
+ t.Error("our own decline did not clear the outbound session")
+ }
+ if v.inbound == nil {
+ t.Error("our outbound decline also cleared the unrelated inbound prompt")
+ }
+
+ // And the inbound one resolves on its own id.
+ v.Update(inviteEvent("cluster:invite-expired", "theirs"))
+ if v.inbound != nil {
+ t.Error("the inbound prompt survived its own expiry")
+ }
+}
+
+// TestInviteByAddressRetiresItsPin is the regression guard for the by-address
+// path: it has no node UUID, so without matching on the address the PIN it
+// pinned stayed on screen forever after the peer joined.
+func TestInviteByAddressRetiresItsPin(t *testing.T) {
+ v := newNodesView(nil)
+ v.Update(nodeInviteMsg{
+ name: "10.0.0.7", address: "10.0.0.7", inviteID: "inv", pin: "123456",
+ })
+ if v.invitedAddress != "10.0.0.7" {
+ t.Fatalf("invitedAddress = %q, want the invited host", v.invitedAddress)
+ }
+ if !contains(v.status.render(), "123456") {
+ t.Fatal("PIN was not pinned")
+ }
+
+ // The peer joins; discovery reports it with that address.
+ v.feeds.discovered = []availableNode{{
+ HostUUID: "peer-uuid", Name: "peer", IPAddress: "10.0.0.7", Trusted: true,
+ }}
+ v.rebuild()
+
+ if v.invitedAddress != "" {
+ t.Error("pending by-address invite still tracked after the peer joined")
+ }
+ if contains(v.status.render(), "123456") {
+ t.Error("PIN still on screen after the by-address peer joined")
+ }
+}
+
+// TestAddressMatches checks the host comparison used to recognise a peer invited
+// by address, including a typed host:port form and the node's other addresses.
+func TestAddressMatches(t *testing.T) {
+ row := nodeRow{
+ name: "host-a",
+ address: "10.0.0.7",
+ addresses: []string{"10.0.0.7", "192.168.1.9"},
+ }
+ for _, in := range []string{"10.0.0.7", "10.0.0.7:14321", "192.168.1.9", "HOST-A"} {
+ if !addressMatches(row, in) {
+ t.Errorf("addressMatches(%q) = false, want true", in)
+ }
+ }
+ for _, in := range []string{"", "10.0.0.8", "other-host"} {
+ if addressMatches(row, in) {
+ t.Errorf("addressMatches(%q) = true, want false", in)
+ }
+ }
+}
+
+// TestRemoveMemberRequiresConfirmation checks un-pairing a peer is armed rather
+// than immediate, matching leave-cluster. It acts on someone else's row, so a
+// stray keystroke is worse there, not better.
+func TestRemoveMemberRequiresConfirmation(t *testing.T) {
+ v := newNodesView(nil)
+ v.feeds.discovered = []availableNode{{
+ HostUUID: "peer", Name: "peer", IPAddress: "10.0.0.2", Trusted: true,
+ }}
+ v.rebuild()
+ v.selectedKey = "peer"
+
+ if cmd := v.removeSelected(); cmd != nil {
+ t.Error("removal was dispatched without confirmation")
+ }
+ if v.confirmRemove != "peer" {
+ t.Fatalf("confirmRemove = %q, want the selected node", v.confirmRemove)
+ }
+
+ // Any other key cancels.
+ v.handleKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("n")})
+ if v.confirmRemove != "" {
+ t.Error("a non-confirming key left the removal armed")
+ }
+
+ // Re-arm and confirm.
+ v.removeSelected()
+ if cmd := v.handleKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("y")}); cmd == nil {
+ t.Error("confirmation produced no removal command")
+ }
+}
+
+// TestNodesViewExplainsAnEmptyTable is the guard for the worst first
+// impression this tab can give: no rows and no reason.
+//
+// The four feeds behind it each ignored their error, so a broker that could not
+// answer produced a tab identical to a quiet network. Those need opposite
+// responses from the operator — wait, or go look at the service — so the screen
+// has to say which one it is.
+func TestNodesViewExplainsAnEmptyTable(t *testing.T) {
+ v := newNodesView(nil)
+ v.SetSize(100, 30)
+
+ // Nothing wrong, just nothing found yet.
+ if got := v.View(); !contains(got, "Discovery is browsing") {
+ t.Errorf("a quiet network does not read as one:\n%s", got)
+ }
+
+ v.Update(clusterMembersMsg{err: errors.New("worker not running")})
+ got := v.View()
+ if contains(got, "Discovery is browsing") {
+ t.Error("a failed read still claims discovery is simply looking")
+ }
+ if !contains(got, "cluster members") {
+ t.Errorf("the failing feed is not named:\n%s", got)
+ }
+ if !contains(got, "worker not running") {
+ t.Errorf("the reason is not shown:\n%s", got)
+ }
+}
+
+// TestNodesViewWarnsWhenPopulatedButIncomplete checks a partial failure is
+// reported too. A table with rows in it looks authoritative, so a missing feed
+// there is more misleading than an empty one, not less.
+func TestNodesViewWarnsWhenPopulatedButIncomplete(t *testing.T) {
+ v := newNodesView(nil)
+ v.SetSize(100, 30)
+ v.feeds.discovered = []availableNode{{HostUUID: "peer", Name: "peer", IPAddress: "10.0.0.2"}}
+ v.rebuild()
+
+ v.Update(manualNodesMsg{err: errors.New("manual worker down")})
+ if got := v.View(); !contains(got, "manual nodes") {
+ t.Errorf("a populated table hides that a feed is missing:\n%s", got)
+ }
+}
+
+// TestNodesViewClearsFeedWarningOnRecovery checks the warning is a live
+// condition, not a permanent mark: a feed that starts working again stops
+// being reported.
+func TestNodesViewClearsFeedWarningOnRecovery(t *testing.T) {
+ v := newNodesView(nil)
+ v.SetSize(100, 30)
+
+ v.Update(manualNodesMsg{err: errors.New("transient")})
+ if v.feedWarning() == "" {
+ t.Fatal("failure was not recorded")
+ }
+
+ v.Update(manualNodesMsg{})
+ if got := v.feedWarning(); got != "" {
+ t.Errorf("warning survived recovery: %q", got)
+ }
+}
+
+// TestNodesFilterNarrowsWithoutLosingTheCluster checks the filter changes what
+// is shown and nothing else. The cluster summary and the pairing-completion
+// check are about the cluster, not about what the operator is looking at, so a
+// filter must not shrink the member count or strand a pinned PIN.
+func TestNodesFilterNarrowsWithoutLosingTheCluster(t *testing.T) {
+ v := newNodesView(nil)
+ v.SetSize(100, 30)
+ v.identity.ClusterID = "cluster-1"
+ v.feeds.members = []clusterNode{
+ {NodeUUID: "a", Name: "alpha", State: "member"},
+ {NodeUUID: "b", Name: "beta", State: "member"},
+ }
+ v.rebuild()
+
+ if got := len(v.rows); got != 2 {
+ t.Fatalf("unfiltered rows = %d, want 2", got)
+ }
+
+ v.filter = "alpha"
+ v.rebuild()
+
+ if len(v.rows) != 1 || v.rows[0].name != "alpha" {
+ t.Errorf("filtered rows = %+v, want just alpha", v.rows)
+ }
+ if len(v.all) != 2 {
+ t.Errorf("the filter dropped nodes from the full set: %d", len(v.all))
+ }
+ if !contains(v.clusterLine(), "2") {
+ t.Errorf("member count followed the filter instead of the cluster: %q", v.clusterLine())
+ }
+ if !contains(v.View(), "showing 1 of 2") {
+ t.Errorf("a filtered table does not say it is filtered:\n%s", v.View())
+ }
+}
+
+// TestNodesFilterRetiresPinForAHiddenPeer checks a peer that joins while
+// filtered out still retires its PIN. The pairing completed; whether the
+// operator can currently see the row is irrelevant.
+func TestNodesFilterRetiresPinForAHiddenPeer(t *testing.T) {
+ v := newNodesView(nil)
+ v.SetSize(100, 30)
+ v.Update(nodeInviteMsg{name: "beta", inviteID: "inv", pin: "123456"})
+ v.invitedKey = "b"
+ v.filter = "alpha" // hides the very node we invited
+
+ v.feeds.members = []clusterNode{{NodeUUID: "b", Name: "beta", State: "member"}}
+ v.rebuild()
+
+ if v.invitedKey != "" {
+ t.Error("a peer that joined while filtered out left its invite pending")
+ }
+ if contains(v.status.render(), "123456") {
+ t.Error("the PIN is still on screen after the hidden peer joined")
+ }
+}
+
+// TestCancelInviteClearsThePinImmediately checks the inviter's half of decline.
+// Without it a PIN read to the wrong person could only be retired by waiting
+// for it to expire, staying answerable the whole time.
+func TestCancelInviteClearsThePinImmediately(t *testing.T) {
+ v := newNodesView(nil)
+ v.SetSize(100, 30)
+ v.Update(nodeInviteMsg{name: "peer", inviteID: "inv-1", pin: "123456"})
+ if !contains(v.status.render(), "123456") {
+ t.Fatal("PIN was not pinned")
+ }
+
+ if cmd := v.cancelInvite(); cmd == nil {
+ t.Error("cancelling produced no request")
+ }
+ if v.outboundInviteID != "" {
+ t.Error("the invite is still tracked after cancelling")
+ }
+ if contains(v.status.render(), "123456") {
+ t.Error("the PIN is still displayed after cancelling; it must stop being readable at once")
+ }
+
+ // Nothing pending is a no-op, not an error.
+ if cmd := v.cancelInvite(); cmd != nil {
+ t.Error("cancelling with no invite pending still sent a request")
+ }
+}
+
+// TestWrongPinIsNotReportedAsSuccess is the regression guard for the worst lie
+// this interface could tell.
+//
+// A wrong PIN is not a JSON-RPC error. The cluster manager tears the pairing
+// session down and replies *successfully* with the invite, whose state is
+// "failed" and whose reason says why. Reading only the transport error reported
+// a green "accept pairing ok" for a pairing that had just been rejected — and
+// because the prompt is cleared by then, nothing later corrected it. The
+// operator would go looking for a peer that was never going to appear.
+func TestWrongPinIsNotReportedAsSuccess(t *testing.T) {
+ v := newNodesView(nil)
+ v.SetSize(100, 30)
+
+ v.Update(pairingResultMsg{from: "peer", state: "failed", reason: reasonIncorrectPIN})
+
+ got := v.status.render()
+ if contains(got, " ok") {
+ t.Errorf("a rejected pairing reported success: %q", got)
+ }
+ if !contains(got, "wrong PIN") {
+ t.Errorf("status %q does not say the PIN was wrong", got)
+ }
+ if !contains(got, "new invite") {
+ t.Errorf("status %q does not say what to do next; the PIN is single-use", got)
+ }
+}
+
+// TestPairingOutcomesAreDistinguished checks each terminal state gets its own
+// answer, since they call for different things from the operator.
+func TestPairingOutcomesAreDistinguished(t *testing.T) {
+ cases := []struct {
+ state, reason string
+ want string
+ }{
+ {state: "paired", want: "paired with peer"},
+ {state: "failed", reason: reasonIncorrectPIN, want: "wrong PIN"},
+ {state: "failed", reason: "unreachable", want: "failed"},
+ {state: "declined", want: "declined"},
+ }
+ for _, tc := range cases {
+ v := newNodesView(nil)
+ v.SetSize(100, 30)
+ v.Update(pairingResultMsg{from: "peer", state: tc.state, reason: tc.reason})
+ if got := v.status.render(); !contains(got, tc.want) {
+ t.Errorf("state=%q reason=%q rendered %q, want it to mention %q",
+ tc.state, tc.reason, got, tc.want)
+ }
+ }
+}
+
+// TestNodesViewClearsInboundInviteOnExpiry checks an inbound prompt stops
+// offering accept/decline once the invite is gone.
+func TestNodesViewClearsInboundInviteOnExpiry(t *testing.T) {
+ // Both the prompt and the assertion read the key off the binding. Spelling
+ // it out meant that when accept moved from p to a, this went on checking
+ // that p was absent — and p by then was "pair", which is always offered, so
+ // it failed for a reason unrelated to what it tests.
+ accept := nodePairKey.Help().Key
+
+ v := newNodesView(nil)
+ v.Update(inviteReceived("inv-1", "peer"))
+ if !contains(v.inboundPrompt(), "to accept") {
+ t.Fatal("no prompt after a pairing request arrived")
+ }
+
+ v.Update(inviteEvent("cluster:invite-expired", "inv-1"))
+
+ if v.inbound != nil {
+ t.Error("expired inbound invite is still pending; accept would target a dead invite")
+ }
+ if contains(v.inboundPrompt(), "to accept") {
+ t.Error("still offering accept/decline for an expired invite")
+ }
+ if v.Help() == nil {
+ t.Error("help bindings unexpectedly nil")
+ }
+ for _, b := range v.Help() {
+ if b.Help().Key == accept && b.Help().Desc == nodePairKey.Help().Desc {
+ t.Error("accept-pairing key still advertised with no pending invite")
+ }
+ }
+}
+
+// TestNodesViewSelectionSurvivesReorder is the guard for selection tracked by
+// key rather than row index: the list re-sorts as nodes come and go, and an
+// index would quietly move the operator onto a different machine.
+func TestNodesViewSelectionSurvivesReorder(t *testing.T) {
+ v := newNodesView(nil)
+ v.feeds.discovered = []availableNode{
+ {HostUUID: "a", Name: "aaa", LastSeen: time.Now().Unix()},
+ {HostUUID: "z", Name: "zzz", LastSeen: time.Now().Unix()},
+ }
+ v.rebuild()
+
+ v.selectedKey = "z"
+ v.restoreSelection()
+ if got := v.selectedRow(); got == nil || got.key != "z" {
+ t.Fatalf("selection did not settle on the requested node")
+ }
+
+ // A new node sorting ahead of the selection must not steal the cursor.
+ v.feeds.discovered = append(v.feeds.discovered,
+ availableNode{HostUUID: "m", Name: "mmm", LastSeen: time.Now().Unix()})
+ v.rebuild()
+
+ if got := v.selectedRow(); got == nil || got.key != "z" {
+ t.Errorf("selection moved to %v after the list grew", got)
+ }
+}
+
+// TestNodesViewGuardsInviteOnEveryPath is the regression guard for re-inviting a
+// node that already has a relationship. The old split tabs guarded the
+// discovered-node path only, so the by-address path could still send a doomed
+// invite.
+func TestNodesViewGuardsInviteOnEveryPath(t *testing.T) {
+ cases := []struct {
+ name string
+ node availableNode
+ }{
+ {"already a member of our cluster", availableNode{HostUUID: "k", Name: "peer", Trusted: true}},
+ {"a member of another cluster", availableNode{HostUUID: "k", Name: "peer", Clustered: true}},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ v := newNodesView(nil)
+ v.feeds.discovered = []availableNode{tc.node}
+ v.rebuild()
+ v.selectedKey = "k"
+
+ if cmd := v.inviteSelected(); cmd != nil {
+ t.Error("invite was dispatched for a node that cannot accept one")
+ }
+ if v.invitedKey != "" {
+ t.Error("invite recorded as pending despite being blocked")
+ }
+ if v.status.render() == "" {
+ t.Error("invite blocked with no explanation to the operator")
+ }
+ })
+ }
+}
+
+// TestNodesViewRefusesSelfInvite checks this machine cannot be invited to its
+// own cluster.
+func TestNodesViewRefusesSelfInvite(t *testing.T) {
+ v := newNodesView(nil)
+ v.feeds.discovered = []availableNode{{HostUUID: "me", Name: "this", LastSeen: time.Now().Unix()}}
+ v.feeds.selfUUID = "me"
+ v.rebuild()
+ v.selectedKey = "me"
+
+ if cmd := v.inviteSelected(); cmd != nil {
+ t.Error("dispatched an invite to this machine")
+ }
+}
+
+// contains is a substring check kept local to avoid importing strings for one
+// assertion style.
+func contains(haystack, needle string) bool {
+ if needle == "" {
+ return true
+ }
+ for i := 0; i+len(needle) <= len(haystack); i++ {
+ if haystack[i:i+len(needle)] == needle {
+ return true
+ }
+ }
+ return false
+}
+
+// assert the views used above still satisfy the interface the shell drives.
+var _ View = (*nodesView)(nil)
+var _ inputCapturer = (*nodesView)(nil)
+var _ tea.Model = Model{}
diff --git a/services/nvpair-tui/ui/ui.go b/services/nvpair-tui/ui/ui.go
index 65015a7b..27021313 100644
--- a/services/nvpair-tui/ui/ui.go
+++ b/services/nvpair-tui/ui/ui.go
@@ -12,10 +12,11 @@ import (
tea "github.com/charmbracelet/bubbletea"
)
-// Run builds the tabbed program over a connected broker client and the
-// broker's stderr stream, and blocks until the user quits. The caller is
-// responsible for shutting the broker down afterwards.
-func Run(client *rpc.Client, stderr io.Reader) error {
+// Run builds the tabbed program over a connected broker client and the broker's
+// stderr stream, and blocks until the user quits. The caller is responsible for
+// shutting the broker down afterwards, and for honouring the returned Outcome
+// once it has.
+func Run(client *rpc.Client, stderr io.Reader) (Outcome, error) {
logCh := make(chan string, 2000)
go scanLines(stderr, logCh)
@@ -23,8 +24,14 @@ func Run(client *rpc.Client, stderr io.Reader) error {
New(client, logCh, defaultViews(client)),
tea.WithAltScreen(),
)
- _, err := p.Run()
- return err
+ final, err := p.Run()
+ if m, ok := final.(Model); ok {
+ // Before returning, so a demo still inside its window does not leave
+ // dispatcher processes behind for the shell to inherit.
+ m.close()
+ return Outcome{WipeData: m.wipeOnExit}, err
+ }
+ return Outcome{}, err
}
// scanLines forwards each line of r onto out, closing out at EOF. The
@@ -40,17 +47,23 @@ func scanLines(r io.Reader, out chan<- string) {
}
// defaultViews lists the tabs in display order.
+//
+// The tab set is machine-first: Nodes is the primary surface because a node is
+// the unit an operator reasons about, and everything specific to one machine
+// hangs off its row rather than living in a tab of its own. Diagnostics come
+// last, errors before logs, which is the order you consult them in.
+//
+// Errors is a plain tab rather than an overlay on a dedicated key. As an overlay
+// it needed a global binding, and every candidate was either a letter that
+// shadowed a view's own verb or a digit that looked like a tab number without
+// being one. Its count rides on the tab label instead, so the tab bar is the
+// indicator and there is nothing extra to learn.
func defaultViews(client *rpc.Client) []View {
return []View{
- newHealthView(client),
- newErrorsView(client),
newNodesView(client),
- newProxiesView(client),
- newWorkloadsView(client),
- newEnginesView(client),
- newClusterView(client),
- newManualView(client),
- newSettingsView(client),
+ newJobsView(client),
+ newServiceView(client),
+ newErrorsView(client),
newLogsView(client),
}
}
diff --git a/services/nvpair-tui/ui/updatecheck.go b/services/nvpair-tui/ui/updatecheck.go
new file mode 100644
index 00000000..e3641c22
--- /dev/null
+++ b/services/nvpair-tui/ui/updatecheck.go
@@ -0,0 +1,225 @@
+// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+package ui
+
+import (
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "os"
+ "strconv"
+ "strings"
+ "time"
+
+ tea "github.com/charmbracelet/bubbletea"
+)
+
+// Update awareness: notice that a newer release exists and say so.
+//
+// Deliberately not an updater. It downloads nothing and installs nothing, which
+// is not timidity — the terminal client is not one binary. It resolves the broker
+// beside its own executable and that broker spawns eleven workers from the same
+// directory, each independently versioned, so replacing "the client" means
+// swapping fourteen binaries atomically while they are serving inference. A
+// partial swap leaves a new client driving old workers across a JSON-RPC
+// contract that may have changed, which is the failure service-contracts:check
+// exists to catch at build time. Telling the operator and letting them install
+// the release properly is the honest half of the job.
+//
+// The desktop app has real auto-update, and on an app install this client is
+// already carried along by it: nvpair-tui ships inside cli-bin, and the nvpair
+// launcher holds an absolute path into it, so replacing the bundle replaces this
+// binary too. What has no updater is a services-tarball install on a headless
+// box, which is exactly where this notice is worth having.
+
+// ReleaseVersion is the release this binary was built from, stamped by the
+// build scripts with -X nvpair-tui/ui.ReleaseVersion=...
+//
+// PAIR carries three numbers and only one of them is comparable against a
+// release tag. This component's own version (versions.json components.nvpair-tui)
+// and the services suite version (versions.json services) both describe parts of
+// the build; the *release* version in desktop/package.json is what users install
+// and what the published tags are named for. Comparing either of the other two
+// against a tag is meaningless — and worse than meaningless while the suite
+// version is numerically ahead of the release, because every check would
+// conclude this build is newer and say nothing forever.
+//
+// Stamped into this package rather than main so it does not have to be threaded
+// through Run, New, and the model to reach the one line that shows it. "dev"
+// means an unstamped build, and no check is made against that.
+var ReleaseVersion = "dev"
+
+// updateFeedURL is the public releases feed. The same place the README and
+// services/readme.md already send people to download PAIR, so nothing new is
+// being contacted, and it serves stable releases only.
+const updateFeedURL = "https://api.github.com/repos/NVIDIA/Personal-AI-Router/releases/latest"
+
+// updateReleasesPage is where the notice sends the operator: the human page,
+// not the API. The same URL the README and services/readme.md already give.
+const updateReleasesPage = "https://github.com/NVIDIA/Personal-AI-Router/releases"
+
+// disableUpdateCheckEnv turns the check off.
+//
+// A server reaching the internet unasked is a legitimate objection — an
+// air-gapped or change-controlled host must be able to refuse — so this is one
+// documented variable rather than a setting to discover.
+const disableUpdateCheckEnv = "NVPAIR_NO_UPDATE_CHECK"
+
+const (
+ // updateCheckTimeout bounds the request. Short: nothing waits on this, and a
+ // network that blackholes the request should cost nothing.
+ updateCheckTimeout = 10 * time.Second
+
+ // updateCheckInterval matches the desktop app's six hours, so the two front
+ // ends notice a release at the same cadence.
+ updateCheckInterval = 6 * time.Hour
+
+ // updateFeedMaxBytes caps the reply. The release object is a few kilobytes;
+ // this is only here so a misbehaving endpoint cannot stream forever.
+ updateFeedMaxBytes = 1 << 20
+)
+
+// updateClient refuses redirects for the same reason the telemetry poller does:
+// a redirect could point this at an arbitrary host, and a release feed has no
+// legitimate reason to issue one.
+var updateClient = &http.Client{
+ Timeout: updateCheckTimeout,
+ CheckRedirect: func(*http.Request, []*http.Request) error {
+ return http.ErrUseLastResponse
+ },
+}
+
+// updateCheckMsg carries the outcome. A failure is recorded and never shown: an
+// operator who cannot reach the internet does not need to be told so every six
+// hours, and this is the least important thing on the screen.
+type updateCheckMsg struct {
+ latest string
+ err error
+}
+
+// updateCheckEnabled reports whether to look at all.
+//
+// An unstamped build is skipped too: a developer running from source has nothing
+// to compare and does not want a notice telling them to download a release.
+func updateCheckEnabled() bool {
+ if strings.TrimSpace(os.Getenv(disableUpdateCheckEnv)) != "" {
+ return false
+ }
+ _, stamped := versionParts(ReleaseVersion)
+ return stamped
+}
+
+// checkUpdateCmd asks the feed for the newest release.
+func checkUpdateCmd() tea.Cmd {
+ if !updateCheckEnabled() {
+ return nil
+ }
+ return func() tea.Msg {
+ latest, err := fetchLatestRelease(updateFeedURL)
+ return updateCheckMsg{latest: latest, err: err}
+ }
+}
+
+// updateCheckTickCmd re-arms the check.
+func updateCheckTickCmd() tea.Cmd {
+ if !updateCheckEnabled() {
+ return nil
+ }
+ return tea.Tick(updateCheckInterval, func(time.Time) tea.Msg {
+ return updateCheckDueMsg{}
+ })
+}
+
+type updateCheckDueMsg struct{}
+
+// fetchLatestRelease returns the newest release's version, without its leading v.
+func fetchLatestRelease(url string) (string, error) {
+ req, err := http.NewRequest(http.MethodGet, url, nil)
+ if err != nil {
+ return "", err
+ }
+ // The documented media type for this endpoint, so a future default change
+ // cannot alter the shape being parsed.
+ req.Header.Set("Accept", "application/vnd.github+json")
+
+ resp, err := updateClient.Do(req)
+ if err != nil {
+ return "", err
+ }
+ defer resp.Body.Close()
+
+ // A repository with no published release answers 404, which is not an error
+ // worth distinguishing: there is nothing newer either way.
+ if resp.StatusCode != http.StatusOK {
+ return "", fmt.Errorf("release feed returned %s", resp.Status)
+ }
+
+ var r struct {
+ TagName string `json:"tag_name"`
+ Draft bool `json:"draft"`
+ Prerelease bool `json:"prerelease"`
+ }
+ if err := json.NewDecoder(io.LimitReader(resp.Body, updateFeedMaxBytes)).Decode(&r); err != nil {
+ return "", err
+ }
+ // Stable and prerelease metadata stay isolated, the same rule the desktop
+ // feed follows: a suffix-free build must never be offered a prerelease.
+ if r.Draft || r.Prerelease {
+ return "", nil
+ }
+ return strings.TrimPrefix(strings.TrimSpace(r.TagName), "v"), nil
+}
+
+// newerVersion reports whether latest is a higher release than running.
+//
+// Compares the numeric dot-separated head and ignores any prerelease suffix, so
+// 0.91.7 beats 0.91.6 and 0.91.7-dev is not offered to 0.91.7. Anything it
+// cannot parse answers false: a wrong "up to date" is a missed notice, while a
+// wrong "update available" sends someone looking for a release that is not there.
+func newerVersion(running, latest string) bool {
+ r, okR := versionParts(running)
+ l, okL := versionParts(latest)
+ if !okR || !okL {
+ return false
+ }
+ for i := 0; i < len(r) || i < len(l); i++ {
+ var a, b int
+ if i < len(r) {
+ a = r[i]
+ }
+ if i < len(l) {
+ b = l[i]
+ }
+ if a != b {
+ return b > a
+ }
+ }
+ return false
+}
+
+// versionParts splits a version's numeric head into components.
+func versionParts(v string) ([]int, bool) {
+ v = strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(v), "v"))
+ if v == "" {
+ return nil, false
+ }
+ // Drop a prerelease or build suffix; only the release numbers are compared.
+ if i := strings.IndexAny(v, "-+"); i >= 0 {
+ v = v[:i]
+ }
+ fields := strings.Split(v, ".")
+ out := make([]int, 0, len(fields))
+ for _, f := range fields {
+ n, err := strconv.Atoi(f)
+ if err != nil {
+ return nil, false
+ }
+ out = append(out, n)
+ }
+ if len(out) == 0 {
+ return nil, false
+ }
+ return out, true
+}
diff --git a/services/nvpair-tui/ui/updatecheck_test.go b/services/nvpair-tui/ui/updatecheck_test.go
new file mode 100644
index 00000000..5c12f72e
--- /dev/null
+++ b/services/nvpair-tui/ui/updatecheck_test.go
@@ -0,0 +1,298 @@
+// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+package ui
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ tea "github.com/charmbracelet/bubbletea"
+ "github.com/charmbracelet/lipgloss"
+)
+
+// withReleaseVersion stamps a version for one test and restores it after.
+func withReleaseVersion(t *testing.T, v string) {
+ t.Helper()
+ prev := ReleaseVersion
+ ReleaseVersion = v
+ t.Cleanup(func() { ReleaseVersion = prev })
+}
+
+func TestNewerVersionComparesReleaseNumbers(t *testing.T) {
+ cases := []struct {
+ running, latest string
+ want bool
+ why string
+ }{
+ {"0.91.7", "0.91.8", true, "patch bump"},
+ {"0.91.7", "0.92.0", true, "minor bump"},
+ {"0.91.7", "1.0.0", true, "major bump"},
+ {"0.91.7", "0.91.7", false, "same version"},
+ {"0.91.8", "0.91.7", false, "feed behind this build"},
+ // Numeric, not lexical: "10" sorts before "9" as a string.
+ {"0.9.0", "0.10.0", true, "double-digit minor beats single"},
+ {"0.10.0", "0.9.0", false, "and not the other way"},
+ // Prerelease isolation, the same rule the desktop feed follows: a
+ // suffix-free build is a stable release and must not be offered a
+ // prerelease of the version it is already on.
+ {"0.91.7", "0.91.7-dev", false, "prerelease of the running version"},
+ {"0.91.7-dev", "0.91.7", false, "the release this prerelease became"},
+ {"0.91.7", "0.91.8-rc1", true, "prerelease of a genuinely later version"},
+ // A leading v is how the tag is written.
+ {"0.91.7", "v0.91.8", true, "tag keeps its v"},
+ // Unparseable answers false. A missed notice is harmless; a false one
+ // sends someone looking for a release that does not exist.
+ {"0.91.7", "", false, "empty feed answer"},
+ {"0.91.7", "nightly", false, "non-numeric tag"},
+ {"dev", "0.91.8", false, "unstamped build"},
+ {"", "0.91.8", false, "empty running version"},
+ // Differing component counts compare as if the shorter were zero-padded.
+ {"1.2", "1.2.1", true, "shorter running version"},
+ {"1.2.0", "1.2", false, "shorter latest version"},
+ }
+ for _, tc := range cases {
+ if got := newerVersion(tc.running, tc.latest); got != tc.want {
+ t.Errorf("newerVersion(%q, %q) = %v, want %v (%s)",
+ tc.running, tc.latest, got, tc.want, tc.why)
+ }
+ }
+}
+
+func TestUpdateCheckIsDisabledByEnvironment(t *testing.T) {
+ // A server reaching the internet unasked is a real objection, so the opt-out
+ // has to actually stop the request being built at all.
+ withReleaseVersion(t, "0.91.7")
+ if !updateCheckEnabled() {
+ t.Fatal("check is disabled with nothing set")
+ }
+
+ t.Setenv(disableUpdateCheckEnv, "1")
+ if updateCheckEnabled() {
+ t.Error("check still enabled with the opt-out set")
+ }
+ if checkUpdateCmd() != nil {
+ t.Error("a command was still issued with the opt-out set")
+ }
+ if updateCheckTickCmd() != nil {
+ t.Error("the check was still re-armed with the opt-out set")
+ }
+}
+
+func TestUpdateCheckIsSkippedForAnUnstampedBuild(t *testing.T) {
+ // Running from source has nothing to compare, and a developer does not want
+ // to be told to go download a release.
+ withReleaseVersion(t, "dev")
+ if updateCheckEnabled() {
+ t.Error("check enabled for an unstamped build")
+ }
+ if checkUpdateCmd() != nil {
+ t.Error("a command was issued for an unstamped build")
+ }
+}
+
+func TestFetchLatestReleaseReadsTheTag(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if got := r.Header.Get("Accept"); got != "application/vnd.github+json" {
+ t.Errorf("Accept header = %q", got)
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(`{"tag_name":"v0.92.0","draft":false,"prerelease":false}`))
+ }))
+ defer srv.Close()
+
+ got, err := fetchLatestRelease(srv.URL)
+ if err != nil {
+ t.Fatalf("fetchLatestRelease: %v", err)
+ }
+ if got != "0.92.0" {
+ t.Errorf("got %q, want the tag without its v", got)
+ }
+}
+
+func TestFetchLatestReleaseIgnoresDraftsAndPrereleases(t *testing.T) {
+ for _, body := range []string{
+ `{"tag_name":"v0.92.0","draft":true,"prerelease":false}`,
+ `{"tag_name":"v0.92.0","draft":false,"prerelease":true}`,
+ } {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ _, _ = w.Write([]byte(body))
+ }))
+ got, err := fetchLatestRelease(srv.URL)
+ srv.Close()
+ if err != nil {
+ t.Fatalf("fetchLatestRelease: %v", err)
+ }
+ if got != "" {
+ t.Errorf("body %s offered %q; drafts and prereleases must be ignored", body, got)
+ }
+ }
+}
+
+func TestFetchLatestReleaseHandlesNoReleases(t *testing.T) {
+ // A repository with nothing published answers 404. There is nothing newer,
+ // and nothing to report.
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(http.StatusNotFound)
+ }))
+ defer srv.Close()
+
+ if _, err := fetchLatestRelease(srv.URL); err == nil {
+ t.Error("a 404 was treated as a successful answer")
+ }
+}
+
+func TestFetchLatestReleaseRefusesRedirects(t *testing.T) {
+ // A redirect could point this at an arbitrary host, and a release feed has
+ // no legitimate reason to issue one.
+ var reached bool
+ target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ reached = true
+ _, _ = w.Write([]byte(`{"tag_name":"v9.9.9"}`))
+ }))
+ defer target.Close()
+
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ http.Redirect(w, r, target.URL, http.StatusFound)
+ }))
+ defer srv.Close()
+
+ got, _ := fetchLatestRelease(srv.URL)
+ if reached {
+ t.Error("the redirect was followed")
+ }
+ if got == "9.9.9" {
+ t.Error("a redirected body was accepted")
+ }
+}
+
+// send drives one message through the shell and hands the model back.
+func send(m Model, msg tea.Msg) Model {
+ next, _ := m.Update(msg)
+ updated, _ := next.(Model)
+ return updated
+}
+
+func TestBannerAnnouncesOnlyANewerRelease(t *testing.T) {
+ withReleaseVersion(t, "0.91.7")
+
+ m := newTestModel(defaultViews(nil)...)
+ m.width, m.height = 120, 30
+
+ if got := m.banner(); got != "" {
+ t.Errorf("a banner appeared before any check: %q", got)
+ }
+
+ // Neither an equal nor an older release is an update.
+ m = send(m, updateCheckMsg{latest: "0.91.7"})
+ m = send(m, updateCheckMsg{latest: "0.90.0"})
+ if got := m.banner(); got != "" {
+ t.Errorf("announced %q for a release that is not newer", got)
+ }
+
+ // A failure is dropped rather than shown.
+ m = send(m, updateCheckMsg{err: errStub{}})
+ if got := m.banner(); got != "" {
+ t.Errorf("a failed check produced %q; it should be silent", got)
+ }
+
+ // A newer one names both versions, where to get it, and how to dismiss it.
+ m = send(m, updateCheckMsg{latest: "0.92.0"})
+ banner := m.banner()
+ for _, want := range []string{"0.92.0", "0.91.7", updateReleasesPage, "ctrl+x"} {
+ if !strings.Contains(banner, want) {
+ t.Errorf("banner %q does not mention %q", banner, want)
+ }
+ }
+}
+
+func TestBannerShowsOnEveryTabAndDismissesEverywhere(t *testing.T) {
+ // The whole point of moving it out of the Service tab: the operator it is
+ // for is the one who never opens that tab.
+ withReleaseVersion(t, "0.91.7")
+
+ m := newTestModel(defaultViews(nil)...)
+ m.width, m.height = 120, 30
+ m = send(m, updateCheckMsg{latest: "0.92.0"})
+
+ for i := range m.views {
+ m.selectTab(i)
+ if !strings.Contains(m.View(), "0.92.0") {
+ t.Errorf("tab %d (%s) does not show the notice", i+1, m.views[i].Title())
+ }
+ }
+
+ // Dismissing from one tab clears it on all of them, and it stays gone.
+ m.selectTab(1)
+ m = send(m, tea.KeyMsg{Type: tea.KeyCtrlX})
+ for i := range m.views {
+ m.selectTab(i)
+ if strings.Contains(m.View(), "0.92.0") {
+ t.Errorf("tab %d (%s) still shows the notice after dismissal", i+1, m.views[i].Title())
+ }
+ }
+
+ // A repeat of the same release does not bring it back.
+ m = send(m, updateCheckMsg{latest: "0.92.0"})
+ if got := m.banner(); got != "" {
+ t.Errorf("the dismissed release came back: %q", got)
+ }
+
+ // A newer one does, because that is not what was acknowledged.
+ m = send(m, updateCheckMsg{latest: "0.93.0"})
+ if !strings.Contains(m.banner(), "0.93.0") {
+ t.Error("a release newer than the dismissed one was suppressed")
+ }
+}
+
+func TestBannerKeepsTheDismissHintAtEveryWidth(t *testing.T) {
+ // The hint is the rightmost thing on the row, and the frame is clamped to
+ // the terminal — so an over-long banner loses exactly the one key that
+ // closes it, leaving a notice the operator cannot get rid of. The full
+ // sentence stops fitting at 118 columns, well inside the widths people use.
+ //
+ // banner() assembles longest-first for that reason; this sweep is what stops
+ // a later, tidier single-line version from quietly putting it back.
+ //
+ // A stub view because the row depends on the width and the versions alone.
+ withReleaseVersion(t, "0.91.7")
+
+ const hint = "ctrl+x to dismiss"
+ for w := minTerminalWidth; w <= 200; w++ {
+ m := newTestModel(&stubView{title: "T", rows: 1})
+ m.width = w
+ m = send(m, updateCheckMsg{latest: "0.92.0"})
+
+ // Clamped the way View() clamps the frame, since that is what truncates.
+ row := lipgloss.NewStyle().MaxWidth(w).Render(m.banner())
+ if !strings.Contains(row, hint) {
+ t.Fatalf("width %d: %q does not keep %q", w, row, hint)
+ }
+ }
+}
+
+func TestBannerComesOutOfTheContentBudget(t *testing.T) {
+ // A row added to the frame without coming out of the budget is a row the
+ // shell deletes from the bottom of the active view — and the bottom is where
+ // every view keeps its messages.
+ withReleaseVersion(t, "0.91.7")
+
+ m := newTestModel(defaultViews(nil)...)
+ m.width, m.height = 120, 30
+
+ before := m.contentHeight()
+ m = send(m, updateCheckMsg{latest: "0.92.0"})
+ after := m.contentHeight()
+
+ if after != before-1 {
+ t.Errorf("budget went from %d to %d; the banner's row was not taken from it",
+ before, after)
+ }
+
+ m = send(m, tea.KeyMsg{Type: tea.KeyCtrlX})
+ if got := m.contentHeight(); got != before {
+ t.Errorf("budget is %d after dismissal, want the original %d", got, before)
+ }
+}
diff --git a/services/nvpair-tui/ui/view.go b/services/nvpair-tui/ui/view.go
index 139a50d0..3ebee4c9 100644
--- a/services/nvpair-tui/ui/view.go
+++ b/services/nvpair-tui/ui/view.go
@@ -39,3 +39,17 @@ type View interface {
type inputCapturer interface {
CapturingInput() bool
}
+
+// resetter is an optional interface a View implements when it has a screen
+// beneath the one it may currently be showing, and that top-level screen is
+// what the operator should find on returning to the tab.
+//
+// Only the tab being left is reset, and only on a deliberate tab change. A
+// view that merely holds a selection or a filter should not implement this:
+// those are where the operator left off, which is worth keeping. It is for a
+// view that replaces itself with something else entirely, where coming back to
+// the tab and finding that other thing is a surprise rather than a
+// convenience.
+type resetter interface {
+ reset()
+}
diff --git a/services/nvpair-tui/ui/workloads.go b/services/nvpair-tui/ui/workloads.go
deleted file mode 100644
index ea7931df..00000000
--- a/services/nvpair-tui/ui/workloads.go
+++ /dev/null
@@ -1,155 +0,0 @@
-// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
-// SPDX-License-Identifier: Apache-2.0
-
-package ui
-
-import (
- "nvpair-tui/rpc"
-
- "github.com/charmbracelet/bubbles/key"
- "github.com/charmbracelet/bubbles/table"
- tea "github.com/charmbracelet/bubbletea"
-)
-
-// workload is the subset of the workload-manager's object the view shows.
-type workload struct {
- ID string `json:"id"`
- Model string `json:"model"`
- Engine string `json:"engine"`
- State string `json:"state"`
- OriginatedFrom string `json:"originatedFrom"`
- CreatedAt int64 `json:"createdAt"` // Unix millis
-}
-
-// workloadsView shows cluster-wide inference workloads. The table is built
-// purely from the live workloads:upsert / workloads:remove stream after
-// subscribing, so a workload already in flight when the TUI starts stays
-// invisible until its next transition. The broker does expose
-// workloads:get-initial for a baseline; this view does not yet call it.
-type workloadsView struct {
- client *rpc.Client
- table table.Model
- order []string
- byKey map[string]workload
- status string
-
- width, height int
-}
-
-type workloadsSubscribedMsg struct{ err error }
-
-func newWorkloadsView(client *rpc.Client) *workloadsView {
- v := &workloadsView{client: client, byKey: map[string]workload{}}
- v.table = newTable(nil)
- return v
-}
-
-func (v *workloadsView) Title() string { return "Workloads" }
-
-func (v *workloadsView) Init() tea.Cmd {
- return call(v.client, "workloads:subscribe", nil, func(_ *rpc.Message, err error) tea.Msg {
- return workloadsSubscribedMsg{err: err}
- })
-}
-
-func (v *workloadsView) SetSize(w, h int) {
- v.width, v.height = w, h
- const engine, state, age = 10, 10, 6
- id := clampWidth((w-engine-state-age-2)/3, 8)
- model := clampWidth(w-engine-state-age-id-2, 10)
- v.table.SetColumns([]table.Column{
- {Title: "ID", Width: id},
- {Title: "MODEL", Width: model},
- {Title: "ENGINE", Width: engine},
- {Title: "STATE", Width: state},
- {Title: "AGE", Width: age},
- })
- v.table.SetWidth(w)
- v.table.SetHeight(clampWidth(h-1, 1))
-}
-
-func (v *workloadsView) Update(msg tea.Msg) tea.Cmd {
- switch msg := msg.(type) {
- case workloadsSubscribedMsg:
- if msg.err != nil {
- v.status = "workloads subscribe failed: " + msg.err.Error()
- }
- return nil
-
- case NotificationMsg:
- switch msg.Msg.Method {
- case "workloads:upsert":
- var p struct {
- WorkloadInfo workload `json:"workloadInfo"`
- }
- _ = decodeParams(msg.Msg.Params, &p)
- v.upsert(p.WorkloadInfo)
- case "workloads:remove":
- var p struct {
- WorkloadID string `json:"workloadId"`
- OriginatedFrom string `json:"originatedFrom"`
- }
- _ = decodeParams(msg.Msg.Params, &p)
- v.remove(workloadKey(p.OriginatedFrom, p.WorkloadID))
- }
- return nil
-
- case tea.KeyMsg:
- var cmd tea.Cmd
- v.table, cmd = v.table.Update(msg)
- return cmd
- }
- return nil
-}
-
-func (v *workloadsView) upsert(w workload) {
- key := workloadKey(w.OriginatedFrom, w.ID)
- if _, ok := v.byKey[key]; !ok {
- v.order = append(v.order, key)
- }
- v.byKey[key] = w
- v.refreshRows()
-}
-
-func (v *workloadsView) remove(key string) {
- if _, ok := v.byKey[key]; !ok {
- return
- }
- delete(v.byKey, key)
- for i, k := range v.order {
- if k == key {
- v.order = append(v.order[:i], v.order[i+1:]...)
- break
- }
- }
- v.refreshRows()
-}
-
-func (v *workloadsView) refreshRows() {
- rows := make([]table.Row, 0, len(v.order))
- for _, k := range v.order {
- w := v.byKey[k]
- rows = append(rows, table.Row{
- truncate(w.ID, 12),
- w.Model,
- w.Engine,
- w.State,
- ageLabel(w.CreatedAt),
- })
- }
- v.table.SetRows(rows)
-}
-
-func (v *workloadsView) View() string {
- if v.status != "" {
- return statusErrStyle.Render(v.status)
- }
- if len(v.order) == 0 {
- return footerStyle.Render("No active workloads. Live cluster workloads will appear here as they run.")
- }
- return v.table.View()
-}
-
-func (v *workloadsView) Help() []key.Binding { return nil }
-
-func workloadKey(origin, id string) string { return origin + "/" + id }
diff --git a/services/nvpair-ui-broker/codec.go b/services/nvpair-ui-broker/codec.go
index 4d7dcc80..d8e1aa25 100644
--- a/services/nvpair-ui-broker/codec.go
+++ b/services/nvpair-ui-broker/codec.go
@@ -23,10 +23,16 @@ type (
)
var (
- NewCodec = jsonrpc.NewCodec
- NewPeer = jsonrpc.NewPeer
+ NewCodec = jsonrpc.NewCodec
+ NewCodecMaxFrame = jsonrpc.NewCodecMaxFrame
+ NewPeer = jsonrpc.NewPeer
)
+// workerFrameBytes is the inbound frame cap for a worker link. Single-sourced
+// with every other hop on the same path, because a reply only arrives if all of
+// them agree.
+const workerFrameBytes = jsonrpc.WorkerFrameBytes
+
// errPeerClosed is the sentinel a worker handle's Call/RelayRequest returns
// once the child's transport has gone away.
var errPeerClosed = jsonrpc.ErrPeerClosed
diff --git a/services/nvpair-ui-broker/rpcworker.go b/services/nvpair-ui-broker/rpcworker.go
index 4a0f295d..3d282e4f 100644
--- a/services/nvpair-ui-broker/rpcworker.go
+++ b/services/nvpair-ui-broker/rpcworker.go
@@ -106,10 +106,14 @@ func startRPCWorker(name, binaryPath string, args []string, onNotify func(method
}
w := &rpcWorker{
- name: name,
- cmd: cmd,
- stdin: stdin,
- peer: NewPeer(NewCodec(readWriter{stdout, stdin})),
+ name: name,
+ cmd: cmd,
+ stdin: stdin,
+ // Worker replies can be far larger than a control message —
+ // engine:catalog's model list is megabytes — and an over-long frame is
+ // a terminal read error that silently takes this peer down while the
+ // child keeps running, so the cap has to match what workers send.
+ peer: NewPeer(NewCodecMaxFrame(readWriter{stdout, stdin}, workerFrameBytes)),
done: make(chan struct{}),
onNotify: onNotify,
}
diff --git a/services/shared/jsonrpc/jsonrpc.go b/services/shared/jsonrpc/jsonrpc.go
index 87e24598..a9671256 100644
--- a/services/shared/jsonrpc/jsonrpc.go
+++ b/services/shared/jsonrpc/jsonrpc.go
@@ -66,6 +66,27 @@ func (m *Message) IsResponse() bool {
return m.ID != nil && m.Method == ""
}
+// WorkerFrameBytes is the inbound frame cap for the hops that carry large
+// worker replies: the orchestrator's generic rpcWorker links (engine-manager,
+// manual-nodes, node-settings, job-scheduler), engine-manager's own inbound
+// codec, and a terminal client's link to the orchestrator.
+//
+// It is declared here rather than per-package because the cap only works if
+// every hop on a path agrees. A reply has to survive the worker's writer, the
+// orchestrator's reader, and the client's reader; one hop left at a smaller
+// value fails the whole path, and it fails badly — bufio.Scanner cannot resync
+// past an over-long line, so the frame surfaces as a terminal read error that
+// takes the peer down (see NewCodecMaxFrame).
+//
+// It is deliberately not repo-wide. The purpose-built links — discovery,
+// errors, cluster, the proxies, workload-manager, node-info — keep their own
+// smaller caps, sized to what those workers actually send. Before raising one,
+// check every hop on that worker's path, not just the one that overflowed.
+//
+// 8 MiB is far above any real frame. The largest today is engine:catalog's
+// Ollama list at roughly 1.9 MiB; the previous 1 MiB default could not carry it.
+const WorkerFrameBytes = 8 << 20
+
// Codec handles newline-delimited JSON-RPC 2.0 over an io.ReadWriter.
// Writes are safe for concurrent use.
type Codec struct {