Skip to content
59 changes: 59 additions & 0 deletions cmd/harnesscli/tui/components/modelswitcher/legend_1403_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
package modelswitcher_test

import (
"strings"
"testing"

"go-agent-harness/cmd/harnesscli/tui/components/modelswitcher"
)

// Issue #1403: the picker must explain its markers and put usable models first.

func TestModelSwitcher_FooterLegend(t *testing.T) {
m := modelswitcher.New("gpt-4.1-mini").Open().WithAvailability(func(p string) bool { return p == "openai" })
view := m.View(120)
if !strings.Contains(view, "● ready") || !strings.Contains(view, "○ needs API key") {
t.Fatalf("provider list footer must carry a legend for the markers, got:\n%s", view)
}
}

func TestModelSwitcher_SearchReadyFirst(t *testing.T) {
ready := func(p string) bool { return p == "openai" }
m := modelswitcher.New("gpt-4.1-mini").Open().WithAvailability(ready).WithKeyStatus(ready)
m = m.EnterSearch().SetSearch("e")
lines := strings.Split(m.View(120), "\n")
lastReady, firstUnavailable := -1, -1
for i, l := range lines {
if strings.Contains(l, "● ready") { // legend line, not a result row
continue
}
if strings.Contains(l, "(unavailable)") && firstUnavailable == -1 {
firstUnavailable = i
}
if strings.Contains(l, "●") && !strings.Contains(l, "(unavailable)") {
lastReady = i
}
}
if firstUnavailable == -1 || lastReady == -1 {
t.Skipf("test needs both ready and unavailable results (ready=%d unavailable=%d)", lastReady, firstUnavailable)
}
if firstUnavailable < lastReady {
t.Fatalf("ready models must be listed before unavailable ones (first unavailable at line %d, last ready at %d):\n%s", firstUnavailable, lastReady, m.View(120))
}
}

func TestModelSwitcher_ProviderOrderCaseInsensitive(t *testing.T) {
m := modelswitcher.New("gpt-4.1-mini").WithModels([]modelswitcher.ServerModelEntry{
{ID: "a/x", Provider: "xai"},
{ID: "b/y", Provider: "cerebras"},
{ID: "c/z", Provider: "openai"},
})
var labels []string
for _, p := range m.Providers() {
labels = append(labels, p.Label)
}
got := strings.Join(labels, ",")
if got != "cerebras,OpenAI,xAI" {
t.Fatalf("providers must sort case-insensitively, got %s", got)
}
}
7 changes: 5 additions & 2 deletions cmd/harnesscli/tui/components/modelswitcher/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -457,8 +457,11 @@ func (m Model) providers() []ProviderSummary {
pd.configured = true
}
}
// Sort alphabetically by label.
sort.Strings(order)
// Sort alphabetically by label, ignoring case so raw ids such as
// "cerebras" sit with the display names instead of trailing "xAI".
sort.SliceStable(order, func(i, j int) bool {
return strings.ToLower(order[i]) < strings.ToLower(order[j])
})
result := make([]ProviderSummary, 0, len(order))
for _, label := range order {
pd := seen[label]
Expand Down
15 changes: 12 additions & 3 deletions cmd/harnesscli/tui/components/modelswitcher/view.go
Original file line number Diff line number Diff line change
Expand Up @@ -220,7 +220,7 @@ func (m Model) viewProviderList(width int) string {
if m.loadError != "" {
sb.WriteString(dimStyle.Render("esc cancel"))
} else {
sb.WriteString(dimStyle.Render("↑/↓ navigate enter select / search esc cancel"))
sb.WriteString(dimStyle.Render("↑/↓ navigate enter select / search esc cancel" + m.legendSuffix()))
}

return boxStyle.Width(innerWidth).BorderForeground(lipgloss.Color("240")).Render(sb.String())
Expand Down Expand Up @@ -364,7 +364,7 @@ func (m Model) viewModelsForProvider(width int) string {
// Footer. Documents "/" (previously undocumented here even though any
// other printable key already started a search — see BUG C).
sb.WriteByte('\n')
sb.WriteString(dimStyle.Render("↑/↓ navigate enter select s star / search esc back"))
sb.WriteString(dimStyle.Render("↑/↓ navigate enter select s star / search esc back" + m.legendSuffix()))

return boxStyle.Width(innerWidth).BorderForeground(lipgloss.Color("240")).Render(sb.String())
}
Expand Down Expand Up @@ -532,7 +532,7 @@ func (m Model) viewFlatModelList(width int) string {
if m.loadError != "" {
sb.WriteString(dimStyle.Render("esc cancel"))
} else {
sb.WriteString(dimStyle.Render("↑/↓ navigate enter select esc cancel search"))
sb.WriteString(dimStyle.Render("↑/↓ navigate enter select esc cancel search" + m.legendSuffix()))
}

return boxStyle.Width(innerWidth).BorderForeground(lipgloss.Color("240")).Render(sb.String())
Expand Down Expand Up @@ -634,3 +634,12 @@ func (m Model) viewReasoning(width int) string {

return box
}

// legendSuffix explains the row markers once availability is known, so a
// first-time user can read "(8) ●" without guessing (#1403).
func (m Model) legendSuffix() string {
if !m.availabilitySet && m.keyStatus == nil {
return ""
}
return "\n● ready ○ needs API key (n) models [R] reasoning model"
}
108 changes: 108 additions & 0 deletions cmd/harnesscli/tui/keys_flow_1403_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
package tui_test

import (
"strings"
"testing"

tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"

"go-agent-harness/cmd/harnesscli/tui"
)

// Issue #1403: a chat message must never end up saved as an API key.

func keysOverlay(t *testing.T, w, h int, providers []tui.ProviderInfo) tui.Model {
t.Helper()
m := initModel(t, w, h)
m = sendSlashCommand(m, "/keys")
m2, _ := m.Update(tui.ProvidersLoadedMsg{Providers: providers})
return m2.(tui.Model)
}

// Printable keys typed while the keys overlay is open (list mode) must not
// leak into the chat input.
func TestOverlay_TypedRunesDoNotReachInput(t *testing.T) {
m := keysOverlay(t, 120, 40, []tui.ProviderInfo{{Name: "openai", APIKeyEnv: "OPENAI_API_KEY"}})
m = typeIntoModel(m, "hello there")
if m.Input() != "" {
t.Fatalf("typed text leaked into the chat input while the keys overlay was open: %q", m.Input())
}
if !m.OverlayActive() {
t.Fatalf("overlay must stay open")
}
}

// The key form rejects values that cannot be API keys and stays in edit mode.
func TestAPIKeys_RejectsImplausibleKey(t *testing.T) {
for _, bad := range []string{"/model", "hello world", " "} {
m := keysOverlay(t, 120, 40, []tui.ProviderInfo{{Name: "openai", APIKeyEnv: "OPENAI_API_KEY"}})
m = sendKey(m, tea.KeyEnter) // edit the highlighted provider
if !m.APIKeyInputMode() {
t.Fatalf("Enter must open the key form")
}
m = typeIntoModel(m, bad)
m = sendKey(m, tea.KeyEnter)
if !m.APIKeyInputMode() {
t.Errorf("value %q must be rejected and keep the form open", bad)
}
if !strings.Contains(strings.ToLower(m.StatusMsg()), "api key") {
t.Errorf("value %q: status must explain the rejection, got %q", bad, m.StatusMsg())
}
}
}

// Selecting an unavailable model must say why the keys screen opened.
func TestModelPicker_UnavailableSelectionExplains(t *testing.T) {
providers := []tui.ProviderInfo{
{Name: "groq", Configured: false, APIKeyEnv: "GROQ_API_KEY"},
{Name: "anthropic", Configured: true, APIKeyEnv: "ANTHROPIC_API_KEY"},
}
t.Setenv("GROQ_API_KEY", "")
m := openModelOverlayWithProviders(t, providers)
m = navigateToModelByID(m, "llama-3.3-70b-versatile")
// Walk down until the highlight sits on a model whose provider is not configured.
found := false
for i := 0; i < 80; i++ {
if entry, ok := m.ModelSwitcher().Accept(); ok && !entry.Available && m.ModelSwitcher().AvailabilityKnown() {
found = true
break
}
m = sendKey(m, tea.KeyDown)
}
if !found {
t.Skip("no unavailable model reachable in the fixture")
}
m = sendKey(m, tea.KeyEnter)
view := m.View()
if !strings.Contains(view, "GROQ_API_KEY") || !strings.Contains(view, "not set up") {
t.Fatalf("keys screen must explain the redirect (provider not set up, which key), view:\n%s", view)
}
}

// Keys rows must fit inside the box at 120 columns, and subscription labels
// must name their own product.
func TestAPIKeys_RowsFitBoxAndLabels(t *testing.T) {
m := keysOverlay(t, 120, 40, []tui.ProviderInfo{
{Name: "codex-subscription", AuthType: "subscription"},
{Name: "kimi-subscription", AuthType: "subscription", Configured: true},
{Name: "openrouter", APIKeyEnv: "OPENROUTER_API_KEY", Configured: true},
{Name: "anthropic", APIKeyEnv: "ANTHROPIC_API_KEY"},
})
view := m.View()
for _, line := range strings.Split(view, "\n") {
if w := lipgloss.Width(line); w > 120 {
t.Errorf("row wider than the terminal (%d): %q", w, line)
}
}
// A wrapped row shows the status on a line of its own.
for _, line := range strings.Split(view, "\n") {
trimmed := strings.TrimSpace(strings.Trim(strings.TrimSpace(line), "│"))
if trimmed == "not connected" || trimmed == "connected" || trimmed == "(env)" {
t.Errorf("status wrapped onto its own line: %q", line)
}
}
if strings.Contains(view, "kimi-subscription ChatGPT") || (strings.Contains(view, "kimi-subscription") && !strings.Contains(view, "Kimi subscription")) {
t.Errorf("kimi-subscription must be labelled as a Kimi subscription, view:\n%s", view)
}
}
Loading
Loading