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}