1package modes
2
3import (
4 "fmt"
5 "strings"
6
7 "github.com/patriceckhart/zot/internal/provider"
8 "github.com/patriceckhart/zot/internal/tui"
9)
10
11// rescueDialog offers a quick model swap when the active turn fails
12// with a recoverable provider error (auth/rate/temporary). It looks
13// and behaves like modelDialog: vertical list of available models,
14// up/down to select, enter to retry, esc to cancel. The candidate
15// list is built dynamically from the providers the user is currently
16// logged in to — no static fallback config.
17type rescueDialog struct {
18 active bool
19 all []provider.Model // candidates, sorted
20 view []provider.Model // filtered view (typed query)
21 cursor int
22 current string // currently active model id (excluded from view)
23 query string
24 failedAt string // failed provider/model pair, e.g. "kimi/kimi-for-coding"
25 reason string // short human-readable reason ("token expired", "rate limited", ...)
26 prompt string // the user prompt that should be retried on Select
27}
28
29type rescueDialogAction struct {
30 Select bool
31 Provider string
32 Model string
33 Prompt string
34 Close bool
35}
36
37func newRescueDialog() *rescueDialog { return &rescueDialog{} }
38
39// Open shows the dialog. current is the currently active model id so
40// it can be excluded from the candidate list. loggedInProviders is
41// the set of provider names with usable credentials right now.
42func (d *rescueDialog) Open(current string, loggedInProviders []string, failedProvider, failedModel, reason, prompt string) {
43 d.active = true
44 d.current = current
45 d.query = ""
46 d.reason = reason
47 d.prompt = prompt
48 d.failedAt = strings.TrimSpace(failedProvider + "/" + failedModel)
49
50 all := provider.Active()
51 provSet := map[string]bool{}
52 for _, p := range loggedInProviders {
53 provSet[p] = true
54 }
55 var filtered []provider.Model
56 for _, m := range all {
57 if !provSet[m.Provider] {
58 continue
59 }
60 // Drop the exact failed pair so users can't retry on the
61 // model that just failed.
62 if m.Provider == failedProvider && m.ID == failedModel {
63 continue
64 }
65 // Drop the currently-active model id (if it differs from
66 // the failed one for some reason). The picker is meant to
67 // help the user move *off* the broken pair.
68 if m.ID == current {
69 continue
70 }
71 filtered = append(filtered, m)
72 }
73 d.all = sortedModels(filtered)
74 d.refilter()
75}
76
77func (d *rescueDialog) Close() { d.active = false }
78func (d *rescueDialog) Active() bool { return d != nil && d.active }
79
80func (d *rescueDialog) refilter() {
81 needle := normalizeModelQuery(d.query)
82 if needle == "" {
83 d.view = append([]provider.Model(nil), d.all...)
84 } else {
85 out := make([]provider.Model, 0, len(d.all))
86 for _, m := range d.all {
87 if strings.Contains(normalizeModelQuery(m.Provider+" "+m.ID+" "+m.DisplayName), needle) {
88 out = append(out, m)
89 }
90 }
91 d.view = out
92 }
93 d.cursor = 0
94}
95
96// Render mirrors modelDialog so a rescue prompt feels identical to
97// every other picker in the TUI.
98func (d *rescueDialog) Render(th tui.Theme, width int) []string {
99 if !d.Active() {
100 return nil
101 }
102 var lines []string
103 header := "rescue turn"
104 if d.failedAt != "" && d.failedAt != "/" {
105 header = "rescue turn — " + d.failedAt + " failed"
106 }
107 lines = append(lines, frameHeader(th, header, width))
108
109 if d.reason != "" {
110 lines = append(lines, th.FG256(th.Warning, " "+d.reason))
111 }
112
113 hint := "retry this turn with another model (↑/↓, enter, esc to cancel)"
114 if d.query != "" {
115 hint = fmt.Sprintf("filter: %s (%d match)", d.query, len(d.view))
116 if len(d.view) != 1 {
117 hint = fmt.Sprintf("filter: %s (%d matches)", d.query, len(d.view))
118 }
119 } else {
120 hint += " - type to filter"
121 }
122 lines = append(lines, th.FG256(th.Muted, hint))
123
124 if len(d.view) == 0 {
125 if len(d.all) == 0 {
126 lines = append(lines, th.FG256(th.Muted, " no other models available — log in to another provider with /login"))
127 } else {
128 lines = append(lines, th.FG256(th.Muted, " no models match "+fmt.Sprintf("%q", d.query)))
129 }
130 lines = append(lines, frameRule(th, width))
131 return lines
132 }
133
134 const visible = 12
135 start := 0
136 end := len(d.view)
137 if end > visible {
138 start = d.cursor - visible/2
139 if start < 0 {
140 start = 0
141 }
142 if start+visible > end {
143 start = end - visible
144 }
145 end = start + visible
146 }
147 for i := start; i < end; i++ {
148 m := d.view[i]
149 reason := " "
150 if m.Reasoning {
151 reason = "✦"
152 }
153 tag := ""
154 switch {
155 case m.Speculative:
156 tag = "[speculative] "
157 case m.Source == "live":
158 tag = "[live] "
159 }
160 plain := fmt.Sprintf(" %-10s %-28s %s %s%s", m.Provider, m.ID, reason, tag, m.DisplayName)
161 if i == d.cursor {
162 lines = append(lines, th.PadHighlight(plain, width))
163 } else {
164 lines = append(lines, th.FG256(th.Muted, plain))
165 }
166 }
167 if start > 0 {
168 lines = append(lines, th.FG256(th.Muted, fmt.Sprintf(" … %d more above", start)))
169 }
170 if end < len(d.view) {
171 lines = append(lines, th.FG256(th.Muted, fmt.Sprintf(" … %d more below", len(d.view)-end)))
172 }
173
174 lines = append(lines, frameRule(th, width))
175 return lines
176}
177
178func (d *rescueDialog) HandleKey(k tui.Key) rescueDialogAction {
179 switch k.Kind {
180 case tui.KeyUp:
181 if d.cursor > 0 {
182 d.cursor--
183 }
184 case tui.KeyDown:
185 if d.cursor < len(d.view)-1 {
186 d.cursor++
187 }
188 case tui.KeyBackspace:
189 if len(d.query) > 0 {
190 r := []rune(d.query)
191 d.query = string(r[:len(r)-1])
192 d.refilter()
193 }
194 case tui.KeyRune:
195 if k.Alt || k.Ctrl {
196 break
197 }
198 if k.Rune >= 0x20 && k.Rune < 0x7f {
199 d.query += string(k.Rune)
200 d.refilter()
201 }
202 case tui.KeyEsc:
203 d.Close()
204 return rescueDialogAction{Close: true}
205 case tui.KeyEnter:
206 if len(d.view) == 0 {
207 d.Close()
208 return rescueDialogAction{Close: true}
209 }
210 m := d.view[d.cursor]
211 prompt := d.prompt
212 d.Close()
213 return rescueDialogAction{Select: true, Provider: m.Provider, Model: m.ID, Prompt: prompt}
214 }
215 return rescueDialogAction{}
216}
217
218// classifyRescueError inspects an agent error and decides whether
219// it's a recoverable provider failure that the user should be offered
220// a rescue picker for, plus a short human-readable reason. Returns
221// (false, "") for errors we should NOT auto-rescue (bad request,
222// context length, transcript serialization issues, etc.) so the
223// regular red status banner still surfaces them.
224func classifyRescueError(err error) (bool, string) {
225 if err == nil {
226 return false, ""
227 }
228 msg := err.Error()
229 low := strings.ToLower(msg)
230
231 // Don't trigger on payload-too-large; that path already has its
232 // own auto-compact handling.
233 if isPayloadTooLargeError(err) {
234 return false, ""
235 }
236
237 // Network failures.
238 if strings.Contains(low, "timeout") ||
239 strings.Contains(low, "deadline exceeded") ||
240 strings.Contains(low, "connection refused") ||
241 strings.Contains(low, "connection reset") ||
242 strings.Contains(low, "no such host") ||
243 strings.Contains(low, "tls handshake") ||
244 strings.Contains(low, "eof") {
245 return true, "network failure: " + shortError(msg)
246 }
247
248 switch {
249 case containsAny(low, "http 401", " 401:", "invalid_authentication", "token expired", "api key appears to be invalid"):
250 return true, "authentication failed: " + shortError(msg)
251 case containsAny(low, "http 403", " 403:", "permission denied", "forbidden"):
252 return true, "permission denied: " + shortError(msg)
253 case containsAny(low, "http 429", " 429:", "rate limit", "rate_limit", "too many requests", "quota"):
254 return true, "rate limited: " + shortError(msg)
255 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"):
256 return true, "provider unavailable: " + shortError(msg)
257 }
258
259 // Anything else (400 bad request, validation errors, etc.) is
260 // usually not fixed by switching models; let it surface as-is.
261 return false, ""
262}
263
264func containsAny(haystack string, needles ...string) bool {
265 for _, n := range needles {
266 if strings.Contains(haystack, n) {
267 return true
268 }
269 }
270 return false
271}
272
273// shortError trims a long http-payload error to something readable in
274// the rescue dialog header without dropping the most useful prefix.
275func shortError(msg string) string {
276 msg = strings.TrimSpace(msg)
277 const max = 140
278 if len(msg) <= max {
279 return msg
280 }
281 return msg[:max] + "…"
282}
283
284// extractFailedProvider tries to pull the failing provider name out
285// of an error string ("kimi: http 401: ...", "openai: http 503: ...").
286// Returns "" when nothing recognisable is found.
287func extractFailedProvider(err error) string {
288 if err == nil {
289 return ""
290 }
291 msg := err.Error()
292 if i := strings.Index(msg, ":"); i > 0 {
293 head := strings.TrimSpace(msg[:i])
294 switch head {
295 case "anthropic", "openai", "openai-codex", "kimi", "ollama":
296 return head
297 }
298 }
299 return ""
300}