Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 5 additions & 6 deletions internal/tui/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -4531,15 +4531,14 @@ func (m model) choosePicker() (tea.Model, tea.Cmd) {
previousProvider, previousModel := m.providerName, m.modelName
text := ""
var switchPersistErr error
owner := strings.TrimSpace(item.OwnerProvider)
_, ownerIsSavedProvider := m.savedProviderByName(owner)
if owner != "" && !strings.EqualFold(owner, strings.TrimSpace(m.providerName)) && ownerIsSavedProvider {
// The row's owner label is derived from the same decision, so it always
// names the provider used here.
if owner := m.modelPickerSwitchOwner(item); owner != "" {
// A model from another saved provider: switch provider + model together.
m, text, _, cmd, switchPersistErr = m.switchProviderModel(owner, item.Value)
} else {
// OwnerProvider is blank, matches the active provider, or (registry-fallback
// / stale-history rows) doesn't resolve to any saved provider: apply against
// the active provider instead of attempting an unresolvable provider switch.
// Apply against the active provider instead of attempting an
// unresolvable provider switch.
m, text, switchPersistErr = m.handleModelCommand(item.Value)
}
if m.providerName != previousProvider || m.modelName != previousModel {
Expand Down
32 changes: 20 additions & 12 deletions internal/tui/mouse.go
Original file line number Diff line number Diff line change
Expand Up @@ -474,23 +474,31 @@ func (wizard *mcpAddWizardState) mcpAddWizardResultActionStartRow() int {

func (m *model) selectModelPickerAtMouse(msg tea.MouseMsg) (mouseSelectionTarget, bool) {
width := chatWidth(m.width)
hit, ok := m.overlayMouseHit(msg, m.modelPickerOverlay(width), width)
overlay, start, count := m.modelPickerOverlayLayout(width)
hit, ok := m.overlayMouseHit(msg, overlay, width)
if !ok {
return mouseSelectionTarget{}, false
}
maxVisible := minInt(pickerOverlayMaxVisible, len(m.picker.items))
start := selectableListStart(len(m.picker.items), maxVisible, clampInt(m.picker.selected, 0, len(m.picker.items)-1))
rowStart := 3
if m.modelPickerLoadError != "" {
rowStart++
line := 3
if strings.TrimSpace(m.modelPickerLoadError) != "" {
line++
}
row := hit.y - rowStart
if row < 0 || row >= maxVisible {
return mouseSelectionTarget{}, false
innerWidth := maxInt(1, modelPickerOverlayWidth(width, m.picker)-4)
lastGroup := ""
for index := start; index < start+count; index++ {
item := m.picker.items[index]
if item.Group != "" && item.Group != lastGroup {
line++
lastGroup = item.Group
}
rowLines := len(viewLines(renderModelPickerRow(innerWidth, index == m.picker.selected, item)))
if hit.y >= line && hit.y < line+rowLines {
m.picker.selected = index
return mouseSelectionTarget{Scope: "picker", Kind: int(m.picker.kind), Value: item.Value, Index: index}, true
}
line += rowLines
}
index := start + row
m.picker.selected = index
return mouseSelectionTarget{Scope: "picker", Kind: int(m.picker.kind), Value: m.picker.items[index].Value, Index: index}, true
return mouseSelectionTarget{}, false
}

func (m *model) selectGenericPickerAtMouse(msg tea.MouseMsg) (mouseSelectionTarget, bool) {
Expand Down
50 changes: 50 additions & 0 deletions internal/tui/mouse_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1014,6 +1014,56 @@ func composerMousePoint(t *testing.T, m model, column int) (int, int) {
return x, y
}

func TestModelPickerMouseUsesRenderedRowBoundaries(t *testing.T) {
for _, line := range []int{6, 7} {
m := mouseTestModel()
m.width, m.height = 30, 40
m.picker = &commandPicker{kind: pickerModel, items: []pickerItem{
{Group: "Recent", Label: "model-subscription-one", Value: "one", OwnerLabel: "subscription-profile"},
{Group: "Recent", Label: "model-subscription-two", Value: "two", OwnerLabel: "subscription-profile"},
}}
overlay := m.modelPickerOverlay(30)
rect := m.overlayMouseRect(len(viewLines(overlay)), 30)
// Border/search/rule/header occupy lines 0–3; each model has two lines.
target, ok := m.selectModelPickerAtMouse(testMouseClick(tea.MouseLeft, 15, rect.y+line))
if !ok || target.Value != "two" {
t.Fatalf("line %d selected %#v, ok=%v; want second model", line, target, ok)
}
if _, ok := m.selectModelPickerAtMouse(testMouseClick(tea.MouseLeft, 15, rect.y+3)); ok {
t.Fatal("group header must not select a model")
}
}
}

func TestModelPickerFitsMultilineRowsToViewport(t *testing.T) {
for _, selected := range []int{0, 5, 9} {
m := mouseTestModel()
m.width, m.height = 30, 24
m.picker = &commandPicker{kind: pickerModel, selected: selected}
for i := 0; i < 10; i++ {
m.picker.items = append(m.picker.items, pickerItem{Group: "Recent", Label: "model-subscription-" + string(rune('a'+i)), OwnerLabel: "work-subscription-provider-east", OwnerNumber: 1})
}
overlay := m.modelPickerOverlay(30)
frame := m.scrollableTranscriptFrame(m.pinnedTitleBar(30), m.footerView(30))
if got := len(viewLines(overlay)); got > frame.bodyHeight {
t.Fatalf("selected=%d: overlay height %d exceeds body %d", selected, got, frame.bodyHeight)
}
plain := plainRender(t, overlay)
assertContains(t, plain, "❯ "+m.picker.items[selected].Label)
assertContains(t, plain, "Enter select")
rect := m.overlayMouseRect(len(viewLines(overlay)), 30)
for line, text := range strings.Split(plain, "\n") {
if !strings.Contains(text, "❯ ") {
continue
}
target, ok := m.selectModelPickerAtMouse(testMouseClick(tea.MouseLeft, 15, rect.y+line+1))
if !ok || target.Index != selected {
t.Fatalf("scrolled owner-line click selected %#v, ok=%v; want %d", target, ok, selected)
}
}
}
}

func mouseTestModel() model {
m := newModel(context.Background(), Options{})
m.width = 100
Expand Down
50 changes: 47 additions & 3 deletions internal/tui/picker.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,9 +48,16 @@ type pickerItem struct {
// /model picker can switch providers when a model from a non-active provider is
// chosen. Empty for non-model items.
OwnerProvider string
Remote bool
Local bool
Favorite bool
// OwnerLabel names the provider Enter will actually use for a row in a
// mixed-provider group (Recent, Favorites), whose section header cannot
// convey it. Empty for provider-grouped rows.
OwnerLabel string
// OwnerNumber distinguishes abbreviated names, even when their visible
// suffixes coincide. It is the saved-profile position (1-based).
OwnerNumber int
Remote bool
Local bool
Favorite bool
}

// commandPicker is a generic single-select overlay reused by /model and /effort
Expand Down Expand Up @@ -462,6 +469,30 @@ func pickerItemDedupKey(item pickerItem) string {
return item.Value
}

// modelPickerSwitchOwner returns the saved provider that choosing item switches
// to, or "" when the model is applied to the active provider: the owner is
// blank, is the active provider, or (registry-fallback / stale-history rows
// after a rename or removal) no longer resolves to a saved provider.
func (m model) modelPickerSwitchOwner(item pickerItem) string {
owner := strings.TrimSpace(item.OwnerProvider)
if owner == "" || strings.EqualFold(owner, strings.TrimSpace(m.providerName)) {
return ""
}
if _, ok := m.savedProviderByName(owner); !ok {
return ""
}
return owner
}

// modelPickerEffectiveOwner names the provider choosing item will use, so a
// row's owner label never promises a provider that selection falls back from.
func (m model) modelPickerEffectiveOwner(item pickerItem) string {
if owner := m.modelPickerSwitchOwner(item); owner != "" {
return owner
}
return strings.TrimSpace(m.providerName)
}

func (m model) assembleModelPickerItems(recent []pickerItem, catalog []pickerItem) []pickerItem {
result := []pickerItem{}
// Favorites keep the pre-provider-aware semantics: one row per favorited
Expand All @@ -476,6 +507,7 @@ func (m model) assembleModelPickerItems(recent []pickerItem, catalog []pickerIte
}
item.Group = "Favorites"
item.Favorite = true
item.OwnerLabel = m.modelPickerEffectiveOwner(item)
result = append(result, item)
favoriteSeen[item.Value] = true
}
Expand All @@ -498,6 +530,7 @@ func (m model) assembleModelPickerItems(recent []pickerItem, catalog []pickerIte
}
item.Group = "Recent"
item.Favorite = m.favoriteModels[item.Value]
item.OwnerLabel = m.modelPickerEffectiveOwner(item)
result = append(result, item)
seen[key] = true
}
Expand All @@ -513,6 +546,17 @@ func (m model) assembleModelPickerItems(recent []pickerItem, catalog []pickerIte
result = append(result, item)
seen[key] = true
}
for i := range result {
if result[i].OwnerLabel == "" {
continue
}
for index, profile := range m.modelPickerProviders() {
if strings.EqualFold(strings.TrimSpace(profile.Name), result[i].OwnerLabel) {
result[i].OwnerNumber = index + 1
break
}
}
}
return result
}

Expand Down
121 changes: 121 additions & 0 deletions internal/tui/picker_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1351,6 +1351,127 @@ func TestModelPickerListsAllSavedProviders(t *testing.T) {
}
}

// A Recent or Favorites row's owner label must name the provider Enter actually
// uses. A history owner that was renamed or removed no longer resolves, so
// selection falls back to the active provider; the label must follow it rather
// than show the stale name.
func TestModelPickerOwnerLabelMatchesSelectedProvider(t *testing.T) {
const staleModel = "anthropic/claude-sonnet-4.5"
for _, group := range []string{"Recent", "Favorites"} {
t.Run(group, func(t *testing.T) {
active := config.ProviderProfile{
Name: "openrouter",
CatalogID: "openrouter",
ProviderKind: config.ProviderKindOpenAICompatible,
Model: "google/gemini-2.5-pro",
APIKeyEnv: "OPENROUTER_API_KEY",
BaseURL: "https://openrouter.ai/api/v1",
APIFormat: "chat-completions",
}
options := Options{
ProviderName: "openrouter",
ModelName: "google/gemini-2.5-pro",
Provider: &fakeProvider{},
ProviderProfile: active,
SavedProviders: []config.ProviderProfile{active, {Name: "xai", CatalogID: "xai", Model: "grok-4"}},
// "chatgpt" was removed after this model was used from it.
RecentModels: []config.RecentModelEntry{
{Provider: "chatgpt", Model: staleModel},
{Provider: "xai", Model: "grok-4"},
},
NewProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) {
return &fakeProvider{}, nil
},
}
if group == "Favorites" {
options.FavoriteModels = []string{staleModel}
}
m := newModel(context.Background(), options)
m.picker = m.newModelPicker()
if m.picker == nil {
t.Fatal("expected model picker")
}
target := -1
for index, item := range m.picker.items {
if item.Group == group && item.Value == staleModel {
target = index
}
if item.Group == "Recent" && item.Value == "grok-4" && item.OwnerLabel != "xai" {
t.Fatalf("resolvable owner label = %q, want xai", item.OwnerLabel)
}
}
if target < 0 {
t.Fatalf("expected %s row for %s, got %#v", group, staleModel, m.picker.items)
}
if got := m.picker.items[target].OwnerLabel; got != "openrouter" {
t.Fatalf("stale owner label = %q, want the active provider", got)
}
row := plainRender(t, renderModelPickerRow(60, false, m.picker.items[target]))
assertContains(t, row, "openrouter · ")
assertNotContains(t, row, "chatgpt")

m.picker.selected = target
updated, _ := m.Update(testKey(tea.KeyEnter))
m = updated.(model)
if m.providerName != "openrouter" || m.modelName != staleModel {
t.Fatalf("selection used %s/%s, want the labeled provider openrouter/%s", m.providerName, m.modelName, staleModel)
}
})
}
}

func TestModelPickerAbbreviatedOwnersMatchSelection(t *testing.T) {
t.Setenv(config.ActiveProviderEnv, "")
home := t.TempDir()
for _, variable := range []string{"HOME", "XDG_CONFIG_HOME", "XDG_CACHE_HOME", "APPDATA", "LOCALAPPDATA"} {
t.Setenv(variable, home)
}
for _, names := range [][]string{
{"work-subscription-provider-east", "work-subscription-provider-west"},
{"east-subscription-provider-shared", "west-subscription-provider-shared"},
{"東-subscription-provider-shared", "西-subscription-provider-shared"},
} {
for _, favorite := range []bool{false, true} {
for _, terminalWidth := range []int{76, 30} {
rows := map[string]bool{}
for selectedOwner, name := range names {
profiles := []config.ProviderProfile{}
for _, owner := range names {
profiles = append(profiles, config.ProviderProfile{Name: owner, CatalogID: "openrouter", ProviderKind: config.ProviderKindOpenAICompatible, Model: "gpt-5.6", APIKey: "test-key", BaseURL: "https://openrouter.ai/api/v1", APIFormat: "chat-completions"})
}
m := newModel(context.Background(), Options{ProviderName: names[0], ProviderProfile: profiles[0], ModelName: "gpt-5.6", Provider: &fakeProvider{}, SavedProviders: profiles,
NewProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) { return &fakeProvider{}, nil },
})
m.favoriteModels = map[string]bool{"gpt-5.6": favorite}
// Put each owner first in turn: Favorites retains only one row per model.
recent := []pickerItem{{Label: "GPT-5.6", Value: "gpt-5.6", OwnerProvider: name}, {Label: "GPT-5.6", Value: "gpt-5.6", OwnerProvider: names[1-selectedOwner]}}
items := m.assembleModelPickerItems(recent, nil)
m.picker = &commandPicker{kind: pickerModel, title: "Choose a model", items: items, allItems: items}
width := modelPickerOverlayWidth(terminalWidth, m.picker)
for _, selected := range []bool{false, true} {
row := plainRender(t, renderModelPickerRow(width-4, selected, items[0]))
assertContains(t, row, "GPT-5.6")
if selected {
if rows[row] {
t.Fatalf("different destinations have identical rows: %q", row)
}
rows[row] = true
}
}
overlay := plainRender(t, m.modelPickerOverlay(terminalWidth))
joined := strings.NewReplacer(" ", "", "\n", "", "│", "").Replace(overlay)
assertContains(t, joined, fmt.Sprintf("[%d]%s", selectedOwner+1, name))
updated, _ := m.Update(testKey(tea.KeyEnter))
got := updated.(model)
if got.providerName != name || got.modelName != "gpt-5.6" {
t.Fatalf("selection = %s/%s, want %s/gpt-5.6", got.providerName, got.modelName, name)
}
}
}
}
}
}

func pickerGroups(items []pickerItem) []string {
groups := []string{}
seen := map[string]bool{}
Expand Down
Loading
Loading