v0.46.0 ea0e112

interactive_commands.go RawHistoryFolder

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

Keyboard shortcuts

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

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

Branches 1

Tags 476

↑↓ moveenter openesc close