v0.34.0 a939a2a

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 // complete diagnostic, including provider response
26	prompt   string // the user prompt that should be retried on Select
27	details  bool
28	scroll   int
29}
30
31type rescueDialogAction struct {
32	Select   bool
33	Provider string
34	Model    string
35	Prompt   string
36	Close    bool
37}
38
39func newRescueDialog() *rescueDialog { return &rescueDialog{} }
40
41// Open shows the dialog. current is the currently active model id so
42// it can be excluded from the candidate list. loggedInProviders is
43// the set of provider names with usable credentials right now.
44func (d *rescueDialog) Open(current string, loggedInProviders []string, failedProvider, failedModel, reason, prompt string) {
45	d.active = true
46	d.current = current
47	d.query = ""
48	d.details = false
49	d.scroll = 0
50	d.reason = reason
51	d.prompt = prompt
52	d.failedAt = strings.TrimSpace(failedProvider + "/" + failedModel)
53
54	all := provider.Active()
55	provSet := map[string]bool{}
56	for _, p := range loggedInProviders {
57		provSet[p] = true
58	}
59	var filtered []provider.Model
60	for _, m := range all {
61		if !provSet[m.Provider] {
62			continue
63		}
64		// Drop the exact failed pair so users can't retry on the
65		// model that just failed.
66		if m.Provider == failedProvider && m.ID == failedModel {
67			continue
68		}
69		// Drop the currently-active model id (if it differs from
70		// the failed one for some reason). The picker is meant to
71		// help the user move *off* the broken pair.
72		if m.ID == current {
73			continue
74		}
75		filtered = append(filtered, m)
76	}
77	d.all = sortedModels(filtered)
78	d.refilter()
79}
80
81func (d *rescueDialog) Close()       { d.active = false }
82func (d *rescueDialog) Active() bool { return d != nil && d.active }
83
84func (d *rescueDialog) refilter() {
85	needle := normalizeModelQuery(d.query)
86	if needle == "" {
87		d.view = append([]provider.Model(nil), d.all...)
88	} else {
89		out := make([]provider.Model, 0, len(d.all))
90		for _, m := range d.all {
91			if strings.Contains(normalizeModelQuery(m.Provider+" "+m.ID+" "+m.DisplayName), needle) {
92				out = append(out, m)
93			}
94		}
95		d.view = out
96	}
97	d.cursor = 0
98}
99
100// Render mirrors modelDialog so a rescue prompt feels identical to
101// every other picker in the TUI.
102func (d *rescueDialog) Render(th tui.Theme, width, height int) []string {
103	if !d.Active() {
104		return nil
105	}
106	var lines []string
107	header := "rescue turn"
108	if d.failedAt != "" && d.failedAt != "/" {
109		header = "rescue turn — " + d.failedAt + " failed"
110	}
111	if d.details {
112		header = "provider error"
113	}
114	lines = append(lines, frameHeader(th, header, width))
115
116	// The picker keeps a bounded preview; the details view retains the entire
117	// diagnostic and owns its viewport rather than letting the pane clip it.
118	errorRows := wrapDialogTextRows(sanitizeSessionTreeText(d.reason), width)
119	if d.details {
120		hint := wrapDialogTextRows("↑/↓ or pgup/pgdn: scroll; tab/esc: back", width)
121		hint = hint[:min(len(hint), max(0, height-1))]
122		visible := max(1, height-len(hint))
123		d.scroll = min(max(0, d.scroll), max(0, len(errorRows)-visible))
124		for _, row := range hint {
125			lines = append(lines, th.FGColor(th.Muted, row))
126		}
127		for _, row := range errorRows[d.scroll:min(len(errorRows), d.scroll+visible)] {
128			lines = append(lines, th.FGColor(th.Warning, row))
129		}
130		return append(lines, frameRule(th, width))
131	}
132	if d.reason != "" {
133		for _, row := range errorRows[:min(3, len(errorRows))] {
134			lines = append(lines, th.FGColor(th.Warning, row))
135		}
136		for _, row := range wrapDialogTextRows("tab: full error", width) {
137			lines = append(lines, th.FGColor(th.Muted, row))
138		}
139	}
140
141	hint := "retry this turn with another model (↑/↓, enter, esc to cancel)"
142	if d.query != "" {
143		hint = fmt.Sprintf("filter: %s (%d match)", d.query, len(d.view))
144		if len(d.view) != 1 {
145			hint = fmt.Sprintf("filter: %s (%d matches)", d.query, len(d.view))
146		}
147	} else {
148		hint += " - type to filter"
149	}
150	lines = append(lines, th.FGColor(th.Muted, hint))
151
152	if len(d.view) == 0 {
153		if len(d.all) == 0 {
154			lines = append(lines, th.FGColor(th.Muted, "  no other models available — log in to another provider with /login"))
155		} else {
156			lines = append(lines, th.FGColor(th.Muted, "  no models match "+fmt.Sprintf("%q", d.query)))
157		}
158		lines = append(lines, frameRule(th, width))
159		return lines
160	}
161
162	const visible = 12
163	start := 0
164	end := len(d.view)
165	if end > visible {
166		start = d.cursor - visible/2
167		if start < 0 {
168			start = 0
169		}
170		if start+visible > end {
171			start = end - visible
172		}
173		end = start + visible
174	}
175	for i := start; i < end; i++ {
176		m := d.view[i]
177		reason := " "
178		if m.Reasoning {
179			reason = "✦"
180		}
181		tag := ""
182		switch {
183		case m.Speculative:
184			tag = "[speculative] "
185		case m.Source == "live":
186			tag = "[live] "
187		}
188		plain := fmt.Sprintf("   %-10s %-28s %s  %s%s", m.Provider, m.ID, reason, tag, m.DisplayName)
189		if i == d.cursor {
190			lines = append(lines, th.PadHighlight(plain, width))
191		} else {
192			lines = append(lines, th.FGColor(th.Muted, plain))
193		}
194	}
195	if start > 0 {
196		lines = append(lines, th.FGColor(th.Muted, fmt.Sprintf("   ... %d more above", start)))
197	}
198	if end < len(d.view) {
199		lines = append(lines, th.FGColor(th.Muted, fmt.Sprintf("   ... %d more below", len(d.view)-end)))
200	}
201
202	lines = append(lines, frameRule(th, width))
203	return lines
204}
205
206func (d *rescueDialog) HandleKey(k tui.Key) rescueDialogAction {
207	if k.Kind == tui.KeyTab {
208		d.details = !d.details
209		d.scroll = 0
210		return rescueDialogAction{}
211	}
212	if d.details {
213		switch k.Kind {
214		case tui.KeyEsc:
215			d.details = false
216		case tui.KeyUp, tui.KeyMouseWheelUp:
217			d.scroll = max(0, d.scroll-1)
218		case tui.KeyDown, tui.KeyMouseWheelDown:
219			d.scroll++
220		case tui.KeyPageUp:
221			d.scroll = max(0, d.scroll-8)
222		case tui.KeyPageDown:
223			d.scroll += 8
224		case tui.KeyHome:
225			d.scroll = 0
226		}
227		return rescueDialogAction{}
228	}
229	switch k.Kind {
230	case tui.KeyUp:
231		if d.cursor > 0 {
232			d.cursor--
233		}
234	case tui.KeyDown:
235		if d.cursor < len(d.view)-1 {
236			d.cursor++
237		}
238	case tui.KeyBackspace:
239		if len(d.query) > 0 {
240			r := []rune(d.query)
241			d.query = string(r[:len(r)-1])
242			d.refilter()
243		}
244	case tui.KeyPaste:
245		d.query += singleLinePaste(k.Paste)
246		d.refilter()
247	case tui.KeyRune:
248		if k.Alt || k.Ctrl {
249			break
250		}
251		if k.Rune >= 0x20 && k.Rune < 0x7f {
252			d.query += string(k.Rune)
253			d.refilter()
254		}
255	case tui.KeyEsc:
256		d.Close()
257		return rescueDialogAction{Close: true}
258	case tui.KeyEnter:
259		if len(d.view) == 0 {
260			d.Close()
261			return rescueDialogAction{Close: true}
262		}
263		m := d.view[d.cursor]
264		prompt := d.prompt
265		d.Close()
266		return rescueDialogAction{Select: true, Provider: m.Provider, Model: m.ID, Prompt: prompt}
267	}
268	return rescueDialogAction{}
269}
270
271// classifyRescueError inspects an agent error and decides whether
272// it's a recoverable provider failure that the user should be offered
273// a rescue picker for, plus a category and the complete diagnostic. Returns
274// (false, "") for errors we should NOT auto-rescue (bad request,
275// context length, transcript serialization issues, etc.) so the
276// regular red status banner still surfaces them.
277func classifyRescueError(err error) (bool, string) {
278	if err == nil {
279		return false, ""
280	}
281	msg := err.Error()
282	low := strings.ToLower(msg)
283
284	// Don't trigger on context overflow; that path already has its own
285	// auto-compact handling.
286	if provider.IsContextOverflowError(err) {
287		return false, ""
288	}
289
290	// Network failures.
291	if strings.Contains(low, "timeout") ||
292		strings.Contains(low, "deadline exceeded") ||
293		strings.Contains(low, "connection refused") ||
294		strings.Contains(low, "connection reset") ||
295		strings.Contains(low, "no such host") ||
296		strings.Contains(low, "tls handshake") ||
297		strings.Contains(low, "eof") {
298		return true, "network failure: " + msg
299	}
300
301	switch {
302	case containsAny(low, "http 401", " 401:", "invalid_authentication", "token expired", "api key appears to be invalid"):
303		return true, "authentication failed: " + msg
304	case containsAny(low, "http 403", " 403:", "permission denied", "forbidden"):
305		return true, "permission denied: " + msg
306	case containsAny(low, "http 429", " 429:", "rate limit", "rate_limit", "too many requests", "quota"):
307		return true, "rate limited: " + msg
308	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"):
309		return true, "provider unavailable: " + msg
310	}
311
312	// Anything else (400 bad request, validation errors, etc.) is
313	// usually not fixed by switching models; let it surface as-is.
314	return false, ""
315}
316
317func containsAny(haystack string, needles ...string) bool {
318	for _, n := range needles {
319		if strings.Contains(haystack, n) {
320			return true
321		}
322	}
323	return false
324}
325
326// extractFailedProvider tries to pull the failing provider name out
327// of an error string ("kimi: http 401: ...", "openai: http 503: ...").
328// Returns "" when nothing recognisable is found.
329func extractFailedProvider(err error) string {
330	if err == nil {
331		return ""
332	}
333	msg := err.Error()
334	if i := strings.Index(msg, ":"); i > 0 {
335		head := strings.TrimSpace(msg[:i])
336		switch head {
337		case "anthropic", "openai", "openai-codex", "kimi", "google", "ollama":
338			return head
339		}
340	}
341	return ""
342}

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