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