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