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.SetPlan(nil)
234 }
235 i.toolCalls = map[string]*tui.ToolCallView{}
236 i.toolOrder = nil
237 i.toolGate = map[string]int{}
238 i.statusErr = ""
239 i.statusOK = ""
240 i.helpBlock = nil
241 i.sessionInfoBlocks = nil
242 i.parkedTurn = 0
243 i.parkedTotal = 0
244 i.scrollOffset = 0
245 i.extNotes = nil
246 i.reloadErrors = nil
247 i.planSnapshots = nil
248 i.planCurrent = 0
249 i.planTotal = 0
250 i.planRevision++
251 handoff, persistHandoff := i.resetCompactContinuationLocked()
252 i.view.InvalidateRenderCache()
253 i.mu.Unlock()
254 if persistHandoff {
255 i.persistCompactHandoff(handoff)
256 }
257 // Persist outside the lock: session writes are file I/O. Only write when
258 // a plan existed in core or on disk, so /clear in --no-session mode stays
259 // a no-op there.
260 if (clearPlan || persistedPlan) && i.cfg.PersistPlan != nil {
261 if err := i.cfg.PersistPlan(nil); err != nil {
262 i.ReportError(fmt.Errorf("clear plan: %w", err))
263 }
264 }
265 case "/help":
266 i.mu.Lock()
267 i.helpBlock = renderHelpBlock(i.cfg.Theme, i.lastCols(), i.llamaConfigured)
268 i.statusErr = ""
269 i.statusOK = ""
270 // Pin the viewport to the newest content so the help block,
271 // which we just appended to the end of the transcript, is
272 // what the user actually sees.
273 i.scrollOffset = 0
274 i.mu.Unlock()
275 case "/info":
276 i.showSessionInfo()
277 case "/login":
278 i.dialog.Open(i.cfg.ZutHome)
279 case "/logout":
280 if len(parts) >= 2 {
281 // Explicit target: /logout anthropic | openai | all
282 i.doLogout(parts[1])
283 break
284 }
285 // No arg: open the picker over whichever providers are
286 // currently logged in. If nothing's logged in, bail with a
287 // status line.
288 i.openLogoutDialog()
289 case "/model":
290 if len(parts) >= 2 {
291 i.applyModelSelection("", parts[1])
292 } else if i.llamaConfigured && i.cfg.RefreshLlamaCPPModels != nil {
293 i.mu.Lock()
294 i.modelRefreshing = true
295 i.statusOK = "Refreshing models"
296 i.statusErr = ""
297 i.mu.Unlock()
298 go func() {
299 refreshCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
300 defer cancel()
301 i.modelRefresh <- modelRefreshResult{err: i.cfg.RefreshLlamaCPPModels(refreshCtx)}
302 }()
303 } else {
304 i.openModelPickerAfterRefresh(nil)
305 }
306 case "/llama":
307 if i.cfg.LlamaCPPConfig == nil {
308 i.mu.Lock()
309 i.statusErr = "llama.cpp configuration is unavailable"
310 i.statusOK = ""
311 i.mu.Unlock()
312 break
313 }
314 baseURL, apiKey, configErr := i.cfg.LlamaCPPConfig()
315 if configErr == nil {
316 configErr = i.llamaDialog.Open(baseURL, apiKey, i.invalidate)
317 }
318 if configErr != nil {
319 i.mu.Lock()
320 i.statusErr = configErr.Error()
321 i.statusOK = ""
322 i.mu.Unlock()
323 }
324 case "/profile":
325 if len(parts) != 2 || len(parts[1]) != 1 || parts[1][0] < '1' || parts[1][0] > '9' {
326 i.mu.Lock()
327 i.statusErr, i.statusOK = "usage: /profile <1-9>", ""
328 i.mu.Unlock()
329 break
330 }
331 i.applyQuickModelShortcut(int(parts[1][0] - '0'))
332 case "/reasoning":
333 i.openReasoningDialog()
334 case "/fast":
335 enabled := i.cfg.FastMode == nil || !*i.cfg.FastMode
336 i.applySettingToggle("fast_mode", enabled)
337 case "/orchestrator":
338 enabled := i.cfg.AutoSubagentsEnabled == nil || !*i.cfg.AutoSubagentsEnabled
339 i.applySettingToggle("auto_subagents_enabled", enabled)
340 case "/settings":
341 i.openSettingsDialog()
342 case "/sessions":
343 if i.cfg.SessionsDisabled {
344 i.mu.Lock()
345 i.statusErr = "sessions are disabled by --no-session"
346 i.statusOK = ""
347 i.mu.Unlock()
348 break
349 }
350 i.mu.Lock()
351 i.sessionLoads = i.sessionDialog.Open(ctx, i.sessionsRoot(), i.cfg.CWD)
352 i.sessionSearches = nil
353 i.mu.Unlock()
354 case "/fork":
355 i.doSessionFork()
356 case "/jump":
357 i.openJumpDialog(parts[1:])
358 case "/btw":
359 i.openBtwDialog(parts[1:])
360 case "/skills":
361 i.openSkillsDialog()
362 case "/compact":
363 i.runCompact(ctx, compactContinuationRequest{origin: compactOriginManual})
364 case "/study":
365 // Canned prompt that tells the agent to read every file
366 // in some target so its later turns have the whole thing
367 // in context. With no argument, the target is the current
368 // directory. With an argument, the target is whatever the
369 // user passed — typed by hand, drag-dropped, or selected
370 // via the @ file picker (which is why we accept both files
371 // and directories; the @-picker chips for both have already
372 // been expanded to absolute paths by expandFileChips above).
373 // Dispatched through the normal queue-or-start path so it
374 // behaves identically to typing the prompt by hand.
375 studyPrompt := buildStudyPrompt(strings.TrimSpace(strings.TrimPrefix(cmd, parts[0])), i.cfg.CWD)
376 i.mu.Lock()
377 busy := i.busy
378 compacting := i.compacting
379 ag := i.agent
380 i.mu.Unlock()
381 if busy {
382 var handoff json.RawMessage
383 var persistHandoff bool
384 if ag != nil && !compacting {
385 i.mu.Lock()
386 handoff, persistHandoff = i.resetCompactContinuationLocked()
387 ag.QueueMessage(studyPrompt, nil)
388 i.mu.Unlock()
389 } else {
390 i.mu.Lock()
391 handoff, persistHandoff = i.resetCompactContinuationLocked()
392 i.queued = append(i.queued, core.QueuedMessage{Text: studyPrompt})
393 i.mu.Unlock()
394 }
395 if persistHandoff {
396 i.persistCompactHandoff(handoff)
397 }
398 i.invalidate()
399 break
400 }
401 i.startTurn(ctx, studyPrompt)
402 case "/cd":
403 // Hidden command: switch the running session's cwd. Not in
404 // slash_suggest, not in /help. Used by the workspaces
405 // extension's panel-key Enter handler so picking a row
406 // jumps zut into that directory without relaunching.
407 //
408 // Recovers the raw argument (path) from the original cmd
409 // string rather than parts, so paths with spaces survive.
410 // The host's ChangeCWD hook handles validation, session
411 // close + reopen, agent rebuild, sandbox re-rooting, and
412 // re-jail-if-jailed semantics.
413 if i.cfg.ChangeCWD == nil {
414 i.mu.Lock()
415 i.statusErr = "/cd unavailable: host did not wire ChangeCWD"
416 i.mu.Unlock()
417 break
418 }
419 path := strings.TrimSpace(strings.TrimPrefix(cmd, parts[0]))
420 if path == "" {
421 i.mu.Lock()
422 i.statusErr = "/cd: missing path"
423 i.mu.Unlock()
424 break
425 }
426 if err := i.cfg.ChangeCWD(path); err != nil {
427 i.mu.Lock()
428 i.statusErr = "/cd: " + err.Error()
429 i.statusOK = ""
430 i.mu.Unlock()
431 break
432 }
433 // ChangeCWD has already updated i.cfg.CWD and swapped the
434 // agent + session. Reset transient TUI state so the new
435 // session opens clean.
436 i.mu.Lock()
437 i.toolCalls = map[string]*tui.ToolCallView{}
438 i.toolOrder = nil
439 i.toolGate = map[string]int{}
440 i.helpBlock = nil
441 i.sessionInfoBlocks = nil
442 i.parkedTurn = 0
443 i.statusOK = "cwd " + i.cfg.CWD
444 i.statusErr = ""
445 i.mu.Unlock()
446 i.fileSuggest.Reset()
447 i.fileSuggest.SetCWD(i.cfg.CWD)
448 i.invalidate()
449 case "/jail":
450 if i.cfg.Sandbox == nil {
451 i.mu.Lock()
452 i.statusErr = "sandbox not available in this build"
453 i.mu.Unlock()
454 break
455 }
456 i.cfg.Sandbox.Lock()
457 i.mu.Lock()
458 i.statusOK = "jailed to " + i.cfg.CWD + " (tools cannot touch paths outside this directory)"
459 i.statusErr = ""
460 i.mu.Unlock()
461 case "/unjail":
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.Unlock()
469 i.mu.Lock()
470 i.statusOK = "unjailed"
471 i.statusErr = ""
472 i.mu.Unlock()
473 case "/reload-ext":
474 i.runReloadExt(ctx)
475 case "/telegram", "/tg":
476 if len(parts) >= 2 {
477 i.doTelegram(parts[1])
478 break
479 }
480 i.openTelegramDialog()
481 case "/session":
482 if len(parts) >= 2 {
483 action := parts[1]
484 arg := ""
485 if len(parts) >= 3 {
486 arg = strings.Join(parts[2:], " ")
487 }
488 i.doSessionOp(action, arg)
489 break
490 }
491 i.openSessionOpsDialog()
492 case "/subagents":
493 i.runSubagents(ctx, parts[1:])
494 default:
495 // Last-resort fallback: try the extension manager. Built-in
496 // cases above always win; this branch only fires for slash
497 // commands the extension manager registered. Same routing as
498 // the editor's submit-handler dispatch path so the autocomplete
499 // "enter on highlighted suggestion" flow also works.
500 extName := strings.TrimPrefix(parts[0], "/")
501 if i.cfg.Extensions != nil && i.cfg.Extensions.HasCommand(extName) {
502 rest := ""
503 if len(parts) > 1 {
504 rest = strings.Join(parts[1:], " ")
505 }
506 go i.invokeExtensionCommand(ctx, extName, rest)
507 return false
508 }
509 i.mu.Lock()
510 i.statusErr = "unknown command: " + parts[0]
511 i.mu.Unlock()
512 }
513 return false
514}
515func (i *Interactive) openModelPickerAfterRefresh(refreshErr error) {
516 var loggedIn []string
517 if i.cfg.LoggedInProviders != nil {
518 loggedIn = i.cfg.LoggedInProviders()
519 }
520 i.modelDialog.Open(i.cfg.Model, loggedIn, i.cfg.Reasoning)
521 i.mu.Lock()
522 if refreshErr != nil {
523 i.statusErr = "llama.cpp model refresh: " + refreshErr.Error()
524 i.statusOK = ""
525 } else if i.statusOK == "Refreshing models" {
526 i.statusOK = ""
527 }
528 i.mu.Unlock()
529}
530func (i *Interactive) openLogoutDialog() {
531 if i.cfg.AuthManager == nil {
532 i.mu.Lock()
533 i.statusErr = "no auth manager configured"
534 i.mu.Unlock()
535 i.invalidate()
536 return
537 }
538 store := i.cfg.AuthManager.Store()
539 if store == nil {
540 i.mu.Lock()
541 i.statusErr = "auth store is not available"
542 i.mu.Unlock()
543 i.invalidate()
544 return
545 }
546 creds, err := store.Load()
547 if err != nil {
548 i.mu.Lock()
549 i.statusErr = "read auth store: " + err.Error()
550 i.mu.Unlock()
551 i.invalidate()
552 return
553 }
554
555 var items []logoutItem
556 for _, p := range []string{"anthropic", "kimi", "google", "github-copilot"} {
557 if creds.Has(p) {
558 method := creds.Method(p)
559 if method == "oauth" {
560 method = "subscription"
561 }
562 items = append(items, logoutItem{
563 label: providerLabel(p),
564 target: p,
565 method: method,
566 })
567 }
568 }
569 if creds.OpenAI.APIKey != "" || creds.OpenAI.APIKeyCommand != nil {
570 items = append(items, logoutItem{label: providerLabel("openai"), target: "openai", method: "api key"})
571 }
572 if creds.OpenAI.OAuth != nil {
573 items = append(items, logoutItem{label: providerLabel("openai-codex"), target: "openai-codex", method: "subscription"})
574 }
575 for p, c := range creds.AdditionalAPIKeyCreds {
576 if c.APIKey != "" || c.APIKeyCommand != nil || c.BaseURL != "" {
577 items = append(items, logoutItem{label: providerLabel(p), target: p, method: "api key"})
578 }
579 }
580 if len(items) == 0 {
581 i.mu.Lock()
582 i.statusOK = "no credentials stored; already logged out"
583 i.statusErr = ""
584 i.mu.Unlock()
585 i.invalidate()
586 return
587 }
588 if len(items) > 1 {
589 items = append(items, logoutItem{label: "all", target: "all"})
590 }
591
592 i.logoutDialog.Open(items)
593 i.invalidate()
594}
595func (i *Interactive) doLogout(target string) {
596 if i.cfg.AuthManager == nil {
597 i.mu.Lock()
598 i.statusErr = "no auth manager configured"
599 i.mu.Unlock()
600 return
601 }
602 store := i.cfg.AuthManager.Store()
603 if store == nil {
604 i.mu.Lock()
605 i.statusErr = "auth store is not available"
606 i.mu.Unlock()
607 return
608 }
609
610 var providers []string
611 switch target {
612 case "", "all":
613 providers = append([]string{"anthropic", "openai", "openai-codex", "kimi", "google", "github-copilot"}, auth.APIKeyProviders()...)
614 case "anthropic", "openai", "openai-codex", "kimi", "google", "github-copilot":
615 providers = []string{target}
616 default:
617 known := false
618 for _, p := range auth.APIKeyProviders() {
619 if target == p {
620 known = true
621 break
622 }
623 }
624 if !known {
625 i.mu.Lock()
626 i.statusErr = "unknown provider: " + target
627 i.mu.Unlock()
628 return
629 }
630 providers = []string{target}
631 }
632
633 var errs []string
634 clearedCurrent := false
635 for _, p := range providers {
636 var err error
637 switch p {
638 case "openai":
639 err = store.ClearAPIKey("openai")
640 case "openai-codex":
641 err = store.ClearOAuth("openai")
642 default:
643 err = store.Clear(p)
644 }
645 if err != nil {
646 errs = append(errs, p+": "+err.Error())
647 continue
648 }
649 if p == "kimi" && i.cfg.SetKimiCLIFallbackDisabled != nil {
650 if err := i.cfg.SetKimiCLIFallbackDisabled(true); err != nil {
651 errs = append(errs, p+": "+err.Error())
652 continue
653 }
654 }
655 if p == i.cfg.Provider {
656 clearedCurrent = true
657 }
658 }
659
660 llamaConfigured := i.llamaConfigured
661 if i.cfg.LlamaCPPConfig != nil {
662 baseURL, _, err := i.cfg.LlamaCPPConfig()
663 llamaConfigured = err == nil && baseURL != ""
664 }
665
666 i.mu.Lock()
667 i.llamaConfigured = llamaConfigured
668 if len(errs) > 0 {
669 i.statusErr = "logout errors: " + strings.Join(errs, "; ")
670 i.mu.Unlock()
671 return
672 }
673 i.statusErr = ""
674 var handoff json.RawMessage
675 var persistHandoff bool
676 if clearedCurrent {
677 // The running agent was using a credential we just wiped. Drop
678 // it so prompts can't go out with the stale client, and hint at
679 // /login. Close any LSP processes owned by its tools first.
680 if i.agent != nil {
681 _ = tools.CloseLSPManagers(i.agent.ToolsSnapshot())
682 }
683 i.agent = nil
684 handoff, persistHandoff = i.resetCompactContinuationLocked()
685 i.statusOK = "logged out of " + strings.Join(providers, ", ") + ". type /login to sign back in."
686 } else {
687 i.statusOK = "logged out of " + strings.Join(providers, ", ")
688 }
689 i.mu.Unlock()
690 if persistHandoff {
691 i.persistCompactHandoff(handoff)
692 }
693}
694func providerSetupInfo(provider string) (string, []string, bool) {
695 const docsURL = "https://raw.githubusercontent.com/bnema/zut/main/docs/providers.md"
696 switch provider {
697 case "amazon-bedrock":
698 return "Amazon Bedrock setup", []string{
699 "Amazon Bedrock uses AWS credentials instead of a generic zut API-key entry.",
700 "Configure an AWS profile, IAM keys, bearer token, or role-based credentials.",
701 "",
702 "For Bedrock API keys, set:",
703 " AWS_BEARER_TOKEN_BEDROCK=...",
704 " AWS_REGION=us-east-1",
705 "",
706 "Docs:",
707 " " + docsURL,
708 }, true
709 case "google-vertex":
710 return "Google Vertex AI setup", []string{
711 "Google Vertex AI usually uses Google Cloud credentials and project settings.",
712 "Set a Google API key, application-default credentials, or a service account.",
713 "",
714 "Common environment:",
715 " GOOGLE_CLOUD_API_KEY=...",
716 " GOOGLE_CLOUD_PROJECT=...",
717 " GOOGLE_CLOUD_LOCATION=us-central1",
718 "",
719 "Docs:",
720 " " + docsURL,
721 }, true
722 case "cloudflare-workers-ai":
723 return "Cloudflare Workers AI setup", []string{
724 "Cloudflare Workers AI needs both an API token and an account ID.",
725 "",
726 "Set:",
727 " CLOUDFLARE_API_KEY=...",
728 " CLOUDFLARE_ACCOUNT_ID=...",
729 "",
730 "Docs:",
731 " " + docsURL,
732 }, true
733 case "cloudflare-ai-gateway":
734 return "Cloudflare AI Gateway setup", []string{
735 "Cloudflare AI Gateway needs an API token, account ID, and gateway ID.",
736 "",
737 "Set:",
738 " CLOUDFLARE_API_KEY=...",
739 " CLOUDFLARE_ACCOUNT_ID=...",
740 " CLOUDFLARE_GATEWAY_ID=...",
741 "",
742 "Docs:",
743 " " + docsURL,
744 }, true
745 case "azure-openai-responses":
746 return "Azure OpenAI Responses setup", []string{
747 "Azure OpenAI needs an API key plus your Azure endpoint or deployment setup.",
748 "",
749 "Set:",
750 " AZURE_OPENAI_API_KEY=...",
751 " AZURE_OPENAI_BASE_URL=https://your-resource.openai.azure.com",
752 " AZURE_OPENAI_API_VERSION=2024-02-01",
753 "",
754 "Docs:",
755 " " + docsURL,
756 }, true
757 default:
758 return "", nil, false
759 }
760}
761func (i *Interactive) saveLlamaCPPLogin(baseURL, apiKey string) {
762 if i.cfg.AuthManager == nil || i.cfg.AuthManager.Store() == nil {
763 i.dialog.ShowResult(false, "auth store is unavailable")
764 return
765 }
766 if err := i.cfg.AuthManager.Store().SetEndpointCredential(provider.LlamaCPPProviderID, baseURL, apiKey); err != nil {
767 i.dialog.ShowResult(false, err.Error())
768 return
769 }
770 i.mu.Lock()
771 i.llamaConfigured = true
772 i.statusErr = ""
773 i.statusOK = "configured llama.cpp router"
774 i.mu.Unlock()
775 i.dialog.ShowResult(true, "")
776}
777func (i *Interactive) startAPIKeyFlow(provider string) {
778 if title, lines, ok := providerSetupInfo(provider); ok {
779 i.dialog.ShowInfo(title, lines)
780 return
781 }
782 if provider == "kimi" && i.cfg.SetKimiCLIFallbackDisabled != nil {
783 _ = i.cfg.SetKimiCLIFallbackDisabled(false)
784 }
785 url, err := i.cfg.AuthManager.StartAPIKey(provider)
786 if err != nil {
787 i.dialog.ShowResult(false, err.Error())
788 return
789 }
790 i.dialog.ShowWaiting(url)
791}
792func (i *Interactive) startOAuthFlow(provider string) {
793 if provider == "kimi" && i.cfg.SetKimiCLIFallbackDisabled != nil {
794 _ = i.cfg.SetKimiCLIFallbackDisabled(false)
795 }
796 // Device-code providers already support headless login and must only
797 // start one polling flow.
798 if provider == "kimi" || provider == "xai" || provider == "github-copilot" {
799 loginURL, err := i.cfg.AuthManager.StartOAuth(provider)
800 if err != nil {
801 i.dialog.ShowResult(false, err.Error())
802 return
803 }
804 i.dialog.ShowWaiting(loginURL)
805 return
806 }
807 // Always run the manual/copy-code flow in parallel with the local
808 // callback server so headless environments (docker, SSH) can paste
809 // the authorization code directly without first pressing 'p'.
810 _, err := i.cfg.AuthManager.StartOAuth(provider)
811 if err != nil {
812 i.dialog.ShowResult(false, err.Error())
813 return
814 }
815 manualURL, mErr := i.cfg.AuthManager.StartManualOAuth(provider)
816 if mErr == nil {
817 i.dialog.ShowWaiting(manualURL)
818 } else {
819 i.dialog.ShowResult(false, mErr.Error())
820 }
821}
822func (i *Interactive) startManualOAuthFlow(provider string) {
823 if i.cfg.AuthManager == nil {
824 return
825 }
826 i.cfg.AuthManager.CancelOAuth()
827 url, err := i.cfg.AuthManager.StartManualOAuth(provider)
828 if err != nil {
829 i.dialog.ShowResult(false, err.Error())
830 return
831 }
832 i.dialog.url = url
833 i.invalidate()
834}
835func (i *Interactive) submitManualOAuthCode(code string) {
836 if i.cfg.AuthManager == nil {
837 return
838 }
839 go func() {
840 if err := i.cfg.AuthManager.CompleteManualOAuth(i.runCtx, code); err != nil {
841 i.dialog.ShowResult(false, err.Error())
842 i.invalidate()
843 }
844 }()
845}
846func (i *Interactive) cancelAndWaitForIdle() {
847 i.mu.Lock()
848 busy := i.busy
849 cancel := i.cancelTurn
850 i.mu.Unlock()
851 if !busy {
852 return
853 }
854 if cancel != nil {
855 i.mu.Lock()
856 handoff, persistHandoff := i.resetCompactContinuationLocked()
857 i.mu.Unlock()
858 cancel()
859 if persistHandoff {
860 i.persistCompactHandoff(handoff)
861 }
862 }
863 // ConfirmToolCall waits on its response channel rather than the turn
864 // context, so cancellation must also resolve every pending prompt.
865 i.confirmDialog.CancelAll("turn cancelled")
866 deadline := time.Now().Add(2 * time.Second)
867 for time.Now().Before(deadline) {
868 i.mu.Lock()
869 done := !i.busy
870 i.mu.Unlock()
871 if done {
872 return
873 }
874 time.Sleep(10 * time.Millisecond)
875 }
876}
877func (i *Interactive) openBtwDialog(args []string) {
878 i.agentMu.Lock()
879 defer i.agentMu.Unlock()
880 i.mu.Lock()
881 ag := i.agent
882 system := ""
883 if ag != nil {
884 system, _ = ag.PromptConfig()
885 }
886 theme := i.cfg.Theme
887 model := i.cfg.Model
888 cwd := i.cfg.CWD
889 compact := i.compactModeEnabled()
890 flatTools := i.cfg.FlatTools
891 lineInput := tui.NormalizeInputStyle(i.cfg.TUIInputStyle) == tui.InputStyleLines
892 i.mu.Unlock()
893 if ag == nil {
894 i.mu.Lock()
895 i.statusErr = "not logged in. type /login first."
896 i.mu.Unlock()
897 return
898 }
899 seed := strings.TrimSpace(strings.Join(args, " "))
900 i.btwDialog.Open(theme, ag, system, model, cwd, seed, compact, flatTools, lineInput, i.invalidate)
901 i.invalidate()
902}
903func (i *Interactive) submitOrQueuePrompt(ctx context.Context, prompt string) {
904 i.mu.Lock()
905 if i.agent == nil {
906 i.statusErr = "not logged in. type /login first."
907 i.statusOK = ""
908 i.mu.Unlock()
909 i.invalidate()
910 return
911 }
912 if i.busy {
913 // Keep the mutex held while enqueueing so the turn-completion
914 // goroutine cannot publish idle and inspect an empty queue between
915 // this check and the append.
916 var handoff json.RawMessage
917 var persistHandoff bool
918 if i.compacting {
919 handoff, persistHandoff = i.resetCompactContinuationLocked()
920 i.queued = append(i.queued, core.QueuedMessage{Text: prompt})
921 } else {
922 handoff, persistHandoff = i.resetCompactContinuationLocked()
923 i.agent.QueueMessage(prompt, nil)
924 }
925 i.mu.Unlock()
926 if persistHandoff {
927 i.persistCompactHandoff(handoff)
928 }
929 i.invalidate()
930 return
931 }
932 i.mu.Unlock()
933 i.startTurn(ctx, prompt)
934}
935func (i *Interactive) openSkillsDialog() {
936 var list []*skills.Skill
937 if i.cfg.SkillSnapshot != nil {
938 list = i.cfg.SkillSnapshot()
939 }
940 i.skillsDialog.Open(list)
941 i.invalidate()
942}
943func (i *Interactive) openJumpDialog(args []string) {
944 if i.view == nil || len(i.view.Messages) == 0 {
945 i.mu.Lock()
946 i.statusErr = "nothing to jump to \u2014 the session is empty"
947 i.mu.Unlock()
948 return
949 }
950 filter := strings.TrimSpace(strings.Join(args, " "))
951 i.jumpDialog.Open(i.view.Messages, filter)
952 // Shortcut: with a filter argument that matches exactly one turn,
953 // jump immediately and skip the picker.
954 if filter != "" {
955 if tgts := i.jumpDialog.Targets(); len(tgts) == 1 {
956 t := tgts[0]
957 i.jumpDialog.Close()
958 i.applyJumpSelection(t.MessageIdx, t.TurnNo)
959 }
960 }
961}
962func (i *Interactive) applyJumpSelection(msgIdx, turnNo int) {
963 cols := i.lastCols()
964 chat, anchors := i.view.BuildWithAnchors(cols)
965 var row int
966 found := false
967 for _, a := range anchors {
968 if a.MessageIdx == msgIdx {
969 row = a.Row
970 found = true
971 break
972 }
973 }
974 if !found {
975 i.mu.Lock()
976 i.statusErr = "could not resolve jump target"
977 i.mu.Unlock()
978 return
979 }
980
981 chatLen := len(chat)
982 page := i.chatPage()
983 if page < 1 {
984 page = 1
985 }
986 // scrollOffset is measured from the bottom of the chat slice, so
987 // to place `row` at the top of the viewport we want:
988 // chatLen - scrollOffset - page == row
989 // Solve for scrollOffset and clamp to [0, chatLen-page].
990 offset := chatLen - (row + page)
991 if offset < 0 {
992 offset = 0
993 }
994 maxOffset := chatLen - page
995 if maxOffset < 0 {
996 maxOffset = 0
997 }
998 if offset > maxOffset {
999 offset = maxOffset
1000 }
1001
1002 i.mu.Lock()
1003 i.scrollOffset = offset
1004 i.parkedTurn = turnNo
1005 i.parkedTotal = totalTurnsLocked(i.view.Messages)
1006 i.statusOK = fmt.Sprintf("jumped to turn %d", turnNo)
1007 i.statusErr = ""
1008 i.mu.Unlock()
1009}
1010func totalTurnsLocked(msgs []provider.Message) int {
1011 n := 0
1012 for _, m := range msgs {
1013 if m.Role == provider.RoleUser {
1014 n++
1015 }
1016 }
1017 return n
1018}
1019func (i *Interactive) canResumeSessionSelection() bool {
1020 if i == nil || i.cfg.LoadSession == nil {
1021 return false
1022 }
1023 i.mu.Lock()
1024 busy := i.busy || i.streamOn || i.streamFlushPending || len(i.streamPending) != 0 ||
1025 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}