v0.0.54 098a797

cli.go RawHistoryFolder

1package agent
2
3import (
4	"bufio"
5	"context"
6	"encoding/json"
7	"fmt"
8	"io"
9	"os"
10	"strings"
11	"time"
12
13	"github.com/patriceckhart/zot/internal/agent/extensions"
14	"github.com/patriceckhart/zot/internal/agent/modes"
15	"github.com/patriceckhart/zot/internal/auth"
16	"github.com/patriceckhart/zot/internal/core"
17	"github.com/patriceckhart/zot/internal/extproto"
18	"github.com/patriceckhart/zot/internal/provider"
19	"github.com/patriceckhart/zot/internal/skills"
20	"github.com/patriceckhart/zot/internal/tui"
21)
22
23// interactiveExtHooks is a tiny adapter that lets the extension
24// manager call back into the Interactive instance built later in
25// runInteractive. The forward-declared *modes.Interactive is filled
26// in immediately after manager construction.
27type interactiveExtHooks struct {
28	ivPtr **modes.Interactive
29}
30
31func (h *interactiveExtHooks) iv() *modes.Interactive {
32	if h == nil || h.ivPtr == nil {
33		return nil
34	}
35	return *h.ivPtr
36}
37
38func (h *interactiveExtHooks) Notify(extName, level, message string) {
39	if iv := h.iv(); iv != nil {
40		iv.Notify(extName, level, message)
41	}
42}
43func (h *interactiveExtHooks) Submit(text string) {
44	if iv := h.iv(); iv != nil {
45		iv.Submit(text)
46	}
47}
48func (h *interactiveExtHooks) Insert(text string) {
49	if iv := h.iv(); iv != nil {
50		iv.Insert(text)
51	}
52}
53func (h *interactiveExtHooks) Display(extName, text string) {
54	if iv := h.iv(); iv != nil {
55		iv.Display(extName, text)
56	}
57}
58
59// extToolAdapter bridges *extensions.Manager to the
60// ExtensionToolSource interface declared in build.go (kept narrow to
61// avoid a build->extensions import cycle). One adapter instance per
62// run; used at every Resolve point so re-built agents pick up the
63// same set of extension tools.
64type extToolAdapter struct {
65	mgr *extensions.Manager
66}
67
68func (a *extToolAdapter) Tools() []ExtensionToolInfo {
69	infos := a.mgr.Tools()
70	out := make([]ExtensionToolInfo, len(infos))
71	for i, t := range infos {
72		out[i] = ExtensionToolInfo{
73			Extension:   t.Extension,
74			Name:        t.Name,
75			Description: t.Description,
76			Schema:      t.Schema,
77		}
78	}
79	return out
80}
81
82func (a *extToolAdapter) NewExtensionTool(info ExtensionToolInfo) core.Tool {
83	return extensions.NewTool(a.mgr, extensions.ToolInfo{
84		Extension:   info.Extension,
85		Name:        info.Name,
86		Description: info.Description,
87		Schema:      info.Schema,
88	})
89}
90
91// fanoutAgentEvent translates a core.AgentEvent into the wire-format
92// EventFromHost and pushes it through the extension manager. Only
93// the events that have a clear extension-facing meaning are
94// forwarded; internal-only ones (text_delta, tool_progress) are
95// dropped to keep the per-extension stream sane.
96func fanoutAgentEvent(mgr *extensions.Manager, ev core.AgentEvent) {
97	if mgr == nil {
98		return
99	}
100	switch e := ev.(type) {
101	case core.EvTurnStart:
102		mgr.EmitEvent(extproto.EventFromHost{Event: "turn_start", Step: e.Step})
103	case core.EvToolCall:
104		mgr.EmitEvent(extproto.EventFromHost{
105			Event: "tool_call", ToolID: e.ID, ToolName: e.Name, ToolArgs: e.Args,
106		})
107	case core.EvAssistantMessage:
108		// Concat the visible text portions of the message; binary
109		// blocks (tool_use, etc.) are skipped because subscribers
110		// usually want a string they can grep / display.
111		var text string
112		for _, c := range e.Message.Content {
113			if tb, ok := c.(provider.TextBlock); ok {
114				text += tb.Text
115			}
116		}
117		mgr.EmitEvent(extproto.EventFromHost{Event: "assistant_message", Text: text})
118	case core.EvTurnEnd:
119		ev := extproto.EventFromHost{Event: "turn_end", Stop: string(e.Stop)}
120		if e.Err != nil {
121			ev.Error = e.Err.Error()
122		}
123		mgr.EmitEvent(ev)
124	}
125}
126
127// Run is the top-level entrypoint for the zot binary.
128func Run(rawArgs []string, version string) error {
129	// Subcommand router: `zot bot ...` is handled separately so the
130	// generic flag parser doesn't reject "bot" as a positional arg.
131	if handled, err := runBotCommand(rawArgs, version); handled {
132		return err
133	}
134	if handled, err := runExtCommand(rawArgs); handled {
135		return err
136	}
137	// `zot rpc` is shorthand for `zot --rpc` so third-party apps can
138	// spawn the binary with a clean argv. Strip the leading 'rpc'
139	// token and let the rest flow through the normal arg parser.
140	if len(rawArgs) > 0 && rawArgs[0] == "rpc" {
141		rawArgs = append([]string{"--rpc"}, rawArgs[1:]...)
142	}
143
144	args, err := ParseArgs(rawArgs)
145	if err != nil {
146		PrintHelp(version)
147		return err
148	}
149	if args.Help {
150		PrintHelp(version)
151		return nil
152	}
153	if args.Version {
154		fmt.Println("zot", version)
155		return nil
156	}
157	// Model catalog: load any cached discovery data before we inspect
158	// the model list (list-models, print/json, interactive).
159	LoadCachedModels()
160
161	if args.ListModels {
162		printModels()
163		return nil
164	}
165
166	ctx := context.Background()
167
168	// Kick an async refresh of the live model catalog. The first run of
169	// zot hits the network; subsequent runs within CacheTTL do nothing.
170	RefreshModelsAsync()
171
172	switch args.Mode {
173	case ModePrint:
174		return runPrintMode(ctx, args, version)
175	case ModeJSON:
176		return runJSONMode(ctx, args, version)
177	case ModeRPC:
178		return runRPCMode(ctx, args, version)
179	default:
180		return runInteractive(ctx, args, version)
181	}
182}
183
184// ---- print / json modes: require credentials, run single-shot ----
185
186// nonInteractiveExtHooks is the HostHooks impl used by print / json
187// modes. They have no TUI, so notify / display go to stderr and
188// submit / insert are no-ops (the extension can't steer a
189// single-shot run once it's in flight anyway).
190type nonInteractiveExtHooks struct{}
191
192func (nonInteractiveExtHooks) Notify(ext, level, message string) {
193	fmt.Fprintf(os.Stderr, "[%s] %s: %s\n", ext, level, message)
194}
195func (nonInteractiveExtHooks) Submit(string)          {}
196func (nonInteractiveExtHooks) Insert(string)          {}
197func (nonInteractiveExtHooks) Display(string, string) {}
198
199// setupNonInteractiveExtensions loads --ext paths and (unless
200// --no-ext) runs discovery. Returns the manager so the caller can
201// wire tools into the resolved registry, and a cleanup closure to
202// defer. Mirrors the interactive-mode setup minus the TUI hooks.
203func setupNonInteractiveExtensions(ctx context.Context, args Args, r *Resolved, version string) (*extensions.Manager, func()) {
204	extMgr := extensions.New(ZotHome(), r.CWD, version, r.Provider, r.Model, nonInteractiveExtHooks{})
205	for _, e := range extMgr.LoadExplicit(ctx, args.Exts) {
206		fmt.Fprintln(os.Stderr, "extension load:", e)
207	}
208	if !args.NoExt {
209		for _, e := range extMgr.Discover(ctx) {
210			fmt.Fprintln(os.Stderr, "extension load:", e)
211		}
212	}
213	extMgr.WaitForReady(3 * time.Second)
214	r.MergeExtensionTools(&extToolAdapter{mgr: extMgr})
215	extMgr.EmitEvent(extproto.EventFromHost{Event: "session_start"})
216	return extMgr, func() { extMgr.Stop(2 * time.Second) }
217}
218
219// wireNonInteractiveAgentExtHooks installs the same BeforeToolExecute
220// / BeforeTurn / BeforeAssistantMessage / OnEvent hooks the
221// interactive path wires up, so extensions get their normal
222// event-intercept surface in print / json / rpc flows too.
223func wireNonInteractiveAgentExtHooks(ctx context.Context, ag *core.Agent, extMgr *extensions.Manager) {
224	if ag == nil || extMgr == nil {
225		return
226	}
227	ag.BeforeToolExecute = func(call provider.ToolCallBlock) (bool, string, json.RawMessage) {
228		res := extMgr.InterceptToolCall(ctx, call.ID, call.Name, call.Arguments)
229		if res.Block {
230			return false, res.Reason, nil
231		}
232		return true, "", res.ModifiedArgs
233	}
234	ag.BeforeTurn = func(step int) (bool, string) {
235		res := extMgr.InterceptTurnStart(ctx, step)
236		return !res.Block, res.Reason
237	}
238	ag.BeforeAssistantMessage = func(text string) (bool, string, string) {
239		res := extMgr.InterceptAssistantMessage(ctx, text)
240		if res.Block {
241			return false, res.Reason, ""
242		}
243		return true, "", res.ReplaceText
244	}
245	ag.OnEvent = func(ev core.AgentEvent) { fanoutAgentEvent(extMgr, ev) }
246}
247
248func runPrintMode(ctx context.Context, args Args, version string) error {
249	if args.NoYolo {
250		fmt.Fprintln(os.Stderr, "warning: --no-yolo has no effect in print mode (no interactive prompt available); tools will run without confirmation")
251	}
252	r, err := Resolve(args, true)
253	if err != nil {
254		return err
255	}
256	extMgr, stopExt := setupNonInteractiveExtensions(ctx, args, &r, version)
257	defer stopExt()
258
259	ag := r.NewAgent()
260	wireNonInteractiveAgentExtHooks(ctx, ag, extMgr)
261	sess, _ := openOrCreateSession(args, r, ag, version)
262	defer sess.Close()
263
264	prompt := args.Prompt
265	if prompt == "" {
266		piped, _ := readAllStdin()
267		prompt = strings.TrimSpace(piped)
268	}
269	if prompt == "" {
270		return fmt.Errorf("print mode requires a prompt (arg or stdin)")
271	}
272
273	start := len(ag.Messages())
274	err = modes.RunPrint(ctx, ag, prompt, nil, os.Stdout)
275	WriteNewTranscript(ag, sess, start)
276	return err
277}
278
279func runJSONMode(ctx context.Context, args Args, version string) error {
280	if args.NoYolo {
281		fmt.Fprintln(os.Stderr, "warning: --no-yolo has no effect in json mode (no interactive prompt available); tools will run without confirmation")
282	}
283	r, err := Resolve(args, true)
284	if err != nil {
285		return err
286	}
287	extMgr, stopExt := setupNonInteractiveExtensions(ctx, args, &r, version)
288	defer stopExt()
289
290	ag := r.NewAgent()
291	wireNonInteractiveAgentExtHooks(ctx, ag, extMgr)
292	sess, _ := openOrCreateSession(args, r, ag, version)
293	defer sess.Close()
294
295	prompt := args.Prompt
296	if prompt == "" {
297		piped, _ := readAllStdin()
298		prompt = strings.TrimSpace(piped)
299	}
300	if prompt == "" {
301		return fmt.Errorf("json mode requires a prompt (arg or stdin)")
302	}
303
304	start := len(ag.Messages())
305	err = modes.RunJSON(ctx, ag, prompt, nil, os.Stdout)
306	WriteNewTranscript(ag, sess, start)
307	return err
308}
309
310// ---- interactive mode: opens the TUI even without credentials ----
311
312func runInteractive(ctx context.Context, args Args, version string) error {
313	// Resolve WITHOUT requiring credentials.
314	r, err := Resolve(args, false)
315	if err != nil {
316		return err
317	}
318
319	authStore := AuthStoreFor()
320	mgr := auth.NewManager(authStore)
321	defer mgr.Close()
322
323	// Keep the sandbox pointer stable across agent rebuilds (login / model
324	// switch). The Interactive UI toggles the lock via this pointer, and
325	// rebuilt tool instances must share the same one so the lock sticks.
326	sharedSandbox := r.Sandbox
327
328	// Build the extension manager BEFORE the agent so we can fold
329	// extension-defined tools into the registry. Forward-declare iv so
330	// the host hooks adapter can dereference it after construction.
331	var iv *modes.Interactive
332	extHooks := &interactiveExtHooks{ivPtr: &iv}
333	extMgr := extensions.New(ZotHome(), r.CWD, version, r.Provider, r.Model, extHooks)
334	// --ext paths first so they win against installed extensions of
335	// the same name (loadOne's first-write-wins semantics).
336	for _, e := range extMgr.LoadExplicit(ctx, args.Exts) {
337		fmt.Fprintln(os.Stderr, "extension load:", e)
338	}
339	// --no-ext skips the global + project-local discovery scan;
340	// explicit --ext paths above are still honoured so you can run
341	// "only this extension" with --no-ext --ext ./x.
342	if !args.NoExt {
343		for _, e := range extMgr.Discover(ctx) {
344			fmt.Fprintln(os.Stderr, "extension load:", e)
345		}
346	}
347	// Wait briefly for extensions to flush their initial register_tool
348	// frames before we build the agent's tool registry. Half a second
349	// is plenty for any extension that's actually well-behaved; ones
350	// that don't send a ready frame eat the full grace and proceed.
351	// 3s is the per-extension grace period for the ready frame.
352	// Native binaries are instant; runtimes like `npx tsx` take ~1.5s
353	// from cold cache. The wait is tight only for extensions that
354	// haven't sent ready by then; ones that signalled earlier release
355	// the wait immediately.
356	extMgr.WaitForReady(3 * time.Second)
357	defer extMgr.Stop(2 * time.Second)
358
359	extToolAdapter := &extToolAdapter{mgr: extMgr}
360	r.MergeExtensionTools(extToolAdapter)
361
362	// Confirmation gate: when --no-yolo is on, the agent must ask
363	// the user before every tool call. In interactive mode the TUI
364	// provides the Confirmer; in print/json/rpc modes there's no
365	// way to prompt, so the gate is constructed with a nil inner
366	// which auto-refuses every call with a helpful reason.
367	var confirmGate *core.ConfirmGate
368	if args.NoYolo {
369		confirmGate = core.NewConfirmGate(nil) // set below for interactive
370	}
371
372	// Capture current args in a closure so BuildAgent can re-resolve
373	// after a successful login (picks up the newly stored credential).
374	wireAgentExt := func(a *core.Agent) *core.Agent {
375		if a == nil {
376			return a
377		}
378		a.BeforeToolExecute = func(call provider.ToolCallBlock) (bool, string, json.RawMessage) {
379			// Confirm gate runs FIRST: if the user refused, we don't
380			// waste extension-intercept time or let guards see the call.
381			if confirmGate != nil {
382				ok, reason, _ := confirmGate.Check(call.Name, core.BuildPreview(call.Arguments, 120))
383				if !ok {
384					return false, reason, nil
385				}
386			}
387			r := extMgr.InterceptToolCall(ctx, call.ID, call.Name, call.Arguments)
388			if r.Block {
389				return false, r.Reason, nil
390			}
391			return true, "", r.ModifiedArgs
392		}
393		a.BeforeTurn = func(step int) (bool, string) {
394			r := extMgr.InterceptTurnStart(ctx, step)
395			return !r.Block, r.Reason
396		}
397		a.BeforeAssistantMessage = func(text string) (bool, string, string) {
398			r := extMgr.InterceptAssistantMessage(ctx, text)
399			if r.Block {
400				return false, r.Reason, ""
401			}
402			return true, "", r.ReplaceText
403		}
404		a.OnEvent = func(ev core.AgentEvent) { fanoutAgentEvent(extMgr, ev) }
405		return a
406	}
407
408	buildAgent := func() (*core.Agent, string, string, error) {
409		resolved, err := Resolve(args, true)
410		if err != nil {
411			return nil, "", "", err
412		}
413		resolved.UseSandbox(sharedSandbox)
414		resolved.MergeExtensionTools(extToolAdapter)
415		return wireAgentExt(resolved.NewAgent()), resolved.Provider, resolved.Model, nil
416	}
417
418	// Rebuild agent with an explicit provider/model override.
419	buildAgentFor := func(providerOverride, modelOverride string) (*core.Agent, string, string, error) {
420		next := args
421		if providerOverride != "" {
422			next.Provider = providerOverride
423		}
424		if modelOverride != "" {
425			next.Model = modelOverride
426		}
427		resolved, err := Resolve(next, true)
428		if err != nil {
429			return nil, "", "", err
430		}
431		resolved.UseSandbox(sharedSandbox)
432		resolved.MergeExtensionTools(extToolAdapter)
433		return wireAgentExt(resolved.NewAgent()), resolved.Provider, resolved.Model, nil
434	}
435
436	var ag *core.Agent
437	if r.HasCredential() {
438		ag = wireAgentExt(r.NewAgent())
439	}
440
441	// /reload-ext callback: after the manager has respawned every
442	// extension, re-resolve the tool registry (built-ins + freshly-
443	// registered extension tools) and swap it onto the current
444	// agent in-place. The current agent may have been replaced by a
445	// /model swap since spawn, so re-read the live `ag` on each
446	// invocation.
447	extMgr.SetOnReload(func() {
448		current := ag
449		if current == nil {
450			return
451		}
452		resolved, err := Resolve(args, true)
453		if err != nil {
454			return
455		}
456		resolved.UseSandbox(sharedSandbox)
457		resolved.MergeExtensionTools(extToolAdapter)
458		current.SetTools(resolved.ToolRegistry)
459	})
460
461	// Fire session_start once we know the manager's running.
462	extMgr.EmitEvent(extproto.EventFromHost{Event: "session_start"})
463
464	var sess *core.Session
465	var sessBaselineMsgs int // messages already on disk when current session opened
466	if !args.NoSess && ag != nil {
467		sess, _ = openOrCreateSession(args, r, ag, version)
468		if ag != nil {
469			sessBaselineMsgs = len(ag.Messages())
470		}
471	}
472	defer func() {
473		if sess != nil {
474			sess.Close()
475		}
476	}()
477
478	// loadSession replaces the current session with the one at path and
479	// hands its messages to the agent. Used by the /sessions picker.
480	loadSession := func(path string) error {
481		currentAg := ag // captured
482		if currentAg == nil {
483			return fmt.Errorf("no agent running; log in first")
484		}
485		newSess, msgs, err := core.OpenSession(path)
486		if err != nil {
487			return err
488		}
489		// Flush any unsaved messages to the old session before swapping.
490		if sess != nil {
491			WriteNewTranscript(currentAg, sess, sessBaselineMsgs)
492			_ = sess.Close()
493		}
494		sess = newSess
495		currentAg.SetMessages(msgs)
496		sessBaselineMsgs = len(msgs)
497		return nil
498	}
499
500	term := tui.NewProcTerm()
501
502	// Kick off the async update check so the banner can appear when the
503	// http response eventually arrives (usually <1s on cached DNS). Map
504	// agent.UpdateInfo -> modes.UpdateInfo here to avoid a cyclic import.
505	updateCh := make(chan modes.UpdateInfo, 1)
506	go func() {
507		defer close(updateCh)
508		src := <-CheckForUpdateAsync(ZotHome(), version)
509		updateCh <- modes.UpdateInfo{
510			Current:   src.Current,
511			Latest:    src.Latest,
512			Available: src.Available,
513			URL:       src.URL,
514		}
515	}()
516
517	// Changelog: when the running version differs from the last
518	// version whose release notes the user dismissed, fetch the
519	// release body from GitHub and have the TUI show it once. On
520	// first-ever launch (no prior LastChangelogShown), seed the
521	// stored version silently — don't dump release notes at someone
522	// who just installed.
523	changelogCh := make(chan modes.ChangelogPayload, 1)
524	go func() {
525		defer close(changelogCh)
526		cfg, _ := LoadConfig()
527		if cfg.LastChangelogShown == "" {
528			SeedChangelogVersion(version)
529			return
530		}
531		if !ShouldShowChangelog(version, cfg) {
532			return
533		}
534		info := <-FetchChangelogAsync(version)
535		if info.Body == "" {
536			return
537		}
538		changelogCh <- modes.ChangelogPayload{
539			Version: info.Version,
540			Body:    info.Body,
541			URL:     info.URL,
542		}
543	}()
544
545	iv = modes.NewInteractive(modes.InteractiveConfig{
546		Terminal:       term,
547		Theme:          tui.Dark,
548		Model:          r.Model,
549		Provider:       r.Provider,
550		AuthMethod:     r.AuthMethod,
551		BaseURL:        r.BaseURL,
552		Reasoning:      r.Reasoning,
553		SystemPrompt:   r.SystemPrompt,
554		Tools:          r.ToolRegistry,
555		MaxSteps:       r.MaxSteps,
556		CWD:            r.CWD,
557		ZotHome:        ZotHome(),
558		Version:        version,
559		UpdateInfoChan: updateCh,
560		Sandbox:        sharedSandbox,
561		Agent:          ag,
562		InitialInput:   args.Prompt,
563		AuthManager:    mgr,
564		BuildAgent:     buildAgent,
565		BuildAgentFor:  buildAgentFor,
566		LoadSession:    loadSession,
567		Extensions:     extMgr,
568		ChangelogChan:  changelogCh,
569		OnChangelogDismiss: func() {
570			_ = MarkChangelogShown(version)
571		},
572		SkillSnapshot: func() []*skills.Skill {
573			if args.NoSkill {
574				// --no-skill: nothing for the picker to show.
575				return nil
576			}
577			// Re-discover so the picker reflects edits made during
578			// the session. Cheap; SKILL.md files are small. Filter
579			// out built-in skills — they're hidden from user-facing
580			// surfaces because they're implementation detail; the
581			// model still sees them through the system-prompt
582			// manifest and the skill tool. User skills only appear
583			// when --with-skills is set; without it the picker shows
584			// nothing but the model still has the built-ins.
585			userHome, _ := os.UserHomeDir()
586			list, _ := skills.Discover(ZotHome(), r.CWD, userHome, args.WithSkills)
587			return skills.VisibleSkills(list)
588		},
589		NoYolo:      args.NoYolo,
590		ConfirmGate: confirmGate,
591		PersistModel: func(providerName, model string) {
592			// Update config.json so next launch uses the same pick.
593			cfg, _ := LoadConfig()
594			cfg.Provider = providerName
595			cfg.Model = model
596			_ = SaveConfig(cfg)
597			// Update the active session's meta so resume picks this up.
598			if sess != nil {
599				_ = sess.UpdateModel(providerName, model)
600			}
601		},
602	})
603
604	// Bind the interactive TUI as the Confirmer. We deferred this
605	// until now because the gate is constructed before the TUI
606	// (the BeforeToolExecute closure captures it). SetConfirmer
607	// is mutex-guarded on the gate so this is safe.
608	if confirmGate != nil {
609		confirmGate.SetConfirmer(iv)
610	}
611
612	runErr := iv.Run(ctx)
613
614	// Flush final transcript to session (only if we had / ended up with an agent).
615	if finalAg := iv.Agent(); finalAg != nil && sess != nil {
616		WriteNewTranscript(finalAg, sess, sessBaselineMsgs)
617	}
618	return runErr
619}
620
621// openOrCreateSession returns a session for the run. sess may be nil
622// with a nil error if session persistence is disabled.
623func openOrCreateSession(args Args, r Resolved, ag *core.Agent, version string) (*core.Session, error) {
624	if args.NoSess {
625		return nil, nil
626	}
627	// Sweep meta-only files left over from older zot versions (and from
628	// any session that crashed before its first AppendMessage). Cheap;
629	// reads the first few bytes of each file in the cwd's session dir.
630	core.PruneEmptySessions(ZotHome(), args.CWD)
631	var (
632		s    *core.Session
633		msgs []provider.Message
634		err  error
635	)
636	switch {
637	case args.Session != "":
638		s, msgs, err = core.OpenSession(args.Session)
639	case args.Continue:
640		latest := core.LatestSession(ZotHome(), args.CWD)
641		if latest != "" {
642			s, msgs, err = core.OpenSession(latest)
643		}
644	case args.Resume:
645		picked, perr := pickSession(args.CWD)
646		if perr != nil {
647			return nil, perr
648		}
649		if picked != "" {
650			s, msgs, err = core.OpenSession(picked)
651		}
652	}
653	if err != nil {
654		return nil, err
655	}
656	if s != nil {
657		ag.SetMessages(msgs)
658		return s, nil
659	}
660	return core.NewSession(ZotHome(), args.CWD, r.Provider, r.Model, version)
661}
662
663func pickSession(cwd string) (string, error) {
664	files := core.ListSessions(ZotHome(), cwd)
665	if len(files) == 0 {
666		fmt.Fprintln(os.Stderr, "no sessions for", cwd)
667		return "", nil
668	}
669	for i, f := range files {
670		fmt.Fprintf(os.Stderr, "  %2d) %s\n", i+1, f)
671	}
672	fmt.Fprint(os.Stderr, "pick #: ")
673	rd := bufio.NewReader(os.Stdin)
674	line, _ := rd.ReadString('\n')
675	line = strings.TrimSpace(line)
676	var n int
677	if _, err := fmt.Sscanf(line, "%d", &n); err != nil || n < 1 || n > len(files) {
678		return "", fmt.Errorf("invalid selection")
679	}
680	return files[n-1], nil
681}
682
683// WriteNewTranscript appends only messages after index `from` from the
684// agent's transcript to the session.
685func WriteNewTranscript(ag *core.Agent, sess *core.Session, from int) {
686	if sess == nil || ag == nil {
687		return
688	}
689	msgs := ag.Messages()
690	for i := from; i < len(msgs); i++ {
691		_ = sess.AppendMessage(msgs[i])
692	}
693	cum := ag.Cost()
694	_ = sess.AppendUsage(cum, cum)
695}
696
697func readAllStdin() (string, error) {
698	fi, err := os.Stdin.Stat()
699	if err != nil {
700		return "", err
701	}
702	if (fi.Mode() & os.ModeCharDevice) != 0 {
703		return "", nil
704	}
705	b, err := io.ReadAll(os.Stdin)
706	return string(b), err
707}
708
709func printModels() {
710	fmt.Println("provider   model id                       context  max-out  reasoning  source        name")
711	for _, m := range provider.Active() {
712		reason := " "
713		if m.Reasoning {
714			reason = "✓"
715		}
716		source := m.Source
717		if source == "" {
718			source = "catalog"
719		}
720		if m.Speculative {
721			source = "speculative"
722		}
723		fmt.Printf("%-10s %-30s %8d %8d     %s        %-11s   %s\n",
724			m.Provider, m.ID, m.ContextWindow, m.MaxOutput, reason, source, m.DisplayName)
725	}
726}

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