Skip to content

feat(tui): show per-model health and throughput in the model selector - #46888

Closed
ai-dev-2024 wants to merge 1 commit into
anomalyco:devfrom
ai-dev-2024:feat/model-health-indicators
Closed

ai-dev-2024 wants to merge 1 commit into
anomalyco:devfrom
ai-dev-2024:feat/model-health-indicators

Conversation

@ai-dev-2024

Copy link
Copy Markdown

What

The model selector now shows a health/throughput line for models you have actually used on this machine:

● 44 tok/s · Free      (green dot — working, recent average speed)
● 12 tok/s             (red dot — errored in the last 15 minutes)

Models with no local history render exactly as before — no clutter, no fake data.

Why

#43857 and #46882 cover tok/s during and after generation. This closes the loop on the third part: choosing a model. Gateways and shared free pools mean the same model name can be fast one turn and dead the next (upstream 429s, capacity errors) — nothing in the selector reflects that today. With this, the selector answers "which of my models are actually usable right now, and how fast are they?" from observed reality.

Design disclosure

  • Client-local, not server-side: stats are recorded by the TUI from message.updated events it already receives (exact tok/s from tokens.output / elapsed on completion, error counts on info.error), kept for the last 10 responses per model, and persisted atomically (Flock + writeJsonAtomic) to model-stats.json in the TUI state directory. No protocol, schema, or SDK changes; no network calls; nothing leaves the machine.
  • Failure semantics: a recent error (last 15 min) turns the dot red — the speed average stays visible so a slow-but-alive model is distinguishable from a dead one. The window is arbitrary; happy to make it configurable or expiry-only.
  • Known ceiling: stats reflect this client's own traffic only. A shared/cross-device aggregate would need a core endpoint — happy to explore that as a follow-up if maintainers want it.

Verification

  • bun turbo run typecheck --filter=@opencode-ai/tui — passes
  • Manual: bun run dev in packages/tui — send a few messages across models (including one against a rate-limited/errored model), open the model selector: used models show ● <avg> tok/s, the errored one shows red. Untried models are unchanged.

Screenshot

To follow in a comment (needs an interactive session; behavior described above is testable with any two models, one forced to error).

Copilot AI lite review requested due to automatic review settings September 2, 2026 19:55
@github-actions github-actions Bot added the needs:compliance This means the issue will auto-close after 2 hours. label Sep 2, 2026
@github-actions

github-actions Bot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

This PR doesn't fully meet our contributing guidelines and PR template.

What needs to be fixed:

  • PR description is missing required template sections. Please use the PR template.

Please edit this PR description to address the above within 2 hours, or it will be automatically closed.

If you believe this was flagged incorrectly, please let a maintainer know.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

There are a few concrete UX/runtime issues (free-model sorting with JSX footers, avg===0 treated as missing, and unhandled state-file read + unbounded seen growth) that should be fixed before merging.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

Adds a TUI-local “model health + throughput” signal to the model selector, based on recently observed message.updated events on the current machine and persisted to the TUI state directory (no protocol/schema changes).

Changes:

  • Introduces a persisted per-model stats store (model-stats.json) capturing recent tok/s samples and recent error timestamps.
  • Hooks message.updated events in the TUI app to record throughput/errors.
  • Renders a colored health dot + average tok/s in the model selector footer for models with local history.
File summaries
File Description
packages/tui/src/util/model-stats.ts New persistence + summarization + event recording for per-model tok/s and error health.
packages/tui/src/component/dialog-model.tsx Displays per-model health/throughput footer and updates sorting/type constraints.
packages/tui/src/app.tsx Initializes model stats storage and subscribes to message.updated events.
Review details

Suppressed comments (1)

packages/tui/src/component/dialog-model.tsx:216

  • sortModelOptions should not depend on the rendered footer value to decide whether a model is free, since the footer may be JSX and/or include other decorations. Sorting should use a dedicated boolean field (e.g. isFree) that remains stable regardless of rendering.
export function sortModelOptions<T extends { footer?: string | JSX.Element; releaseDate: string | number; title: string }>(
  options: T[],
  newestFirst: boolean,
) {
  if (newestFirst) return sortBy(options, [(option) => option.releaseDate, "desc"], (option) => option.title)
  • Files reviewed: 3/3 changed files
  • Comments generated: 4
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +31 to +33
const dot = <span style={{ fg: summary.healthy ? theme.diffAdded : theme.error }}>●</span>
const speed = summary.avg ? ` ${summary.avg} tok/s` : " err"
const base = existing ? ` · ${existing}` : ""
Comment on lines 105 to 109
: undefined,
category: connected() ? provider.name : undefined,
disabled: provider.id === "opencode" && model.includes("-nano"),
footer: info.cost?.input === 0 && provider.id === "opencode" ? "Free" : undefined,
footer: statFooter(provider.id, model, info.cost?.input === 0 && provider.id === "opencode" ? "Free" : undefined),
onSelect() {
Comment on lines +44 to +47
void Flock.withLock(LOCK, async () => {
const stored = await readJson<Record<string, ModelStat>>(file())
if (stored && typeof stored === "object") setStats(reconcile(stored))
})
Comment on lines +67 to +84
if (info.error) {
if (!seen.has(info.id)) {
seen.add(info.id)
const prev = stats[key] ?? { samples: [], errors: 0 }
setStats(key, { samples: prev.samples, errors: prev.errors + 1, lastErrorAt: Date.now() })
persist()
}
return
}

if (seen.has(info.id)) return
const time = info.time ?? {}
if (typeof time.completed !== "number" || typeof time.created !== "number") return
seen.add(info.id)

const output = info.tokens?.output ?? 0
const seconds = (time.completed - time.created) / 1000
if (output <= 0 || seconds <= 0 || info.finish === "error") return
@github-actions

github-actions Bot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

This pull request has been automatically closed because it was not updated to meet our contributing guidelines within the 2-hour window.

Feel free to open a new pull request that follows our guidelines.

@github-actions github-actions Bot removed the needs:compliance This means the issue will auto-close after 2 hours. label Sep 2, 2026
@github-actions github-actions Bot closed this Sep 2, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants