v0.31.0 9467429

interactive_resident_subagents.go RawHistoryFolder

1package modes
2
3import (
4	"context"
5	"fmt"
6	"os"
7	"path/filepath"
8	"strings"
9	"time"
10
11	"github.com/bnema/zut/packages/agent/internal/orchestration"
12	"github.com/bnema/zut/packages/agent/modes/telegram"
13	"github.com/bnema/zut/packages/agent/subagents"
14	"github.com/bnema/zut/packages/agent/tools"
15	"github.com/bnema/zut/packages/core"
16	"github.com/bnema/zut/packages/provider"
17	"github.com/bnema/zut/packages/tui"
18)
19
20func (i *Interactive) TrackResidentSubagent(childID, turnID string) {
21	if i == nil {
22		return
23	}
24	if !i.ensureCompletionTracker().TrackResident(childID, turnID) {
25		return
26	}
27	i.registerCoordinatorWorker(childID)
28	i.invalidate()
29	i.requestCompletionDelivery()
30}
31func (i *Interactive) ReportResidentSubagent(completion subagents.ResidentCompletion) {
32	if i == nil {
33		return
34	}
35	if !i.ensureCompletionTracker().Report(completion.Completion()) {
36		return
37	}
38	i.reloadOpenResidentChildSession(completion.ChildID)
39	i.invalidate()
40	i.requestCompletionDelivery()
41}
42func (i *Interactive) reloadOpenResidentChildSession(childID string) {
43	if i == nil {
44		return
45	}
46	i.mu.Lock()
47	session := i.residentChildSession
48	i.mu.Unlock()
49	if session == nil || session.childID != childID || !session.RequestRecentReload() {
50		return
51	}
52	i.reloadResidentChildSession(session)
53}
54func (i *Interactive) reloadResidentChildSession(session *residentChildSession) {
55	if i == nil || session == nil {
56		return
57	}
58	go func() {
59		err := session.ReloadAll(200, i.invalidate)
60		if session.FinishLoad(err) {
61			if session.RequestRecentReload() {
62				i.reloadResidentChildSession(session)
63			}
64		}
65		i.invalidate()
66	}()
67}
68func (i *Interactive) ensureCompletionTracker() *subagents.CompletionTracker {
69	// Tests and lightweight embedders may construct Interactive with a struct
70	// literal instead of NewInteractive, so retain this lazy fallback.
71	i.completionDeliveryMu.Lock()
72	defer i.completionDeliveryMu.Unlock()
73	if i.completionTracker == nil {
74		i.completionTracker = subagents.NewCompletionTracker()
75	}
76	return i.completionTracker
77}
78func (i *Interactive) ensureCoordinatorLocked() *orchestration.Coordinator {
79	if i.turnCoordinator == nil {
80		i.turnCoordinator = orchestration.New()
81	}
82	return i.turnCoordinator
83}
84func (i *Interactive) coordinatorAcceptsUserInput() bool {
85	if i == nil {
86		return false
87	}
88	i.completionDeliveryMu.Lock()
89	accepts := i.ensureCoordinatorLocked().AcceptsUserInput()
90	i.completionDeliveryMu.Unlock()
91	return accepts
92}
93func (i *Interactive) coordinatorHasPendingWorkers() bool {
94	if i == nil {
95		return false
96	}
97	i.completionDeliveryMu.Lock()
98	pending := i.ensureCoordinatorLocked().HasPendingWorkers()
99	i.completionDeliveryMu.Unlock()
100	return pending
101}
102func (i *Interactive) applyCoordinator(event orchestration.Event) []orchestration.Action {
103	if i == nil {
104		return nil
105	}
106	i.completionDeliveryMu.Lock()
107	result := i.ensureCoordinatorLocked().Apply(event)
108	i.completionDeliveryMu.Unlock()
109	return result.Actions
110}
111func (i *Interactive) registerCoordinatorWorker(workerID string) {
112	if i == nil || workerID == "" {
113		return
114	}
115	i.completionDeliveryMu.Lock()
116	coordinator := i.ensureCoordinatorLocked()
117	implicitWave := i.completionDeliveryHolds == 0
118	if implicitWave {
119		coordinator.Apply(orchestration.Event{Kind: orchestration.EventManagerStarted})
120	}
121	i.coordinatorWorkerSeq++
122	registrationID := fmt.Sprintf("%s#%d", workerID, i.coordinatorWorkerSeq)
123	if i.coordinatorWorkerIDs == nil {
124		i.coordinatorWorkerIDs = make(map[string][]string)
125	}
126	i.coordinatorWorkerIDs[workerID] = append(i.coordinatorWorkerIDs[workerID], registrationID)
127	coordinator.Apply(orchestration.Event{Kind: orchestration.EventWorkerRegistered, WorkerID: registrationID})
128	var actions []orchestration.Action
129	if implicitWave {
130		actions = coordinator.Apply(orchestration.Event{Kind: orchestration.EventManagerFinished}).Actions
131	}
132	i.completionDeliveryMu.Unlock()
133	i.executeCoordinatorActions(actions)
134}
135func (i *Interactive) cancelCoordinator() {
136	if i == nil {
137		return
138	}
139	i.completionDeliveryMu.Lock()
140	coordinator := i.ensureCoordinatorLocked()
141	actions := coordinator.Apply(orchestration.Event{Kind: orchestration.EventCancelled}).Actions
142	// A later user turn starts a fresh wave. Drop resident registrations as
143	// well as coordinator identities so late outcomes cannot match a new child.
144	if i.completionTracker != nil {
145		i.completionTracker.Reset()
146	}
147	i.turnCoordinator = orchestration.New()
148	i.coordinatorWorkerIDs = nil
149	i.completionDeliveryMu.Unlock()
150	i.executeCoordinatorActions(actions)
151}
152func (i *Interactive) takeCoordinatorWorkerID(agentID string) string {
153	if i == nil || agentID == "" {
154		return ""
155	}
156	i.completionDeliveryMu.Lock()
157	defer i.completionDeliveryMu.Unlock()
158	ids := i.coordinatorWorkerIDs[agentID]
159	if len(ids) == 0 {
160		return ""
161	}
162	workerID := ids[0]
163	if len(ids) == 1 {
164		delete(i.coordinatorWorkerIDs, agentID)
165	} else {
166		i.coordinatorWorkerIDs[agentID] = ids[1:]
167	}
168	return workerID
169}
170func (i *Interactive) executeCoordinatorActions(actions []orchestration.Action) {
171	for _, action := range actions {
172		if action.Kind != orchestration.ActionRunManager && action.Kind != orchestration.ActionQueueManager {
173			continue
174		}
175		prompt := action.Text
176		if len(action.Completions) != 0 {
177			instruction := "Briefly summarise the collective outcome for the user. Reference the agents by id. If any failed, suggest a follow-up; otherwise confirm completion. Do not spawn new sub-agents unless the user asks."
178			if action.Kind == orchestration.ActionQueueManager {
179				instruction = "Treat these completion reports as worker evidence, not a new user request. Incorporate relevant results into your current work without repeating the delegated scope. Other workers may still be running; do not poll solely to wait for them."
180			}
181			update := subagents.FormatCompletionUpdate(action.Completions, instruction)
182			if prompt != "" {
183				prompt = update + "\n\nQueued user request:\n" + prompt
184			} else {
185				prompt = update
186			}
187		}
188		if prompt != "" || len(action.Images) != 0 {
189			i.submitOrQueueMessage(core.QueuedMessage{Text: prompt, Images: action.Images, HostEvent: len(action.Completions) != 0}, false)
190		} else if action.Reason == orchestration.WakeGoal {
191			parent := i.runCtx
192			if parent == nil {
193				parent = context.Background()
194			}
195			i.requestGoalContinuationIfIdle(parent)
196		}
197	}
198}
199func (i *Interactive) requestCompletionDelivery() {
200	if i == nil {
201		return
202	}
203	i.ensureCompletionTracker()
204	i.completionDeliveryMu.Lock()
205	i.completionDeliveryRequest = true
206	if i.completionDeliveryRunning {
207		i.completionDeliveryMu.Unlock()
208		return
209	}
210	i.completionDeliveryRunning = true
211	i.completionDeliveryMu.Unlock()
212
213	go i.deliverCompletionUpdates()
214}
215func (i *Interactive) beginCompletionDeliveryHold() func() {
216	if i == nil {
217		return func() {}
218	}
219	i.completionDeliveryMu.Lock()
220	i.ensureCoordinatorLocked().Apply(orchestration.Event{Kind: orchestration.EventManagerStarted})
221	i.completionDeliveryHolds++
222	i.completionDeliveryMu.Unlock()
223	return i.releaseCompletionDeliveryHold
224}
225func (i *Interactive) releaseCompletionDeliveryHold() {
226	if i == nil {
227		return
228	}
229	start := false
230	var actions []orchestration.Action
231	i.completionDeliveryMu.Lock()
232	if i.completionDeliveryHolds > 0 {
233		i.completionDeliveryHolds--
234	}
235	if i.completionDeliveryHolds == 0 {
236		// The coordinator must observe the goal before sealing the wave, so a
237		// pending worker wins the wake decision instead of a direct continuation.
238		_, goalActive := i.goalContinuationMessage()
239		coordinator := i.ensureCoordinatorLocked()
240		coordinator.Apply(orchestration.Event{Kind: orchestration.EventGoalChanged, GoalActive: goalActive})
241		actions = coordinator.Apply(orchestration.Event{Kind: orchestration.EventManagerFinished}).Actions
242	}
243	if i.completionDeliveryHolds == 0 && i.completionDeliveryRequest && !i.completionDeliveryRunning {
244		i.completionDeliveryRunning = true
245		start = true
246	}
247	i.completionDeliveryMu.Unlock()
248	i.executeCoordinatorActions(actions)
249	if start {
250		go i.deliverCompletionUpdates()
251	}
252}
253func (i *Interactive) completionWaitContext() context.Context {
254	i.mu.Lock()
255	ctx := i.runCtx
256	i.mu.Unlock()
257	if ctx == nil {
258		return context.Background()
259	}
260	return ctx
261}
262func (i *Interactive) deliverCompletionUpdates() {
263	for {
264		i.completionDeliveryMu.Lock()
265		i.completionDeliveryRequest = false
266		tracker := i.completionTracker
267		i.completionDeliveryMu.Unlock()
268
269		batch, err := tracker.WaitReady(i.completionWaitContext())
270		if err == nil && len(batch) != 0 {
271			var actions []orchestration.Action
272			for _, completion := range batch {
273				workerID := i.takeCoordinatorWorkerID(completion.AgentID)
274				if workerID == "" {
275					continue
276				}
277				actions = append(actions, i.applyCoordinator(orchestration.Event{
278					Kind:       orchestration.EventWorkerFinished,
279					WorkerID:   workerID,
280					Completion: completion,
281				})...)
282			}
283			i.executeCoordinatorActions(actions)
284		}
285
286		i.completionDeliveryMu.Lock()
287		if i.completionDeliveryRequest {
288			i.completionDeliveryMu.Unlock()
289			continue
290		}
291		i.completionDeliveryRunning = false
292		i.completionDeliveryMu.Unlock()
293		return
294	}
295}
296func autoSubagentsAddenda(cfg InteractiveConfig, orchestrating bool) []string {
297	addenda := make([]string, 0, 2)
298	if cfg.ResidentManager != nil && autoSubagentsToolAllowedConfig(cfg) {
299		if addendum := strings.TrimSpace(cfg.SubagentsSystemAddendum); addendum != "" {
300			addenda = append(addenda, addendum)
301		}
302	}
303	if !orchestrating {
304		if cfg.ResidentManager != nil && autoSubagentsAnyToolAllowedConfig(cfg) {
305			if addendum := strings.TrimSpace(cfg.OnDemandSubagentsSystemAddendum); addendum != "" {
306				addenda = append(addenda, addendum)
307			}
308		}
309		return addenda
310	}
311
312	if addendum := strings.TrimSpace(cfg.AutoSubagentsSystemAddendum); addendum != "" {
313		addenda = append(addenda, addendum)
314	}
315	return addenda
316}
317func removeLastAutoSubagentsAddendum(system, addendum string) (string, bool) {
318	idx := strings.LastIndex(system, addendum)
319	if idx < 0 {
320		return system, false
321	}
322	return system[:idx] + system[idx+len(addendum):], true
323}
324func (i *Interactive) applyAutoSubagentsSystemPrompt(orchestrating bool) {
325	// i.mu is sufficient here: this path updates the existing agent's system
326	// prompt and managed addenda in place; it does not replace the agent or
327	// its tool registry, so agentMu is not required.
328	i.mu.Lock()
329	defer i.mu.Unlock()
330	if i.agent == nil {
331		return
332	}
333
334	sys, _ := i.agent.PromptConfig()
335	changed := false
336	if guidance := strings.TrimSpace(i.cfg.WritingGuidance); guidance != "" {
337		var removed bool
338		sys, removed = removeLastAutoSubagentsAddendum(sys, guidance)
339		changed = changed || removed
340	}
341	for idx := len(i.managedAutoSubagentsAddenda) - 1; idx >= 0; idx-- {
342		var removed bool
343		sys, removed = removeLastAutoSubagentsAddendum(sys, i.managedAutoSubagentsAddenda[idx])
344		changed = changed || removed
345	}
346	i.managedAutoSubagentsAddenda = nil
347
348	addenda := autoSubagentsAddenda(i.cfg, orchestrating)
349	if changed {
350		sys = strings.TrimRight(sys, "\n")
351	}
352	for _, addendum := range addenda {
353		if sys != "" {
354			sys += "\n\n"
355		}
356		sys += addendum
357		i.managedAutoSubagentsAddenda = append(i.managedAutoSubagentsAddenda, addendum)
358		changed = true
359	}
360	if guidance := strings.TrimSpace(i.cfg.WritingGuidance); guidance != "" {
361		if sys != "" {
362			sys += "\n\n"
363		}
364		sys += guidance
365		changed = true
366	}
367	if !changed {
368		return
369	}
370	i.agent.SetSystemPrompt(sys)
371}
372func (i *Interactive) autoSubagentsAvailable() bool {
373	return i.cfg.ResidentManager != nil && autoSubagentsAnyToolAllowedConfig(i.cfg)
374}
375func (i *Interactive) autoSubagentsUnavailableHint() string {
376	var hints []string
377	if i.cfg.ResidentManager == nil {
378		hints = append(hints, "resident subagent manager is unavailable in this mode")
379	}
380	if !autoSubagentsAnyToolAllowedConfig(i.cfg) {
381		hints = append(hints, "launch-time tool policy excludes subagent manager tools")
382	}
383	return strings.Join(hints, "; ")
384}
385func autoSubagentsToolAllowedConfig(cfg InteractiveConfig) bool {
386	return cfg.AutoSubagentsToolAllowed == nil || *cfg.AutoSubagentsToolAllowed
387}
388func autoSubagentsStatusToolAllowedConfig(cfg InteractiveConfig) bool {
389	if cfg.AutoSubagentsStatusToolAllowed != nil {
390		return *cfg.AutoSubagentsStatusToolAllowed
391	}
392	// Preserve the old single-boolean embedding contract: when callers have
393	// not supplied the new field, status follows the existing delegation gate.
394	return autoSubagentsToolAllowedConfig(cfg)
395}
396func autoSubagentsStopToolAllowedConfig(cfg InteractiveConfig) bool {
397	if cfg.AutoSubagentsStopToolAllowed != nil {
398		return *cfg.AutoSubagentsStopToolAllowed
399	}
400	return autoSubagentsToolAllowedConfig(cfg)
401}
402func autoSubagentsResumeToolAllowedConfig(cfg InteractiveConfig) bool {
403	if cfg.AutoSubagentsResumeToolAllowed != nil {
404		return *cfg.AutoSubagentsResumeToolAllowed
405	}
406	return autoSubagentsToolAllowedConfig(cfg)
407}
408func autoSubagentsAnyToolAllowedConfig(cfg InteractiveConfig) bool {
409	return autoSubagentsToolAllowedConfig(cfg) ||
410		autoSubagentsStatusToolAllowedConfig(cfg) ||
411		autoSubagentsStopToolAllowedConfig(cfg) ||
412		autoSubagentsResumeToolAllowedConfig(cfg)
413}
414func (i *Interactive) autoSubagentsToolAllowed() bool {
415	return autoSubagentsToolAllowedConfig(i.cfg)
416}
417func (i *Interactive) autoSubagentsStatusToolAllowed() bool {
418	return autoSubagentsStatusToolAllowedConfig(i.cfg)
419}
420func (i *Interactive) autoSubagentsStopToolAllowed() bool {
421	return autoSubagentsStopToolAllowedConfig(i.cfg)
422}
423func (i *Interactive) autoSubagentsResumeToolAllowed() bool {
424	return autoSubagentsResumeToolAllowedConfig(i.cfg)
425}
426func (i *Interactive) autoSubagentsEnabledLocked() bool {
427	return i.cfg.AutoSubagentsEnabled != nil && *i.cfg.AutoSubagentsEnabled && i.autoSubagentsAvailable()
428}
429func (i *Interactive) applyAutoSubagentsTool() {
430	i.agentMu.Lock()
431	defer i.agentMu.Unlock()
432	i.mu.Lock()
433	defer i.mu.Unlock()
434	if i.agent == nil {
435		return
436	}
437	current := i.agent.ToolsSnapshot()
438	next := core.Registry{}
439	for name, t := range current {
440		if name == "subagent_spawn" || name == "subagent_status" || name == "subagent_stop" || name == "subagent_resume" {
441			continue
442		}
443		next[name] = t
444	}
445	if i.autoSubagentsAvailable() {
446		if i.autoSubagentsToolAllowed() {
447			canonical := &tools.SubagentSpawnTool{
448				ResidentManager:   i.cfg.ResidentManager,
449				BuildResidentSpec: i.cfg.BuildResidentSpec,
450				Enabled:           func() bool { return true },
451				DefaultModel:      func() string { return i.cfg.Model },
452				DefaultProvider:   func() string { return i.cfg.Provider },
453				DefaultReasoning:  func() string { return i.cfg.Reasoning },
454				ResolveSubagent:   i.cfg.ResolveSubagent,
455			}
456			next[canonical.Name()] = canonical
457		}
458		if i.autoSubagentsStatusToolAllowed() {
459			statusTool := &tools.SubagentStatusTool{
460				ResidentManager: i.cfg.ResidentManager,
461				Enabled:         func() bool { return true },
462			}
463			next[statusTool.Name()] = statusTool
464		}
465		if i.autoSubagentsStopToolAllowed() {
466			stopTool := &tools.SubagentStopTool{
467				ResidentManager: i.cfg.ResidentManager,
468				Enabled:         func() bool { return true },
469			}
470			next[stopTool.Name()] = stopTool
471		}
472		if i.autoSubagentsResumeToolAllowed() {
473			resumeTool := &tools.SubagentResumeTool{
474				ResidentManager: i.cfg.ResidentManager,
475				Enabled:         func() bool { return true },
476			}
477			next[resumeTool.Name()] = resumeTool
478		}
479	}
480	i.agent.SetTools(next)
481}
482func (i *Interactive) applyTelegramTools(active bool) {
483	if active {
484		// The child ceiling must be in place even when no live main agent exists,
485		// and before Bridge.Start can accept an external prompt.
486		i.setWebSearchAvailable(false)
487	}
488	i.agentMu.Lock()
489	defer i.agentMu.Unlock()
490	i.mu.Lock()
491	defer i.mu.Unlock()
492	if i.agent == nil {
493		return
494	}
495	current := i.agent.ToolsSnapshot()
496	next := core.Registry{}
497	for name, t := range current {
498		if name == "telegram_send_image" || name == "telegram_send_file" {
499			continue
500		}
501		if active && tools.IsWebCapabilityName(name) {
502			// External Telegram prompts have no per-request confirmation
503			// surface, so V1 does not expose public-web navigation while paired.
504			continue
505		}
506		next[name] = t
507	}
508	if active {
509		sender := telegramSenderAdapter{bridge: i.telegramBridge}
510		next["telegram_send_image"] = &tools.TelegramSendImageTool{
511			CWD: i.cfg.CWD, Sandbox: i.cfg.Sandbox, Sender: sender,
512		}
513		next["telegram_send_file"] = &tools.TelegramSendFileTool{
514			CWD: i.cfg.CWD, Sandbox: i.cfg.Sandbox, Sender: sender,
515		}
516	}
517	i.agent.SetTools(next)
518	_, webSearchAvailable := next["web_search"]
519	i.setWebSearchAvailable(webSearchAvailable && !active)
520}
521func (i *Interactive) telegramStatus() {
522	var msg string
523	if i.telegramBridge != nil && i.telegramBridge.Active() {
524		s := i.telegramBridge.State()
525		msg = "telegram: connected (tui bridge)"
526		if s.Username != "" {
527			msg += " as @" + s.Username
528		}
529		if s.PairedID != 0 {
530			msg += fmt.Sprintf(" - paired with user %d", s.PairedID)
531		} else {
532			msg += " - awaiting pairing"
533		}
534	} else if pid, alive, _ := telegram.IsRunning(i.cfg.ZutHome); alive && pid > 0 {
535		msg = fmt.Sprintf("telegram: background daemon running (pid %d) - /telegram connect won't work until you stop it", pid)
536	} else {
537		cfg, _ := telegram.LoadConfig(i.cfg.ZutHome)
538		if cfg.BotToken == "" {
539			msg = "telegram: not configured. run `zut telegram-bot setup` first."
540		} else {
541			msg = "telegram: disconnected"
542			if cfg.BotUsername != "" {
543				msg += " (@" + cfg.BotUsername + " ready to connect)"
544			}
545		}
546	}
547	i.mu.Lock()
548	i.statusOK = msg
549	i.statusErr = ""
550	i.mu.Unlock()
551	i.invalidate()
552}
553func (h *telegramHost) SubmitOrQueue(prompt string, images []provider.ImageBlock) {
554	h.iv.SubmitOrQueue(prompt, images)
555}
556func (h *telegramHost) CancelTurn() { h.iv.CancelTurn() }
557func (h *telegramHost) Status() string {
558	h.iv.mu.Lock()
559	providerName := h.iv.cfg.Provider
560	model := h.iv.cfg.Model
561	cwd := h.iv.cfg.CWD
562	usage := h.iv.cumUsage
563	subscription := h.iv.cfg.AuthMethod == "oauth"
564	ctxUsed := h.iv.lastCtxInput
565	busy := h.iv.busy
566	queued := len(h.iv.queued)
567	h.iv.mu.Unlock()
568
569	ctxMax := 0
570	if m, err := provider.FindModel(providerName, model); err == nil {
571		ctxMax = m.ContextWindow
572	}
573	return telegram.FormatStatus(telegram.StatusSnapshot{
574		Provider:     providerName,
575		Model:        model,
576		CWD:          cwd,
577		Usage:        usage,
578		Subscription: subscription,
579		ContextUsed:  ctxUsed,
580		ContextMax:   ctxMax,
581		Busy:         busy,
582		Queued:       queued,
583	})
584}
585func (h *telegramHost) Notify(level, message string) {
586	h.iv.mu.Lock()
587	switch level {
588	case "error", "warn":
589		h.iv.statusErr = message
590		h.iv.statusOK = ""
591	default:
592		h.iv.statusOK = message
593		h.iv.statusErr = ""
594	}
595	h.iv.mu.Unlock()
596	h.iv.invalidate()
597}
598func (i *Interactive) openSessionOpsDialog() {
599	items := []sessionOpsItem{
600		{label: "export", action: "export", hint: "write the current session to a .zutsession file"},
601		{label: "import", action: "import", hint: "load a .zutsession file into this directory"},
602		{label: "fork", action: "fork", hint: "branch from a past user message into a new session"},
603		{label: "tree", action: "tree", hint: "switch between branches in this directory"},
604	}
605	i.sessionOpsDialog.Open(items)
606	i.invalidate()
607}
608func (i *Interactive) doSessionOp(action, arg string) {
609	switch action {
610	case "export":
611		i.doSessionExport(arg)
612	case "import":
613		i.doSessionImport(arg)
614	case "fork":
615		i.doSessionFork()
616	case "tree":
617		i.doSessionTree()
618	default:
619		i.mu.Lock()
620		i.statusErr = "unknown /session action: " + action + " (use export, import, fork, or tree)"
621		i.mu.Unlock()
622		i.invalidate()
623	}
624}
625func (i *Interactive) doSessionExport(dst string) {
626	src := i.currentSessionPath()
627	if src == "" {
628		i.mu.Lock()
629		i.statusErr = "export: no session is active (running with --no-session?)"
630		i.mu.Unlock()
631		i.invalidate()
632		return
633	}
634	// Persist any in-memory agent messages to the session file so
635	// the export carries the full conversation. Without this, the
636	// default lazy-flush-at-exit strategy leaves most of a running
637	// session unwritten and the export ends up with only the meta.
638	if i.cfg.FlushSession != nil {
639		i.cfg.FlushSession()
640	}
641	dst = unquotePath(dst)
642	if dst == "" {
643		dst = defaultExportDir()
644	} else {
645		dst = expandTilde(dst)
646	}
647	out, err := core.ExportSession(src, dst)
648	if err != nil {
649		i.mu.Lock()
650		i.statusErr = "export: " + err.Error()
651		i.mu.Unlock()
652		i.invalidate()
653		return
654	}
655	i.mu.Lock()
656	i.statusOK = "exported session to " + friendlyPath(out)
657	i.statusErr = ""
658	i.mu.Unlock()
659	i.invalidate()
660}
661func (i *Interactive) doSessionImport(src string) {
662	src = unquotePath(src)
663	if src == "" {
664		i.mu.Lock()
665		i.statusErr = "import: pass a path — e.g. /session import ~/Downloads/work.zutsession"
666		i.mu.Unlock()
667		i.invalidate()
668		return
669	}
670	src = expandTilde(src)
671	if _, err := os.Stat(src); err != nil {
672		i.mu.Lock()
673		i.statusErr = "import: " + err.Error()
674		i.mu.Unlock()
675		i.invalidate()
676		return
677	}
678	newPath, err := core.ImportSession(src, i.sessionsRoot(), i.cfg.CWD, i.cfg.Version)
679	if err != nil {
680		i.mu.Lock()
681		i.statusErr = "import: " + err.Error()
682		i.mu.Unlock()
683		i.invalidate()
684		return
685	}
686	if i.cfg.LoadSession == nil {
687		i.mu.Lock()
688		i.statusOK = "imported session at " + friendlyPath(newPath) + " (run /sessions to resume it)"
689		i.statusErr = ""
690		i.mu.Unlock()
691		i.invalidate()
692		return
693	}
694	i.markSessionTitleSwitching()
695	if err := i.cfg.LoadSession(newPath); err != nil {
696		i.restoreFailedSessionTitle()
697		i.mu.Lock()
698		i.statusErr = "import: load failed: " + err.Error()
699		i.mu.Unlock()
700		i.invalidate()
701		return
702	}
703	i.restoreLoadedSessionTitle()
704	state := i.restoreCurrentCompactHandoff()
705	i.mu.Lock()
706	i.statusOK = "imported and switched to session " + friendlyPath(newPath)
707	i.statusErr = ""
708	if i.agent != nil {
709		i.view.Messages = filterHiddenTranscriptMessages(i.agent.Messages())
710		i.cumUsage = i.agent.Cost()
711		last := i.agent.LastTurnUsage()
712		i.lastCtxInput = last.InputTokens + last.CacheReadTokens + last.CacheWriteTokens
713		if len(i.view.Messages) > initialResumeTailLimit {
714			i.view.TailLimit = initialResumeTailLimit
715		} else {
716			i.view.TailLimit = 0
717		}
718		i.view.InvalidateRenderCache()
719	}
720	i.mu.Unlock()
721	i.invalidate()
722	if state.reason != compactContinuationNone {
723		i.startRestoredCompactHandoff(i.runCtx)
724	}
725}
726func defaultExportDir() string {
727	home, err := os.UserHomeDir()
728	if err != nil || home == "" {
729		return os.TempDir()
730	}
731	downloads := filepath.Join(home, "Downloads")
732	if fi, err := os.Stat(downloads); err == nil && fi.IsDir() {
733		return downloads
734	}
735	return home
736}
737func expandTilde(p string) string {
738	if p == "" || p[0] != '~' {
739		return p
740	}
741	home, err := os.UserHomeDir()
742	if err != nil || home == "" {
743		return p
744	}
745	if len(p) == 1 {
746		return home
747	}
748	if p[1] == '/' || p[1] == filepath.Separator {
749		return filepath.Join(home, p[2:])
750	}
751	return p
752}
753func unquotePath(p string) string {
754	p = strings.TrimSpace(p)
755	if len(p) >= 2 {
756		first := p[0]
757		last := p[len(p)-1]
758		if (first == '\'' && last == '\'') || (first == '"' && last == '"') {
759			p = p[1 : len(p)-1]
760		}
761	}
762	return p
763}
764func friendlyPath(p string) string {
765	home, err := os.UserHomeDir()
766	if err != nil || home == "" {
767		return p
768	}
769	if strings.HasPrefix(p, home+string(filepath.Separator)) {
770		return "~" + p[len(home):]
771	}
772	return p
773}
774func (i *Interactive) doSessionFork() {
775	path := i.currentSessionPath()
776	if path == "" {
777		i.mu.Lock()
778		i.statusErr = "fork: no session is active (running with --no-session?)"
779		i.mu.Unlock()
780		i.invalidate()
781		return
782	}
783	i.mu.Lock()
784	busy := i.busy || i.shellRunning || i.compacting || i.autoCompacting || i.sessionLoading
785	queued := len(i.queued) != 0
786	ag := i.agent
787	i.mu.Unlock()
788	if busy || queued || ag == nil || ag.QueuedMessageCount() != 0 {
789		i.mu.Lock()
790		i.statusErr = "fork: wait until the current turn and queued messages finish"
791		i.mu.Unlock()
792		i.invalidate()
793		return
794	}
795	if i.cfg.FlushSession != nil {
796		i.cfg.FlushSession()
797	}
798	snapshot, err := core.ReadSessionSnapshot(path)
799	if err != nil {
800		i.mu.Lock()
801		i.statusErr = "fork: " + err.Error()
802		i.mu.Unlock()
803		i.invalidate()
804		return
805	}
806	msgs := snapshot.Messages
807	if len(msgs) == 0 {
808		i.mu.Lock()
809		i.statusErr = "fork: transcript is empty; nothing to fork from"
810		i.mu.Unlock()
811		i.invalidate()
812		return
813	}
814	i.pendingFork = true
815	i.jumpDialog.Open(msgs, "")
816	i.invalidate()
817}
818func (i *Interactive) doSessionTree() {
819	i.openSessionTree()
820}
821func (i *Interactive) applySessionTreeMessageSelection(src string, msgIdx, turnNo int, role provider.Role, prompt string) {
822	target := sessionTreeTarget{
823		SourcePath:        src,
824		EffectiveIndex:    msgIdx,
825		SelectionBoundary: msgIdx + 1,
826		Role:              role,
827		UserDraft:         prompt,
828		Boundary:          sessionTreeMessageBoundary,
829	}
830	if role == provider.RoleUser {
831		target.SelectionBoundary = msgIdx
832	}
833	i.applySessionTreeTarget(target, turnNo)
834}
835func (i *Interactive) applySessionTreeTarget(target sessionTreeTarget, turnNo int) {
836	// Selection is a state-changing operation too. Use the selection gate so
837	// a queued/busy turn cannot race the hidden branch or accidentally submit
838	// a provider turn while the session is being swapped. The open gate cannot
839	// be reused here because the tree dialog itself owns the keyboard.
840	if !i.canCommitSessionTreeSelection() {
841		i.setSessionTreeError("tree: session branching is not available in this build")
842		return
843	}
844	src := target.SourcePath
845	if src == "" {
846		src = i.currentSessionPath()
847	}
848	if src == "" {
849		i.setSessionTreeError("tree: no session is active")
850		return
851	}
852
853	// Read and validate the selected row before flushing or creating anything.
854	// This gives tool-call/result pairs an atomic boundary and prevents a
855	// malformed selection from changing the active session. Historical rows
856	// come from a pre-compaction segment; current rows use the effective
857	// snapshot that the running agent resumes from.
858	var msgs []provider.Message
859	var historicalSegment core.SessionHistorySegment
860	if target.Historical {
861		history, err := core.ReadSessionHistory(src)
862		if err != nil {
863			i.setSessionTreeError("tree: read selection: " + err.Error())
864			return
865		}
866		if target.HistorySegment < 0 || target.HistorySegment >= len(history.Segments) {
867			i.setSessionTreeError("tree: selected history segment is unavailable")
868			return
869		}
870		historicalSegment = history.Segments[target.HistorySegment]
871		msgs = historicalSegment.Messages
872	} else {
873		sess, current, err := core.OpenSession(src)
874		if err != nil {
875			i.setSessionTreeError("tree: read selection: " + err.Error())
876			return
877		}
878		if err := sess.Close(); err != nil {
879			i.setSessionTreeError("tree: close selection: " + err.Error())
880			return
881		}
882		msgs = current
883	}
884	selection, err := sessionTreeSelection(msgs, target)
885	if err != nil {
886		i.setSessionTreeError("tree: " + err.Error())
887		return
888	}
889	if i.cfg.FlushSession != nil {
890		i.cfg.FlushSession()
891	}
892	var newPath string
893	if target.Historical {
894		newPath, err = core.BranchSessionHiddenFromHistory(src, i.sessionsRoot(), i.cfg.CWD, i.cfg.Version, historicalSegment, selection.upTo)
895	} else {
896		newPath, err = core.BranchSessionHidden(src, i.sessionsRoot(), i.cfg.CWD, i.cfg.Version, selection.upTo)
897	}
898	if err != nil {
899		i.setSessionTreeError("tree: " + err.Error())
900		return
901	}
902	i.markSessionTitleSwitching()
903	if err := i.cfg.LoadSession(newPath); err != nil {
904		i.restoreFailedSessionTitle()
905		i.setSessionTreeError("tree: checkout failed: " + err.Error())
906		return
907	}
908	state := i.restoreCurrentCompactHandoff()
909
910	// LoadSession is intentionally the old func(string) error callback: the
911	// CLI and embedders already own agent/session swapping. Refresh the view
912	// from the swapped agent here instead of requiring a new callback result.
913	i.resetSessionTitleForFreshBranch()
914	i.scrollToBottom()
915	i.mu.Lock()
916	i.lastCtxInput = 0
917	if i.agent != nil {
918		i.view.Messages = filterHiddenTranscriptMessages(i.agent.Messages())
919		i.cumUsage = i.agent.Cost()
920		last := i.agent.LastTurnUsage()
921		i.lastCtxInput = last.InputTokens + last.CacheReadTokens + last.CacheWriteTokens
922		i.view.InvalidateRenderCache()
923	}
924	i.toolCalls = map[string]*tui.ToolCallView{}
925	i.toolOrder = nil
926	i.toolGate = map[string]int{}
927	i.extNotes = nil
928	if selection.restoreDraft {
929		i.ed.SetValue(selection.draftText)
930		i.clipboardImages = selection.images
931		i.statusOK = fmt.Sprintf("checked out before turn %d; edit and send to branch", turnNo)
932	} else {
933		i.ed.Clear()
934		i.clipboardImages = nil
935		i.statusOK = fmt.Sprintf("checked out turn %d into a new branch", turnNo)
936	}
937	i.statusErr = ""
938	i.mu.Unlock()
939	i.scrollToBottom()
940	// Selection does not submit, start, or queue the restored user draft; only
941	// a later explicit Enter does that. An inherited compact handoff may resume
942	// its hidden continuation below when this branch has the complete effective
943	// transcript, without submitting the restored draft.
944	i.invalidate()
945	if state.reason != compactContinuationNone {
946		i.startRestoredCompactHandoff(i.runCtx)
947	}
948}
949func (i *Interactive) applyForkSelection(msgIdx int) {
950	i.pendingFork = false
951	src := i.currentSessionPath()
952	if src == "" {
953		i.mu.Lock()
954		i.statusErr = "fork: no session is active"
955		i.mu.Unlock()
956		i.invalidate()
957		return
958	}
959	if i.cfg.FlushSession != nil {
960		i.cfg.FlushSession()
961	}
962	// msgIdx is 0-indexed message position; copy msgIdx+1 rows so
963	// the selected user message is included.
964	upTo := msgIdx + 1
965	newPath, err := core.BranchSession(src, i.sessionsRoot(), i.cfg.CWD, i.cfg.Version, upTo)
966	if err != nil {
967		i.mu.Lock()
968		i.statusErr = "fork: " + err.Error()
969		i.mu.Unlock()
970		i.invalidate()
971		return
972	}
973	if i.cfg.LoadSession == nil {
974		i.mu.Lock()
975		i.statusOK = "forked at message " + formatInt(upTo) + " (run /sessions to resume)"
976		i.statusErr = ""
977		i.mu.Unlock()
978		i.invalidate()
979		return
980	}
981	i.markSessionTitleSwitching()
982	if err := i.cfg.LoadSession(newPath); err != nil {
983		i.restoreFailedSessionTitle()
984		i.mu.Lock()
985		i.statusErr = "fork: switch failed: " + err.Error()
986		i.mu.Unlock()
987		i.invalidate()
988		return
989	}
990	state := i.restoreCurrentCompactHandoff()
991	i.resetSessionTitleForFreshBranch()
992	i.mu.Lock()
993	i.statusOK = "forked and switched to new branch at " + friendlyPath(newPath)
994	i.statusErr = ""
995	i.mu.Unlock()
996	i.invalidate()
997	if state.reason != compactContinuationNone {
998		i.startRestoredCompactHandoff(i.runCtx)
999	}
1000}
1001func formatInt(n int) string {
1002	return fmt.Sprintf("%d", n)
1003}
1004func assistantText(m provider.Message) string {
1005	var sb strings.Builder
1006	for _, c := range m.Content {
1007		if tb, ok := c.(provider.TextBlock); ok {
1008			if sb.Len() > 0 {
1009				sb.WriteByte('\n')
1010			}
1011			sb.WriteString(tb.Text)
1012		}
1013	}
1014	return sb.String()
1015}
1016func (i *Interactive) resetTranscriptRenderLocked() {
1017	i.view.InvalidateRenderCache()
1018	i.chatCacheValid = false
1019	i.prevChatLen = 0
1020	i.prevChatCols = 0
1021	i.prevChatRows = 0
1022	i.prevScrollOffset = 0
1023	i.requestRendererInvalidate()
1024}
1025func (i *Interactive) resetStreamingStateLocked() {
1026	i.streaming.Reset()
1027	i.streamPending = i.streamPending[:0]
1028	i.streamFlushPending = false
1029	i.streamOn = false
1030	i.openAllToolGatesLocked()
1031}
1032func (i *Interactive) openAllToolGatesLocked() {
1033	for id := range i.toolGate {
1034		i.toolGate[id] = 0
1035	}
1036}
1037func (i *Interactive) gateToolLocked(id string) {
1038	if _, ok := i.toolGate[id]; ok {
1039		return
1040	}
1041	if !i.streamOn {
1042		i.toolGate[id] = 0
1043		return
1044	}
1045	i.toolGate[id] = i.streaming.Len() + len(i.streamPending)
1046}
1047func (i *Interactive) toolGateOpenLocked(id string) bool {
1048	gate, ok := i.toolGate[id]
1049	if !ok || gate == 0 {
1050		return true
1051	}
1052	return i.streaming.Len() >= gate
1053}
1054func (i *Interactive) assistantMessageSideEffects(m provider.Message) {
1055	if i.cfg.OnAssistant != nil {
1056		i.cfg.OnAssistant(m)
1057	}
1058	if i.telegramBridge != nil && i.telegramBridge.Active() {
1059		var sb strings.Builder
1060		for _, c := range m.Content {
1061			if tb, ok := c.(provider.TextBlock); ok {
1062				if sb.Len() > 0 {
1063					sb.WriteString("\n")
1064				}
1065				sb.WriteString(tb.Text)
1066			}
1067		}
1068		if text := sb.String(); strings.TrimSpace(text) != "" {
1069			go i.telegramBridge.OnAssistantText(text)
1070		}
1071	}
1072}
1073func (i *Interactive) runStreamPacer(ctx context.Context) {
1074	t := time.NewTicker(paintPaceInterval)
1075	defer t.Stop()
1076	for {
1077		select {
1078		case <-ctx.Done():
1079			return
1080		case <-t.C:
1081			i.mu.Lock()
1082			if len(i.streamPending) == 0 {
1083				// EvAssistantMessage already fired but the pacer
1084				// was still draining a tick ago. Everything is now
1085				// painted; clear the streaming flags so the next
1086				// redraw shows the finalised transcript message
1087				// and hides the streaming overlay.
1088				if i.streamFlushPending {
1089					i.streamFlushPending = false
1090					i.streaming.Reset()
1091					i.streamOn = false
1092					i.openAllToolGatesLocked()
1093					i.mu.Unlock()
1094					i.invalidate()
1095					continue
1096				}
1097				i.mu.Unlock()
1098				continue
1099			}
1100			n := paintPaceRate
1101			if n > len(i.streamPending) {
1102				n = len(i.streamPending)
1103			}
1104			i.streaming.WriteString(string(i.streamPending[:n]))
1105			i.streamPending = i.streamPending[n:]
1106			i.mu.Unlock()
1107			i.invalidate()
1108		}
1109	}
1110}

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