v0.50.1 a637010

interactive_resident_subagents.go RawHistoryFolder

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

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