v0.20.0 29be61c

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

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