v0.27.1 131edba

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 "/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}

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