package tui
import (
"context"
"fmt"
"strings"
"time"
"github.com/charmbracelet/bubbles/textarea"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"codey/internal/llm"
)
// modelFetchTimeout bounds the wait. Someone is sitting in front of a picker
// that says it is asking; a minute of that is not a picker, it is a hang.
const modelFetchTimeout = 20 * time.Second
// modelsMsg carries the answer to GET /v1/models back into the UI.
type modelsMsg struct {
models []llm.Model
err error
}
// fetchModels asks the endpoint what it serves. It runs off the UI goroutine
// like any other command, and reports failure as a value rather than by
// leaving the picker waiting forever.
func fetchModels(c *llm.Client) tea.Cmd {
return func() tea.Msg {
ctx, cancel := context.WithTimeout(context.Background(), modelFetchTimeout)
defer cancel()
models, err := c.ListModels(ctx)
return modelsMsg{models: models, err: err}
}
}
// openModelPicker puts the picker up and starts asking.
func (m *model) openModelPicker() tea.Cmd {
m.phase = phaseModel
m.models, m.modelIdx, m.modelsLoading = nil, 0, true
m.closeMenu()
return tea.Batch(m.sync(), m.spin.Tick, fetchModels(m.opts.Client))
}
// onModels lands the answer. An answer nobody is waiting for any more is
// dropped: the picker was escaped, and reopening it over whatever the user
// went on to do would be the UI arguing with them.
func (m *model) onModels(msg modelsMsg) tea.Cmd {
if m.phase != phaseModel {
return nil
}
m.modelsLoading = false
switch {
case msg.err != nil:
m.phase = m.backFromPicker()
m.tr.addError("could not list models from " + endpointLabel(m.opts.Client) + ": " +
msg.err.Error() + " — this endpoint may not serve /v1/models; -model or /model <id> still works")
case len(msg.models) == 0:
m.phase = m.backFromPicker()
m.tr.addError(endpointLabel(m.opts.Client) + " returned no models — -model or /model <id> still works")
default:
m.models = msg.models
m.modelIdx = modelIndex(msg.models, m.opts.Client.Model())
}
return tea.Batch(m.sync(), textarea.Blink)
}
// modelIndex finds the model in force, so the picker opens where the session
// already is rather than at the top of a list.
func modelIndex(models []llm.Model, current string) int {
for i, mo := range models {
if mo.ID == current {
return i
}
}
return 0
}
// onModelKey drives the picker. While the fetch is in flight there is nothing
// to move through, so only the ways out are live.
func (m *model) onModelKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
key := msg.String()
if key == "esc" || key == "ctrl+c" {
m.phase = m.backFromPicker()
m.models, m.modelsLoading = nil, false
return m, tea.Batch(m.sync(), textarea.Blink)
}
if m.modelsLoading || len(m.models) == 0 {
return m, nil
}
last := len(m.models) - 1
switch key {
case "up", "ctrl+p":
m.modelIdx = maxInt(0, m.modelIdx-1)
case "down", "ctrl+n":
m.modelIdx = minInt(last, m.modelIdx+1)
case "home":
m.modelIdx = 0
case "end":
m.modelIdx = last
case "enter", "tab":
id := m.models[m.modelIdx].ID
m.phase = m.backFromPicker()
m.models = nil
m.applyModel(id)
return m, tea.Batch(m.sync(), textarea.Blink)
default:
if len(key) == 1 && key[0] >= '1' && key[0] <= '9' {
if i := int(key[0] - '1'); i <= last {
m.modelIdx = i
}
}
}
return m, m.sync()
}
// useModel switches the client. The session keeps its history: the
// conversation so far is replayed to the new model on the next turn, which is
// the point of switching mid-session.
//
// It is separate from applyModel because the setup wizard changes several
// settings at once and says so in one line, where the picker changes this one
// and announces it. (setModel is taken: that is the /model command.)
func (m *model) useModel(id string) {
if id == "" {
return
}
m.opts.Client.SetModel(id)
m.refreshSplash()
}
// applyModel switches the client and says so.
func (m *model) applyModel(id string) {
m.useModel(id)
m.tr.addNotice("model: " + prettyModel(id) + " (" + id + ") — from the next turn on")
}
// modelView is the picker: a heading, where the list came from, and a row per
// model with what it is called, its id, and what it costs.
func (m *model) modelView() string {
width := maxInt(1, m.mainWidth())
head := " " + styHeading.Render("Select model")
from := styDim.Render("from " + endpointLabel(m.opts.Client))
if m.modelsLoading {
return head + "\n" + truncateStyled(" "+m.spin.View()+" "+from+styFaint.Render(" · esc to cancel"), width)
}
start, end := m.modelWindow()
// One column for the names, so the ids line up under each other.
names := make([]string, len(m.models))
col := 0
for i, mo := range m.models {
names[i] = mo.DisplayName
if names[i] == "" {
names[i] = prettyModel(mo.ID)
}
if i >= start && i < end {
col = maxInt(col, lipgloss.Width(names[i]))
}
}
col = minInt(col, maxInt(10, width/3))
var b strings.Builder
b.WriteString(head + "\n")
b.WriteString(truncateStyled(" "+from+styFaint.Render(" · ↑↓ move · enter apply · esc cancel"), width) + "\n")
for i := start; i < end; i++ {
mo := m.models[i]
marker, name := " ", styDim.Render(pad(names[i], col))
if i == m.modelIdx {
marker, name = styKey.Render("❯ "), styKey.Render(pad(names[i], col))
}
// An endpoint that gave no display name leaves the id as the name;
// repeating it in the detail column says nothing twice.
detail := ""
if names[i] != mo.ID {
detail = mo.ID
}
if mo.MaxInputTokens > 0 {
if detail != "" {
detail += " · "
}
detail += human(mo.MaxInputTokens) + " in"
}
if p, ok := llm.PriceOf(mo.ID); ok {
if detail != "" {
detail += " · "
}
detail += fmt.Sprintf("$%g / $%g per Mtok", p.InputPerMTok, p.OutputPerMTok)
}
line := marker + fmt.Sprintf("%d. ", i+1) + name + " " + styFaint.Render(detail)
if mo.ID == m.opts.Client.Model() {
line += styOK.Render(" ✓")
}
b.WriteString(truncateStyled(" "+line, width) + "\n")
}
if n := len(m.models) - (end - start); n > 0 {
b.WriteString(truncateStyled(" "+styFaint.Render(fmt.Sprintf("… +%d more", n)), width) + "\n")
}
return strings.TrimRight(b.String(), "\n")
}
// modelHeadRows is how many rows come before the list: the heading, and the
// line saying where the models came from.
const modelHeadRows = 2
// modelWindow is the slice of the list that is on screen, shared by the view
// and by the hit test, so a click lands on the model it looks like it is on.
func (m *model) modelWindow() (start, end int) {
rows := m.modelRows()
shown := rows
if len(m.models) > rows {
shown = maxInt(1, rows-1)
}
if m.modelIdx >= shown {
start = m.modelIdx - shown + 1
}
return start, minInt(len(m.models), start+shown)
}
// modelRows is how many model rows fit: the same arithmetic the completion
// menu does, minus the heading and the line saying where the list came from.
func (m *model) modelRows() int {
room := m.height - 6
return maxInt(1, minInt(modelMaxRows, room))
}
// modelMaxRows caps the list. Anthropic serves a couple of dozen models and
// nobody scrolls a picker looking for the twenty-fourth.
const modelMaxRows = 10
// ---- describing a model -------------------------------------------------
//
// The picker and the setup wizard show the same models and must say the same
// things about them, so what a model is called and what it costs is decided
// once, here.
// pricedModels is the list to fall back on when the endpoint will not answer
// GET /v1/models. The picker treats that as an error and says so — there is a
// session to go back to. The wizard cannot: it is the first run, and a model
// has to be chosen before there is anything to go back to.
func pricedModels() []llm.Model {
ids := llm.PricedModels()
out := make([]llm.Model, 0, len(ids))
for _, id := range ids {
out = append(out, llm.Model{ID: id, DisplayName: prettyModel(id)})
}
return out
}
// priceLabel is a model's list pricing, or "" when this build cannot price it.
// A blank says "not known"; a zero would say "free", which is worse than
// saying nothing.
func priceLabel(id string) string {
p, ok := llm.PriceOf(id)
if !ok {
return ""
}
return fmt.Sprintf("$%g / $%g per Mtok", p.InputPerMTok, p.OutputPerMTok)
}
// modelDetail is the line under a highlighted model: its id, and its context
// window when the endpoint reported one.
func modelDetail(m llm.Model) string {
detail := m.ID
if m.MaxInputTokens > 0 {
detail += " · " + human(m.MaxInputTokens) + " token context"
}
return detail
}