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