v0.17.0 949637b

rescue_dialog.go RawHistoryFolder

1package modes
2
3import (
4	"fmt"
5	"strings"
6
7	"github.com/bnema/zut/packages/provider"
8	"github.com/bnema/zut/packages/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.FGColor(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.FGColor(th.Muted, hint))
123
124	if len(d.view) == 0 {
125		if len(d.all) == 0 {
126			lines = append(lines, th.FGColor(th.Muted, "  no other models available — log in to another provider with /login"))
127		} else {
128			lines = append(lines, th.FGColor(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.FGColor(th.Muted, plain))
165		}
166	}
167	if start > 0 {
168		lines = append(lines, th.FGColor(th.Muted, fmt.Sprintf("   ... %d more above", start)))
169	}
170	if end < len(d.view) {
171		lines = append(lines, th.FGColor(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.KeyPaste:
195		d.query += singleLinePaste(k.Paste)
196		d.refilter()
197	case tui.KeyRune:
198		if k.Alt || k.Ctrl {
199			break
200		}
201		if k.Rune >= 0x20 && k.Rune < 0x7f {
202			d.query += string(k.Rune)
203			d.refilter()
204		}
205	case tui.KeyEsc:
206		d.Close()
207		return rescueDialogAction{Close: true}
208	case tui.KeyEnter:
209		if len(d.view) == 0 {
210			d.Close()
211			return rescueDialogAction{Close: true}
212		}
213		m := d.view[d.cursor]
214		prompt := d.prompt
215		d.Close()
216		return rescueDialogAction{Select: true, Provider: m.Provider, Model: m.ID, Prompt: prompt}
217	}
218	return rescueDialogAction{}
219}
220
221// classifyRescueError inspects an agent error and decides whether
222// it's a recoverable provider failure that the user should be offered
223// a rescue picker for, plus a short human-readable reason. Returns
224// (false, "") for errors we should NOT auto-rescue (bad request,
225// context length, transcript serialization issues, etc.) so the
226// regular red status banner still surfaces them.
227func classifyRescueError(err error) (bool, string) {
228	if err == nil {
229		return false, ""
230	}
231	msg := err.Error()
232	low := strings.ToLower(msg)
233
234	// Don't trigger on context overflow; that path already has its own
235	// auto-compact handling.
236	if provider.IsContextOverflowError(err) {
237		return false, ""
238	}
239
240	// Network failures.
241	if strings.Contains(low, "timeout") ||
242		strings.Contains(low, "deadline exceeded") ||
243		strings.Contains(low, "connection refused") ||
244		strings.Contains(low, "connection reset") ||
245		strings.Contains(low, "no such host") ||
246		strings.Contains(low, "tls handshake") ||
247		strings.Contains(low, "eof") {
248		return true, "network failure: " + shortError(msg)
249	}
250
251	switch {
252	case containsAny(low, "http 401", " 401:", "invalid_authentication", "token expired", "api key appears to be invalid"):
253		return true, "authentication failed: " + shortError(msg)
254	case containsAny(low, "http 403", " 403:", "permission denied", "forbidden"):
255		return true, "permission denied: " + shortError(msg)
256	case containsAny(low, "http 429", " 429:", "rate limit", "rate_limit", "too many requests", "quota"):
257		return true, "rate limited: " + shortError(msg)
258	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"):
259		return true, "provider unavailable: " + shortError(msg)
260	}
261
262	// Anything else (400 bad request, validation errors, etc.) is
263	// usually not fixed by switching models; let it surface as-is.
264	return false, ""
265}
266
267func containsAny(haystack string, needles ...string) bool {
268	for _, n := range needles {
269		if strings.Contains(haystack, n) {
270			return true
271		}
272	}
273	return false
274}
275
276// shortError trims a long http-payload error to something readable in
277// the rescue dialog header without dropping the most useful prefix.
278func shortError(msg string) string {
279	msg = strings.TrimSpace(msg)
280	const max = 140
281	if len(msg) <= max {
282		return msg
283	}
284	return msg[:max] + "..."
285}
286
287// extractFailedProvider tries to pull the failing provider name out
288// of an error string ("kimi: http 401: ...", "openai: http 503: ...").
289// Returns "" when nothing recognisable is found.
290func extractFailedProvider(err error) string {
291	if err == nil {
292		return ""
293	}
294	msg := err.Error()
295	if i := strings.Index(msg, ":"); i > 0 {
296		head := strings.TrimSpace(msg[:i])
297		switch head {
298		case "anthropic", "openai", "openai-codex", "kimi", "google", "ollama":
299			return head
300		}
301	}
302	return ""
303}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 477

↑↓ moveenter openesc close