v0.29.1 d92a60f

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

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