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}