v0.42.0 82578de

interactive_commands.go RawHistoryFolder

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

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 477

↑↓ moveenter openesc close