From a45a8f91b322b315d0fff0cc8d1235ece82eff0a Mon Sep 17 00:00:00 2001 From: Amp Date: Fri, 25 Sep 2026 14:24:20 +0000 Subject: [PATCH 1/5] fix(tui): show provider ownership in recent and favorite models Display saved provider names in mixed-provider picker groups and include them in row sizing. Preserve provider-grouped rows and selection values. Fixes Gitlawb/zero#840 Amp-Thread-ID: https://ampcode.com/threads/T-01a0d8e9-d346-71f9-bdac-4ac762d810a5 Co-authored-by: Pierre Bruno --- internal/tui/rendering_lime_test.go | 34 ++++++++++++++++++++++++++++- internal/tui/view.go | 24 +++++++++++--------- 2 files changed, 47 insertions(+), 11 deletions(-) diff --git a/internal/tui/rendering_lime_test.go b/internal/tui/rendering_lime_test.go index 12b74d0f4..6d1ea4fdb 100644 --- a/internal/tui/rendering_lime_test.go +++ b/internal/tui/rendering_lime_test.go @@ -1894,10 +1894,42 @@ func TestModelPickerRowsCarryCapabilityMeta(t *testing.T) { } } +func TestModelPickerRowShowsOwnerInMixedGroups(t *testing.T) { + for _, group := range []string{"Recent", "Favorites"} { + for _, owner := range []string{"chatgpt", "openai", "work-openai"} { + for _, selected := range []bool{false, true} { + item := pickerItem{Group: group, Label: "GPT-5.6", Value: "gpt-5.6", Provider: "openai", OwnerProvider: owner, Favorite: group == "Favorites"} + want := owner + " · GPT-5.6" + if item.Favorite { + want = "* " + want + } + got := plainRender(t, renderModelPickerRow(60, selected, item)) + if !strings.Contains(got, want) { + t.Errorf("group=%s selected=%v: row = %q, want %q", group, selected, got, want) + } + } + } + } + item := pickerItem{Group: "Recent", Value: "custom-model"} + if got := strings.TrimSpace(plainRender(t, renderModelPickerRow(60, false, item))); got != "custom-model" { + t.Fatalf("ownerless fallback row = %q", got) + } +} + +func TestModelPickerWidthIncludesOwner(t *testing.T) { + item := pickerItem{Group: "Recent", Label: strings.Repeat("m", 40), OwnerProvider: "subscription-profile", Favorite: true} + picker := &commandPicker{items: []pickerItem{item}} + width := modelPickerOverlayWidth(120, picker) + got := plainRender(t, renderModelPickerRow(width-4, false, item)) + if want := "* subscription-profile · " + item.Label; !strings.Contains(got, want) { + t.Fatalf("row clipped at overlay width %d: %q, want %q", width, got, want) + } +} + func TestModelPickerRowOmitsProviderTag(t *testing.T) { // The provider is shown as a section header above each group, so a row renders // just the model label — no repeated right-aligned provider tag. - item := pickerItem{Label: "Claude Sonnet 4.6", Value: "claude-sonnet-4-6", Provider: "anthropic", Remote: true} + item := pickerItem{Group: "anthropic", Label: "Claude Sonnet 4.6", Value: "claude-sonnet-4-6", Provider: "anthropic", OwnerProvider: "anthropic", Remote: true} got := plainRender(t, renderModelPickerRow(60, false, item)) if !strings.Contains(got, "Claude Sonnet 4.6") { t.Fatalf("row = %q, missing model label", got) diff --git a/internal/tui/view.go b/internal/tui/view.go index 98a141366..cad867ea6 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -1120,10 +1120,7 @@ func modelPickerOverlayWidth(terminalWidth int, picker *commandPicker) int { target = maxInt(target, lipgloss.Width(" Using built-in model list")) if picker != nil { for _, item := range picker.items { - labelWidth := lipgloss.Width(item.Label) - if item.Favorite { - labelWidth += lipgloss.Width("* ") - } + labelWidth := lipgloss.Width(modelPickerRowLabel(item)) target = maxInt(target, lipgloss.Width("❯ ")+labelWidth) if detail := modelPickerItemDetail(item); detail != "" { target = maxInt(target, lipgloss.Width(" "+detail)) @@ -1158,18 +1155,25 @@ func renderModelPickerRow(width int, selected bool, item pickerItem) string { surface = zeroTheme.onSel marker = surface(zeroTheme.accent).Render("❯ ") } + left := marker + surface(zeroTheme.ink).Render(modelPickerRowLabel(item)) + return fillPaletteLine(left, width, surface) +} + +func modelPickerRowLabel(item pickerItem) string { label := strings.TrimSpace(item.Label) if label == "" { label = strings.TrimSpace(item.Value) } - prefix := "" + // Mixed-provider groups cannot convey ownership through their header. + if item.Group == "Recent" || item.Group == "Favorites" { + if owner := strings.TrimSpace(item.OwnerProvider); owner != "" { + label = owner + " · " + label + } + } if item.Favorite { - prefix = "* " + label = "* " + label } - left := marker + surface(zeroTheme.ink).Render(prefix+label) - // The provider is shown as a section header above each group, so rows no longer - // repeat it as a right-aligned tag (matches a grouped provider+model list). - return fillPaletteLine(left, width, surface) + return label } func modelPickerItemDetail(item pickerItem) string { From 7f939bba7124e9ae7dc634d765ff7b8b8c5fedce Mon Sep 17 00:00:00 2001 From: PierrunoYT Date: Sun, 27 Sep 2026 21:56:52 +0200 Subject: [PATCH 2/5] fix(tui): label mixed-group rows with the provider Enter uses Recent and Favorites rows labeled their owner from recent history, but selection falls back to the active provider when that owner was renamed or removed, so the label could name a provider that would not be used. Derive the label from the same decision selection makes (modelPickerSwitchOwner) and store it on the row as OwnerLabel. Cap the owner prefix at 20 cells so an unbounded profile name cannot consume the model name once the overlay reaches its 76-column maximum. Co-Authored-By: Claude Opus 5.5 (1M context) --- internal/tui/model.go | 11 +++-- internal/tui/picker.go | 36 +++++++++++++-- internal/tui/picker_test.go | 69 +++++++++++++++++++++++++++++ internal/tui/rendering_lime_test.go | 25 ++++++++++- internal/tui/view.go | 14 +++--- 5 files changed, 139 insertions(+), 16 deletions(-) diff --git a/internal/tui/model.go b/internal/tui/model.go index 3c69814df..b0a156f14 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -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 { diff --git a/internal/tui/picker.go b/internal/tui/picker.go index b18b405b6..925839d90 100644 --- a/internal/tui/picker.go +++ b/internal/tui/picker.go @@ -48,9 +48,13 @@ 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 + Remote bool + Local bool + Favorite bool } // commandPicker is a generic single-select overlay reused by /model and /effort @@ -462,6 +466,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 @@ -476,6 +504,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 } @@ -498,6 +527,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 } diff --git a/internal/tui/picker_test.go b/internal/tui/picker_test.go index 888e21de8..7bb363752 100644 --- a/internal/tui/picker_test.go +++ b/internal/tui/picker_test.go @@ -1351,6 +1351,75 @@ 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 pickerGroups(items []pickerItem) []string { groups := []string{} seen := map[string]bool{} diff --git a/internal/tui/rendering_lime_test.go b/internal/tui/rendering_lime_test.go index 6d1ea4fdb..e596e9024 100644 --- a/internal/tui/rendering_lime_test.go +++ b/internal/tui/rendering_lime_test.go @@ -1898,7 +1898,7 @@ func TestModelPickerRowShowsOwnerInMixedGroups(t *testing.T) { for _, group := range []string{"Recent", "Favorites"} { for _, owner := range []string{"chatgpt", "openai", "work-openai"} { for _, selected := range []bool{false, true} { - item := pickerItem{Group: group, Label: "GPT-5.6", Value: "gpt-5.6", Provider: "openai", OwnerProvider: owner, Favorite: group == "Favorites"} + item := pickerItem{Group: group, Label: "GPT-5.6", Value: "gpt-5.6", Provider: "openai", OwnerProvider: owner, OwnerLabel: owner, Favorite: group == "Favorites"} want := owner + " · GPT-5.6" if item.Favorite { want = "* " + want @@ -1917,7 +1917,7 @@ func TestModelPickerRowShowsOwnerInMixedGroups(t *testing.T) { } func TestModelPickerWidthIncludesOwner(t *testing.T) { - item := pickerItem{Group: "Recent", Label: strings.Repeat("m", 40), OwnerProvider: "subscription-profile", Favorite: true} + item := pickerItem{Group: "Recent", Label: strings.Repeat("m", 40), OwnerProvider: "subscription-profile", OwnerLabel: "subscription-profile", Favorite: true} picker := &commandPicker{items: []pickerItem{item}} width := modelPickerOverlayWidth(120, picker) got := plainRender(t, renderModelPickerRow(width-4, false, item)) @@ -1926,6 +1926,27 @@ func TestModelPickerWidthIncludesOwner(t *testing.T) { } } +// Profile names are unbounded; a long owner must not consume the model name +// once the overlay reaches its maximum width. +func TestModelPickerLongOwnerKeepsModelVisible(t *testing.T) { + owner := strings.Repeat("o", 70) + for _, group := range []string{"Recent", "Favorites"} { + for _, label := range []string{"GPT-5.6", strings.Repeat("m", 40)} { + item := pickerItem{Group: group, Label: label, Value: label, OwnerProvider: owner, OwnerLabel: owner, Favorite: group == "Favorites"} + wide := pickerItem{Group: "Recent", Label: strings.Repeat("w", 100)} + width := modelPickerOverlayWidth(200, &commandPicker{items: []pickerItem{item, wide}}) + if width != modelPickerOverlayMaxWidth { + t.Fatalf("overlay width = %d, want the %d cap", width, modelPickerOverlayMaxWidth) + } + got := plainRender(t, renderModelPickerRow(width-4, false, item)) + want := truncateDisplayWidth(owner, modelPickerOwnerMaxWidth) + " · " + label + if !strings.Contains(got, want) || !strings.Contains(want, "…") { + t.Fatalf("group=%s: row = %q, want %q with the owner capped", group, got, want) + } + } + } +} + func TestModelPickerRowOmitsProviderTag(t *testing.T) { // The provider is shown as a section header above each group, so a row renders // just the model label — no repeated right-aligned provider tag. diff --git a/internal/tui/view.go b/internal/tui/view.go index cad867ea6..b15f60c3c 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -24,6 +24,10 @@ const ( pickerOverlayMinWidth = 56 modelPickerOverlayMaxWidth = 76 modelPickerOverlayMinWidth = 58 + // modelPickerOwnerMaxWidth caps the owner prefix on Recent/Favorites rows so + // even a favorite ("* ") at the maximum overlay width keeps over 40 cells for + // the model name. + modelPickerOwnerMaxWidth = 20 ) // layoutTier buckets the terminal width into the spec's adaptive tiers. It @@ -1164,11 +1168,11 @@ func modelPickerRowLabel(item pickerItem) string { if label == "" { label = strings.TrimSpace(item.Value) } - // Mixed-provider groups cannot convey ownership through their header. - if item.Group == "Recent" || item.Group == "Favorites" { - if owner := strings.TrimSpace(item.OwnerProvider); owner != "" { - label = owner + " · " + label - } + // Mixed-provider groups cannot convey ownership through their header. Profile + // names are unbounded, so cap the owner to keep the model name visible within + // the overlay's maximum width. + if owner := strings.TrimSpace(item.OwnerLabel); owner != "" { + label = truncateDisplayWidth(owner, modelPickerOwnerMaxWidth) + " · " + label } if item.Favorite { label = "* " + label From de673c485de410ec29097c6a0299fa43354b01c1 Mon Sep 17 00:00:00 2001 From: Amp Date: Mon, 28 Sep 2026 14:27:30 +0000 Subject: [PATCH 3/5] fix(tui): distinguish abbreviated model picker owners Budget owner cues against the rendered row width, preserve model names, and distinguish abbreviated saved profiles with numbered suffix cues. Show the highlighted full owner as a wrapped key before selection. Cover shared-prefix and shared-suffix owners, narrow terminals, Favorites, and Enter routing. Amp-Thread-ID: https://ampcode.com/threads/T-01a0e862-13e1-756e-9608-58d5fed96f05 Co-authored-by: Pierre Bruno --- internal/tui/picker.go | 20 +++++++++-- internal/tui/picker_test.go | 52 +++++++++++++++++++++++++++++ internal/tui/rendering_lime_test.go | 14 +++++++- internal/tui/view.go | 34 +++++++++++++++---- 4 files changed, 109 insertions(+), 11 deletions(-) diff --git a/internal/tui/picker.go b/internal/tui/picker.go index 925839d90..94f06ecc8 100644 --- a/internal/tui/picker.go +++ b/internal/tui/picker.go @@ -52,9 +52,12 @@ type pickerItem struct { // mixed-provider group (Recent, Favorites), whose section header cannot // convey it. Empty for provider-grouped rows. OwnerLabel string - Remote bool - Local bool - Favorite bool + // 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 @@ -543,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 } diff --git a/internal/tui/picker_test.go b/internal/tui/picker_test.go index 7bb363752..9957f9dc9 100644 --- a/internal/tui/picker_test.go +++ b/internal/tui/picker_test.go @@ -1420,6 +1420,58 @@ func TestModelPickerOwnerLabelMatchesSelectedProvider(t *testing.T) { } } +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{} diff --git a/internal/tui/rendering_lime_test.go b/internal/tui/rendering_lime_test.go index e596e9024..0fcf34668 100644 --- a/internal/tui/rendering_lime_test.go +++ b/internal/tui/rendering_lime_test.go @@ -1939,7 +1939,7 @@ func TestModelPickerLongOwnerKeepsModelVisible(t *testing.T) { t.Fatalf("overlay width = %d, want the %d cap", width, modelPickerOverlayMaxWidth) } got := plainRender(t, renderModelPickerRow(width-4, false, item)) - want := truncateDisplayWidth(owner, modelPickerOwnerMaxWidth) + " · " + label + want := "…" + strings.Repeat("o", 19) + " · " + label if !strings.Contains(got, want) || !strings.Contains(want, "…") { t.Fatalf("group=%s: row = %q, want %q with the owner capped", group, got, want) } @@ -1947,6 +1947,18 @@ func TestModelPickerLongOwnerKeepsModelVisible(t *testing.T) { } } +func TestModelPickerNarrowOwnerKeepsModelVisible(t *testing.T) { + for _, favorite := range []bool{false, true} { + for _, selected := range []bool{false, true} { + item := pickerItem{Label: "GPT-5.6", OwnerLabel: "subscription-profile", Favorite: favorite} + width := modelPickerOverlayWidth(30, &commandPicker{items: []pickerItem{item}}) + got := plainRender(t, renderModelPickerRow(width-4, selected, item)) + assertContains(t, got, "GPT-5.6") + assertContains(t, got, "profile · ") + } + } +} + func TestModelPickerRowOmitsProviderTag(t *testing.T) { // The provider is shown as a section header above each group, so a row renders // just the model label — no repeated right-aligned provider tag. diff --git a/internal/tui/view.go b/internal/tui/view.go index b15f60c3c..42d05ad3a 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -11,6 +11,7 @@ import ( "charm.land/lipgloss/v2" "github.com/alecthomas/chroma/v2" + "github.com/charmbracelet/x/ansi" "github.com/Gitlawb/zero/internal/agent" ) @@ -1064,6 +1065,14 @@ func (m model) modelPickerOverlay(width int) string { lines = append(lines, fillPaletteLine(searchPrefix+zeroTheme.faint.Render("no matching models"), innerWidth, transparentSurface)) } if item, ok := m.picker.current(); ok { + if item.OwnerNumber > 0 && !strings.Contains(modelPickerRowLabel(item, innerWidth-searchInset), item.OwnerLabel) { + // Give abbreviated cues a full-name key before Enter, including on + // narrow terminals where the distinguishing part may not fit in a row. + owner := fmt.Sprintf("[%d] %s", item.OwnerNumber, item.OwnerLabel) + for _, line := range strings.Split(ansi.Hardwrap(owner, maxInt(1, innerWidth-searchInset), false), "\n") { + lines = append(lines, fillPaletteLine(searchPrefix+zeroTheme.faint.Render(line), innerWidth, transparentSurface)) + } + } if detail := modelPickerItemDetail(item); detail != "" { lines = append(lines, zeroTheme.line.Render(strings.Repeat("─", innerWidth))) lines = append(lines, fillPaletteLine(searchPrefix+zeroTheme.faint.Render(detail), innerWidth, transparentSurface)) @@ -1124,7 +1133,7 @@ func modelPickerOverlayWidth(terminalWidth int, picker *commandPicker) int { target = maxInt(target, lipgloss.Width(" Using built-in model list")) if picker != nil { for _, item := range picker.items { - labelWidth := lipgloss.Width(modelPickerRowLabel(item)) + labelWidth := lipgloss.Width(modelPickerRowLabel(item, maxInt(0, available-6))) target = maxInt(target, lipgloss.Width("❯ ")+labelWidth) if detail := modelPickerItemDetail(item); detail != "" { target = maxInt(target, lipgloss.Width(" "+detail)) @@ -1159,20 +1168,31 @@ func renderModelPickerRow(width int, selected bool, item pickerItem) string { surface = zeroTheme.onSel marker = surface(zeroTheme.accent).Render("❯ ") } - left := marker + surface(zeroTheme.ink).Render(modelPickerRowLabel(item)) + left := marker + surface(zeroTheme.ink).Render(modelPickerRowLabel(item, maxInt(0, width-2))) return fillPaletteLine(left, width, surface) } -func modelPickerRowLabel(item pickerItem) string { +func modelPickerRowLabel(item pickerItem, width int) string { label := strings.TrimSpace(item.Label) if label == "" { label = strings.TrimSpace(item.Value) } - // Mixed-provider groups cannot convey ownership through their header. Profile - // names are unbounded, so cap the owner to keep the model name visible within - // the overlay's maximum width. + if item.Favorite { + width -= 2 + } + // Reserve the model's cells against the actual terminal width, not just the + // overlay cap. Keep a small owner cue even for models that themselves overflow. if owner := strings.TrimSpace(item.OwnerLabel); owner != "" { - label = truncateDisplayWidth(owner, modelPickerOwnerMaxWidth) + " · " + label + budget := minInt(modelPickerOwnerMaxWidth, maxInt(6, width-3-lipgloss.Width(label))) + if lipgloss.Width(owner) > budget { + prefix := "" + if item.OwnerNumber > 0 { + prefix = fmt.Sprintf("[%d]", item.OwnerNumber) + } + remove := lipgloss.Width(owner) - maxInt(1, budget-lipgloss.Width(prefix)) + 1 + owner = prefix + ansi.TruncateLeft(owner, remove, "…") + } + label = owner + " · " + label } if item.Favorite { label = "* " + label From 26c12a876c07837b7db377f649ccf6ed5c1d86aa Mon Sep 17 00:00:00 2001 From: Amp Date: Mon, 28 Sep 2026 14:38:39 +0000 Subject: [PATCH 4/5] fix(tui): preserve model labels when owner cues cannot fit Split owner cues onto a second line when the model needs the row width. Check the actually rendered row before suppressing the full owner key, so owner text hidden inside a clipped model label cannot hide that key. Amp-Thread-ID: https://ampcode.com/threads/T-01a0e862-13e1-756e-9608-58d5fed96f05 Co-authored-by: Pierre Bruno --- internal/tui/rendering_lime_test.go | 25 +++++++++++++++++++++++++ internal/tui/view.go | 26 +++++++++++++++++++------- 2 files changed, 44 insertions(+), 7 deletions(-) diff --git a/internal/tui/rendering_lime_test.go b/internal/tui/rendering_lime_test.go index 0fcf34668..451c0055b 100644 --- a/internal/tui/rendering_lime_test.go +++ b/internal/tui/rendering_lime_test.go @@ -1959,6 +1959,31 @@ func TestModelPickerNarrowOwnerKeepsModelVisible(t *testing.T) { } } +func TestModelPickerNarrowLongModelKeepsSuffix(t *testing.T) { + for _, favorite := range []bool{false, true} { + for _, selected := range []bool{false, true} { + for _, label := range []string{"model-subscription-one", "model-subscription-two"} { + item := pickerItem{Label: label, OwnerLabel: "subscription-profile", OwnerNumber: 1, Favorite: favorite} + got := plainRender(t, renderModelPickerRow(26, selected, item)) + assertContains(t, got, label) + assertContains(t, got, "subscription-profile") + if len(strings.Split(got, "\n")) != 2 { + t.Fatalf("expected separate model and owner lines: %q", got) + } + } + } + } +} + +func TestModelPickerClippedModelCannotHideOwnerKey(t *testing.T) { + m := limeTestModel() + item := pickerItem{Group: "Recent", Label: strings.Repeat("model-", 10) + "work-subscription-profile", OwnerLabel: "work-subscription-profile", OwnerNumber: 1} + m.picker = &commandPicker{kind: pickerModel, items: []pickerItem{item}} + got := plainRender(t, m.modelPickerOverlay(30)) + joined := strings.NewReplacer(" ", "", "\n", "", "│", "").Replace(got) + assertContains(t, joined, "[1]work-subscription-profile") +} + func TestModelPickerRowOmitsProviderTag(t *testing.T) { // The provider is shown as a section header above each group, so a row renders // just the model label — no repeated right-aligned provider tag. diff --git a/internal/tui/view.go b/internal/tui/view.go index 42d05ad3a..eeca4d2fd 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -1059,13 +1059,13 @@ func (m model) modelPickerOverlay(width int) string { lines = append(lines, fillPaletteLine(zeroTheme.accent.Bold(true).Render(item.Group), innerWidth, transparentSurface)) lastGroup = item.Group } - lines = append(lines, renderModelPickerRow(innerWidth, start+index == m.picker.selected, item)) + lines = append(lines, strings.Split(renderModelPickerRow(innerWidth, start+index == m.picker.selected, item), "\n")...) } if len(visible) == 0 { lines = append(lines, fillPaletteLine(searchPrefix+zeroTheme.faint.Render("no matching models"), innerWidth, transparentSurface)) } if item, ok := m.picker.current(); ok { - if item.OwnerNumber > 0 && !strings.Contains(modelPickerRowLabel(item, innerWidth-searchInset), item.OwnerLabel) { + if item.OwnerNumber > 0 && !strings.Contains(ansi.Strip(renderModelPickerRow(innerWidth, true, item)), item.OwnerLabel) { // Give abbreviated cues a full-name key before Enter, including on // narrow terminals where the distinguishing part may not fit in a row. owner := fmt.Sprintf("[%d] %s", item.OwnerNumber, item.OwnerLabel) @@ -1168,8 +1168,12 @@ func renderModelPickerRow(width int, selected bool, item pickerItem) string { surface = zeroTheme.onSel marker = surface(zeroTheme.accent).Render("❯ ") } - left := marker + surface(zeroTheme.ink).Render(modelPickerRowLabel(item, maxInt(0, width-2))) - return fillPaletteLine(left, width, surface) + lines := strings.Split(modelPickerRowLabel(item, maxInt(0, width-2)), "\n") + for i, line := range lines { + lines[i] = fillPaletteLine(marker+surface(zeroTheme.ink).Render(line), width, surface) + marker = surface(zeroTheme.ink).Render(" ") + } + return strings.Join(lines, "\n") } func modelPickerRowLabel(item pickerItem, width int) string { @@ -1181,9 +1185,13 @@ func modelPickerRowLabel(item pickerItem, width int) string { width -= 2 } // Reserve the model's cells against the actual terminal width, not just the - // overlay cap. Keep a small owner cue even for models that themselves overflow. + // overlay cap. If both cannot fit, give the owner its own line. if owner := strings.TrimSpace(item.OwnerLabel); owner != "" { - budget := minInt(modelPickerOwnerMaxWidth, maxInt(6, width-3-lipgloss.Width(label))) + budget := minInt(modelPickerOwnerMaxWidth, width-3-lipgloss.Width(label)) + separateLine := budget < minInt(6, lipgloss.Width(owner)) + if separateLine { + budget = maxInt(1, minInt(modelPickerOwnerMaxWidth, width)) + } if lipgloss.Width(owner) > budget { prefix := "" if item.OwnerNumber > 0 { @@ -1192,7 +1200,11 @@ func modelPickerRowLabel(item pickerItem, width int) string { remove := lipgloss.Width(owner) - maxInt(1, budget-lipgloss.Width(prefix)) + 1 owner = prefix + ansi.TruncateLeft(owner, remove, "…") } - label = owner + " · " + label + if separateLine { + label += "\n" + owner + } else { + label = owner + " · " + label + } } if item.Favorite { label = "* " + label From 0e0e4f4f3b4f1dcb3afdcda009b6aae4ccf5513e Mon Sep 17 00:00:00 2001 From: Amp Date: Mon, 28 Sep 2026 14:53:12 +0000 Subject: [PATCH 5/5] fix(tui): fit multiline model rows and mouse targets Fit the model picker item window to its rendered viewport height and share that window with mouse hit-testing. Account for group headers and two-line rows so clicking either line selects the correct model and the keyboard-selected item remains visible. Amp-Thread-ID: https://ampcode.com/threads/T-01a0e862-13e1-756e-9608-58d5fed96f05 Co-authored-by: Pierre Bruno --- internal/tui/mouse.go | 32 +++++++++++++++--------- internal/tui/mouse_test.go | 50 ++++++++++++++++++++++++++++++++++++++ internal/tui/view.go | 40 +++++++++++++++++++++++------- 3 files changed, 101 insertions(+), 21 deletions(-) diff --git a/internal/tui/mouse.go b/internal/tui/mouse.go index 71bee47b6..00aa8f437 100644 --- a/internal/tui/mouse.go +++ b/internal/tui/mouse.go @@ -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) { diff --git a/internal/tui/mouse_test.go b/internal/tui/mouse_test.go index 019f53808..aacebbe50 100644 --- a/internal/tui/mouse_test.go +++ b/internal/tui/mouse_test.go @@ -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 diff --git a/internal/tui/view.go b/internal/tui/view.go index eeca4d2fd..785b97969 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -1028,22 +1028,44 @@ func joinThemePickerColumns(left, right []string, leftWidth, rightWidth int) []s } func (m model) modelPickerOverlay(width int) string { + overlay, _, _ := m.modelPickerOverlayLayout(width) + return overlay +} + +// Share the fitted item window with mouse hit-testing. Rows and the selected +// owner key can span multiple lines, so an item count alone cannot bound height. +func (m model) modelPickerOverlayLayout(width int) (string, int, int) { if m.picker == nil { - return "" + return "", 0, 0 } if m.modelPickerLoading { - return m.modelPickerLoadingOverlay(width) + return m.modelPickerLoadingOverlay(width), 0, 0 + } + height := normalizedStartupHeight(m.height) + if m.altScreen && m.height > 0 { + height = m.scrollableTranscriptFrame(m.pinnedTitleBar(width), m.footerView(width)).bodyHeight } - overlayWidth := modelPickerOverlayWidth(width, m.picker) - innerWidth := maxInt(1, overlayWidth-4) - maxVisible := minInt(pickerOverlayMaxVisible, len(m.picker.items)) - start := 0 - visible := []pickerItem{} if len(m.picker.items) > 0 { m.picker.selected = clampInt(m.picker.selected, 0, len(m.picker.items)-1) - start = selectableListStart(len(m.picker.items), maxVisible, m.picker.selected) - visible = m.picker.items[start : start+maxVisible] } + count := minInt(pickerOverlayMaxVisible, len(m.picker.items)) + for { + start := selectableListStart(len(m.picker.items), count, m.picker.selected) + if count > 0 { + start = maxInt(start, m.picker.selected-count+1) + } + overlay := m.renderModelPickerOverlay(width, start, count) + if len(viewLines(overlay)) <= height || count <= 1 { + return overlay, start, count + } + count-- + } +} + +func (m model) renderModelPickerOverlay(width, start, count int) string { + overlayWidth := modelPickerOverlayWidth(width, m.picker) + innerWidth := maxInt(1, overlayWidth-4) + visible := m.picker.items[start : start+count] lines := make([]string, 0, len(visible)+6) searchInset := lipgloss.Width("❯ ")