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