package modes import ( "fmt" "strings" "github.com/bnema/zut/packages/provider" "github.com/bnema/zut/packages/tui" ) // rescueDialog offers a quick model swap when the active turn fails // with a recoverable provider error (auth/rate/temporary). It looks // and behaves like modelDialog: vertical list of available models, // up/down to select, enter to retry, esc to cancel. The candidate // list is built dynamically from the providers the user is currently // logged in to — no static fallback config. type rescueDialog struct { active bool all []provider.Model // candidates, sorted view []provider.Model // filtered view (typed query) cursor int current string // currently active model id (excluded from view) query string failedAt string // failed provider/model pair, e.g. "kimi/kimi-for-coding" reason string // complete diagnostic, including provider response prompt string // the user prompt that should be retried on Select details bool scroll int } type rescueDialogAction struct { Select bool Provider string Model string Prompt string Close bool } func newRescueDialog() *rescueDialog { return &rescueDialog{} } // Open shows the dialog. current is the currently active model id so // it can be excluded from the candidate list. loggedInProviders is // the set of provider names with usable credentials right now. func (d *rescueDialog) Open(current string, loggedInProviders []string, failedProvider, failedModel, reason, prompt string) { d.active = true d.current = current d.query = "" d.details = false d.scroll = 0 d.reason = reason d.prompt = prompt d.failedAt = strings.TrimSpace(failedProvider + "/" + failedModel) all := provider.Active() provSet := map[string]bool{} for _, p := range loggedInProviders { provSet[p] = true } var filtered []provider.Model for _, m := range all { if !provSet[m.Provider] { continue } // Drop the exact failed pair so users can't retry on the // model that just failed. if m.Provider == failedProvider && m.ID == failedModel { continue } // Drop the currently-active model id (if it differs from // the failed one for some reason). The picker is meant to // help the user move *off* the broken pair. if m.ID == current { continue } filtered = append(filtered, m) } d.all = sortedModels(filtered) d.refilter() } func (d *rescueDialog) Close() { d.active = false } func (d *rescueDialog) Active() bool { return d != nil && d.active } func (d *rescueDialog) refilter() { needle := normalizeModelQuery(d.query) if needle == "" { d.view = append([]provider.Model(nil), d.all...) } else { out := make([]provider.Model, 0, len(d.all)) for _, m := range d.all { if strings.Contains(normalizeModelQuery(m.Provider+" "+m.ID+" "+m.DisplayName), needle) { out = append(out, m) } } d.view = out } d.cursor = 0 } // Render mirrors modelDialog so a rescue prompt feels identical to // every other picker in the TUI. func (d *rescueDialog) Render(th tui.Theme, width, height int) []string { if !d.Active() { return nil } var lines []string header := "rescue turn" if d.failedAt != "" && d.failedAt != "/" { header = "rescue turn — " + d.failedAt + " failed" } if d.details { header = "provider error" } lines = append(lines, frameHeader(th, header, width)) // The picker keeps a bounded preview; the details view retains the entire // diagnostic and owns its viewport rather than letting the pane clip it. errorRows := wrapDialogTextRows(sanitizeSessionTreeText(d.reason), width) if d.details { hint := wrapDialogTextRows("↑/↓ or pgup/pgdn: scroll; tab/esc: back", width) hint = hint[:min(len(hint), max(0, height-1))] visible := max(1, height-len(hint)) d.scroll = min(max(0, d.scroll), max(0, len(errorRows)-visible)) for _, row := range hint { lines = append(lines, th.FGColor(th.Muted, row)) } for _, row := range errorRows[d.scroll:min(len(errorRows), d.scroll+visible)] { lines = append(lines, th.FGColor(th.Warning, row)) } return append(lines, frameRule(th, width)) } if d.reason != "" { for _, row := range errorRows[:min(3, len(errorRows))] { lines = append(lines, th.FGColor(th.Warning, row)) } for _, row := range wrapDialogTextRows("tab: full error", width) { lines = append(lines, th.FGColor(th.Muted, row)) } } hint := "retry this turn with another model (↑/↓, enter, esc to cancel)" if d.query != "" { hint = fmt.Sprintf("filter: %s (%d match)", d.query, len(d.view)) if len(d.view) != 1 { hint = fmt.Sprintf("filter: %s (%d matches)", d.query, len(d.view)) } } else { hint += " - type to filter" } lines = append(lines, th.FGColor(th.Muted, hint)) if len(d.view) == 0 { if len(d.all) == 0 { lines = append(lines, th.FGColor(th.Muted, " no other models available — log in to another provider with /login")) } else { lines = append(lines, th.FGColor(th.Muted, " no models match "+fmt.Sprintf("%q", d.query))) } lines = append(lines, frameRule(th, width)) return lines } const visible = 12 start := 0 end := len(d.view) if end > visible { start = d.cursor - visible/2 if start < 0 { start = 0 } if start+visible > end { start = end - visible } end = start + visible } for i := start; i < end; i++ { m := d.view[i] reason := " " if m.Reasoning { reason = "✦" } tag := "" switch { case m.Speculative: tag = "[speculative] " case m.Source == "live": tag = "[live] " } plain := fmt.Sprintf(" %-10s %-28s %s %s%s", m.Provider, m.ID, reason, tag, m.DisplayName) if i == d.cursor { lines = append(lines, th.PadHighlight(plain, width)) } else { lines = append(lines, th.FGColor(th.Muted, plain)) } } if start > 0 { lines = append(lines, th.FGColor(th.Muted, fmt.Sprintf(" ... %d more above", start))) } if end < len(d.view) { lines = append(lines, th.FGColor(th.Muted, fmt.Sprintf(" ... %d more below", len(d.view)-end))) } lines = append(lines, frameRule(th, width)) return lines } func (d *rescueDialog) HandleKey(k tui.Key) rescueDialogAction { if k.Kind == tui.KeyTab { d.details = !d.details d.scroll = 0 return rescueDialogAction{} } if d.details { switch k.Kind { case tui.KeyEsc: d.details = false case tui.KeyUp, tui.KeyMouseWheelUp: d.scroll = max(0, d.scroll-1) case tui.KeyDown, tui.KeyMouseWheelDown: d.scroll++ case tui.KeyPageUp: d.scroll = max(0, d.scroll-8) case tui.KeyPageDown: d.scroll += 8 case tui.KeyHome: d.scroll = 0 } return rescueDialogAction{} } switch k.Kind { case tui.KeyUp: if d.cursor > 0 { d.cursor-- } case tui.KeyDown: if d.cursor < len(d.view)-1 { d.cursor++ } case tui.KeyBackspace: if len(d.query) > 0 { r := []rune(d.query) d.query = string(r[:len(r)-1]) d.refilter() } case tui.KeyPaste: d.query += singleLinePaste(k.Paste) d.refilter() case tui.KeyRune: if k.Alt || k.Ctrl { break } if k.Rune >= 0x20 && k.Rune < 0x7f { d.query += string(k.Rune) d.refilter() } case tui.KeyEsc: d.Close() return rescueDialogAction{Close: true} case tui.KeyEnter: if len(d.view) == 0 { d.Close() return rescueDialogAction{Close: true} } m := d.view[d.cursor] prompt := d.prompt d.Close() return rescueDialogAction{Select: true, Provider: m.Provider, Model: m.ID, Prompt: prompt} } return rescueDialogAction{} } // classifyRescueError inspects an agent error and decides whether // it's a recoverable provider failure that the user should be offered // a rescue picker for, plus a category and the complete diagnostic. Returns // (false, "") for errors we should NOT auto-rescue (bad request, // context length, transcript serialization issues, etc.) so the // regular red status banner still surfaces them. func classifyRescueError(err error) (bool, string) { if err == nil { return false, "" } msg := err.Error() low := strings.ToLower(msg) // Don't trigger on context overflow; that path already has its own // auto-compact handling. if provider.IsContextOverflowError(err) { return false, "" } // Network failures. if strings.Contains(low, "timeout") || strings.Contains(low, "deadline exceeded") || strings.Contains(low, "connection refused") || strings.Contains(low, "connection reset") || strings.Contains(low, "no such host") || strings.Contains(low, "tls handshake") || strings.Contains(low, "eof") { return true, "network failure: " + msg } switch { case containsAny(low, "http 401", " 401:", "invalid_authentication", "token expired", "api key appears to be invalid"): return true, "authentication failed: " + msg case containsAny(low, "http 403", " 403:", "permission denied", "forbidden"): return true, "permission denied: " + msg case containsAny(low, "http 429", " 429:", "rate limit", "rate_limit", "too many requests", "quota"): return true, "rate limited: " + msg case containsAny(low, "http 500", "http 502", "http 503", "http 504", " 500:", " 502:", " 503:", " 504:", "upstream connect error", "service unavailable", "internal server error", "bad gateway", "gateway timeout"): return true, "provider unavailable: " + msg } // Anything else (400 bad request, validation errors, etc.) is // usually not fixed by switching models; let it surface as-is. return false, "" } func containsAny(haystack string, needles ...string) bool { for _, n := range needles { if strings.Contains(haystack, n) { return true } } return false } // extractFailedProvider tries to pull the failing provider name out // of an error string ("kimi: http 401: ...", "openai: http 503: ..."). // Returns "" when nothing recognisable is found. func extractFailedProvider(err error) string { if err == nil { return "" } msg := err.Error() if i := strings.Index(msg, ":"); i > 0 { head := strings.TrimSpace(msg[:i]) switch head { case "anthropic", "openai", "openai-codex", "kimi", "google", "ollama": return head } } return "" }