v0.20.0 29be61c

interactive_resident_subagents.go RawHistoryFolder

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

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