diff --git a/cmd/harnesscli/tui/components/modelswitcher/legend_1403_test.go b/cmd/harnesscli/tui/components/modelswitcher/legend_1403_test.go new file mode 100644 index 00000000..e51765f1 --- /dev/null +++ b/cmd/harnesscli/tui/components/modelswitcher/legend_1403_test.go @@ -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) + } +} diff --git a/cmd/harnesscli/tui/components/modelswitcher/model.go b/cmd/harnesscli/tui/components/modelswitcher/model.go index d01a7b0d..a074a66b 100644 --- a/cmd/harnesscli/tui/components/modelswitcher/model.go +++ b/cmd/harnesscli/tui/components/modelswitcher/model.go @@ -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] diff --git a/cmd/harnesscli/tui/components/modelswitcher/view.go b/cmd/harnesscli/tui/components/modelswitcher/view.go index c769e163..4557bc2d 100644 --- a/cmd/harnesscli/tui/components/modelswitcher/view.go +++ b/cmd/harnesscli/tui/components/modelswitcher/view.go @@ -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()) @@ -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()) } @@ -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()) @@ -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" +} diff --git a/cmd/harnesscli/tui/keys_flow_1403_test.go b/cmd/harnesscli/tui/keys_flow_1403_test.go new file mode 100644 index 00000000..21b573ff --- /dev/null +++ b/cmd/harnesscli/tui/keys_flow_1403_test.go @@ -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) + } +} diff --git a/cmd/harnesscli/tui/model.go b/cmd/harnesscli/tui/model.go index d9a71502..b7bfb801 100644 --- a/cmd/harnesscli/tui/model.go +++ b/cmd/harnesscli/tui/model.go @@ -335,6 +335,9 @@ type Model struct { apiKeyInput string // apiKeyInputMode is true when the user is typing a key value. apiKeyInputMode bool + // apiKeyReason explains why the keys panel opened (set when the model + // picker redirects here for an unconfigured provider, #1403). + apiKeyReason string // pendingAPIKeys holds keys loaded from config or entered via /keys, replayed on Init(). pendingAPIKeys map[string]string // envAPIKeys holds keys read from the shell environment at startup. @@ -793,6 +796,10 @@ func (m Model) ConversationID() string { } // SelectedModel returns the currently active model ID (for testing). +// EffectiveModelAndProvider exposes the model id and provider the next run +// will be sent with (for testing). +func (m Model) EffectiveModelAndProvider() (string, string) { return m.effectiveModelAndProvider() } + func (m Model) SelectedModel() string { return m.selectedModel } @@ -2168,6 +2175,7 @@ func executeKeysCommand(m *Model, _ Command) ([]tea.Cmd, bool) { m.apiKeyCursor = 0 m.apiKeyInput = "" m.apiKeyInputMode = false + m.apiKeyReason = "" return []tea.Cmd{fetchProvidersCmd(m.config.BaseURL, m.config.APIKey)}, false } @@ -2966,6 +2974,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } else { m.overlayActive = false m.activeOverlay = "" + m.apiKeyReason = "" } return m, tea.Batch(cmds...) } @@ -3206,9 +3215,17 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } // When the apikeys overlay is active, Enter enters input mode or confirms. if m.overlayActive && m.activeOverlay == "apikeys" { - if m.apiKeyInputMode && m.apiKeyInput != "" { + if m.apiKeyInputMode { + apiKey := strings.TrimSpace(m.apiKeyInput) + // Keys never contain whitespace and never start with "/"; a + // value like that is a chat message or a slash command that + // landed in the wrong box (#1403). Keep the form open and say so. + if apiKey == "" || strings.ContainsAny(apiKey, " \t") || strings.HasPrefix(apiKey, "/") { + m.apiKeyInput = "" + cmds = append(cmds, m.setStatusMsg("That doesn't look like an API key (no spaces, doesn't start with /). Paste the key, or press Esc to cancel.")) + return m, tea.Batch(cmds...) + } provider := m.apiKeyProviders[m.apiKeyCursor].Name - apiKey := m.apiKeyInput m.apiKeyInputMode = false m.apiKeyInput = "" cmds = append(cmds, setProviderKeyCmd(m.config.BaseURL, provider, apiKey, m.config.APIKey)) @@ -3289,6 +3306,14 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } else { m.apiKeyCursor = 0 } + // Say why the keys panel opened and what to do (#1403). + m.apiKeyReason = fmt.Sprintf("%s is not set up, so %s cannot be used yet.", entry.ProviderLabel, entry.DisplayName) + if m.apiKeyCursor < len(m.apiKeyProviders) && m.apiKeyProviders[m.apiKeyCursor].APIKeyEnv != "" { + m.apiKeyReason += fmt.Sprintf(" Enter its API key (%s) below: press Enter to edit, Esc to go back.", m.apiKeyProviders[m.apiKeyCursor].APIKeyEnv) + } else { + m.apiKeyReason += " Press Enter to set it up, Esc to go back." + } + cmds = append(cmds, m.setStatusMsg(m.apiKeyReason)) return m, tea.Batch(cmds...) } // Provider is configured (or availability not yet known) — enter the config panel normally. @@ -3920,6 +3945,13 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return m, tea.Batch(cmds...) } + // While an overlay is open, printable keys the overlay did not claim + // must not fall through into the chat input (#1403: typed text then + // became an API key one Enter later). Say what to do instead. + if m.overlayActive && (msg.Type == tea.KeyRunes || msg.Type == tea.KeySpace) { + cmds = append(cmds, m.setStatusMsg("Press Esc to close this panel before typing a message")) + return m, tea.Batch(cmds...) + } // Route to input area var cmd tea.Cmd m.input, cmd = m.input.Update(msg) @@ -5793,6 +5825,13 @@ func (m Model) effectiveModelAndProvider() (model, provider string) { if m.selectedGateway == "openrouter" { return modelswitcher.OpenRouterSlug(m.selectedModel), "openrouter" } + // A model that came from the OpenRouter provider only exists there: send + // its id untouched to OpenRouter regardless of the gateway setting. The + // slug-to-native rewrite below turned "deepseek/deepseek-v4-pro" into + // "deepseek-v4" and OpenRouter rejected it (#1403). + if m.selectedProvider == "openrouter" { + return m.selectedModel, "openrouter" + } modelID := m.selectedModel // When the model list was sourced from OpenRouter, selectedModel may be // an OpenRouter slug (e.g. "deepseek/deepseek-v4-flash"). For a direct @@ -5889,6 +5928,19 @@ func (m Model) viewAPIKeysOverlay() string { unsetStyle := lipgloss.NewStyle().Faint(true) var rows []string + nameCol, detailCol := 14, 24 + for _, p := range m.apiKeyProviders { + if l := len(p.Name); l > nameCol { + nameCol = l + } + d := p.APIKeyEnv + if p.AuthType == "subscription" { + d = subscriptionLabel(p.Name) + } + if l := len(d); l > detailCol { + detailCol = l + } + } for i, p := range m.apiKeyProviders { cursor := " " style := lipgloss.NewStyle() @@ -5913,9 +5965,9 @@ func (m Model) viewAPIKeysOverlay() string { } detail := p.APIKeyEnv if p.AuthType == "subscription" { - detail = "ChatGPT subscription" + detail = subscriptionLabel(p.Name) } - label := style.Render(fmt.Sprintf("%s%-14s %-24s", cursor, p.Name, detail)) + label := style.Render(fmt.Sprintf("%s%-*s %-*s", cursor, nameCol, p.Name, detailCol, detail)) if p.AuthType == "subscription" { status = unsetStyle.Render("○ not connected") if p.Configured { @@ -5931,22 +5983,87 @@ func (m Model) viewAPIKeysOverlay() string { footer := lipgloss.NewStyle().Faint(true).Render(string('\u2191') + "/" + string('\u2193') + " navigate enter edit/setup i import subscription esc close") - content := strings.Join(rows, "\n") + "\n\n" + footer + // Size the box to its widest row so status text never wraps onto a line + // of its own (#1403), capped to the terminal; rows wider than that are + // shortened with an ellipsis instead. + const borderAndPad = 6 // border 1 + padding 2 on each side + inner := lipgloss.Width(footer) + for _, r := range rows { + if w := lipgloss.Width(r); w > inner { + inner = w + } + } + if maxInner := m.width - borderAndPad - 2; maxInner > 20 && inner > maxInner { + inner = maxInner + } + for i, r := range rows { + rows[i] = truncateVisible(r, inner) + } + header := []string{lipgloss.NewStyle().Bold(true).Render(title)} + if m.apiKeyReason != "" { + header = append(header, "", lipgloss.NewStyle().Foreground(lipgloss.Color("220")).Render(wrapPlain(m.apiKeyReason, inner))) + } + content := strings.Join(rows, "\n") + "\n\n" + truncateVisible(footer, inner) box := lipgloss.NewStyle(). Border(lipgloss.RoundedBorder()). BorderForeground(lipgloss.Color("62")). Padding(1, 2). - Width(width). + Width(inner + borderAndPad). Render(lipgloss.JoinVertical(lipgloss.Left, - lipgloss.NewStyle().Bold(true).Render(title), - "", - content, + append(append(header, ""), content)..., )) return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, box) } +// subscriptionLabel names the product behind a subscription-auth provider. +func subscriptionLabel(provider string) string { + switch provider { + case "codex-subscription": + return "ChatGPT subscription" + case "kimi-subscription": + return "Kimi subscription" + } + return "Subscription" +} + +// truncateVisible shortens s to width terminal columns with an ellipsis, +// counting styled text by its visible width. +func truncateVisible(s string, width int) string { + if lipgloss.Width(s) <= width || width < 2 { + return s + } + runes := []rune(s) + for len(runes) > 0 && lipgloss.Width(string(runes))+1 > width { + runes = runes[:len(runes)-1] + } + return strings.TrimRight(string(runes), " ") + "…" +} + +// wrapPlain word-wraps unstyled text to width columns. +func wrapPlain(s string, width int) string { + if width < 10 { + return s + } + var lines []string + line := "" + for _, w := range strings.Fields(s) { + if line == "" { + line = w + } else if lipgloss.Width(line)+1+lipgloss.Width(w) <= width { + line += " " + w + } else { + lines = append(lines, line) + line = w + } + } + if line != "" { + lines = append(lines, line) + } + return strings.Join(lines, "\n") +} + // viewModelConfigPanel renders the Level-1 model configuration panel. // It shows model name, provider, gateway selection, API key status, and // optionally reasoning effort selection (for reasoning models). @@ -5978,7 +6095,15 @@ func (m Model) viewModelConfigPanel() string { } var gwRows []string + if entry.Provider == "openrouter" { + // This id is only served by OpenRouter; a "Direct" choice would be + // meaningless (and used to break the run, #1403). + gwRows = append(gwRows, " "+dimStyle.Render("OpenRouter served only by OpenRouter")) + } for i, opt := range gatewayOptions { + if entry.Provider == "openrouter" { + break + } isSelected := i == m.modelConfigGatewayCursor var rowStyle lipgloss.Style var cursor string @@ -6054,7 +6179,7 @@ func (m Model) viewModelConfigPanel() string { // --- Footer --- var footer string if !m.modelConfigKeyInputMode { - footer = dimStyle.Render("↑/↓ sections ←/→ gateway enter confirm esc back") + footer = dimStyle.Render(configPanelFooter(entry.Provider)) } var innerContent string @@ -6240,3 +6365,12 @@ func editorExecCommand(editor, file string) *exec.Cmd { cmd.Stderr = os.Stderr return cmd } + +// configPanelFooter omits the gateway arrows for models that are only served +// by OpenRouter, where the gateway choice does not exist (#1403). +func configPanelFooter(provider string) string { + if provider == "openrouter" { + return "↑/↓ sections enter confirm esc back" + } + return "↑/↓ sections ←/→ gateway enter confirm esc back" +} diff --git a/cmd/harnesscli/tui/openrouter_routing_1403_test.go b/cmd/harnesscli/tui/openrouter_routing_1403_test.go new file mode 100644 index 00000000..8067a8a7 --- /dev/null +++ b/cmd/harnesscli/tui/openrouter_routing_1403_test.go @@ -0,0 +1,49 @@ +package tui_test + +import ( + "strings" + "testing" + + tea "github.com/charmbracelet/bubbletea" + + "go-agent-harness/cmd/harnesscli/tui" + "go-agent-harness/cmd/harnesscli/tui/components/modelswitcher" +) + +// Issue #1403 (routing): a model that only exists on OpenRouter must be sent to +// OpenRouter with its own id, whatever the "gateway" setting says. Before the +// fix, the direct gateway rewrote "deepseek/deepseek-v4-pro" to "deepseek-v4" +// and still sent it to OpenRouter, which rejected it. +func TestOpenRouterModel_DirectGatewayKeepsSlugAndProvider(t *testing.T) { + m := initModel(t, 120, 40) + m2, _ := m.Update(tui.GatewaySelectedMsg{Gateway: ""}) + m = m2.(tui.Model) + m3, _ := m.Update(tui.ModelSelectedMsg{ModelID: "deepseek/deepseek-v4-pro", Provider: "openrouter"}) + m = m3.(tui.Model) + model, provider := m.EffectiveModelAndProvider() + if model != "deepseek/deepseek-v4-pro" || provider != "openrouter" { + t.Fatalf("want (deepseek/deepseek-v4-pro, openrouter), got (%s, %s)", model, provider) + } +} + +// The configuration panel must not offer a "Direct" gateway for such a model; +// it says the model is served by OpenRouter. +func TestOpenRouterModel_ConfigPanelExplainsRouting(t *testing.T) { + providers := []tui.ProviderInfo{{Name: "openrouter", Configured: true, APIKeyEnv: "OPENROUTER_API_KEY"}} + m := openModelOverlayWithProviders(t, providers) + m2, _ := m.Update(tui.ModelsFetchedMsg{Models: []modelswitcher.ServerModelEntry{{ID: "deepseek/deepseek-v4-pro", Provider: "openrouter"}}}) + m = m2.(tui.Model) + // Reach the model through the picker's search, as a user would. + m = typeIntoModel(m, "/deepseek/deepseek-v4-pro") + if entry, ok := m.ModelSwitcher().Accept(); !ok || entry.ID != "deepseek/deepseek-v4-pro" { + t.Fatalf("search did not land on the model, got %+v", entry) + } + m = sendKey(m, tea.KeyEnter) + view := m.View() + if !strings.Contains(view, "served only by OpenRouter") { + t.Fatalf("config panel must explain that the model is served by OpenRouter, view:\n%s", view) + } + if strings.Contains(view, "Use each model's native provider") { + t.Fatalf("config panel must not offer the Direct gateway for an OpenRouter-only model") + } +} diff --git a/docs/logs/engineering-log.md b/docs/logs/engineering-log.md index 21686391..8b5c0053 100644 --- a/docs/logs/engineering-log.md +++ b/docs/logs/engineering-log.md @@ -1,5 +1,11 @@ # Engineering Log +## 2026-09-06 — A chat message could be saved as an API key (#1403) + +- Symptom: in the TUI, selecting a model whose provider had no key jumped to the API Keys panel with no explanation; letters typed while the panel was open fell through into the chat input; Enter then opened the key form, and the next text plus Enter (`/model`) was stored as the DeepSeek key both client-side (`~/.config/harnesscli/config.json`) and on the daemon. Keys rows also wrapped inside the box and `kimi-subscription` was labelled "ChatGPT subscription"; the picker had no legend for `●/○/(n)` and sorted providers case-sensitively. +- Cause: the final key-routing fallthrough in `cmd/harnesscli/tui/model.go` had no overlay guard; the key form accepted any non-empty string; the redirect set no message; the keys box had a fixed 54-column width with 14/24-column fields. +- Fix: swallow printable keys while an overlay is open (status hint), validate keys (no whitespace, not starting with `/`), set `apiKeyReason` on redirect and render it under the panel title, size the keys box to its rows and truncate with `…`, product-specific subscription labels, a legend line in the picker footers, case-insensitive provider order. Live tmux captures at 120x40 in PR #1404. + ## 2026-09-06 — Slash-command menu polish (#1401) - Symptom: driving `harnesscli --tui` in tmux, Tab ignored the item highlighted with ↑/↓ (the input box's prefix completer handled the key), Enter on a bare `/` ran `/add-dir`, a query with no matches made the menu vanish, descriptions were chopped mid-word at 40-60 columns, a blank row appeared under the menu, no key hint was shown, and the menu added rows to the screen (38 → 47 at 120x40) instead of borrowing them from the transcript. diff --git a/website/docs/cli/tui.md b/website/docs/cli/tui.md index 67a4c12f..4c2ef7d0 100644 --- a/website/docs/cli/tui.md +++ b/website/docs/cli/tui.md @@ -145,6 +145,11 @@ While a run is in flight, type corrective input and press `Ctrl+G` to inject it Type `/` to open the command menu. `↑`/`↓` move the highlight, `Enter` runs the highlighted command, `Tab` completes it into the input without running it, and `Esc` closes the menu (a second `Esc` clears the input). A bare `/` plus `Enter` does not run anything; type part of a name or move the highlight first. When nothing matches, the menu says so instead of disappearing; `Enter` then shows the unknown-command hint. Descriptions are shortened with `…` on narrow terminals, and the menu takes its rows from the top of the transcript so the input and status bar never move. Commands are case-insensitive. +:::note What the model picker shows +Provider rows end with `(n)` (number of models) and `●` (ready: an API key is configured) or `○` (needs an API key); `[R]` marks reasoning models. Selecting a model whose provider is not set up opens the API Keys panel with an explanation of what is missing. While any panel is open, typed text is not sent to the chat input (press `Esc` first). The key form rejects values that cannot be keys, such as text with spaces or anything starting with `/`. +::: + + | Command | Description | |---|---| | `/model` | Open the model picker |