v0.21.0 cd9706d

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

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