v0.38.0 58133ea

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

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