v0.38.0 58133ea

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

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 476

↑↓ moveenter openesc close