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/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/picker.go b/internal/tui/picker.go index b18b405b6..94f06ecc8 100644 --- a/internal/tui/picker.go +++ b/internal/tui/picker.go @@ -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 @@ -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 @@ -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 } @@ -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 } @@ -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 } diff --git a/internal/tui/picker_test.go b/internal/tui/picker_test.go index 888e21de8..9957f9dc9 100644 --- a/internal/tui/picker_test.go +++ b/internal/tui/picker_test.go @@ -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{} diff --git a/internal/tui/rendering_lime_test.go b/internal/tui/rendering_lime_test.go index 12b74d0f4..451c0055b 100644 --- a/internal/tui/rendering_lime_test.go +++ b/internal/tui/rendering_lime_test.go @@ -1894,10 +1894,100 @@ 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, OwnerLabel: 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", OwnerLabel: "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) + } +} + +// 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 := "…" + 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) + } + } + } +} + +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 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. - 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..785b97969 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" ) @@ -24,6 +25,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 @@ -1023,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("❯ ") @@ -1054,12 +1081,20 @@ 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(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) + 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)) @@ -1120,10 +1155,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, maxInt(0, available-6))) target = maxInt(target, lipgloss.Width("❯ ")+labelWidth) if detail := modelPickerItemDetail(item); detail != "" { target = maxInt(target, lipgloss.Width(" "+detail)) @@ -1158,18 +1190,48 @@ func renderModelPickerRow(width int, selected bool, item pickerItem) string { surface = zeroTheme.onSel marker = surface(zeroTheme.accent).Render("❯ ") } + 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 { label := strings.TrimSpace(item.Label) if label == "" { label = strings.TrimSpace(item.Value) } - prefix := "" if item.Favorite { - prefix = "* " + width -= 2 + } + // Reserve the model's cells against the actual terminal width, not just the + // overlay cap. If both cannot fit, give the owner its own line. + if owner := strings.TrimSpace(item.OwnerLabel); owner != "" { + 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 { + prefix = fmt.Sprintf("[%d]", item.OwnerNumber) + } + remove := lipgloss.Width(owner) - maxInt(1, budget-lipgloss.Width(prefix)) + 1 + owner = prefix + ansi.TruncateLeft(owner, remove, "…") + } + if separateLine { + label += "\n" + owner + } else { + label = owner + " · " + label + } + } + if item.Favorite { + 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 {