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