v0.51.0 a9d674e

interactive_commands.go RawHistoryFolder

1package modes
2
3import (
4	"context"
5	"encoding/json"
6	"errors"
7	"fmt"
8	"os"
9	"path/filepath"
10	"strings"
11	"time"
12
13	"github.com/bnema/zut/packages/agent/skills"
14	"github.com/bnema/zut/packages/agent/tools"
15	"github.com/bnema/zut/packages/core"
16	"github.com/bnema/zut/packages/provider"
17	"github.com/bnema/zut/packages/provider/auth"
18	"github.com/bnema/zut/packages/tui"
19)
20
21func buildStudyPrompt(arg, cwd string) string {
22	arg = strings.TrimSpace(arg)
23	if arg == "" {
24		return "Read and understand everything in the current directory."
25	}
26	abs := arg
27	if !filepath.IsAbs(abs) {
28		abs = filepath.Join(cwd, abs)
29	}
30	display := arg
31	if rel, err := filepath.Rel(cwd, abs); err == nil && !strings.HasPrefix(rel, "..") {
32		display = rel
33	}
34	if info, err := os.Stat(abs); err == nil && !info.IsDir() {
35		return "Read and understand the file " + display + "."
36	}
37	return "Read and understand everything in the directory " + display + "."
38}
39func tryPathTabCompleteEditor(ed *tui.Editor, cwd string) bool {
40	if ed == nil {
41		return false
42	}
43	val := ed.Value()
44	// Find the trailing run of non-whitespace.
45	start := len(val)
46	for start > 0 {
47		r := val[start-1]
48		if r == ' ' || r == '\t' || r == '\n' {
49			break
50		}
51		start--
52	}
53	token := val[start:]
54	if token == "" {
55		return false
56	}
57	if !looksLikePathToken(token) {
58		return false
59	}
60
61	// Resolve the absolute parent directory + base prefix to match.
62	parentAbs, basePrefix, displayParent, ok := resolvePathTabToken(token, cwd)
63	if !ok {
64		return true
65	}
66	entries, err := os.ReadDir(parentAbs)
67	if err != nil {
68		return true
69	}
70	var names []string
71	var isDir []bool
72	for _, e := range entries {
73		name := e.Name()
74		if !strings.HasPrefix(name, basePrefix) {
75			continue
76		}
77		// Hide dotfiles unless the user explicitly typed a leading dot,
78		// mirroring bash's default behaviour.
79		if strings.HasPrefix(name, ".") && !strings.HasPrefix(basePrefix, ".") {
80			continue
81		}
82		names = append(names, name)
83		isDir = append(isDir, e.IsDir())
84	}
85	if len(names) == 0 {
86		return true
87	}
88
89	var completed string
90	var completedIsDir bool
91	if len(names) == 1 {
92		completed = names[0]
93		completedIsDir = isDir[0]
94	} else {
95		completed = longestCommonPrefix(names)
96		if completed == basePrefix {
97			// Already at the deepest unambiguous prefix; nothing to add.
98			return true
99		}
100	}
101
102	// Build the replacement token in the same display form the user
103	// typed (preserve ~ vs absolute vs relative).
104	newToken := displayParent + completed
105	if len(names) == 1 && completedIsDir {
106		newToken += "/"
107	}
108
109	ed.SetValue(val[:start] + newToken)
110	return true
111}
112func (i *Interactive) tryPathTabComplete() bool {
113	if tryPathTabCompleteEditor(i.ed, i.cfg.CWD) {
114		i.invalidate()
115		return true
116	}
117	return false
118}
119func looksLikePathToken(tok string) bool {
120	if tok == "" {
121		return false
122	}
123	if tok[0] == '~' || tok[0] == '/' {
124		return true
125	}
126	if strings.HasPrefix(tok, "./") || strings.HasPrefix(tok, "../") {
127		return true
128	}
129	return strings.Contains(tok, "/")
130}
131func resolvePathTabToken(tok, cwd string) (parentAbs, basePrefix, displayParent string, ok bool) {
132	// Detect ~ expansion.
133	expanded := tok
134	homePrefix := ""
135	if tok == "~" {
136		home, err := os.UserHomeDir()
137		if err != nil || home == "" {
138			return "", "", "", false
139		}
140		// "~" alone: complete in $HOME. parent = home, base = "".
141		return home, "", "~/", true
142	}
143	if strings.HasPrefix(tok, "~/") {
144		home, err := os.UserHomeDir()
145		if err != nil || home == "" {
146			return "", "", "", false
147		}
148		expanded = home + tok[1:]
149		homePrefix = "~"
150	}
151
152	dir, base := splitDirBase(expanded)
153	if !filepath.IsAbs(dir) {
154		dir = filepath.Join(cwd, dir)
155	}
156
157	// Reconstruct the display form the user typed for the parent,
158	// keeping ~ when they used it. The base is dropped — the caller
159	// substitutes the completed name.
160	display := tok[:len(tok)-len(base)]
161	if homePrefix != "" && !strings.HasPrefix(display, "~") {
162		display = homePrefix + display[len(homePrefix):]
163	}
164	return dir, base, display, true
165}
166func splitDirBase(p string) (dir, base string) {
167	if p == "" {
168		return ".", ""
169	}
170	i := strings.LastIndex(p, "/")
171	if i < 0 {
172		return ".", p
173	}
174	return p[:i+1], p[i+1:]
175}
176func longestCommonPrefix(ss []string) string {
177	if len(ss) == 0 {
178		return ""
179	}
180	prefix := ss[0]
181	for _, s := range ss[1:] {
182		n := 0
183		for n < len(prefix) && n < len(s) && prefix[n] == s[n] {
184			n++
185		}
186		prefix = prefix[:n]
187		if prefix == "" {
188			return ""
189		}
190	}
191	return prefix
192}
193func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) {
194	parts := strings.Fields(cmd)
195	if len(parts) == 0 {
196		return false
197	}
198
199	if isSkillCommand(cmd) {
200		var available []*skills.Skill
201		if i.cfg.SkillSnapshot != nil {
202			available = i.cfg.SkillSnapshot()
203		}
204		prompt, _, err := expandSkillCommand(cmd, available)
205		if err != nil {
206			i.mu.Lock()
207			i.statusErr = err.Error()
208			i.statusOK = ""
209			i.mu.Unlock()
210			return false
211		}
212		i.submitOrQueuePrompt(ctx, prompt)
213		return false
214	}
215
216	switch strings.ToLower(parts[0]) {
217	case "/goal":
218		i.runGoalCommand(ctx, cmd, parts)
219	case "/exit":
220		return true
221	case "/clear":
222		// The bound session may still hold a plan core no longer has (a failed or
223		// skipped seed, a stray write). Read it before taking i.mu: the callback
224		// takes the host persistence lock, which must not nest under the mode
225		// lock. It is part of the clear decision so a divergence still clears the
226		// file.
227		persistedPlan := i.cfg.PersistedPlan != nil && i.cfg.PersistedPlan() != nil
228		// Pinned-skill discovery and preference loads are file I/O as well:
229		// prefetch them beside the plan read, publish under the lock below.
230		pinFetch, pinDone := i.lockWithSkillPinFetch()
231		// Read i.agent under i.mu, the same field the locked Agent accessor
232		// returns; the lock-free read this replaced could race a session swap.
233		clearPlan := i.agent != nil && len(i.agent.CurrentPlan()) > 0
234		if i.agent != nil {
235			i.agent.SetMessages(nil)
236			i.agent.ResetRepetitionGuard()
237			i.agent.SetPlan(nil)
238		}
239		i.toolCalls = map[string]*tui.ToolCallView{}
240		i.toolOrder = nil
241		i.stream.Reset()
242		i.statusErr = ""
243		i.statusOK = ""
244		i.helpBlock = nil
245		i.sessionInfoBlocks = nil
246		i.parkedTurn = 0
247		i.parkedTotal = 0
248		i.lastCtxInput = 0
249		i.scrollOffset = 0
250		i.extNotes = nil
251		i.reloadErrors = nil
252		i.planSnapshots = nil
253		i.planCurrent = 0
254		i.planTotal = 0
255		i.planRevision++
256		handoff, persistHandoff := i.resetCompactContinuationLocked()
257		i.view.InvalidateRenderCache()
258		// A cleared conversation gets its pinned skills again.
259		i.armSkillPinsLocked(pinFetch)
260		i.mu.Unlock()
261		pinDone()
262		if persistHandoff {
263			i.persistCompactHandoff(handoff)
264		}
265		// Persist outside the lock: session writes are file I/O. Only write when
266		// a plan existed in core or on disk, so /clear in --no-session mode stays
267		// a no-op there.
268		if (clearPlan || persistedPlan) && i.cfg.PersistPlan != nil {
269			if err := i.cfg.PersistPlan(nil); err != nil {
270				i.ReportError(fmt.Errorf("clear plan: %w", err))
271			}
272		}
273	case "/help":
274		i.mu.Lock()
275		i.helpBlock = renderHelpBlock(i.cfg.Theme, i.lastCols(), i.llamaConfigured)
276		i.statusErr = ""
277		i.statusOK = ""
278		// Pin the viewport to the newest content so the help block,
279		// which we just appended to the end of the transcript, is
280		// what the user actually sees.
281		i.scrollOffset = 0
282		i.mu.Unlock()
283	case "/info":
284		i.showSessionInfo()
285	case "/mcp":
286		i.showMCPStatus()
287	case "/login":
288		i.dialog.Open(i.cfg.ZutHome)
289	case "/logout":
290		if len(parts) >= 2 {
291			// Explicit target: /logout anthropic | openai | all
292			i.doLogout(parts[1])
293			break
294		}
295		// No arg: open the picker over whichever providers are
296		// currently logged in. If nothing's logged in, bail with a
297		// status line.
298		i.openLogoutDialog()
299	case "/model":
300		if len(parts) >= 2 {
301			i.applyModelSelection("", parts[1])
302		} else if i.modelRefreshNeeded() {
303			i.mu.Lock()
304			i.modelRefreshing = true
305			i.statusOK = "Refreshing models"
306			i.statusErr = ""
307			i.mu.Unlock()
308			go func() {
309				err := i.refreshModelCatalogs(ctx)
310				select {
311				case i.modelRefresh <- modelRefreshResult{err: err}:
312				case <-ctx.Done():
313				}
314			}()
315		} else {
316			i.openModelPickerAfterRefresh(nil)
317		}
318	case "/llama":
319		if i.cfg.LlamaCPPConfig == nil {
320			i.mu.Lock()
321			i.statusErr = "llama.cpp configuration is unavailable"
322			i.statusOK = ""
323			i.mu.Unlock()
324			break
325		}
326		baseURL, apiKey, configErr := i.cfg.LlamaCPPConfig()
327		if configErr == nil {
328			configErr = i.llamaDialog.Open(baseURL, apiKey, i.invalidate)
329		}
330		if configErr != nil {
331			i.mu.Lock()
332			i.statusErr = configErr.Error()
333			i.statusOK = ""
334			i.mu.Unlock()
335		}
336	case "/profile":
337		if len(parts) != 2 || len(parts[1]) != 1 || parts[1][0] < '1' || parts[1][0] > '9' {
338			i.mu.Lock()
339			i.statusErr, i.statusOK = "usage: /profile <1-9>", ""
340			i.mu.Unlock()
341			break
342		}
343		i.applyQuickModelShortcut(int(parts[1][0] - '0'))
344	case "/reasoning":
345		i.openReasoningDialog()
346	case "/fast":
347		enabled := i.cfg.FastMode == nil || !*i.cfg.FastMode
348		i.applySettingToggle("fast_mode", enabled)
349	case "/orchestrator":
350		enabled := i.cfg.AutoSubagentsEnabled == nil || !*i.cfg.AutoSubagentsEnabled
351		i.applySettingToggle("auto_subagents_enabled", enabled)
352	case "/settings":
353		i.openSettingsDialog()
354	case "/sessions":
355		if i.cfg.SessionsDisabled {
356			i.mu.Lock()
357			i.statusErr = "sessions are disabled by --no-session"
358			i.statusOK = ""
359			i.mu.Unlock()
360			break
361		}
362		i.mu.Lock()
363		i.sessionLoads = i.sessionDialog.Open(ctx, i.sessionsRoot(), i.cfg.CWD)
364		i.sessionSearches = nil
365		i.mu.Unlock()
366	case "/fork":
367		i.doSessionFork()
368	case "/jump":
369		i.openJumpDialog(parts[1:])
370	case "/btw":
371		i.openBtwDialog(parts[1:])
372	case "/skills":
373		i.openSkillsDialog()
374	case "/compact":
375		i.runCompact(ctx, compactContinuationRequest{origin: compactOriginManual})
376	case "/study":
377		// Canned prompt that tells the agent to read every file
378		// in some target so its later turns have the whole thing
379		// in context. With no argument, the target is the current
380		// directory. With an argument, the target is whatever the
381		// user passed — typed by hand, drag-dropped, or selected
382		// via the @ file picker (which is why we accept both files
383		// and directories; the @-picker chips for both have already
384		// been expanded to absolute paths by expandFileChips above).
385		// Dispatched through the normal queue-or-start path so it
386		// behaves identically to typing the prompt by hand.
387		studyPrompt := buildStudyPrompt(strings.TrimSpace(strings.TrimPrefix(cmd, parts[0])), i.cfg.CWD)
388		i.mu.Lock()
389		busy := i.busy
390		compacting := i.compacting
391		ag := i.agent
392		i.mu.Unlock()
393		if busy {
394			var handoff json.RawMessage
395			var persistHandoff bool
396			if ag != nil && !compacting {
397				i.mu.Lock()
398				handoff, persistHandoff = i.resetCompactContinuationLocked()
399				ag.QueueMessage(studyPrompt, nil)
400				i.mu.Unlock()
401			} else {
402				i.mu.Lock()
403				handoff, persistHandoff = i.resetCompactContinuationLocked()
404				i.queued = append(i.queued, core.QueuedMessage{Text: studyPrompt})
405				i.mu.Unlock()
406			}
407			if persistHandoff {
408				i.persistCompactHandoff(handoff)
409			}
410			i.invalidate()
411			break
412		}
413		i.startTurn(ctx, studyPrompt)
414	case "/cd":
415		// Hidden command: switch the running session's cwd. Not in
416		// slash_suggest, not in /help. Used by the workspaces
417		// extension's panel-key Enter handler so picking a row
418		// jumps zut into that directory without relaunching.
419		//
420		// Recovers the raw argument (path) from the original cmd
421		// string rather than parts, so paths with spaces survive.
422		// The host's ChangeCWD hook handles validation, session
423		// close + reopen, agent rebuild, sandbox re-rooting, and
424		// re-jail-if-jailed semantics.
425		if i.cfg.ChangeCWD == nil {
426			i.mu.Lock()
427			i.statusErr = "/cd unavailable: host did not wire ChangeCWD"
428			i.mu.Unlock()
429			break
430		}
431		path := strings.TrimSpace(strings.TrimPrefix(cmd, parts[0]))
432		if path == "" {
433			i.mu.Lock()
434			i.statusErr = "/cd: missing path"
435			i.mu.Unlock()
436			break
437		}
438		if err := i.cfg.ChangeCWD(path); err != nil {
439			i.mu.Lock()
440			i.statusErr = "/cd: " + err.Error()
441			i.statusOK = ""
442			i.mu.Unlock()
443			break
444		}
445		// ChangeCWD has already updated i.cfg.CWD and swapped the
446		// agent + session. Reset transient TUI state so the new
447		// session opens clean.
448		i.mu.Lock()
449		i.toolCalls = map[string]*tui.ToolCallView{}
450		i.toolOrder = nil
451		i.stream.Reset()
452		i.helpBlock = nil
453		i.sessionInfoBlocks = nil
454		i.parkedTurn = 0
455		i.statusOK = "cwd " + i.cfg.CWD
456		i.statusErr = ""
457		i.mu.Unlock()
458		i.fileSuggest.Reset()
459		i.fileSuggest.SetCWD(i.cfg.CWD)
460		i.invalidate()
461	case "/jail":
462		if i.cfg.Sandbox == nil {
463			i.mu.Lock()
464			i.statusErr = "sandbox not available in this build"
465			i.mu.Unlock()
466			break
467		}
468		i.cfg.Sandbox.Lock()
469		i.mu.Lock()
470		i.statusOK = "jailed to " + i.cfg.CWD + " (tools cannot touch paths outside this directory)"
471		i.statusErr = ""
472		i.mu.Unlock()
473	case "/unjail":
474		if i.cfg.Sandbox == nil {
475			i.mu.Lock()
476			i.statusErr = "sandbox not available in this build"
477			i.mu.Unlock()
478			break
479		}
480		i.cfg.Sandbox.Unlock()
481		i.mu.Lock()
482		i.statusOK = "unjailed"
483		i.statusErr = ""
484		i.mu.Unlock()
485	case "/reload-ext":
486		i.runReloadExt(ctx)
487	case "/telegram", "/tg":
488		if len(parts) >= 2 {
489			i.doTelegram(parts[1])
490			break
491		}
492		i.openTelegramDialog()
493	case "/session":
494		if len(parts) >= 2 {
495			action := parts[1]
496			arg := ""
497			if len(parts) >= 3 {
498				arg = strings.Join(parts[2:], " ")
499			}
500			i.doSessionOp(action, arg)
501			break
502		}
503		i.openSessionOpsDialog()
504	case "/subagents":
505		i.runSubagents(ctx, parts[1:])
506	default:
507		// Last-resort fallback: try the extension manager. Built-in
508		// cases above always win; this branch only fires for slash
509		// commands the extension manager registered. Same routing as
510		// the editor's submit-handler dispatch path so the autocomplete
511		// "enter on highlighted suggestion" flow also works.
512		extName := strings.TrimPrefix(parts[0], "/")
513		if i.cfg.Extensions != nil && i.cfg.Extensions.HasCommand(extName) {
514			rest := ""
515			if len(parts) > 1 {
516				rest = strings.Join(parts[1:], " ")
517			}
518			go i.invokeExtensionCommand(ctx, extName, rest)
519			return false
520		}
521		i.mu.Lock()
522		i.statusErr = "unknown command: " + parts[0]
523		i.mu.Unlock()
524	}
525	return false
526}
527
528// modelRefreshNeeded reports whether /model must synchronize an optional
529// transient catalog (llama.cpp router or opted-in custom discovery) first.
530func (i *Interactive) modelRefreshNeeded() bool {
531	if i.llamaConfigured && i.cfg.RefreshLlamaCPPModels != nil {
532		return true
533	}
534	return i.customDiscoveryEnabled()
535}
536
537func (i *Interactive) customDiscoveryEnabled() bool {
538	return i.cfg.RefreshCustomProviderModels != nil && i.cfg.CustomDiscoveryConfigured != nil && i.cfg.CustomDiscoveryConfigured()
539}
540
541// refreshModelCatalogs runs each applicable refresh under its own 3 second
542// deadline, so one slow source cannot starve the other. Failures are joined;
543// the picker still opens with whatever catalog is available.
544func (i *Interactive) refreshModelCatalogs(ctx context.Context) error {
545	var errs []error
546	run := func(refresh func(context.Context) error) {
547		refreshCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
548		defer cancel()
549		if err := refresh(refreshCtx); err != nil {
550			errs = append(errs, err)
551		}
552	}
553	if i.llamaConfigured && i.cfg.RefreshLlamaCPPModels != nil {
554		run(i.cfg.RefreshLlamaCPPModels)
555	}
556	if i.customDiscoveryEnabled() {
557		run(i.cfg.RefreshCustomProviderModels)
558	}
559	return errors.Join(errs...)
560}
561
562func (i *Interactive) openModelPickerAfterRefresh(refreshErr error) {
563	var loggedIn []string
564	if i.cfg.LoggedInProviders != nil {
565		loggedIn = i.cfg.LoggedInProviders()
566	}
567	i.modelDialog.Open(i.cfg.Model, loggedIn, i.cfg.Reasoning)
568	i.mu.Lock()
569	if refreshErr != nil {
570		i.statusErr = "model refresh: " + refreshErr.Error()
571		i.statusOK = ""
572	} else if i.statusOK == "Refreshing models" {
573		i.statusOK = ""
574	}
575	i.mu.Unlock()
576}
577func (i *Interactive) openLogoutDialog() {
578	if i.cfg.AuthManager == nil {
579		i.mu.Lock()
580		i.statusErr = "no auth manager configured"
581		i.mu.Unlock()
582		i.invalidate()
583		return
584	}
585	store := i.cfg.AuthManager.Store()
586	if store == nil {
587		i.mu.Lock()
588		i.statusErr = "auth store is not available"
589		i.mu.Unlock()
590		i.invalidate()
591		return
592	}
593	creds, err := store.Load()
594	if err != nil {
595		i.mu.Lock()
596		i.statusErr = "read auth store: " + err.Error()
597		i.mu.Unlock()
598		i.invalidate()
599		return
600	}
601
602	var items []logoutItem
603	for _, p := range []string{"anthropic", "kimi", "google", "github-copilot"} {
604		if creds.Has(p) {
605			method := creds.Method(p)
606			if method == "oauth" {
607				method = "subscription"
608			}
609			items = append(items, logoutItem{
610				label:  providerLabel(p),
611				target: p,
612				method: method,
613			})
614		}
615	}
616	if creds.OpenAI.APIKey != "" || creds.OpenAI.APIKeyCommand != nil {
617		items = append(items, logoutItem{label: providerLabel("openai"), target: "openai", method: "api key"})
618	}
619	if creds.OpenAI.OAuth != nil {
620		items = append(items, logoutItem{label: providerLabel("openai-codex"), target: "openai-codex", method: "subscription"})
621	}
622	for p, c := range creds.AdditionalAPIKeyCreds {
623		if c.APIKey != "" || c.APIKeyCommand != nil || c.BaseURL != "" {
624			items = append(items, logoutItem{label: providerLabel(p), target: p, method: "api key"})
625		}
626	}
627	if len(items) == 0 {
628		i.mu.Lock()
629		i.statusOK = "no credentials stored; already logged out"
630		i.statusErr = ""
631		i.mu.Unlock()
632		i.invalidate()
633		return
634	}
635	if len(items) > 1 {
636		items = append(items, logoutItem{label: "all", target: "all"})
637	}
638
639	i.logoutDialog.Open(items)
640	i.invalidate()
641}
642func (i *Interactive) doLogout(target string) {
643	if i.cfg.AuthManager == nil {
644		i.mu.Lock()
645		i.statusErr = "no auth manager configured"
646		i.mu.Unlock()
647		return
648	}
649	store := i.cfg.AuthManager.Store()
650	if store == nil {
651		i.mu.Lock()
652		i.statusErr = "auth store is not available"
653		i.mu.Unlock()
654		return
655	}
656
657	var providers []string
658	switch target {
659	case "", "all":
660		providers = append([]string{"anthropic", "openai", "openai-codex", "kimi", "google", "github-copilot"}, auth.APIKeyProviders()...)
661	case "anthropic", "openai", "openai-codex", "kimi", "google", "github-copilot":
662		providers = []string{target}
663	default:
664		known := false
665		for _, p := range auth.APIKeyProviders() {
666			if target == p {
667				known = true
668				break
669			}
670		}
671		if !known {
672			i.mu.Lock()
673			i.statusErr = "unknown provider: " + target
674			i.mu.Unlock()
675			return
676		}
677		providers = []string{target}
678	}
679
680	var errs []string
681	clearedCurrent := false
682	for _, p := range providers {
683		var err error
684		switch p {
685		case "openai":
686			err = store.ClearAPIKey("openai")
687		case "openai-codex":
688			err = store.ClearOAuth("openai")
689		default:
690			err = store.Clear(p)
691		}
692		if err != nil {
693			errs = append(errs, p+": "+err.Error())
694			continue
695		}
696		if p == "kimi" && i.cfg.SetKimiCLIFallbackDisabled != nil {
697			if err := i.cfg.SetKimiCLIFallbackDisabled(true); err != nil {
698				errs = append(errs, p+": "+err.Error())
699				continue
700			}
701		}
702		if p == i.cfg.Provider {
703			clearedCurrent = true
704		}
705	}
706
707	llamaConfigured := i.llamaConfigured
708	if i.cfg.LlamaCPPConfig != nil {
709		baseURL, _, err := i.cfg.LlamaCPPConfig()
710		llamaConfigured = err == nil && baseURL != ""
711	}
712
713	i.mu.Lock()
714	i.llamaConfigured = llamaConfigured
715	if len(errs) > 0 {
716		i.statusErr = "logout errors: " + strings.Join(errs, "; ")
717		i.mu.Unlock()
718		return
719	}
720	i.statusErr = ""
721	var handoff json.RawMessage
722	var persistHandoff bool
723	if clearedCurrent {
724		// The running agent was using a credential we just wiped. Drop
725		// it so prompts can't go out with the stale client, and hint at
726		// /login. Close any LSP processes owned by its tools first.
727		if i.agent != nil {
728			_ = tools.CloseLSPManagers(i.agent.ToolsSnapshot())
729		}
730		i.agent = nil
731		handoff, persistHandoff = i.resetCompactContinuationLocked()
732		i.statusOK = "logged out of " + strings.Join(providers, ", ") + ". type /login to sign back in."
733	} else {
734		i.statusOK = "logged out of " + strings.Join(providers, ", ")
735	}
736	i.mu.Unlock()
737	if persistHandoff {
738		i.persistCompactHandoff(handoff)
739	}
740}
741func providerSetupInfo(provider string) (string, []string, bool) {
742	const docsURL = "https://raw.githubusercontent.com/bnema/zut/main/docs/providers.md"
743	switch provider {
744	case "amazon-bedrock":
745		return "Amazon Bedrock setup", []string{
746			"Amazon Bedrock uses AWS credentials instead of a generic zut API-key entry.",
747			"Configure an AWS profile, IAM keys, bearer token, or role-based credentials.",
748			"",
749			"For Bedrock API keys, set:",
750			"  AWS_BEARER_TOKEN_BEDROCK=...",
751			"  AWS_REGION=us-east-1",
752			"",
753			"Docs:",
754			"  " + docsURL,
755		}, true
756	case "google-vertex":
757		return "Google Vertex AI setup", []string{
758			"Google Vertex AI usually uses Google Cloud credentials and project settings.",
759			"Set a Google API key, application-default credentials, or a service account.",
760			"",
761			"Common environment:",
762			"  GOOGLE_CLOUD_API_KEY=...",
763			"  GOOGLE_CLOUD_PROJECT=...",
764			"  GOOGLE_CLOUD_LOCATION=us-central1",
765			"",
766			"Docs:",
767			"  " + docsURL,
768		}, true
769	case "cloudflare-workers-ai":
770		return "Cloudflare Workers AI setup", []string{
771			"Cloudflare Workers AI needs both an API token and an account ID.",
772			"",
773			"Set:",
774			"  CLOUDFLARE_API_KEY=...",
775			"  CLOUDFLARE_ACCOUNT_ID=...",
776			"",
777			"Docs:",
778			"  " + docsURL,
779		}, true
780	case "cloudflare-ai-gateway":
781		return "Cloudflare AI Gateway setup", []string{
782			"Cloudflare AI Gateway needs an API token, account ID, and gateway ID.",
783			"",
784			"Set:",
785			"  CLOUDFLARE_API_KEY=...",
786			"  CLOUDFLARE_ACCOUNT_ID=...",
787			"  CLOUDFLARE_GATEWAY_ID=...",
788			"",
789			"Docs:",
790			"  " + docsURL,
791		}, true
792	case "azure-openai-responses":
793		return "Azure OpenAI Responses setup", []string{
794			"Azure OpenAI needs an API key plus your Azure endpoint or deployment setup.",
795			"",
796			"Set:",
797			"  AZURE_OPENAI_API_KEY=...",
798			"  AZURE_OPENAI_BASE_URL=https://your-resource.openai.azure.com",
799			"  AZURE_OPENAI_API_VERSION=2024-02-01",
800			"",
801			"Docs:",
802			"  " + docsURL,
803		}, true
804	default:
805		return "", nil, false
806	}
807}
808func (i *Interactive) saveLlamaCPPLogin(baseURL, apiKey string) {
809	if i.cfg.AuthManager == nil || i.cfg.AuthManager.Store() == nil {
810		i.dialog.ShowResult(false, "auth store is unavailable")
811		return
812	}
813	if err := i.cfg.AuthManager.Store().SetEndpointCredential(provider.LlamaCPPProviderID, baseURL, apiKey); err != nil {
814		i.dialog.ShowResult(false, err.Error())
815		return
816	}
817	i.mu.Lock()
818	i.llamaConfigured = true
819	i.statusErr = ""
820	i.statusOK = "configured llama.cpp router"
821	i.mu.Unlock()
822	i.dialog.ShowResult(true, "")
823}
824func (i *Interactive) startAPIKeyFlow(provider string) {
825	if title, lines, ok := providerSetupInfo(provider); ok {
826		i.dialog.ShowInfo(title, lines)
827		return
828	}
829	if provider == "kimi" && i.cfg.SetKimiCLIFallbackDisabled != nil {
830		_ = i.cfg.SetKimiCLIFallbackDisabled(false)
831	}
832	url, err := i.cfg.AuthManager.StartAPIKey(provider)
833	if err != nil {
834		i.dialog.ShowResult(false, err.Error())
835		return
836	}
837	i.dialog.ShowWaiting(url)
838}
839func (i *Interactive) startOAuthFlow(provider string) {
840	if provider == "kimi" && i.cfg.SetKimiCLIFallbackDisabled != nil {
841		_ = i.cfg.SetKimiCLIFallbackDisabled(false)
842	}
843	// Device-code providers already support headless login and must only
844	// start one polling flow.
845	if provider == "kimi" || provider == "xai" || provider == "github-copilot" {
846		loginURL, err := i.cfg.AuthManager.StartOAuth(provider)
847		if err != nil {
848			i.dialog.ShowResult(false, err.Error())
849			return
850		}
851		i.dialog.ShowWaiting(loginURL)
852		return
853	}
854	// One browser transaction: the callback server and the pasted-code path
855	// share the same PKCE verifier and state inside the auth manager.
856	loginURL, err := i.cfg.AuthManager.StartOAuth(provider)
857	if err != nil {
858		i.dialog.ShowResult(false, err.Error())
859		return
860	}
861	i.dialog.ShowWaiting(loginURL)
862}
863func (i *Interactive) startManualOAuthFlow(provider string) {
864	if i.cfg.AuthManager == nil {
865		return
866	}
867	i.cfg.AuthManager.CancelOAuth()
868	url, err := i.cfg.AuthManager.StartManualOAuth(provider)
869	if err != nil {
870		i.dialog.ShowResult(false, err.Error())
871		return
872	}
873	i.dialog.ShowPasteCode(url)
874	i.invalidate()
875}
876func (i *Interactive) submitManualOAuthCode(code string) {
877	if i.cfg.AuthManager == nil {
878		return
879	}
880	go func() {
881		err := i.cfg.AuthManager.CompleteManualOAuth(i.runCtx, code)
882		if err == nil || errors.Is(err, auth.ErrOAuthCanceled) {
883			// A canceled or replaced login must not overwrite the dialog of
884			// whatever login is current now.
885			return
886		}
887		i.dialog.ShowResult(false, err.Error())
888		i.invalidate()
889	}()
890}
891func (i *Interactive) cancelAndWaitForIdle() {
892	i.mu.Lock()
893	busy := i.busy
894	cancel := i.cancelTurn
895	i.mu.Unlock()
896	if !busy {
897		return
898	}
899	if cancel != nil {
900		i.mu.Lock()
901		handoff, persistHandoff := i.resetCompactContinuationLocked()
902		i.mu.Unlock()
903		cancel()
904		if persistHandoff {
905			i.persistCompactHandoff(handoff)
906		}
907	}
908	// ConfirmToolCall waits on its response channel rather than the turn
909	// context, so cancellation must also resolve every pending prompt.
910	i.confirmDialog.CancelAll("turn cancelled")
911	deadline := time.Now().Add(2 * time.Second)
912	for time.Now().Before(deadline) {
913		i.mu.Lock()
914		done := !i.busy
915		i.mu.Unlock()
916		if done {
917			return
918		}
919		time.Sleep(10 * time.Millisecond)
920	}
921}
922func (i *Interactive) openBtwDialog(args []string) {
923	i.agentMu.Lock()
924	defer i.agentMu.Unlock()
925	i.mu.Lock()
926	ag := i.agent
927	system := ""
928	if ag != nil {
929		system, _ = ag.PromptConfig()
930	}
931	theme := i.cfg.Theme
932	model := i.cfg.Model
933	cwd := i.cfg.CWD
934	compact := i.compactModeEnabled()
935	flatTools := i.cfg.FlatTools
936	lineInput := tui.NormalizeInputStyle(i.cfg.TUIInputStyle) == tui.InputStyleLines
937	i.mu.Unlock()
938	if ag == nil {
939		i.mu.Lock()
940		i.statusErr = "not logged in. type /login first."
941		i.mu.Unlock()
942		return
943	}
944	seed := strings.TrimSpace(strings.Join(args, " "))
945	i.btwDialog.Open(theme, ag, system, model, cwd, seed, compact, flatTools, lineInput, i.invalidate)
946	i.invalidate()
947}
948func (i *Interactive) submitOrQueuePrompt(ctx context.Context, prompt string) {
949	i.mu.Lock()
950	if i.agent == nil {
951		i.statusErr = "not logged in. type /login first."
952		i.statusOK = ""
953		i.mu.Unlock()
954		i.invalidate()
955		return
956	}
957	// A typed /skill:name command is a genuine user prompt.
958	prompt = i.skillPins.consume(prompt)
959	if i.busy {
960		// Keep the mutex held while enqueueing so the turn-completion
961		// goroutine cannot publish idle and inspect an empty queue between
962		// this check and the append.
963		var handoff json.RawMessage
964		var persistHandoff bool
965		if i.compacting {
966			handoff, persistHandoff = i.resetCompactContinuationLocked()
967			i.queued = append(i.queued, core.QueuedMessage{Text: prompt})
968		} else {
969			handoff, persistHandoff = i.resetCompactContinuationLocked()
970			i.agent.QueueMessage(prompt, nil)
971		}
972		i.mu.Unlock()
973		if persistHandoff {
974			i.persistCompactHandoff(handoff)
975		}
976		i.invalidate()
977		return
978	}
979	i.mu.Unlock()
980	i.startTurn(ctx, prompt)
981}
982func (i *Interactive) openSkillsDialog() {
983	i.openSkillsDialogWithPins()
984	i.invalidate()
985}
986func (i *Interactive) openJumpDialog(args []string) {
987	if i.view == nil || len(i.view.Messages) == 0 {
988		i.mu.Lock()
989		i.statusErr = "nothing to jump to \u2014 the session is empty"
990		i.mu.Unlock()
991		return
992	}
993	filter := strings.TrimSpace(strings.Join(args, " "))
994	i.jumpDialog.Open(i.view.Messages, filter)
995	// Shortcut: with a filter argument that matches exactly one turn,
996	// jump immediately and skip the picker.
997	if filter != "" {
998		if tgts := i.jumpDialog.Targets(); len(tgts) == 1 {
999			t := tgts[0]
1000			i.jumpDialog.Close()
1001			i.applyJumpSelection(t.MessageIdx, t.TurnNo)
1002		}
1003	}
1004}
1005func (i *Interactive) applyJumpSelection(msgIdx, turnNo int) {
1006	cols := i.lastCols()
1007	chat, anchors := i.view.BuildWithAnchors(cols)
1008	var row int
1009	found := false
1010	for _, a := range anchors {
1011		if a.MessageIdx == msgIdx {
1012			row = a.Row
1013			found = true
1014			break
1015		}
1016	}
1017	if !found {
1018		i.mu.Lock()
1019		i.statusErr = "could not resolve jump target"
1020		i.mu.Unlock()
1021		return
1022	}
1023
1024	chatLen := len(chat)
1025	page := i.chatPage()
1026	if page < 1 {
1027		page = 1
1028	}
1029	// scrollOffset is measured from the bottom of the chat slice, so
1030	// to place `row` at the top of the viewport we want:
1031	//     chatLen - scrollOffset - page == row
1032	// Solve for scrollOffset and clamp to [0, chatLen-page].
1033	offset := chatLen - (row + page)
1034	if offset < 0 {
1035		offset = 0
1036	}
1037	maxOffset := chatLen - page
1038	if maxOffset < 0 {
1039		maxOffset = 0
1040	}
1041	if offset > maxOffset {
1042		offset = maxOffset
1043	}
1044
1045	i.mu.Lock()
1046	i.scrollOffset = offset
1047	i.parkedTurn = turnNo
1048	i.parkedTotal = totalTurnsLocked(i.view.Messages)
1049	i.statusOK = fmt.Sprintf("jumped to turn %d", turnNo)
1050	i.statusErr = ""
1051	i.mu.Unlock()
1052}
1053func totalTurnsLocked(msgs []provider.Message) int {
1054	n := 0
1055	for _, m := range msgs {
1056		if m.Role == provider.RoleUser {
1057			n++
1058		}
1059	}
1060	return n
1061}
1062func (i *Interactive) canResumeSessionSelection() bool {
1063	if i == nil || i.cfg.LoadSession == nil {
1064		return false
1065	}
1066	i.mu.Lock()
1067	busy := i.busy || i.stream.Active() || i.shellRunning || i.compacting || i.autoCompacting || i.awaitingStartupPre || i.sessionLoading || i.modelRefreshing
1068	queued := len(i.queued) != 0
1069	ag := i.agent
1070	i.mu.Unlock()
1071	return !busy && !queued && ag != nil && ag.QueuedMessageCount() == 0
1072}
1073
1074// applySessionDeletion permanently removes a session selected in the
1075// picker. The active session is protected because unlinking its open file
1076// would lose subsequent transcript writes on Unix and behaves differently
1077// on Windows.
1078func (i *Interactive) applySessionDeletion(path string) {
1079	if i.cfg.CurrentSessionPath == nil {
1080		i.mu.Lock()
1081		i.statusErr = "session deletion is not wired in this build"
1082		i.statusOK = ""
1083		i.mu.Unlock()
1084		return
1085	}
1086	current := i.cfg.CurrentSessionPath()
1087	if current != "" && filepath.Clean(current) == filepath.Clean(path) {
1088		i.mu.Lock()
1089		i.statusErr = "cannot delete the active session; resume another session first"
1090		i.statusOK = ""
1091		i.mu.Unlock()
1092		return
1093	}
1094	if err := core.DeleteSession(path); err != nil {
1095		i.mu.Lock()
1096		i.statusErr = "delete session: " + err.Error()
1097		i.statusOK = ""
1098		i.mu.Unlock()
1099		return
1100	}
1101	// Remove runs under the mutex: the render-scheduler goroutine assembles
1102	// state and renders the dialog concurrently while it stays active.
1103	i.mu.Lock()
1104	i.sessionDialog.Remove(path)
1105	i.statusOK = "deleted session: " + friendlyPath(path)
1106	i.statusErr = ""
1107	i.mu.Unlock()
1108}
1109
1110func (i *Interactive) applySessionSelection(path string) {
1111	if i.cfg.LoadSession == nil {
1112		i.mu.Lock()
1113		i.statusErr = "session loading is not wired in this build"
1114		i.mu.Unlock()
1115		return
1116	}
1117	i.mu.Lock()
1118	if i.sessionLoading {
1119		i.statusErr = "already resuming a session"
1120		i.mu.Unlock()
1121		i.invalidate()
1122		return
1123	}
1124	i.sessionLoading = true
1125	i.statusOK = "resuming session: " + path
1126	i.statusErr = ""
1127	i.mu.Unlock()
1128	i.invalidate()
1129	i.markSessionTitleSwitching()
1130
1131	go func() {
1132		load := i.cfg.LoadSession
1133		if i.cfg.LoadSessionContext != nil {
1134			ctx := i.runCtx
1135			if ctx == nil {
1136				ctx = context.Background()
1137			}
1138			err := i.cfg.LoadSessionContext(ctx, path)
1139			i.finishSessionSelection(path, err)
1140			return
1141		}
1142		err := load(path)
1143		i.finishSessionSelection(path, err)
1144	}()
1145}
1146
1147func (i *Interactive) finishSessionSelection(path string, err error) {
1148	if err != nil {
1149		i.restoreFailedSessionTitle()
1150		i.mu.Lock()
1151		i.sessionLoading = false
1152		i.statusErr = err.Error()
1153		i.statusOK = ""
1154		i.mu.Unlock()
1155		i.invalidate()
1156		return
1157	}
1158	i.restoreLoadedSessionTitle()
1159	state := decodeCompactHandoff(i.currentCompactHandoff())
1160	i.mu.Lock()
1161	i.compactContinuation = state
1162	i.sessionLoading = false
1163	i.statusOK = "resumed session: " + path
1164	i.statusErr = ""
1165	i.parkedTurn = 0
1166	i.parkedTotal = 0
1167	i.scrollOffset = 0
1168	// Fresh transcript swapped in: drop the auto-follow baseline so
1169	// the next render's follow guard doesn't see the wholesale
1170	// length change as a delta and jump the viewport.
1171	i.prevChatLen = 0
1172	i.prevChatCols = 0
1173	i.extNotes = nil
1174	i.view.InvalidateRenderCache()
1175	if i.agent != nil {
1176		i.view.Messages = filterHiddenTranscriptMessages(i.agent.Messages())
1177		i.cumUsage = i.agent.Cost()
1178		if last := i.agent.LastTurnUsage(); last.InputTokens > 0 || last.CacheReadTokens > 0 || last.CacheWriteTokens > 0 {
1179			i.lastCtxInput = last.InputTokens + last.CacheReadTokens + last.CacheWriteTokens
1180		} else {
1181			i.lastCtxInput = 0
1182		}
1183		// Snap to the tail again — the swap brought in a fresh
1184		// transcript whose markdown / chroma cost we don't want
1185		// blocking the redraw.
1186		if len(i.view.Messages) > initialResumeTailLimit {
1187			i.view.TailLimit = initialResumeTailLimit
1188		} else {
1189			i.view.TailLimit = 0
1190		}
1191	}
1192	i.mu.Unlock()
1193	i.invalidate()
1194	if state.reason != compactContinuationNone {
1195		i.startRestoredCompactHandoff(i.runCtx)
1196	}
1197}
1198
1199func (i *Interactive) applyModelSelection(prov, model string) {
1200	i.swapModel(prov, model, i.cfg.BuildAgentFor, false)
1201}
1202func (i *Interactive) applyRescueModelSelection(prov, model string) {
1203	builder := i.cfg.BuildAgentForRescue
1204	if builder == nil {
1205		builder = i.cfg.BuildAgentFor
1206	}
1207	i.swapModel(prov, model, builder, true)
1208}
1209func (i *Interactive) swapModel(prov, model string, builder func(string, string) (*core.Agent, string, string, error), rescue bool) {
1210	var replaced bool
1211	swap := func() {
1212		var success bool
1213		replaced, success = i.swapModelUnserialized(prov, model, builder, rescue)
1214		if success {
1215			i.saveActiveModelProfile()
1216		}
1217	}
1218	if i.cfg.SessionTransition != nil {
1219		i.cfg.SessionTransition(swap)
1220	} else {
1221		swap()
1222	}
1223	if replaced {
1224		i.resetCompactHandoff()
1225	}
1226}
1227
1228// swapModelUnserialized reports agent replacement separately from success: a
1229// same-provider model change succeeds without replacing the agent.
1230func (i *Interactive) swapModelUnserialized(prov, model string, builder func(string, string) (*core.Agent, string, string, error), rescue bool) (replaced, success bool) {
1231	if model == "" {
1232		return false, false
1233	}
1234	m, err := provider.FindModel(prov, model)
1235	if err != nil {
1236		i.mu.Lock()
1237		i.statusErr = err.Error()
1238		i.mu.Unlock()
1239		return false, false
1240	}
1241	// Same provider AND not a rescue retry: just swap the model on
1242	// the existing agent. Mixed-API providers dispatch from model metadata,
1243	// so the client remains reusable. Rescue retries always rebuild so a stale
1244	// auth header / base URL can't carry over.
1245	if !rescue && i.agent != nil && m.Provider == i.cfg.Provider {
1246		i.mu.Lock()
1247		i.cfg.Model = m.ID
1248		i.agent.Model = m.ID
1249		// Output budget and context capacity are per model; keeping the
1250		// previous model's values can truncate responses or mis-size
1251		// compaction. Router clients also retain the selected metadata.
1252		if setter, ok := i.agent.Client.(provider.ModelMetadataSetter); ok {
1253			setter.SetModelMetadata(m)
1254		}
1255		i.agent.MaxTokens = m.MaxOutput
1256		if m.ContextWindow > 0 {
1257			i.agent.ContextWindow = m.ContextWindow
1258		}
1259		i.statusOK = "model: " + m.ID
1260		i.statusErr = ""
1261		i.mu.Unlock()
1262		if i.cfg.PersistModel != nil {
1263			i.cfg.PersistModel(i.cfg.Provider, m.ID)
1264		}
1265		return false, true
1266	}
1267	if builder == nil {
1268		i.mu.Lock()
1269		i.statusErr = "cannot switch provider: no builder configured"
1270		i.mu.Unlock()
1271		return false, false
1272	}
1273	// Snapshot the current transcript and cumulative usage BEFORE we
1274	// build the replacement agent so we can hand them off. Without
1275	// this the user perceives the entire session as wiped on a
1276	// cross-provider /model swap.
1277	var carryMsgs []provider.Message
1278	var carryCost provider.Usage
1279	var carryPlan []core.PlanStep
1280	if i.agent != nil {
1281		carryMsgs = i.agent.Messages()
1282		carryCost = i.agent.Cost()
1283		carryPlan = i.agent.CurrentPlan()
1284	}
1285
1286	ag, p, md, err := builder(m.Provider, m.ID)
1287	if err != nil {
1288		i.mu.Lock()
1289		i.statusErr = err.Error()
1290		i.mu.Unlock()
1291		return false, false
1292	}
1293
1294	// Replay the transcript and seed the cost on the freshly-built
1295	// agent. Messages travel cleanly between providers because they
1296	// use the same provider.Message shape; tool-call ids are local
1297	// to a turn so cross-provider continuation never confuses the
1298	// new model (it just sees the assistant's reply, no orphan
1299	// tool_use blocks because /model swaps are gated to idle state).
1300	if len(carryMsgs) > 0 {
1301		ag.SetMessages(carryMsgs)
1302	}
1303	// A provider rebuild is a fresh agent; carry the session-scoped plan over so
1304	// the checklist survives a cross-provider /model swap, matching the way the
1305	// transcript and cumulative cost are handed off.
1306	ag.SetPlan(carryPlan)
1307	ag.SeedCost(carryCost)
1308
1309	if i.agent != nil && i.agent != ag {
1310		_ = tools.CloseLSPManagers(i.agent.ToolsSnapshot())
1311	}
1312	i.agentMu.Lock()
1313	i.mu.Lock()
1314	i.prepareReplacementAgentLocked(ag)
1315	i.agent = ag
1316	i.cfg.Provider = p
1317	i.cfg.Model = md
1318	if rescue {
1319		i.statusOK = "rescue retry: switched to " + p + " / " + md + " (ignored --api-key / --base-url)"
1320	} else {
1321		i.statusOK = "switched to " + p + " / " + md
1322	}
1323	i.statusErr = ""
1324	// Render cache keys are width+content based, so the new agent's
1325	// identical messages will reuse the existing entries. Nothing
1326	// to invalidate.
1327	i.mu.Unlock()
1328	i.agentMu.Unlock()
1329	// The new agent was built off the base tool registry, so any
1330	// dynamically-registered tools need to be reattached. The apply
1331	// helpers are no-ops when their feature is inactive, so the
1332	// cross-provider path still works on a vanilla setup.
1333	i.applyAutoSubagentsTool()
1334	i.applyTelegramTools(i.telegramBridge != nil)
1335	if i.cfg.PersistModel != nil {
1336		i.cfg.PersistModel(p, md)
1337	}
1338	return true, true
1339}
1340func (i *Interactive) handleAuthEvent(ev auth.Event) {
1341	switch ev.Kind {
1342	case "started":
1343		// The manual flow already shows its own URL in the paste-code step.
1344		if i.dialog.step == loginStepPasteCode && i.dialog.url == ev.URL {
1345			return
1346		}
1347		i.dialog.ShowWaiting(ev.URL)
1348	case "browser_open":
1349		// no-op
1350	case "error":
1351		i.dialog.ShowResult(false, ev.Message)
1352	case "success":
1353		// Keep the credential-driven resolve and its final replacement in the
1354		// same transition as settings/session changes. Otherwise a build that
1355		// started under the old web-search policy could commit afterward.
1356		var buildErr error
1357		var clearHandoff bool
1358		buildAndCommitLogin := func() {
1359			ag, prov, model, err := i.cfg.BuildAgent()
1360			if err != nil {
1361				buildErr = err
1362				return
1363			}
1364			i.agentMu.Lock()
1365			i.mu.Lock()
1366			oldAgent := i.agent
1367			i.prepareReplacementAgentLocked(ag)
1368			_, clearHandoff = i.resetCompactContinuationLocked()
1369			i.agent = ag
1370			i.cfg.Provider = prov
1371			i.cfg.Model = model
1372			i.cfg.detachMismatchedModelProfile()
1373			i.statusErr = ""
1374			i.statusOK = "logged in to " + ev.Provider + " via " + ev.Method
1375			i.mu.Unlock()
1376			i.agentMu.Unlock()
1377			if oldAgent != nil {
1378				_ = tools.CloseLSPManagers(oldAgent.ToolsSnapshot())
1379			}
1380			i.applyAutoSubagentsTool()
1381			i.applyTelegramTools(i.telegramBridge != nil)
1382			// Authentication can change the provider used by the live
1383			// agent. Persist it on the active session just like /model.
1384			if i.cfg.PersistModel != nil {
1385				i.cfg.PersistModel(prov, model)
1386			}
1387		}
1388		if i.cfg.SessionTransition != nil {
1389			i.cfg.SessionTransition(buildAndCommitLogin)
1390		} else {
1391			buildAndCommitLogin()
1392		}
1393		if buildErr != nil {
1394			i.dialog.ShowResult(false, buildErr.Error())
1395			return
1396		}
1397		if clearHandoff {
1398			i.persistCompactHandoff(nil)
1399		}
1400		i.dialog.ShowResult(true, "")
1401	}
1402}

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 476

↑↓ moveenter openesc close