v0.48.1 deaab59

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

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