v0.45.0 cb619f7

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

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