detached 949637b

Merge pull request #127 from bnema/refactor/interactive-mode-files

refactor(modes): split interactive responsibilities
commit
949637b0f271a77951867c571ffe5baee6f1530b
author
bnema · · 2026-08-14 03:58 UTC
committer
GitHub · · 2026-08-14 03:58 UTC
merge of
180d726 + cf1ed1b · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
refs
tag: v0.17.0
15 files · +8257 -8227
M packages/agent/modes/interactive.go +160 -8227 large diff (8387 lines changed): collapsed
A packages/agent/modes/interactive_alerts.go +79 -0
@@ -0,0 +1,79 @@
1+package modes
2+
3+import (
4+ "strings"
5+
6+ "github.com/bnema/zut/packages/agent/extproto"
7+ "github.com/bnema/zut/packages/tui"
8+)
9+
10+// Notify is the manager's NotifyFromExt entry point.
11+func (i *Interactive) Notify(extName, level, message string) {
12+ i.appendExtensionNote(extName, message, level)
13+ i.invalidate()
14+}
15+
16+// Alert is the manager's AlertFromExt entry point. Alerts are emitted
17+// through the same terminal writer as redraws while holding the TUI mutex,
18+// so a BEL cannot be interleaved with a frame update.
19+func (i *Interactive) Alert(_ string, alert extproto.AlertRequest) {
20+ i.mu.Lock()
21+ i.emitAlertLocked(alert)
22+ i.mu.Unlock()
23+}
24+
25+func terminalAlertsEnabled(enabled *bool) bool {
26+ return enabled == nil || *enabled
27+}
28+
29+// emitAlertLocked applies the shared terminal-alert policy. The caller must
30+// hold i.mu because terminal writes share the renderer's output boundary.
31+func (i *Interactive) emitAlertLocked(alert extproto.AlertRequest) {
32+ if alert.Kind != extproto.AlertKindBell || !terminalAlertsEnabled(i.cfg.TerminalAlertsEnabled) || i.cfg.Terminal == nil {
33+ return
34+ }
35+ _ = tui.WriteBell(i.cfg.Terminal)
36+}
37+
38+// scheduleMainAlert defers a main-session alert until the next redraw
39+// commits the final frame. Keeping this deferred even when no paced text is
40+// pending avoids racing the pacer's final state transition.
41+func (i *Interactive) scheduleMainAlert(reason string) {
42+ if reason == "" {
43+ return
44+ }
45+ i.mu.Lock()
46+ i.pendingAlert = &extproto.AlertRequest{Kind: extproto.AlertKindBell, Reason: reason}
47+ i.mu.Unlock()
48+ i.invalidate()
49+}
50+
51+// ClearNotes removes every note line owned by extName from the
52+// bottom-sticky ext-notes block. Extensions use this to retract a
53+// transient status line (e.g. an approval prompt) once it no longer
54+// applies, instead of leaving it stacked forever. Notes from other
55+// extensions and internal notes (auto-compact) are left untouched.
56+func (i *Interactive) ClearNotes(extName string) {
57+ marker := "[" + extName + "] "
58+ i.mu.Lock()
59+ if len(i.extNotes) == 0 {
60+ i.mu.Unlock()
61+ return
62+ }
63+ kept := i.extNotes[:0:0]
64+ changed := false
65+ for _, line := range i.extNotes {
66+ if strings.Contains(line, marker) {
67+ changed = true
68+ continue
69+ }
70+ kept = append(kept, line)
71+ }
72+ if changed {
73+ i.extNotes = kept
74+ }
75+ i.mu.Unlock()
76+ if changed {
77+ i.invalidate()
78+ }
79+}
A packages/agent/modes/interactive_commands.go +1226 -0
@@ -0,0 +1,1226 @@
1+package modes
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "fmt"
7+ "os"
8+ "path/filepath"
9+ "strings"
10+ "time"
11+
12+ "github.com/bnema/zut/packages/agent/skills"
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/provider/auth"
17+ "github.com/bnema/zut/packages/tui"
18+)
19+
20+func buildStudyPrompt(arg, cwd string) string {
21+ arg = strings.TrimSpace(arg)
22+ if arg == "" {
23+ return "Read and understand everything in the current directory."
24+ }
25+ abs := arg
26+ if !filepath.IsAbs(abs) {
27+ abs = filepath.Join(cwd, abs)
28+ }
29+ display := arg
30+ if rel, err := filepath.Rel(cwd, abs); err == nil && !strings.HasPrefix(rel, "..") {
31+ display = rel
32+ }
33+ if info, err := os.Stat(abs); err == nil && !info.IsDir() {
34+ return "Read and understand the file " + display + "."
35+ }
36+ return "Read and understand everything in the directory " + display + "."
37+}
38+func tryPathTabCompleteEditor(ed *tui.Editor, cwd string) bool {
39+ if ed == nil {
40+ return false
41+ }
42+ val := ed.Value()
43+ // Find the trailing run of non-whitespace.
44+ start := len(val)
45+ for start > 0 {
46+ r := val[start-1]
47+ if r == ' ' || r == '\t' || r == '\n' {
48+ break
49+ }
50+ start--
51+ }
52+ token := val[start:]
53+ if token == "" {
54+ return false
55+ }
56+ if !looksLikePathToken(token) {
57+ return false
58+ }
59+
60+ // Resolve the absolute parent directory + base prefix to match.
61+ parentAbs, basePrefix, displayParent, ok := resolvePathTabToken(token, cwd)
62+ if !ok {
63+ return true
64+ }
65+ entries, err := os.ReadDir(parentAbs)
66+ if err != nil {
67+ return true
68+ }
69+ var names []string
70+ var isDir []bool
71+ for _, e := range entries {
72+ name := e.Name()
73+ if !strings.HasPrefix(name, basePrefix) {
74+ continue
75+ }
76+ // Hide dotfiles unless the user explicitly typed a leading dot,
77+ // mirroring bash's default behaviour.
78+ if strings.HasPrefix(name, ".") && !strings.HasPrefix(basePrefix, ".") {
79+ continue
80+ }
81+ names = append(names, name)
82+ isDir = append(isDir, e.IsDir())
83+ }
84+ if len(names) == 0 {
85+ return true
86+ }
87+
88+ var completed string
89+ var completedIsDir bool
90+ if len(names) == 1 {
91+ completed = names[0]
92+ completedIsDir = isDir[0]
93+ } else {
94+ completed = longestCommonPrefix(names)
95+ if completed == basePrefix {
96+ // Already at the deepest unambiguous prefix; nothing to add.
97+ return true
98+ }
99+ }
100+
101+ // Build the replacement token in the same display form the user
102+ // typed (preserve ~ vs absolute vs relative).
103+ newToken := displayParent + completed
104+ if len(names) == 1 && completedIsDir {
105+ newToken += "/"
106+ }
107+
108+ ed.SetValue(val[:start] + newToken)
109+ return true
110+}
111+func (i *Interactive) tryPathTabComplete() bool {
112+ if tryPathTabCompleteEditor(i.ed, i.cfg.CWD) {
113+ i.invalidate()
114+ return true
115+ }
116+ return false
117+}
118+func looksLikePathToken(tok string) bool {
119+ if tok == "" {
120+ return false
121+ }
122+ if tok[0] == '~' || tok[0] == '/' {
123+ return true
124+ }
125+ if strings.HasPrefix(tok, "./") || strings.HasPrefix(tok, "../") {
126+ return true
127+ }
128+ return strings.Contains(tok, "/")
129+}
130+func resolvePathTabToken(tok, cwd string) (parentAbs, basePrefix, displayParent string, ok bool) {
131+ // Detect ~ expansion.
132+ expanded := tok
133+ homePrefix := ""
134+ if tok == "~" {
135+ home, err := os.UserHomeDir()
136+ if err != nil || home == "" {
137+ return "", "", "", false
138+ }
139+ // "~" alone: complete in $HOME. parent = home, base = "".
140+ return home, "", "~/", true
141+ }
142+ if strings.HasPrefix(tok, "~/") {
143+ home, err := os.UserHomeDir()
144+ if err != nil || home == "" {
145+ return "", "", "", false
146+ }
147+ expanded = home + tok[1:]
148+ homePrefix = "~"
149+ }
150+
151+ dir, base := splitDirBase(expanded)
152+ if !filepath.IsAbs(dir) {
153+ dir = filepath.Join(cwd, dir)
154+ }
155+
156+ // Reconstruct the display form the user typed for the parent,
157+ // keeping ~ when they used it. The base is dropped — the caller
158+ // substitutes the completed name.
159+ display := tok[:len(tok)-len(base)]
160+ if homePrefix != "" && !strings.HasPrefix(display, "~") {
161+ display = homePrefix + display[len(homePrefix):]
162+ }
163+ return dir, base, display, true
164+}
165+func splitDirBase(p string) (dir, base string) {
166+ if p == "" {
167+ return ".", ""
168+ }
169+ i := strings.LastIndex(p, "/")
170+ if i < 0 {
171+ return ".", p
172+ }
173+ return p[:i+1], p[i+1:]
174+}
175+func longestCommonPrefix(ss []string) string {
176+ if len(ss) == 0 {
177+ return ""
178+ }
179+ prefix := ss[0]
180+ for _, s := range ss[1:] {
181+ n := 0
182+ for n < len(prefix) && n < len(s) && prefix[n] == s[n] {
183+ n++
184+ }
185+ prefix = prefix[:n]
186+ if prefix == "" {
187+ return ""
188+ }
189+ }
190+ return prefix
191+}
192+func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) {
193+ parts := strings.Fields(cmd)
194+ if len(parts) == 0 {
195+ return false
196+ }
197+
198+ if isSkillCommand(cmd) {
199+ var available []*skills.Skill
200+ if i.cfg.SkillSnapshot != nil {
201+ available = i.cfg.SkillSnapshot()
202+ }
203+ prompt, _, err := expandSkillCommand(cmd, available)
204+ if err != nil {
205+ i.mu.Lock()
206+ i.statusErr = err.Error()
207+ i.statusOK = ""
208+ i.mu.Unlock()
209+ return false
210+ }
211+ i.submitOrQueuePrompt(ctx, prompt)
212+ return false
213+ }
214+
215+ switch strings.ToLower(parts[0]) {
216+ case "/goal":
217+ i.runGoalCommand(ctx, cmd, parts)
218+ case "/exit":
219+ return true
220+ case "/clear":
221+ if i.agent != nil {
222+ i.agent.SetMessages(nil)
223+ }
224+ i.mu.Lock()
225+ i.toolCalls = map[string]*tui.ToolCallView{}
226+ i.toolOrder = nil
227+ i.toolGate = map[string]int{}
228+ i.statusErr = ""
229+ i.statusOK = ""
230+ i.helpBlock = nil
231+ i.parkedTurn = 0
232+ i.parkedTotal = 0
233+ i.scrollOffset = 0
234+ i.extNotes = nil
235+ i.reloadErrors = nil
236+ handoff, persistHandoff := i.resetCompactContinuationLocked()
237+ i.view.InvalidateRenderCache()
238+ i.mu.Unlock()
239+ if persistHandoff {
240+ i.persistCompactHandoff(handoff)
241+ }
242+ case "/help":
243+ i.mu.Lock()
244+ i.helpBlock = renderHelpBlock(i.cfg.Theme, i.lastCols(), i.llamaConfigured)
245+ i.statusErr = ""
246+ i.statusOK = ""
247+ // Pin the viewport to the newest content so the help block,
248+ // which we just appended to the end of the transcript, is
249+ // what the user actually sees.
250+ i.scrollOffset = 0
251+ i.mu.Unlock()
252+ case "/login":
253+ i.dialog.Open(i.cfg.ZutHome)
254+ case "/logout":
255+ if len(parts) >= 2 {
256+ // Explicit target: /logout anthropic | openai | all
257+ i.doLogout(parts[1])
258+ break
259+ }
260+ // No arg: open the picker over whichever providers are
261+ // currently logged in. If nothing's logged in, bail with a
262+ // status line.
263+ i.openLogoutDialog()
264+ case "/model":
265+ if len(parts) >= 2 {
266+ i.applyModelSelection("", parts[1])
267+ } else if i.llamaConfigured && i.cfg.RefreshLlamaCPPModels != nil {
268+ i.mu.Lock()
269+ i.modelRefreshing = true
270+ i.statusOK = "Refreshing models"
271+ i.statusErr = ""
272+ i.mu.Unlock()
273+ go func() {
274+ refreshCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
275+ defer cancel()
276+ i.modelRefresh <- modelRefreshResult{err: i.cfg.RefreshLlamaCPPModels(refreshCtx)}
277+ }()
278+ } else {
279+ i.openModelPickerAfterRefresh(nil)
280+ }
281+ case "/llama":
282+ if i.cfg.LlamaCPPConfig == nil {
283+ i.mu.Lock()
284+ i.statusErr = "llama.cpp configuration is unavailable"
285+ i.statusOK = ""
286+ i.mu.Unlock()
287+ break
288+ }
289+ baseURL, apiKey, configErr := i.cfg.LlamaCPPConfig()
290+ if configErr == nil {
291+ configErr = i.llamaDialog.Open(baseURL, apiKey, i.invalidate)
292+ }
293+ if configErr != nil {
294+ i.mu.Lock()
295+ i.statusErr = configErr.Error()
296+ i.statusOK = ""
297+ i.mu.Unlock()
298+ }
299+ case "/reasoning":
300+ i.openReasoningDialog()
301+ case "/fast":
302+ enabled := i.cfg.FastMode == nil || !*i.cfg.FastMode
303+ i.applySettingToggle("fast_mode", enabled)
304+ case "/orchestrator":
305+ enabled := i.cfg.AutoSubagentsEnabled == nil || !*i.cfg.AutoSubagentsEnabled
306+ i.applySettingToggle("auto_subagents_enabled", enabled)
307+ case "/settings":
308+ i.openSettingsDialog()
309+ case "/sessions":
310+ i.mu.Lock()
311+ i.sessionLoads = i.sessionDialog.Open(ctx, i.sessionsRoot(), i.cfg.CWD)
312+ i.mu.Unlock()
313+ case "/fork":
314+ i.doSessionFork()
315+ case "/jump":
316+ i.openJumpDialog(parts[1:])
317+ case "/btw":
318+ i.openBtwDialog(parts[1:])
319+ case "/skills":
320+ i.openSkillsDialog()
321+ case "/compact":
322+ i.runCompact(ctx, compactContinuationRequest{origin: compactOriginManual})
323+ case "/study":
324+ // Canned prompt that tells the agent to read every file
325+ // in some target so its later turns have the whole thing
326+ // in context. With no argument, the target is the current
327+ // directory. With an argument, the target is whatever the
328+ // user passed — typed by hand, drag-dropped, or selected
329+ // via the @ file picker (which is why we accept both files
330+ // and directories; the @-picker chips for both have already
331+ // been expanded to absolute paths by expandFileChips above).
332+ // Dispatched through the normal queue-or-start path so it
333+ // behaves identically to typing the prompt by hand.
334+ studyPrompt := buildStudyPrompt(strings.TrimSpace(strings.TrimPrefix(cmd, parts[0])), i.cfg.CWD)
335+ i.mu.Lock()
336+ busy := i.busy
337+ compacting := i.compacting
338+ ag := i.agent
339+ i.mu.Unlock()
340+ if busy {
341+ var handoff json.RawMessage
342+ var persistHandoff bool
343+ if ag != nil && !compacting {
344+ i.mu.Lock()
345+ handoff, persistHandoff = i.resetCompactContinuationLocked()
346+ ag.QueueMessage(studyPrompt)
347+ i.mu.Unlock()
348+ } else {
349+ i.mu.Lock()
350+ handoff, persistHandoff = i.resetCompactContinuationLocked()
351+ i.queued = append(i.queued, studyPrompt)
352+ i.mu.Unlock()
353+ }
354+ if persistHandoff {
355+ i.persistCompactHandoff(handoff)
356+ }
357+ i.invalidate()
358+ break
359+ }
360+ i.startTurn(ctx, studyPrompt)
361+ case "/cd":
362+ // Hidden command: switch the running session's cwd. Not in
363+ // slash_suggest, not in /help. Used by the workspaces
364+ // extension's panel-key Enter handler so picking a row
365+ // jumps zut into that directory without relaunching.
366+ //
367+ // Recovers the raw argument (path) from the original cmd
368+ // string rather than parts, so paths with spaces survive.
369+ // The host's ChangeCWD hook handles validation, session
370+ // close + reopen, agent rebuild, sandbox re-rooting, and
371+ // re-jail-if-jailed semantics.
372+ if i.cfg.ChangeCWD == nil {
373+ i.mu.Lock()
374+ i.statusErr = "/cd unavailable: host did not wire ChangeCWD"
375+ i.mu.Unlock()
376+ break
377+ }
378+ path := strings.TrimSpace(strings.TrimPrefix(cmd, parts[0]))
379+ if path == "" {
380+ i.mu.Lock()
381+ i.statusErr = "/cd: missing path"
382+ i.mu.Unlock()
383+ break
384+ }
385+ if err := i.cfg.ChangeCWD(path); err != nil {
386+ i.mu.Lock()
387+ i.statusErr = "/cd: " + err.Error()
388+ i.statusOK = ""
389+ i.mu.Unlock()
390+ break
391+ }
392+ // ChangeCWD has already updated i.cfg.CWD and swapped the
393+ // agent + session. Reset transient TUI state so the new
394+ // session opens clean.
395+ i.mu.Lock()
396+ i.toolCalls = map[string]*tui.ToolCallView{}
397+ i.toolOrder = nil
398+ i.toolGate = map[string]int{}
399+ i.helpBlock = nil
400+ i.parkedTurn = 0
401+ i.statusOK = "cwd " + i.cfg.CWD
402+ i.statusErr = ""
403+ i.mu.Unlock()
404+ i.fileSuggest.Reset()
405+ i.fileSuggest.SetCWD(i.cfg.CWD)
406+ i.invalidate()
407+ case "/jail":
408+ if i.cfg.Sandbox == nil {
409+ i.mu.Lock()
410+ i.statusErr = "sandbox not available in this build"
411+ i.mu.Unlock()
412+ break
413+ }
414+ i.cfg.Sandbox.Lock()
415+ i.mu.Lock()
416+ i.statusOK = "jailed to " + i.cfg.CWD + " (tools cannot touch paths outside this directory)"
417+ i.statusErr = ""
418+ i.mu.Unlock()
419+ case "/unjail":
420+ if i.cfg.Sandbox == nil {
421+ i.mu.Lock()
422+ i.statusErr = "sandbox not available in this build"
423+ i.mu.Unlock()
424+ break
425+ }
426+ i.cfg.Sandbox.Unlock()
427+ i.mu.Lock()
428+ i.statusOK = "unjailed"
429+ i.statusErr = ""
430+ i.mu.Unlock()
431+ case "/reload-ext":
432+ i.runReloadExt(ctx)
433+ case "/telegram", "/tg":
434+ if len(parts) >= 2 {
435+ i.doTelegram(parts[1])
436+ break
437+ }
438+ i.openTelegramDialog()
439+ case "/session":
440+ if len(parts) >= 2 {
441+ action := parts[1]
442+ arg := ""
443+ if len(parts) >= 3 {
444+ arg = strings.Join(parts[2:], " ")
445+ }
446+ i.doSessionOp(action, arg)
447+ break
448+ }
449+ i.openSessionOpsDialog()
450+ case "/subagents":
451+ i.runSubagents(ctx, parts[1:])
452+ default:
453+ // Last-resort fallback: try the extension manager. Built-in
454+ // cases above always win; this branch only fires for slash
455+ // commands the extension manager registered. Same routing as
456+ // the editor's submit-handler dispatch path so the autocomplete
457+ // "enter on highlighted suggestion" flow also works.
458+ extName := strings.TrimPrefix(parts[0], "/")
459+ if i.cfg.Extensions != nil && i.cfg.Extensions.HasCommand(extName) {
460+ rest := ""
461+ if len(parts) > 1 {
462+ rest = strings.Join(parts[1:], " ")
463+ }
464+ go i.invokeExtensionCommand(ctx, extName, rest)
465+ return false
466+ }
467+ i.mu.Lock()
468+ i.statusErr = "unknown command: " + parts[0]
469+ i.mu.Unlock()
470+ }
471+ return false
472+}
473+func (i *Interactive) openModelPickerAfterRefresh(refreshErr error) {
474+ var loggedIn []string
475+ if i.cfg.LoggedInProviders != nil {
476+ loggedIn = i.cfg.LoggedInProviders()
477+ }
478+ i.modelDialog.Open(i.cfg.Model, loggedIn, i.cfg.Reasoning)
479+ i.mu.Lock()
480+ if refreshErr != nil {
481+ i.statusErr = "llama.cpp model refresh: " + refreshErr.Error()
482+ i.statusOK = ""
483+ } else if i.statusOK == "Refreshing models" {
484+ i.statusOK = ""
485+ }
486+ i.mu.Unlock()
487+}
488+func (i *Interactive) openLogoutDialog() {
489+ if i.cfg.AuthManager == nil {
490+ i.mu.Lock()
491+ i.statusErr = "no auth manager configured"
492+ i.mu.Unlock()
493+ i.invalidate()
494+ return
495+ }
496+ store := i.cfg.AuthManager.Store()
497+ if store == nil {
498+ i.mu.Lock()
499+ i.statusErr = "auth store is not available"
500+ i.mu.Unlock()
501+ i.invalidate()
502+ return
503+ }
504+ creds, err := store.Load()
505+ if err != nil {
506+ i.mu.Lock()
507+ i.statusErr = "read auth store: " + err.Error()
508+ i.mu.Unlock()
509+ i.invalidate()
510+ return
511+ }
512+
513+ var items []logoutItem
514+ for _, p := range []string{"anthropic", "kimi", "google", "github-copilot"} {
515+ if creds.Has(p) {
516+ method := creds.Method(p)
517+ if method == "oauth" {
518+ method = "subscription"
519+ }
520+ items = append(items, logoutItem{
521+ label: providerLabel(p),
522+ target: p,
523+ method: method,
524+ })
525+ }
526+ }
527+ if creds.OpenAI.APIKey != "" || creds.OpenAI.APIKeyCommand != nil {
528+ items = append(items, logoutItem{label: providerLabel("openai"), target: "openai", method: "api key"})
529+ }
530+ if creds.OpenAI.OAuth != nil {
531+ items = append(items, logoutItem{label: providerLabel("openai-codex"), target: "openai-codex", method: "subscription"})
532+ }
533+ for p, c := range creds.AdditionalAPIKeyCreds {
534+ if c.APIKey != "" || c.APIKeyCommand != nil || c.BaseURL != "" {
535+ items = append(items, logoutItem{label: providerLabel(p), target: p, method: "api key"})
536+ }
537+ }
538+ if len(items) == 0 {
539+ i.mu.Lock()
540+ i.statusOK = "no credentials stored; already logged out"
541+ i.statusErr = ""
542+ i.mu.Unlock()
543+ i.invalidate()
544+ return
545+ }
546+ if len(items) > 1 {
547+ items = append(items, logoutItem{label: "all", target: "all"})
548+ }
549+
550+ i.logoutDialog.Open(items)
551+ i.invalidate()
552+}
553+func (i *Interactive) doLogout(target string) {
554+ if i.cfg.AuthManager == nil {
555+ i.mu.Lock()
556+ i.statusErr = "no auth manager configured"
557+ i.mu.Unlock()
558+ return
559+ }
560+ store := i.cfg.AuthManager.Store()
561+ if store == nil {
562+ i.mu.Lock()
563+ i.statusErr = "auth store is not available"
564+ i.mu.Unlock()
565+ return
566+ }
567+
568+ var providers []string
569+ switch target {
570+ case "", "all":
571+ providers = append([]string{"anthropic", "openai", "openai-codex", "kimi", "google", "github-copilot"}, auth.APIKeyProviders()...)
572+ case "anthropic", "openai", "openai-codex", "kimi", "google", "github-copilot":
573+ providers = []string{target}
574+ default:
575+ known := false
576+ for _, p := range auth.APIKeyProviders() {
577+ if target == p {
578+ known = true
579+ break
580+ }
581+ }
582+ if !known {
583+ i.mu.Lock()
584+ i.statusErr = "unknown provider: " + target
585+ i.mu.Unlock()
586+ return
587+ }
588+ providers = []string{target}
589+ }
590+
591+ var errs []string
592+ clearedCurrent := false
593+ for _, p := range providers {
594+ var err error
595+ switch p {
596+ case "openai":
597+ err = store.ClearAPIKey("openai")
598+ case "openai-codex":
599+ err = store.ClearOAuth("openai")
600+ default:
601+ err = store.Clear(p)
602+ }
603+ if err != nil {
604+ errs = append(errs, p+": "+err.Error())
605+ continue
606+ }
607+ if p == "kimi" && i.cfg.SetKimiCLIFallbackDisabled != nil {
608+ if err := i.cfg.SetKimiCLIFallbackDisabled(true); err != nil {
609+ errs = append(errs, p+": "+err.Error())
610+ continue
611+ }
612+ }
613+ if p == i.cfg.Provider {
614+ clearedCurrent = true
615+ }
616+ }
617+
618+ llamaConfigured := i.llamaConfigured
619+ if i.cfg.LlamaCPPConfig != nil {
620+ baseURL, _, err := i.cfg.LlamaCPPConfig()
621+ llamaConfigured = err == nil && baseURL != ""
622+ }
623+
624+ i.mu.Lock()
625+ i.llamaConfigured = llamaConfigured
626+ if len(errs) > 0 {
627+ i.statusErr = "logout errors: " + strings.Join(errs, "; ")
628+ i.mu.Unlock()
629+ return
630+ }
631+ i.statusErr = ""
632+ var handoff json.RawMessage
633+ var persistHandoff bool
634+ if clearedCurrent {
635+ // The running agent was using a credential we just wiped. Drop
636+ // it so prompts can't go out with the stale client, and hint at
637+ // /login. Close any LSP processes owned by its tools first.
638+ if i.agent != nil {
639+ _ = tools.CloseLSPManagers(i.agent.ToolsSnapshot())
640+ }
641+ i.agent = nil
642+ handoff, persistHandoff = i.resetCompactContinuationLocked()
643+ i.statusOK = "logged out of " + strings.Join(providers, ", ") + ". type /login to sign back in."
644+ } else {
645+ i.statusOK = "logged out of " + strings.Join(providers, ", ")
646+ }
647+ i.mu.Unlock()
648+ if persistHandoff {
649+ i.persistCompactHandoff(handoff)
650+ }
651+}
652+func providerSetupInfo(provider string) (string, []string, bool) {
653+ const docsURL = "https://raw.githubusercontent.com/bnema/zut/main/docs/providers.md"
654+ switch provider {
655+ case "amazon-bedrock":
656+ return "Amazon Bedrock setup", []string{
657+ "Amazon Bedrock uses AWS credentials instead of a generic zut API-key entry.",
658+ "Configure an AWS profile, IAM keys, bearer token, or role-based credentials.",
659+ "",
660+ "For Bedrock API keys, set:",
661+ " AWS_BEARER_TOKEN_BEDROCK=...",
662+ " AWS_REGION=us-east-1",
663+ "",
664+ "Docs:",
665+ " " + docsURL,
666+ }, true
667+ case "google-vertex":
668+ return "Google Vertex AI setup", []string{
669+ "Google Vertex AI usually uses Google Cloud credentials and project settings.",
670+ "Set a Google API key, application-default credentials, or a service account.",
671+ "",
672+ "Common environment:",
673+ " GOOGLE_CLOUD_API_KEY=...",
674+ " GOOGLE_CLOUD_PROJECT=...",
675+ " GOOGLE_CLOUD_LOCATION=us-central1",
676+ "",
677+ "Docs:",
678+ " " + docsURL,
679+ }, true
680+ case "cloudflare-workers-ai":
681+ return "Cloudflare Workers AI setup", []string{
682+ "Cloudflare Workers AI needs both an API token and an account ID.",
683+ "",
684+ "Set:",
685+ " CLOUDFLARE_API_KEY=...",
686+ " CLOUDFLARE_ACCOUNT_ID=...",
687+ "",
688+ "Docs:",
689+ " " + docsURL,
690+ }, true
691+ case "cloudflare-ai-gateway":
692+ return "Cloudflare AI Gateway setup", []string{
693+ "Cloudflare AI Gateway needs an API token, account ID, and gateway ID.",
694+ "",
695+ "Set:",
696+ " CLOUDFLARE_API_KEY=...",
697+ " CLOUDFLARE_ACCOUNT_ID=...",
698+ " CLOUDFLARE_GATEWAY_ID=...",
699+ "",
700+ "Docs:",
701+ " " + docsURL,
702+ }, true
703+ case "azure-openai-responses":
704+ return "Azure OpenAI Responses setup", []string{
705+ "Azure OpenAI needs an API key plus your Azure endpoint or deployment setup.",
706+ "",
707+ "Set:",
708+ " AZURE_OPENAI_API_KEY=...",
709+ " AZURE_OPENAI_BASE_URL=https://your-resource.openai.azure.com",
710+ " AZURE_OPENAI_API_VERSION=2024-02-01",
711+ "",
712+ "Docs:",
713+ " " + docsURL,
714+ }, true
715+ default:
716+ return "", nil, false
717+ }
718+}
719+func (i *Interactive) saveLlamaCPPLogin(baseURL, apiKey string) {
720+ if i.cfg.AuthManager == nil || i.cfg.AuthManager.Store() == nil {
721+ i.dialog.ShowResult(false, "auth store is unavailable")
722+ return
723+ }
724+ if err := i.cfg.AuthManager.Store().SetEndpointCredential(provider.LlamaCPPProviderID, baseURL, apiKey); err != nil {
725+ i.dialog.ShowResult(false, err.Error())
726+ return
727+ }
728+ i.mu.Lock()
729+ i.llamaConfigured = true
730+ i.statusErr = ""
731+ i.statusOK = "configured llama.cpp router"
732+ i.mu.Unlock()
733+ i.dialog.ShowResult(true, "")
734+}
735+func (i *Interactive) startAPIKeyFlow(provider string) {
736+ if title, lines, ok := providerSetupInfo(provider); ok {
737+ i.dialog.ShowInfo(title, lines)
738+ return
739+ }
740+ if provider == "kimi" && i.cfg.SetKimiCLIFallbackDisabled != nil {
741+ _ = i.cfg.SetKimiCLIFallbackDisabled(false)
742+ }
743+ url, err := i.cfg.AuthManager.StartAPIKey(provider)
744+ if err != nil {
745+ i.dialog.ShowResult(false, err.Error())
746+ return
747+ }
748+ i.dialog.ShowWaiting(url)
749+}
750+func (i *Interactive) startOAuthFlow(provider string) {
751+ if provider == "kimi" && i.cfg.SetKimiCLIFallbackDisabled != nil {
752+ _ = i.cfg.SetKimiCLIFallbackDisabled(false)
753+ }
754+ // Device-code providers already support headless login and must only
755+ // start one polling flow.
756+ if provider == "kimi" || provider == "xai" || provider == "github-copilot" {
757+ loginURL, err := i.cfg.AuthManager.StartOAuth(provider)
758+ if err != nil {
759+ i.dialog.ShowResult(false, err.Error())
760+ return
761+ }
762+ i.dialog.ShowWaiting(loginURL)
763+ return
764+ }
765+ // Always run the manual/copy-code flow in parallel with the local
766+ // callback server so headless environments (docker, SSH) can paste
767+ // the authorization code directly without first pressing 'p'.
768+ _, err := i.cfg.AuthManager.StartOAuth(provider)
769+ if err != nil {
770+ i.dialog.ShowResult(false, err.Error())
771+ return
772+ }
773+ manualURL, mErr := i.cfg.AuthManager.StartManualOAuth(provider)
774+ if mErr == nil {
775+ i.dialog.ShowWaiting(manualURL)
776+ } else {
777+ i.dialog.ShowResult(false, mErr.Error())
778+ }
779+}
780+func (i *Interactive) startManualOAuthFlow(provider string) {
781+ if i.cfg.AuthManager == nil {
782+ return
783+ }
784+ i.cfg.AuthManager.CancelOAuth()
785+ url, err := i.cfg.AuthManager.StartManualOAuth(provider)
786+ if err != nil {
787+ i.dialog.ShowResult(false, err.Error())
788+ return
789+ }
790+ i.dialog.url = url
791+ i.invalidate()
792+}
793+func (i *Interactive) submitManualOAuthCode(code string) {
794+ if i.cfg.AuthManager == nil {
795+ return
796+ }
797+ go func() {
798+ if err := i.cfg.AuthManager.CompleteManualOAuth(i.runCtx, code); err != nil {
799+ i.dialog.ShowResult(false, err.Error())
800+ i.invalidate()
801+ }
802+ }()
803+}
804+func (i *Interactive) cancelAndWaitForIdle() {
805+ i.mu.Lock()
806+ busy := i.busy
807+ cancel := i.cancelTurn
808+ i.mu.Unlock()
809+ if !busy {
810+ return
811+ }
812+ if cancel != nil {
813+ i.mu.Lock()
814+ handoff, persistHandoff := i.resetCompactContinuationLocked()
815+ i.mu.Unlock()
816+ cancel()
817+ if persistHandoff {
818+ i.persistCompactHandoff(handoff)
819+ }
820+ }
821+ // ConfirmToolCall waits on its response channel rather than the turn
822+ // context, so cancellation must also resolve every pending prompt.
823+ i.confirmDialog.CancelAll("turn cancelled")
824+ deadline := time.Now().Add(2 * time.Second)
825+ for time.Now().Before(deadline) {
826+ i.mu.Lock()
827+ done := !i.busy
828+ i.mu.Unlock()
829+ if done {
830+ return
831+ }
832+ time.Sleep(10 * time.Millisecond)
833+ }
834+}
835+func (i *Interactive) openBtwDialog(args []string) {
836+ i.agentMu.Lock()
837+ defer i.agentMu.Unlock()
838+ i.mu.Lock()
839+ ag := i.agent
840+ system := ""
841+ if ag != nil {
842+ system, _ = ag.PromptConfig()
843+ }
844+ theme := i.cfg.Theme
845+ model := i.cfg.Model
846+ cwd := i.cfg.CWD
847+ compact := i.compactModeEnabled()
848+ flatTools := i.cfg.FlatTools
849+ lineInput := tui.NormalizeInputStyle(i.cfg.TUIInputStyle) == tui.InputStyleLines
850+ i.mu.Unlock()
851+ if ag == nil {
852+ i.mu.Lock()
853+ i.statusErr = "not logged in. type /login first."
854+ i.mu.Unlock()
855+ return
856+ }
857+ seed := strings.TrimSpace(strings.Join(args, " "))
858+ i.btwDialog.Open(theme, ag, system, model, cwd, seed, compact, flatTools, lineInput, i.invalidate)
859+ i.invalidate()
860+}
861+func (i *Interactive) submitOrQueuePrompt(ctx context.Context, prompt string) {
862+ i.mu.Lock()
863+ if i.agent == nil {
864+ i.statusErr = "not logged in. type /login first."
865+ i.statusOK = ""
866+ i.mu.Unlock()
867+ i.invalidate()
868+ return
869+ }
870+ if i.busy {
871+ // Keep the mutex held while enqueueing so the turn-completion
872+ // goroutine cannot publish idle and inspect an empty queue between
873+ // this check and the append.
874+ var handoff json.RawMessage
875+ var persistHandoff bool
876+ if i.compacting {
877+ handoff, persistHandoff = i.resetCompactContinuationLocked()
878+ i.queued = append(i.queued, prompt)
879+ } else {
880+ handoff, persistHandoff = i.resetCompactContinuationLocked()
881+ i.agent.QueueMessage(prompt)
882+ }
883+ i.mu.Unlock()
884+ if persistHandoff {
885+ i.persistCompactHandoff(handoff)
886+ }
887+ i.invalidate()
888+ return
889+ }
890+ i.mu.Unlock()
891+ i.startTurn(ctx, prompt)
892+}
893+func (i *Interactive) openSkillsDialog() {
894+ var list []*skills.Skill
895+ if i.cfg.SkillSnapshot != nil {
896+ list = i.cfg.SkillSnapshot()
897+ }
898+ i.skillsDialog.Open(list)
899+ i.invalidate()
900+}
901+func (i *Interactive) openJumpDialog(args []string) {
902+ if i.view == nil || len(i.view.Messages) == 0 {
903+ i.mu.Lock()
904+ i.statusErr = "nothing to jump to \u2014 the session is empty"
905+ i.mu.Unlock()
906+ return
907+ }
908+ filter := strings.TrimSpace(strings.Join(args, " "))
909+ i.jumpDialog.Open(i.view.Messages, filter)
910+ // Shortcut: with a filter argument that matches exactly one turn,
911+ // jump immediately and skip the picker.
912+ if filter != "" {
913+ if tgts := i.jumpDialog.Targets(); len(tgts) == 1 {
914+ t := tgts[0]
915+ i.jumpDialog.Close()
916+ i.applyJumpSelection(t.MessageIdx, t.TurnNo)
917+ }
918+ }
919+}
920+func (i *Interactive) applyJumpSelection(msgIdx, turnNo int) {
921+ cols := i.lastCols()
922+ chat, anchors := i.view.BuildWithAnchors(cols)
923+ var row int
924+ found := false
925+ for _, a := range anchors {
926+ if a.MessageIdx == msgIdx {
927+ row = a.Row
928+ found = true
929+ break
930+ }
931+ }
932+ if !found {
933+ i.mu.Lock()
934+ i.statusErr = "could not resolve jump target"
935+ i.mu.Unlock()
936+ return
937+ }
938+
939+ chatLen := len(chat)
940+ page := i.chatPage()
941+ if page < 1 {
942+ page = 1
943+ }
944+ // scrollOffset is measured from the bottom of the chat slice, so
945+ // to place `row` at the top of the viewport we want:
946+ // chatLen - scrollOffset - page == row
947+ // Solve for scrollOffset and clamp to [0, chatLen-page].
948+ offset := chatLen - (row + page)
949+ if offset < 0 {
950+ offset = 0
951+ }
952+ maxOffset := chatLen - page
953+ if maxOffset < 0 {
954+ maxOffset = 0
955+ }
956+ if offset > maxOffset {
957+ offset = maxOffset
958+ }
959+
960+ i.mu.Lock()
961+ i.scrollOffset = offset
962+ i.parkedTurn = turnNo
963+ i.parkedTotal = totalTurnsLocked(i.view.Messages)
964+ i.statusOK = fmt.Sprintf("jumped to turn %d", turnNo)
965+ i.statusErr = ""
966+ i.mu.Unlock()
967+}
968+func totalTurnsLocked(msgs []provider.Message) int {
969+ n := 0
970+ for _, m := range msgs {
971+ if m.Role == provider.RoleUser {
972+ n++
973+ }
974+ }
975+ return n
976+}
977+func (i *Interactive) applySessionSelection(path string) {
978+ if i.cfg.LoadSession == nil {
979+ i.mu.Lock()
980+ i.statusErr = "session loading is not wired in this build"
981+ i.mu.Unlock()
982+ return
983+ }
984+ i.mu.Lock()
985+ if i.sessionLoading {
986+ i.statusErr = "already resuming a session"
987+ i.mu.Unlock()
988+ i.invalidate()
989+ return
990+ }
991+ i.sessionLoading = true
992+ i.statusOK = "resuming session: " + path
993+ i.statusErr = ""
994+ i.mu.Unlock()
995+ i.invalidate()
996+ i.markSessionTitleSwitching()
997+
998+ go func() {
999+ err := i.cfg.LoadSession(path)
1000+ if err != nil {
1001+ i.restoreFailedSessionTitle()
1002+ i.mu.Lock()
1003+ i.sessionLoading = false
1004+ i.statusErr = err.Error()
1005+ i.statusOK = ""
1006+ i.mu.Unlock()
1007+ i.invalidate()
1008+ return
1009+ }
1010+ i.restoreLoadedSessionTitle()
1011+ state := decodeCompactHandoff(i.currentCompactHandoff())
1012+ i.mu.Lock()
1013+ i.compactContinuation = state
1014+ i.sessionLoading = false
1015+ i.statusOK = "resumed session: " + path
1016+ i.statusErr = ""
1017+ i.parkedTurn = 0
1018+ i.parkedTotal = 0
1019+ i.scrollOffset = 0
1020+ // Fresh transcript swapped in: drop the auto-follow baseline so
1021+ // the next render's follow guard doesn't see the wholesale
1022+ // length change as a delta and jump the viewport.
1023+ i.prevChatLen = 0
1024+ i.prevChatCols = 0
1025+ i.extNotes = nil
1026+ i.view.InvalidateRenderCache()
1027+ if i.agent != nil {
1028+ i.view.Messages = filterHiddenTranscriptMessages(i.agent.Messages())
1029+ i.cumUsage = i.agent.Cost()
1030+ if last := i.agent.LastTurnUsage(); last.InputTokens > 0 || last.CacheReadTokens > 0 || last.CacheWriteTokens > 0 {
1031+ i.lastCtxInput = last.InputTokens + last.CacheReadTokens + last.CacheWriteTokens
1032+ } else {
1033+ i.lastCtxInput = 0
1034+ }
1035+ // Snap to the tail again — the swap brought in a fresh
1036+ // transcript whose markdown / chroma cost we don't want
1037+ // blocking the redraw.
1038+ if len(i.view.Messages) > initialResumeTailLimit {
1039+ i.view.TailLimit = initialResumeTailLimit
1040+ } else {
1041+ i.view.TailLimit = 0
1042+ }
1043+ }
1044+ i.mu.Unlock()
1045+ i.invalidate()
1046+ if state.reason != compactContinuationNone {
1047+ i.startRestoredCompactHandoff(i.runCtx)
1048+ }
1049+ }()
1050+}
1051+func (i *Interactive) applyModelSelection(prov, model string) {
1052+ i.swapModel(prov, model, i.cfg.BuildAgentFor, false)
1053+}
1054+func (i *Interactive) applyRescueModelSelection(prov, model string) {
1055+ builder := i.cfg.BuildAgentForRescue
1056+ if builder == nil {
1057+ builder = i.cfg.BuildAgentFor
1058+ }
1059+ i.swapModel(prov, model, builder, true)
1060+}
1061+func (i *Interactive) swapModel(prov, model string, builder func(string, string) (*core.Agent, string, string, error), rescue bool) {
1062+ var replaced bool
1063+ swap := func() {
1064+ replaced = i.swapModelUnserialized(prov, model, builder, rescue)
1065+ }
1066+ if i.cfg.SessionTransition != nil {
1067+ i.cfg.SessionTransition(swap)
1068+ } else {
1069+ swap()
1070+ }
1071+ if replaced {
1072+ i.resetCompactHandoff()
1073+ }
1074+}
1075+func (i *Interactive) swapModelUnserialized(prov, model string, builder func(string, string) (*core.Agent, string, string, error), rescue bool) bool {
1076+ if model == "" {
1077+ return false
1078+ }
1079+ m, err := provider.FindModel(prov, model)
1080+ if err != nil {
1081+ i.mu.Lock()
1082+ i.statusErr = err.Error()
1083+ i.mu.Unlock()
1084+ return false
1085+ }
1086+ // Same provider AND not a rescue retry: just swap the model on
1087+ // the existing agent. Mixed-API providers dispatch from model metadata,
1088+ // so the client remains reusable. Rescue retries always rebuild so a stale
1089+ // auth header / base URL can't carry over.
1090+ if !rescue && i.agent != nil && m.Provider == i.cfg.Provider {
1091+ i.mu.Lock()
1092+ i.cfg.Model = m.ID
1093+ i.agent.Model = m.ID
1094+ i.statusOK = "model: " + m.ID
1095+ i.statusErr = ""
1096+ i.mu.Unlock()
1097+ if i.cfg.PersistModel != nil {
1098+ i.cfg.PersistModel(i.cfg.Provider, m.ID)
1099+ }
1100+ return false
1101+ }
1102+ if builder == nil {
1103+ i.mu.Lock()
1104+ i.statusErr = "cannot switch provider: no builder configured"
1105+ i.mu.Unlock()
1106+ return false
1107+ }
1108+ // Snapshot the current transcript and cumulative usage BEFORE we
1109+ // build the replacement agent so we can hand them off. Without
1110+ // this the user perceives the entire session as wiped on a
1111+ // cross-provider /model swap.
1112+ var carryMsgs []provider.Message
1113+ var carryCost provider.Usage
1114+ if i.agent != nil {
1115+ carryMsgs = i.agent.Messages()
1116+ carryCost = i.agent.Cost()
1117+ }
1118+
1119+ ag, p, md, err := builder(m.Provider, m.ID)
1120+ if err != nil {
1121+ i.mu.Lock()
1122+ i.statusErr = err.Error()
1123+ i.mu.Unlock()
1124+ return false
1125+ }
1126+
1127+ // Replay the transcript and seed the cost on the freshly-built
1128+ // agent. Messages travel cleanly between providers because they
1129+ // use the same provider.Message shape; tool-call ids are local
1130+ // to a turn so cross-provider continuation never confuses the
1131+ // new model (it just sees the assistant's reply, no orphan
1132+ // tool_use blocks because /model swaps are gated to idle state).
1133+ if len(carryMsgs) > 0 {
1134+ ag.SetMessages(carryMsgs)
1135+ }
1136+ ag.SeedCost(carryCost)
1137+
1138+ if i.agent != nil && i.agent != ag {
1139+ _ = tools.CloseLSPManagers(i.agent.ToolsSnapshot())
1140+ }
1141+ i.agentMu.Lock()
1142+ i.mu.Lock()
1143+ i.prepareReplacementAgentLocked(ag)
1144+ i.agent = ag
1145+ i.cfg.Provider = p
1146+ i.cfg.Model = md
1147+ if rescue {
1148+ i.statusOK = "rescue retry: switched to " + p + " / " + md + " (ignored --api-key / --base-url)"
1149+ } else {
1150+ i.statusOK = "switched to " + p + " / " + md
1151+ }
1152+ i.statusErr = ""
1153+ // Render cache keys are width+content based, so the new agent's
1154+ // identical messages will reuse the existing entries. Nothing
1155+ // to invalidate.
1156+ i.mu.Unlock()
1157+ i.agentMu.Unlock()
1158+ // The new agent was built off the base tool registry, so any
1159+ // dynamically-registered tools need to be reattached. The apply
1160+ // helpers are no-ops when their feature is inactive, so the
1161+ // cross-provider path still works on a vanilla setup.
1162+ i.applyAutoSubagentsTool()
1163+ i.applyTelegramTools(i.telegramBridge != nil)
1164+ if i.cfg.PersistModel != nil {
1165+ i.cfg.PersistModel(p, md)
1166+ }
1167+ return true
1168+}
1169+func (i *Interactive) handleAuthEvent(ev auth.Event) {
1170+ switch ev.Kind {
1171+ case "started":
1172+ i.dialog.ShowWaiting(ev.URL)
1173+ case "browser_open":
1174+ // no-op
1175+ case "error":
1176+ i.dialog.ShowResult(false, ev.Message)
1177+ case "success":
1178+ // Keep the credential-driven resolve and its final replacement in the
1179+ // same transition as settings/session changes. Otherwise a build that
1180+ // started under the old web-search policy could commit afterward.
1181+ var buildErr error
1182+ var clearHandoff bool
1183+ buildAndCommitLogin := func() {
1184+ ag, prov, model, err := i.cfg.BuildAgent()
1185+ if err != nil {
1186+ buildErr = err
1187+ return
1188+ }
1189+ i.agentMu.Lock()
1190+ i.mu.Lock()
1191+ oldAgent := i.agent
1192+ i.prepareReplacementAgentLocked(ag)
1193+ _, clearHandoff = i.resetCompactContinuationLocked()
1194+ i.agent = ag
1195+ i.cfg.Provider = prov
1196+ i.cfg.Model = model
1197+ i.statusErr = ""
1198+ i.statusOK = "logged in to " + ev.Provider + " via " + ev.Method
1199+ i.mu.Unlock()
1200+ i.agentMu.Unlock()
1201+ if oldAgent != nil {
1202+ _ = tools.CloseLSPManagers(oldAgent.ToolsSnapshot())
1203+ }
1204+ i.applyAutoSubagentsTool()
1205+ i.applyTelegramTools(i.telegramBridge != nil)
1206+ // Authentication can change the provider used by the live
1207+ // agent. Persist it on the active session just like /model.
1208+ if i.cfg.PersistModel != nil {
1209+ i.cfg.PersistModel(prov, model)
1210+ }
1211+ }
1212+ if i.cfg.SessionTransition != nil {
1213+ i.cfg.SessionTransition(buildAndCommitLogin)
1214+ } else {
1215+ buildAndCommitLogin()
1216+ }
1217+ if buildErr != nil {
1218+ i.dialog.ShowResult(false, buildErr.Error())
1219+ return
1220+ }
1221+ if clearHandoff {
1222+ i.persistCompactHandoff(nil)
1223+ }
1224+ i.dialog.ShowResult(true, "")
1225+ }
1226+}
A packages/agent/modes/interactive_compaction.go +400 -0
@@ -0,0 +1,400 @@
1+package modes
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "fmt"
7+
8+ "github.com/bnema/zut/packages/provider"
9+ "github.com/bnema/zut/packages/tui"
10+)
11+
12+func (i *Interactive) clearPendingCompactTurnLocked() {
13+ i.pendingCompactPrompt = ""
14+ i.pendingCompactImages = nil
15+ i.hasPendingCompactPrompt = false
16+ i.continueAfterCompact = false
17+ i.pendingPostCompactNote = ""
18+}
19+func (i *Interactive) compactHandoffLocked() json.RawMessage {
20+ return encodeCompactHandoff(i.compactContinuation)
21+}
22+func (i *Interactive) setCompactContinuationLocked(state compactContinuationState) (json.RawMessage, bool) {
23+ if i.compactContinuation == state {
24+ return i.compactHandoffLocked(), false
25+ }
26+ i.compactContinuation = state
27+ return i.compactHandoffLocked(), true
28+}
29+func (i *Interactive) resetCompactContinuationLocked() (json.RawMessage, bool) {
30+ return i.setCompactContinuationLocked(compactContinuationState{})
31+}
32+func (i *Interactive) persistCompactHandoff(state json.RawMessage) {
33+ if persist := i.cfg.PersistCompactHandoff; persist != nil {
34+ if err := persist(state); err != nil {
35+ i.ReportError(fmt.Errorf("persist compact handoff: %w", err))
36+ }
37+ }
38+}
39+func (i *Interactive) resetCompactHandoff() {
40+ i.mu.Lock()
41+ state, persist := i.resetCompactContinuationLocked()
42+ i.mu.Unlock()
43+ if persist {
44+ i.persistCompactHandoff(state)
45+ }
46+}
47+func (i *Interactive) currentCompactHandoff() json.RawMessage {
48+ if current := i.cfg.CurrentCompactHandoff; current != nil {
49+ return current()
50+ }
51+ return nil
52+}
53+func (i *Interactive) restoreCompactHandoff(state compactContinuationState) {
54+ i.mu.Lock()
55+ i.compactContinuation = state
56+ i.mu.Unlock()
57+}
58+func (i *Interactive) restoreCurrentCompactHandoff() compactContinuationState {
59+ state := decodeCompactHandoff(i.currentCompactHandoff())
60+ i.restoreCompactHandoff(state)
61+ return state
62+}
63+func classifyCompactHandoffResume(messages []provider.Message) compactHandoffResume {
64+ lastHandoff := -1
65+ for idx := len(messages) - 1; idx >= 0; idx-- {
66+ message := messages[idx]
67+ if message.Role == provider.RoleUser && message.Meta[autoCompactContinueMetaKey] == "true" {
68+ lastHandoff = idx
69+ break
70+ }
71+ }
72+ if lastHandoff < 0 {
73+ return compactHandoffAppendPrompt
74+ }
75+ for _, message := range messages[lastHandoff+1:] {
76+ switch message.Role {
77+ case provider.RoleTool:
78+ continue
79+ case provider.RoleAssistant:
80+ hasToolCall := false
81+ for _, content := range message.Content {
82+ if _, ok := content.(provider.ToolCallBlock); ok {
83+ hasToolCall = true
84+ break
85+ }
86+ }
87+ if !hasToolCall {
88+ return compactHandoffDiscard
89+ }
90+ default:
91+ return compactHandoffDiscard
92+ }
93+ }
94+ return compactHandoffContinueExisting
95+}
96+func (i *Interactive) startRestoredCompactHandoff(parent context.Context) {
97+ if parent == nil {
98+ parent = i.runCtx
99+ }
100+ if parent == nil {
101+ parent = context.Background()
102+ }
103+ i.mu.Lock()
104+ state := i.compactContinuation
105+ ag := i.agent
106+ busy := i.busy || i.compacting || i.autoCompacting || i.shellRunning
107+ if state.reason == compactContinuationNone || ag == nil || busy {
108+ i.mu.Unlock()
109+ return
110+ }
111+
112+ var next string
113+ continueQueued := false
114+ var resume compactHandoffResume
115+ var handoff json.RawMessage
116+ var persistHandoff bool
117+ switch {
118+ case state.reason != compactContinuationForcedLength && len(i.queued) > 0:
119+ next, i.queued = i.queued[0], i.queued[1:]
120+ handoff, persistHandoff = i.resetCompactContinuationLocked()
121+ case state.reason != compactContinuationForcedLength && ag.QueuedMessageCount() > 0:
122+ continueQueued = true
123+ handoff, persistHandoff = i.resetCompactContinuationLocked()
124+ default:
125+ resume = classifyCompactHandoffResume(ag.Messages())
126+ if resume == compactHandoffDiscard {
127+ handoff, persistHandoff = i.resetCompactContinuationLocked()
128+ }
129+ }
130+ // Reserve the turn slot before persisting or dispatching so concurrent input
131+ // can only queue behind the restored handoff, never start a competing turn.
132+ starting := next != "" || continueQueued || resume == compactHandoffContinueExisting || resume == compactHandoffAppendPrompt
133+ i.busy = starting
134+ i.mu.Unlock()
135+ if persistHandoff {
136+ i.persistCompactHandoff(handoff)
137+ }
138+ if !starting {
139+ i.invalidate()
140+ return
141+ }
142+ switch {
143+ case next != "":
144+ i.startTurn(parent, next)
145+ case continueQueued:
146+ i.startTurnRequest(parent, "", nil, true, false)
147+ case resume == compactHandoffContinueExisting:
148+ i.startTurnRequest(parent, "", nil, true, false)
149+ case resume == compactHandoffAppendPrompt:
150+ i.startAutoCompactContinuation(parent)
151+ }
152+}
153+func (i *Interactive) startAutoCompactContinuation(parent context.Context) {
154+ i.mu.Lock()
155+ ag := i.agent
156+ if ag == nil {
157+ i.busy = false
158+ pendingIdleWork := i.takePendingIdleWorkLocked()
159+ i.mu.Unlock()
160+ runPendingIdleWork(pendingIdleWork)
161+ i.invalidate()
162+ return
163+ }
164+ // The caller selected the continuation while holding i.mu. Keep that
165+ // ordering through the hidden append so an explicitly queued user prompt
166+ // cannot be overtaken by an old-task handoff. Forced truncation is the
167+ // existing exception: it keeps priority and the queued prompt runs after it.
168+ if i.compactContinuation.reason != compactContinuationForcedLength && len(i.queued) > 0 {
169+ next := i.queued[0]
170+ i.queued = i.queued[1:]
171+ handoff, persistHandoff := i.resetCompactContinuationLocked()
172+ i.mu.Unlock()
173+ if persistHandoff {
174+ i.persistCompactHandoff(handoff)
175+ }
176+ i.startTurn(parent, next)
177+ return
178+ }
179+ if i.compactContinuation.reason != compactContinuationForcedLength && ag.QueuedMessageCount() > 0 {
180+ handoff, persistHandoff := i.resetCompactContinuationLocked()
181+ i.mu.Unlock()
182+ if persistHandoff {
183+ i.persistCompactHandoff(handoff)
184+ }
185+ i.startTurnRequest(parent, "", nil, true, false)
186+ return
187+ }
188+ ag.AppendUserContext(autoCompactContinuationPrompt, map[string]string{
189+ autoCompactContinueMetaKey: "true",
190+ })
191+ i.mu.Unlock()
192+ i.startTurnRequest(parent, "", nil, true, false)
193+}
194+func (i *Interactive) runCompact(parent context.Context, request compactContinuationRequest) {
195+ auto := request.origin != compactOriginManual
196+ if i.agent == nil {
197+ i.mu.Lock()
198+ i.statusErr = "not logged in. type /login first."
199+ i.mu.Unlock()
200+ return
201+ }
202+ ctx, cancel := context.WithCancel(parent)
203+ i.mu.Lock()
204+ i.busy = true
205+ i.compacting = true
206+ i.spin.Start()
207+ i.activity = newAgentActivity(i.cfg.Provider, i.cfg.Model)
208+ if auto {
209+ i.activity.kind = activityCondensingHistory
210+ i.autoCompacting = true
211+ } else {
212+ i.activity.kind = activityCompactingHistory
213+ }
214+ i.cancelTurn = cancel
215+ var initialHandoff json.RawMessage
216+ persistInitialHandoff := false
217+ if request.origin == compactOriginManual || request.origin == compactOriginRecovery {
218+ initialHandoff, persistInitialHandoff = i.resetCompactContinuationLocked()
219+ }
220+ i.statusErr = ""
221+ i.statusOK = ""
222+ // Do NOT set streamOn: the summary text should not be visible
223+ // in the chat while compacting. The user just sees the spinner
224+ // and can keep typing / queue prompts.
225+ i.scrollOffset = 0
226+ i.helpBlock = nil
227+ i.mu.Unlock()
228+ if persistInitialHandoff {
229+ i.persistCompactHandoff(initialHandoff)
230+ }
231+ i.invalidate()
232+
233+ go func() {
234+ // Sink discards deltas — we don't stream the summary to the UI.
235+ sink := func(delta string) {}
236+ msgsBefore := i.agent.Messages()
237+ i.mu.Lock()
238+ statusRescueActive := i.compactContinuation.reason == compactContinuationStatusRescue
239+ i.mu.Unlock()
240+ continuationReason := classifyCompactionContinuation(request.origin, statusRescueActive, request.lastStop, request.turnError, msgsBefore)
241+ if request.force {
242+ continuationReason = compactContinuationForcedLength
243+ }
244+ // Keep the usual recent tail when possible, but never let it cover
245+ // the whole transcript. A short session can still be at 70–90% of
246+ // a model's window because one prompt or tool result is large; the
247+ // automatic path must summarize that session instead of failing
248+ // before it reaches the compaction provider.
249+ keepTail := 4
250+ if n := len(msgsBefore); n > 0 && keepTail >= n {
251+ keepTail = n - 1
252+ }
253+ summary, err := i.agent.Compact(ctx, keepTail, sink)
254+ _ = summary
255+ goalMessage, goalActive := i.goalContinuationMessage()
256+ i.mu.Lock()
257+ // Keep busy/compacting asserted while cleanup and queue selection run.
258+ // Completion updates can arrive in this window; clearing busy before
259+ // inspecting the queues lets them race a new turn or be stranded.
260+ i.resetStreamingStateLocked()
261+ i.cancelTurn = nil
262+ i.autoCompacting = false
263+ pendingIdleWork := i.takePendingIdleWorkLocked()
264+
265+ // Drain pending work after the transcript is clean. Overflow recovery
266+ // continues the user message already in the transcript. Pre-turn
267+ // compaction preserves its complete text-and-images request. Regular
268+ // prompts typed during compaction remain in the host queue.
269+ var next string
270+ var nextImages []provider.ImageBlock
271+ var hasNext bool
272+ var continueExisting bool
273+ var continueAutomatically bool
274+ var continueGoal bool
275+ var handoff json.RawMessage
276+ var persistHandoff bool
277+
278+ switch {
279+ case err != nil && ctx.Err() != nil:
280+ i.statusErr = ""
281+ if auto {
282+ i.statusOK = "auto-condense cancelled"
283+ } else {
284+ i.statusOK = "compaction cancelled"
285+ }
286+ i.queued = nil // drop queue on cancel
287+ i.clearPendingCompactTurnLocked()
288+ handoff, persistHandoff = i.resetCompactContinuationLocked()
289+ if i.agent != nil {
290+ i.agent.DrainQueuedMessages()
291+ }
292+ case err != nil:
293+ i.statusErr = "compaction failed: " + err.Error()
294+ i.statusOK = ""
295+ i.queued = nil // drop queue on error
296+ i.clearPendingCompactTurnLocked()
297+ handoff, persistHandoff = i.resetCompactContinuationLocked()
298+ if i.agent != nil {
299+ i.agent.DrainQueuedMessages()
300+ }
301+ default:
302+ i.statusErr = ""
303+ // Read token count from the compaction message meta.
304+ tokens := ""
305+ msgs := i.agent.Messages()
306+ if len(msgs) > 0 && msgs[0].Meta["compaction"] == "true" {
307+ tokens = msgs[0].Meta["tokens_before"]
308+ }
309+ switch {
310+ case i.pendingPostCompactNote != "":
311+ i.statusOK = i.pendingPostCompactNote
312+ case tokens != "":
313+ i.statusOK = fmt.Sprintf("compacted from ~%s tokens (ctrl+o to expand)", tokens)
314+ default:
315+ i.statusOK = "compacted (ctrl+o to expand)"
316+ }
317+ i.pendingPostCompactNote = ""
318+ i.extNotes = stripAutoCompactNotes(i.extNotes)
319+ i.lastCtxInput = 0
320+ i.toolCalls = map[string]*tui.ToolCallView{}
321+ i.toolOrder = nil
322+ i.toolGate = map[string]int{}
323+ i.resetTranscriptRenderLocked()
324+ switch {
325+ case i.continueAfterCompact:
326+ continueExisting = true
327+ i.continueAfterCompact = false
328+ case i.hasPendingCompactPrompt:
329+ next = i.pendingCompactPrompt
330+ nextImages = i.pendingCompactImages
331+ i.pendingCompactPrompt = ""
332+ i.pendingCompactImages = nil
333+ i.hasPendingCompactPrompt = false
334+ hasNext = true
335+ case continuationReason == compactContinuationForcedLength:
336+ // Forced truncated-output continuation keeps its existing
337+ // priority over an explicitly queued prompt.
338+ handoff, persistHandoff = i.setCompactContinuationLocked(compactContinuationState{reason: continuationReason})
339+ continueAutomatically = true
340+ case len(i.queued) > 0:
341+ next, i.queued = i.queued[0], i.queued[1:]
342+ hasNext = true
343+ handoff, persistHandoff = i.resetCompactContinuationLocked()
344+ case continuationReason == compactContinuationStructuralTail || continuationReason == compactContinuationStatusRescue:
345+ if continuationReason == compactContinuationStatusRescue {
346+ attempts := 1
347+ if i.compactContinuation.reason == compactContinuationStatusRescue {
348+ attempts = i.compactContinuation.rescueAttempts + 1
349+ }
350+ if attempts > maxStatusRescueContinuations {
351+ handoff, persistHandoff = i.resetCompactContinuationLocked()
352+ break
353+ }
354+ handoff, persistHandoff = i.setCompactContinuationLocked(compactContinuationState{
355+ reason: continuationReason,
356+ rescueAttempts: attempts,
357+ })
358+ } else {
359+ handoff, persistHandoff = i.setCompactContinuationLocked(compactContinuationState{reason: continuationReason})
360+ }
361+ continueAutomatically = true
362+ case auto && goalActive && !i.coordinatorHasPendingWorkers():
363+ continueGoal = true
364+ handoff, persistHandoff = i.resetCompactContinuationLocked()
365+ default:
366+ handoff, persistHandoff = i.resetCompactContinuationLocked()
367+ }
368+ }
369+ // Keep the host busy until the hand-off decision is committed under
370+ // the mutex. A completion update arriving after the compaction result
371+ // but before this assignment is then queued for the selected follow-up
372+ // instead of starting a competing turn.
373+ i.busy = hasNext || continueExisting || continueAutomatically || continueGoal
374+ i.compacting = false
375+ i.autoCompacting = false
376+ i.mu.Unlock()
377+ if persistHandoff {
378+ i.persistCompactHandoff(handoff)
379+ }
380+ runPendingIdleWork(pendingIdleWork)
381+ i.invalidate()
382+
383+ if hasNext || continueExisting || continueAutomatically || continueGoal {
384+ p := i.runCtx
385+ if p == nil {
386+ p = context.Background()
387+ }
388+ switch {
389+ case continueExisting:
390+ i.startTurnRequest(p, "", nil, true, true)
391+ case hasNext:
392+ i.startTurnWithImages(p, next, nextImages)
393+ case continueAutomatically:
394+ i.startAutoCompactContinuation(p)
395+ case continueGoal:
396+ i.startGoalContinuation(p, goalMessage)
397+ }
398+ }
399+ }()
400+}
A packages/agent/modes/interactive_events.go +347 -0
@@ -0,0 +1,347 @@
1+package modes
2+
3+import (
4+ "context"
5+ "strings"
6+ "time"
7+
8+ "github.com/bnema/zut/packages/agent/tools"
9+ "github.com/bnema/zut/packages/core"
10+ "github.com/bnema/zut/packages/provider"
11+ "github.com/bnema/zut/packages/tui"
12+)
13+
14+func eventAffectsPresentation(ev core.AgentEvent) bool {
15+ switch ev.(type) {
16+ case core.EvToolProgress:
17+ return false
18+ default:
19+ return true
20+ }
21+}
22+func (i *Interactive) bumpToolRevisionLocked(tc *tui.ToolCallView) {
23+ i.toolRenderRevision++
24+ if i.toolRenderRevision == 0 {
25+ // Overflow is practically unreachable, but resetting the sequence must
26+ // also drop old revisions before they can alias new frames.
27+ if i.view != nil {
28+ i.view.InvalidateRenderCache()
29+ }
30+ i.toolRenderRevision = 1
31+ }
32+ tc.Revision = i.toolRenderRevision
33+}
34+func (i *Interactive) handleEventForPresentation(ev core.AgentEvent) {
35+ i.handleEvent(ev)
36+ // Progress payloads are still delivered to the core sink and applied
37+ // in order, but they have no interactive-visible representation.
38+ if eventAffectsPresentation(ev) {
39+ i.invalidate()
40+ }
41+}
42+func (i *Interactive) handleEvent(ev core.AgentEvent) {
43+ i.mu.Lock()
44+ defer i.mu.Unlock()
45+ i.activity.apply(ev)
46+ switch e := ev.(type) {
47+ case core.EvAssistantStart:
48+ // Fires at the top of every oneTurn, including follow-up
49+ // turns after tool use. Without this, the streaming buffer
50+ // is still marked off from the previous assistant message
51+ // and the final summary text pops in all at once instead
52+ // of typewriter-streaming delta by delta.
53+ i.streaming.Reset()
54+ i.streamPending = i.streamPending[:0]
55+ i.streamFlushPending = false
56+ i.streamOn = true
57+ // Clear the live tool-call overlay. Any tools from the
58+ // previous round are now fully folded into the transcript
59+ // (assistant tool_use block + tool role message with the
60+ // result), so keeping them in the overlay would duplicate
61+ // them in the view — once inside the finalised transcript
62+ // and once below the streaming block, with the streaming
63+ // summary sandwiched in between. The next EvToolUseStart
64+ // will populate fresh entries for this turn's tools.
65+ i.toolCalls = map[string]*tui.ToolCallView{}
66+ i.toolOrder = nil
67+ i.toolGate = map[string]int{}
68+ case core.EvTextDelta:
69+ // Buffer into streamPending; the paintPace ticker drains
70+ // it into i.streaming a few runes at a time for a smooth
71+ // typewriter effect independent of upstream chunk size.
72+ i.streamPending = append(i.streamPending, []rune(e.Delta)...)
73+ i.streamOn = true
74+ case core.EvAssistantMessage:
75+ // OnAssistant + telegram mirroring always fire on message
76+ // arrival — they read the FINAL message content, which is
77+ // complete regardless of what's still in the pacer.
78+ i.assistantMessageSideEffects(e.Message)
79+ // If the pacer still has characters to drain, keep streamOn
80+ // true and mark flush pending; the paintPace ticker will
81+ // drain the remainder and reset streaming state when done.
82+ // Otherwise (rare: full-replay sessions, abort paths) clear
83+ // synchronously so a later render doesn't show stale text.
84+ if len(i.streamPending) > 0 {
85+ i.streamFlushPending = true
86+ return
87+ }
88+ i.resetStreamingStateLocked()
89+ case core.EvToolUseStart:
90+ // Live streaming: pre-create the view so the user sees the
91+ // tool call being composed in real time. Any subsequent
92+ // EvToolCall for the same ID updates the same struct (the
93+ // final parsed args + name are already known here).
94+ if _, exists := i.toolCalls[e.ID]; !exists {
95+ tc := &tui.ToolCallView{
96+ ID: e.ID,
97+ Name: e.Name,
98+ Streaming: true,
99+ }
100+ i.bumpToolRevisionLocked(tc)
101+ i.toolCalls[e.ID] = tc
102+ i.toolOrder = append(i.toolOrder, e.ID)
103+ i.gateToolLocked(e.ID)
104+ }
105+ case core.EvToolUseArgs:
106+ if tc, ok := i.toolCalls[e.ID]; ok {
107+ tc.RawJSONBuf += e.Delta
108+ i.bumpToolRevisionLocked(tc)
109+ // Refresh the live path as soon as it parses; used in
110+ // the header (write /Users/example/Desktop/demo.ts)
111+ // while the content is still streaming.
112+ if p, pok, _ := tui.ExtractPartialStringField(tc.RawJSONBuf, "path"); pok {
113+ tc.LivePath = p
114+ } else if p, pok, _ := tui.ExtractPartialStringField(tc.RawJSONBuf, "file_path"); pok {
115+ tc.LivePath = p
116+ }
117+ }
118+ case core.EvToolUseEnd:
119+ if tc, ok := i.toolCalls[e.ID]; ok {
120+ tc.Streaming = false
121+ i.bumpToolRevisionLocked(tc)
122+ }
123+ case core.EvToolCall:
124+ // If we already pre-created the view during streaming, just
125+ // refresh the final Args summary. Otherwise create a new one
126+ // (non-streaming providers or legacy paths).
127+ if tc, ok := i.toolCalls[e.ID]; ok {
128+ tc.Args = tui.ShortArgs(e.Name, e.Args)
129+ i.bumpToolRevisionLocked(tc)
130+ if tc.RawJSONBuf == "" {
131+ tc.RawJSONBuf = string(e.Args)
132+ }
133+ tc.Streaming = false
134+ } else {
135+ tc := &tui.ToolCallView{
136+ ID: e.ID,
137+ Name: e.Name,
138+ Args: tui.ShortArgs(e.Name, e.Args),
139+ RawJSONBuf: string(e.Args),
140+ }
141+ i.bumpToolRevisionLocked(tc)
142+ i.toolCalls[e.ID] = tc
143+ i.toolOrder = append(i.toolOrder, e.ID)
144+ i.gateToolLocked(e.ID)
145+ }
146+ case core.EvToolResult:
147+ if tc, ok := i.toolCalls[e.ID]; ok {
148+ tc.Done = true
149+ tc.Error = e.Result.IsError
150+ tc.Preview = ""
151+ var text strings.Builder
152+ for _, c := range e.Result.Content {
153+ if tb, ok := c.(provider.TextBlock); ok {
154+ if text.Len() > 0 {
155+ text.WriteString("\n")
156+ }
157+ text.WriteString(tb.Text)
158+ }
159+ }
160+ tc.Result = text.String()
161+ i.bumpToolRevisionLocked(tc)
162+ }
163+ if update, ok := tools.GoalUpdateFromResult(e.Result); ok {
164+ if update.Status == core.GoalActive {
165+ // A manager may advance a terminal goal to the next persisted
166+ // goal in the same mission. Do not let a late tool result resume
167+ // a goal explicitly paused by the user.
168+ if i.goalStatus != core.GoalPaused {
169+ i.goalStatus = core.GoalActive
170+ }
171+ } else if i.goalStatus == core.GoalActive {
172+ i.goalStatus = update.Status
173+ }
174+ }
175+ case core.EvUsage:
176+ i.cumUsage = e.Cumulative
177+ if contextUsed := e.Usage.InputTokens + e.Usage.CacheReadTokens + e.Usage.CacheWriteTokens; contextUsed > 0 {
178+ i.lastCtxInput = contextUsed
179+ }
180+ case core.EvTurnEnd:
181+ if e.Stop == provider.StopAborted {
182+ i.resetStreamingStateLocked()
183+ i.statusErr = ""
184+ i.statusOK = "cancelled"
185+ return
186+ }
187+ if e.Stop == provider.StopLength {
188+ // The model hit its output-token cap mid-response, so the
189+ // reply (often a long write/edit) is truncated. Surface it
190+ // explicitly, otherwise the turn just ends and reads like
191+ // the UI gave up. The agent already requests the model's
192+ // full MaxOutput budget, so this means the response genuinely
193+ // exceeded that ceiling; ask the user to continue.
194+ i.statusErr = "response hit the model's output-token limit and was cut off, ask it to continue"
195+ i.statusOK = ""
196+ return
197+ }
198+ // Don't surface mid-loop stream errors as a red banner here.
199+ // EvTurnEnd fires after every step in a multi-step tool loop,
200+ // so a transient 503 / network blip would briefly paint a red
201+ // banner over the still-streaming chat before the agent loop
202+ // either retries or exits. The final error (if any) is set by
203+ // startTurnWithImages once Prompt() returns, and recoverable
204+ // failures are routed to the rescue picker instead — which
205+ // keeps the chat clean while the agent is working.
206+ _ = e.Err
207+ }
208+}
209+func (i *Interactive) WebSearchPolicyGeneration() uint64 {
210+ return i.webSearchPolicyGeneration.Load()
211+}
212+func (i *Interactive) ApplyAgentPromptConfig(ag *core.Agent, system string, tools core.Registry) (core.Registry, bool) {
213+ return i.applyAgentPromptConfig(ag, system, tools, 0, false)
214+}
215+func (i *Interactive) ApplyAgentPromptConfigAtWebSearchGeneration(ag *core.Agent, system string, tools core.Registry, generation uint64) (core.Registry, bool) {
216+ return i.applyAgentPromptConfig(ag, system, tools, generation, true)
217+}
218+func (i *Interactive) applyAgentPromptConfig(ag *core.Agent, system string, tools core.Registry, generation uint64, checkGeneration bool) (core.Registry, bool) {
219+ if ag == nil {
220+ return nil, false
221+ }
222+ // Match the lock order used by dynamic tool mutations so a refresh
223+ // cannot race a snapshot-and-replace operation that would reintroduce
224+ // the old registry after the atomic prompt swap.
225+ i.agentMu.Lock()
226+ defer i.agentMu.Unlock()
227+ i.mu.Lock()
228+ defer i.mu.Unlock()
229+ if i.agent != ag || checkGeneration && i.webSearchPolicyGeneration.Load() != generation {
230+ return nil, false
231+ }
232+ if i.telegramBridge != nil {
233+ // Telegram prompts arrive over an external messaging channel without a
234+ // per-request confirmation surface. Keep the normal interactive
235+ // registry from reintroducing web_search from the moment the bridge is
236+ // attached, including while its startup handshake is in flight.
237+ delete(tools, "web_search")
238+ }
239+ oldTools := ag.SetPromptConfig(system, tools)
240+ _, webSearchAvailable := tools["web_search"]
241+ i.setWebSearchAvailable(webSearchAvailable)
242+ return oldTools, true
243+}
244+func (i *Interactive) prepareReplacementAgentLocked(ag *core.Agent) {
245+ if ag == nil {
246+ i.setWebSearchAvailable(false)
247+ return
248+ }
249+ registry := ag.ToolsSnapshot()
250+ if i.telegramBridge != nil {
251+ // External Telegram prompts may arrive as soon as the replacement is
252+ // published, so post-swap cleanup is too late.
253+ delete(registry, "web_search")
254+ ag.SetTools(registry)
255+ }
256+ _, webSearchAvailable := registry["web_search"]
257+ i.setWebSearchAvailable(webSearchAvailable && i.telegramBridge == nil)
258+}
259+func (i *Interactive) DeferUntilIdle(fn func()) {
260+ if fn == nil {
261+ return
262+ }
263+ i.mu.Lock()
264+ if i.busy {
265+ i.pendingIdleWork = append(i.pendingIdleWork, fn)
266+ i.mu.Unlock()
267+ return
268+ }
269+ i.mu.Unlock()
270+ fn()
271+}
272+func (i *Interactive) takePendingIdleWorkLocked() []func() {
273+ work := i.pendingIdleWork
274+ i.pendingIdleWork = nil
275+ return work
276+}
277+func runPendingIdleWork(work []func()) {
278+ for _, fn := range work {
279+ if fn != nil {
280+ fn()
281+ }
282+ }
283+}
284+func (i *Interactive) Agent() *core.Agent {
285+ i.mu.Lock()
286+ defer i.mu.Unlock()
287+ return i.agent
288+}
289+func (i *Interactive) runReloadExt(ctx context.Context) {
290+ if i.cfg.Extensions == nil {
291+ i.mu.Lock()
292+ i.statusErr = "no extension manager in this build"
293+ i.mu.Unlock()
294+ i.invalidate()
295+ return
296+ }
297+ i.mu.Lock()
298+ i.statusOK = "Reloading extensions..."
299+ i.statusErr = ""
300+ i.mu.Unlock()
301+ i.invalidate()
302+
303+ go func() {
304+ stats := i.cfg.Extensions.Reload(ctx, 2*time.Second)
305+ msg, failed := formatReloadStatus(stats)
306+ seq := i.setReloadStatus(msg, failed)
307+ i.invalidate()
308+ go i.dismissReloadStatus(ctx, seq, msg, failed)
309+ }()
310+}
311+func (i *Interactive) Confirm(toolName string, preview string) core.ConfirmDecision {
312+ return i.ConfirmToolCall(core.ToolCallConfirmation{Name: toolName, Summary: preview})
313+}
314+func (i *Interactive) ConfirmToolCall(call core.ToolCallConfirmation) core.ConfirmDecision {
315+ resp := make(chan core.ConfirmDecision, 1)
316+ if isBtwOrigin(call.Origin) {
317+ req := &confirmRequest{
318+ toolName: call.Name,
319+ preview: call.Summary,
320+ resp: resp,
321+ returnToChild: true,
322+ }
323+ if call.ID == "" || i.btwDialog == nil || !i.btwDialog.enqueueToolConfirmation(call.Origin, call.ID, call.Summary, call.Content, func() {
324+ i.confirmDialog.Enqueue(req)
325+ }) {
326+ return core.ConfirmDecision{Allow: false, Reason: "side-chat tool call canceled"}
327+ }
328+ i.invalidate()
329+ return <-resp
330+ }
331+ if call.ID != "" {
332+ i.mu.Lock()
333+ if tc, ok := i.toolCalls[call.ID]; ok {
334+ tc.Args = call.Summary
335+ tc.Preview = call.Content
336+ i.activity.activity = activity{kind: activityAwaitingConfirmation, tool: call.Name, provider: i.cfg.Provider, model: i.cfg.Model}
337+ }
338+ i.mu.Unlock()
339+ }
340+ i.confirmDialog.Enqueue(&confirmRequest{
341+ toolName: call.Name,
342+ preview: call.Summary,
343+ resp: resp,
344+ })
345+ i.invalidate()
346+ return <-resp
347+}
A packages/agent/modes/interactive_host.go +417 -0
@@ -0,0 +1,417 @@
1+package modes
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "fmt"
7+ "os"
8+ "strings"
9+ "time"
10+
11+ "github.com/bnema/zut/packages/agent/extproto"
12+ "github.com/bnema/zut/packages/agent/internal/orchestration"
13+ "github.com/bnema/zut/packages/agent/subagents"
14+ "github.com/bnema/zut/packages/core"
15+ "github.com/bnema/zut/packages/provider"
16+ "github.com/bnema/zut/packages/tui"
17+)
18+
19+func (i *Interactive) invokeExtensionCommand(ctx context.Context, name, args string) {
20+ resp, err := i.cfg.Extensions.Invoke(ctx, name, args, 30*time.Second)
21+ if err != nil {
22+ i.mu.Lock()
23+ i.statusErr = "extension /" + name + ": " + err.Error()
24+ i.statusOK = ""
25+ i.mu.Unlock()
26+ i.invalidate()
27+ return
28+ }
29+ if resp.Error != "" {
30+ i.mu.Lock()
31+ i.statusErr = "extension /" + name + ": " + resp.Error
32+ i.statusOK = ""
33+ i.mu.Unlock()
34+ i.invalidate()
35+ return
36+ }
37+ switch resp.Action {
38+ case "open_panel":
39+ if resp.OpenPanel != nil {
40+ extName := name
41+ if i.cfg.Extensions != nil {
42+ if owner := i.cfg.Extensions.CommandOwner(name); owner != "" {
43+ extName = owner
44+ }
45+ }
46+ i.OpenPanel(extName, *resp.OpenPanel)
47+ }
48+ case "prompt":
49+ if strings.TrimSpace(resp.Prompt) == "" {
50+ return
51+ }
52+ i.startTurn(i.runCtx, resp.Prompt)
53+ case "insert":
54+ i.ed.Insert(resp.Insert)
55+ i.invalidate()
56+ case "display":
57+ i.appendExtensionNote(name, resp.Display, "info")
58+ case "noop", "":
59+ // nothing
60+ default:
61+ i.mu.Lock()
62+ i.statusErr = "extension /" + name + ": unknown action " + resp.Action
63+ i.mu.Unlock()
64+ i.invalidate()
65+ }
66+}
67+func (i *Interactive) appendExtensionNote(extName, msg, level string) {
68+ if msg == "" {
69+ return
70+ }
71+ i.mu.Lock()
72+ defer i.mu.Unlock()
73+ color := i.cfg.Theme.Muted
74+ switch level {
75+ case "warn":
76+ color = i.cfg.Theme.Warning
77+ case "error":
78+ color = i.cfg.Theme.Error
79+ case "success":
80+ color = i.cfg.Theme.Tool
81+ }
82+ prefix := i.cfg.Theme.FGColor(i.cfg.Theme.Accent, "["+extName+"] ")
83+ for _, line := range strings.Split(msg, "\n") {
84+ i.statusOK = "" // clear any stale ok
85+ i.statusErr = ""
86+ i.extNotes = append(i.extNotes, prefix+i.cfg.Theme.FGColor(color, line))
87+ }
88+ // Extension slash commands complete asynchronously. If their result is
89+ // displayed while confirmation is waiting, keep Esc assigned to the note
90+ // until it is dismissed rather than letting it answer the tool call.
91+ i.confirmDialog.Blur()
92+}
93+func (i *Interactive) ApplySessionAgent(ag *core.Agent, providerName, model string) {
94+ i.ApplySessionAgentWithCompactHandoff(ag, providerName, model, nil)
95+}
96+func (i *Interactive) ApplySessionAgentWithCompactHandoff(ag *core.Agent, providerName, model string, compactHandoff json.RawMessage) {
97+ if ag == nil {
98+ return
99+ }
100+ i.agentMu.Lock()
101+ defer i.agentMu.Unlock()
102+ i.mu.Lock()
103+ i.prepareReplacementAgentLocked(ag)
104+ i.compactContinuation = decodeCompactHandoff(compactHandoff)
105+ i.agent = ag
106+ i.cfg.Provider = providerName
107+ i.cfg.Model = model
108+ i.view.Messages = filterHiddenTranscriptMessages(ag.Messages())
109+ i.cumUsage = ag.Cost()
110+ last := ag.LastTurnUsage()
111+ i.lastCtxInput = last.InputTokens + last.CacheReadTokens + last.CacheWriteTokens
112+ if len(i.view.Messages) > initialResumeTailLimit {
113+ i.view.TailLimit = initialResumeTailLimit
114+ } else {
115+ i.view.TailLimit = 0
116+ }
117+ i.view.InvalidateRenderCache()
118+ i.toolCalls = map[string]*tui.ToolCallView{}
119+ i.toolOrder = nil
120+ i.toolGate = map[string]int{}
121+ i.helpBlock = nil
122+ i.extNotes = nil
123+ i.parkedTurn = 0
124+ i.parkedTotal = 0
125+ i.mu.Unlock()
126+ i.invalidate()
127+}
128+func (i *Interactive) SetSubagentSessionScope(_ string) {
129+ i.invalidate()
130+}
131+func (i *Interactive) ApplyChangedCWD(ag *core.Agent, provider, model, cwd string) {
132+ i.applyChangedCWD(ag, provider, model, cwd, nil)
133+}
134+func (i *Interactive) ApplyChangedCWDWithStartupContext(ag *core.Agent, provider, model, cwd string, startupContextPaths []string) {
135+ i.applyChangedCWD(ag, provider, model, cwd, startupContextPaths)
136+}
137+func (i *Interactive) applyChangedCWD(ag *core.Agent, provider, model, cwd string, startupContextPaths []string) {
138+ home, _ := os.UserHomeDir()
139+ profiles, _ := subagents.Discover(cwd, home)
140+ subagentsAddendum := subagents.SystemPromptAddendum(profiles)
141+
142+ i.agentMu.Lock()
143+ defer i.agentMu.Unlock()
144+ i.mu.Lock()
145+ i.prepareReplacementAgentLocked(ag)
146+ i.resetCompactContinuationLocked()
147+ i.agent = ag
148+ i.cfg.CWD = cwd
149+ i.cfg.SubagentsSystemAddendum = subagentsAddendum
150+ i.managedAutoSubagentsAddenda = autoSubagentsAddenda(i.cfg, i.autoSubagentsEnabledLocked())
151+ i.cfg.StartupContextPaths = append([]string(nil), startupContextPaths...)
152+ i.view.StartupContextPaths = nil
153+ i.view.InvalidateRenderCache()
154+ if i.cfg.ShowInstructionsAtStartup != nil && *i.cfg.ShowInstructionsAtStartup {
155+ i.view.StartupContextPaths = append(i.view.StartupContextPaths, startupContextPaths...)
156+ }
157+ // Re-report the working directory to the terminal so "new tab here"
158+ // tracks the /cd change (OSC 7, see issue #38).
159+ if i.cfg.Terminal != nil {
160+ if seq := tui.ReportCWD(cwd); seq != "" {
161+ _, _ = i.cfg.Terminal.Write([]byte(seq))
162+ }
163+ }
164+ i.cfg.Provider = provider
165+ i.cfg.Model = model
166+ titleCancel := i.titleCancel
167+ i.titleCancel = nil
168+ i.titleVersion++
169+ i.sessionTitle = ""
170+ i.titleRealPromptSeen = false
171+ i.titleGenerationStarted = false
172+ i.writeTerminalTitleLocked("")
173+ i.toolCalls = map[string]*tui.ToolCallView{}
174+ i.toolOrder = nil
175+ i.toolGate = map[string]int{}
176+ i.helpBlock = nil
177+ i.parkedTurn = 0
178+ i.statusErr = ""
179+ i.mu.Unlock()
180+ if titleCancel != nil {
181+ titleCancel()
182+ }
183+ i.fileSuggest.Reset()
184+ i.fileSuggest.SetCWD(cwd)
185+ i.invalidate()
186+}
187+func (i *Interactive) SubmitSlash(text string) {
188+ i.mu.Lock()
189+ i.doubleEscape.Reset()
190+ i.mu.Unlock()
191+ text = strings.TrimSpace(text)
192+ if !strings.HasPrefix(text, "/") {
193+ return
194+ }
195+ if slashCommandCancelsTurn(text) {
196+ i.cancelAndWaitForIdle()
197+ }
198+ i.runSlash(i.runCtx, text)
199+ i.invalidate()
200+}
201+func (i *Interactive) SubmitOrQueue(text string, images []provider.ImageBlock) {
202+ i.submitOrQueue(text, images, true)
203+}
204+func (i *Interactive) submitOrQueue(text string, images []provider.ImageBlock, userInput bool) {
205+ if cmd, ok := shellEscapeCommand(text); ok {
206+ i.startShellEscape(i.runCtx, cmd)
207+ return
208+ }
209+ i.mu.Lock()
210+ if i.agent == nil {
211+ i.statusErr = "not logged in. type /login first."
212+ i.mu.Unlock()
213+ i.invalidate()
214+ return
215+ }
216+ i.mu.Unlock()
217+ if userInput && i.cfg.EnsureMission != nil {
218+ if err := i.cfg.EnsureMission(text); err != nil {
219+ i.ReportError(fmt.Errorf("persist mission: %w", err))
220+ return
221+ }
222+ }
223+ // User input is an orchestration event, not merely a queue append. When
224+ // the manager is dormant behind a sealed worker wave, the coordinator makes
225+ // this the next manager turn and carries any completed worker summary with it.
226+ if userInput && i.coordinatorAcceptsUserInput() {
227+ if actions := i.applyCoordinator(orchestration.Event{Kind: orchestration.EventUserInput, Text: text}); len(actions) != 0 {
228+ i.executeCoordinatorActions(actions)
229+ return
230+ }
231+ }
232+ i.maybeStartSessionTitle(i.runCtx, text)
233+ i.mu.Lock()
234+ if i.busy {
235+ // Queue text only; images are dropped for queued prompts. Keep the
236+ // interactive mutex held through Agent.QueueMessage so turn teardown
237+ // cannot inspect the agent queue between the busy check and enqueue.
238+ // Compaction uses the host queue because it has no active agent loop
239+ // to drain Agent.QueueMessage entries.
240+ var handoff json.RawMessage
241+ var persistHandoff bool
242+ if i.agent != nil && !i.compacting {
243+ handoff, persistHandoff = i.resetCompactContinuationLocked()
244+ i.agent.QueueMessage(text)
245+ } else {
246+ handoff, persistHandoff = i.resetCompactContinuationLocked()
247+ i.queued = append(i.queued, text)
248+ }
249+ i.mu.Unlock()
250+ if persistHandoff {
251+ i.persistCompactHandoff(handoff)
252+ }
253+ i.invalidate()
254+ return
255+ }
256+ i.mu.Unlock()
257+ i.startTurnWithImages(i.runCtx, text, images)
258+}
259+func (i *Interactive) ChangelogVersion() string {
260+ if i.changelogDialog != nil {
261+ return i.changelogDialog.version
262+ }
263+ return ""
264+}
265+func (i *Interactive) CancelTurn() {
266+ i.mu.Lock()
267+ cancel := i.cancelTurn
268+ i.mu.Unlock()
269+ if cancel != nil {
270+ i.cancelCoordinator()
271+
272+ i.mu.Lock()
273+ handoff, persistHandoff := i.resetCompactContinuationLocked()
274+ i.mu.Unlock()
275+ cancel()
276+ i.confirmDialog.CancelAll("turn cancelled")
277+ if persistHandoff {
278+ i.persistCompactHandoff(handoff)
279+ }
280+ }
281+}
282+func (i *Interactive) Insert(text string) {
283+ i.ed.Insert(text)
284+ i.invalidate()
285+}
286+func (i *Interactive) Display(extName, text string) {
287+ i.appendExtensionNote(extName, text, "info")
288+ i.invalidate()
289+}
290+func (i *Interactive) ReportError(err error) {
291+ if err == nil {
292+ return
293+ }
294+ i.mu.Lock()
295+ i.statusOK = ""
296+ i.statusErr = err.Error()
297+ i.mu.Unlock()
298+ i.invalidate()
299+}
300+func (i *Interactive) SetStatus(extName, key, level, text string) {
301+ if strings.TrimSpace(extName) == "" || strings.TrimSpace(key) == "" {
302+ return
303+ }
304+ i.mu.Lock()
305+ if i.extStatuses == nil {
306+ i.extStatuses = map[string]map[string]extensionStatus{}
307+ }
308+ if strings.TrimSpace(text) == "" {
309+ if items := i.extStatuses[extName]; items != nil {
310+ delete(items, key)
311+ if len(items) == 0 {
312+ delete(i.extStatuses, extName)
313+ }
314+ }
315+ } else {
316+ if i.extStatuses[extName] == nil {
317+ i.extStatuses[extName] = map[string]extensionStatus{}
318+ }
319+ i.extStatuses[extName][key] = extensionStatus{Level: level, Text: text}
320+ }
321+ i.mu.Unlock()
322+ i.invalidate()
323+}
324+func (i *Interactive) SetWidget(extName, id, position, title string, lines []string) {
325+ if strings.TrimSpace(extName) == "" || strings.TrimSpace(id) == "" {
326+ return
327+ }
328+ position = extproto.NormalizeWidgetPosition(position)
329+ i.mu.Lock()
330+ if i.extWidgets == nil {
331+ i.extWidgets = map[string]map[string]extensionWidget{}
332+ }
333+ if i.extWidgets[extName] == nil {
334+ i.extWidgets[extName] = map[string]extensionWidget{}
335+ }
336+ i.extWidgets[extName][id] = extensionWidget{
337+ Position: position,
338+ Title: title,
339+ Lines: append([]string(nil), lines...),
340+ }
341+ i.mu.Unlock()
342+ i.invalidate()
343+}
344+func (i *Interactive) ClearWidget(extName, id string) {
345+ i.mu.Lock()
346+ if items := i.extWidgets[extName]; items != nil {
347+ delete(items, id)
348+ if len(items) == 0 {
349+ delete(i.extWidgets, extName)
350+ }
351+ }
352+ i.mu.Unlock()
353+ i.invalidate()
354+}
355+func (i *Interactive) ClearExtensionChrome(extName string) {
356+ if strings.TrimSpace(extName) == "" {
357+ return
358+ }
359+ marker := "[" + extName + "] "
360+ i.mu.Lock()
361+ changed := false
362+ if _, ok := i.extStatuses[extName]; ok {
363+ delete(i.extStatuses, extName)
364+ changed = true
365+ }
366+ if _, ok := i.extWidgets[extName]; ok {
367+ delete(i.extWidgets, extName)
368+ changed = true
369+ }
370+ if len(i.extNotes) > 0 {
371+ kept := i.extNotes[:0:0]
372+ for _, line := range i.extNotes {
373+ if strings.Contains(line, marker) {
374+ changed = true
375+ continue
376+ }
377+ kept = append(kept, line)
378+ }
379+ i.extNotes = kept
380+ }
381+ if i.extPanel != nil && i.extPanel.Active() && i.extPanel.ext == extName {
382+ i.extPanel.Close()
383+ changed = true
384+ }
385+ i.mu.Unlock()
386+ if changed {
387+ i.invalidate()
388+ }
389+}
390+func (i *Interactive) OpenPanel(extName string, spec extproto.PanelSpec) {
391+ i.mu.Lock()
392+ defer i.mu.Unlock()
393+ i.extPanel.Open(extName, spec.ID, spec.Title, spec.Lines, spec.Footer)
394+ i.confirmDialog.Blur()
395+ if i.cfg.Extensions != nil {
396+ cols, rows := i.cfg.Terminal.Size()
397+ _ = cols
398+ _ = rows
399+ }
400+ i.invalidate()
401+}
402+func (i *Interactive) UpdatePanel(extName, panelID, title string, lines []string, footer string) {
403+ i.mu.Lock()
404+ defer i.mu.Unlock()
405+ if i.extPanel.Active() && i.extPanel.ext == extName && i.extPanel.id == panelID {
406+ i.extPanel.Update(title, lines, footer)
407+ i.invalidate()
408+ }
409+}
410+func (i *Interactive) ClosePanel(extName, panelID string) {
411+ i.mu.Lock()
412+ defer i.mu.Unlock()
413+ if i.extPanel.Active() && i.extPanel.ext == extName && i.extPanel.id == panelID {
414+ i.extPanel.Close()
415+ i.invalidate()
416+ }
417+}
A packages/agent/modes/interactive_input.go +59 -0
@@ -0,0 +1,59 @@
1+package modes
2+
3+import (
4+ "strings"
5+ "time"
6+
7+ "github.com/bnema/zut/packages/tui"
8+)
9+
10+// prepareInputKey applies main-editor key state that must happen before
11+// routing the key to a dialog, suggestion, or editor. It returns false when
12+// clipboard resolution failed and the key must not be delivered further.
13+func (i *Interactive) prepareInputKey(k tui.Key) (tui.Key, bool) {
14+ // A bare Escape is eligible for the session-tree gesture only while the
15+ // main input owns the key. Any other key, modified Escape, or visible
16+ // child interaction resets the pending first tap before normal routing.
17+ if !isUnmodifiedEscape(k) || i.sessionTreeEscapeBlocked() {
18+ i.mu.Lock()
19+ i.doubleEscape.Reset()
20+ i.mu.Unlock()
21+ }
22+
23+ // Dialogs route keys before the main clipboard handler below. Resolve text
24+ // here when a child interaction owns input so every editor and filter sees
25+ // the same KeyPaste event as terminal-native bracketed paste. Main chat is
26+ // left image-first to preserve macOS clipboard image attachments.
27+ if k.Kind == tui.KeyPasteClipboard && i.confirmChildActive() {
28+ resolved, pastedText, err := resolveClipboardText(k, tui.ReadClipboardText)
29+ if err != nil {
30+ i.mu.Lock()
31+ i.statusErr = "clipboard paste failed: " + err.Error()
32+ i.statusOK = ""
33+ i.mu.Unlock()
34+ return tui.Key{}, false
35+ }
36+ if pastedText {
37+ k = resolved
38+ i.mu.Lock()
39+ i.statusErr = ""
40+ i.statusOK = ""
41+ i.mu.Unlock()
42+ }
43+ }
44+
45+ // Any key that isn't ctrl+c invalidates an armed ctrl+c-exit, so pressing
46+ // ctrl+c then typing then ctrl+c much later doesn't quit unexpectedly. The
47+ // hint message also goes stale; clear it.
48+ if k.Kind != tui.KeyCtrlC {
49+ i.mu.Lock()
50+ if !i.lastCtrlC.IsZero() {
51+ i.lastCtrlC = time.Time{}
52+ if strings.HasPrefix(i.statusOK, "input cleared") || strings.HasPrefix(i.statusOK, "press ctrl+c") {
53+ i.statusOK = ""
54+ }
55+ }
56+ i.mu.Unlock()
57+ }
58+ return k, true
59+}
A packages/agent/modes/interactive_input_routing.go +1054 -0
@@ -0,0 +1,1054 @@
1+package modes
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "fmt"
7+ "strings"
8+ "time"
9+
10+ "github.com/bnema/zut/packages/core"
11+ "github.com/bnema/zut/packages/provider"
12+ "github.com/bnema/zut/packages/tui"
13+)
14+
15+func (i *Interactive) armCtrlCExit() {
16+ i.mu.Lock()
17+ i.lastCtrlC = time.Now()
18+ i.mu.Unlock()
19+}
20+func (i *Interactive) ctrlCExitArmed() bool {
21+ i.mu.Lock()
22+ t := i.lastCtrlC
23+ i.mu.Unlock()
24+ return !t.IsZero() && time.Since(t) <= ctrlCExitWindow
25+}
26+func (i *Interactive) clearFileSuggestQuery() {
27+ val := i.ed.Value()
28+ if idx := strings.LastIndex(val, "@"); idx >= 0 {
29+ i.ed.SetValue(val[:idx+1])
30+ }
31+}
32+func (i *Interactive) setToolExpansion(expanded bool) {
33+ i.mu.Lock()
34+ i.view.ExpandAll = expanded
35+ i.requestRendererClear()
36+ i.mu.Unlock()
37+ i.invalidate()
38+}
39+func (i *Interactive) toggleToolExpansion() {
40+ i.mu.Lock()
41+ expanded := !i.view.ExpandAll
42+ i.mu.Unlock()
43+ i.setToolExpansion(expanded)
44+}
45+func (i *Interactive) toggleBtwToolExpansion() {
46+ i.btwDialog.ToggleToolExpansion()
47+ i.mu.Lock()
48+ i.requestRendererClear()
49+ i.mu.Unlock()
50+ i.invalidate()
51+}
52+func (i *Interactive) confirmChildActive() bool {
53+ i.mu.Lock()
54+ defer i.mu.Unlock()
55+ return i.confirmChildActiveLocked()
56+}
57+func (i *Interactive) confirmChildActiveLocked() bool {
58+ return len(i.helpBlock) > 0 ||
59+ len(i.extNotes) > 0 ||
60+ i.dialog.Active() ||
61+ i.modelDialog.Active() ||
62+ i.llamaDialog.Active() ||
63+ i.rescueDialog.Active() ||
64+ i.sessionDialog.Active() ||
65+ i.residentSubagentsDialog.Active() ||
66+ i.residentChildSession != nil ||
67+ i.jumpDialog.Active() ||
68+ i.btwDialog.Active() ||
69+ i.skillsDialog.Active() ||
70+ i.changelogDialog.Active() ||
71+ i.logoutDialog.Active() ||
72+ i.telegramDialog.Active() ||
73+ i.settingsDialog.Active() ||
74+ i.sessionOpsDialog.Active() ||
75+ i.sessionTreeDialog.Active() ||
76+ i.extPanel.Active()
77+}
78+func (i *Interactive) restoreConfirmFocus() {
79+ if !i.confirmDialog.Active() || i.confirmDialog.Focused() {
80+ return
81+ }
82+ if !i.ed.IsEmpty() || i.confirmChildActive() {
83+ return
84+ }
85+ i.confirmDialog.Focus()
86+ i.invalidate()
87+}
88+func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
89+ defer i.restoreConfirmFocus()
90+
91+ var ok bool
92+ if k, ok = i.prepareInputKey(k); !ok {
93+ return false
94+ }
95+
96+ // Dialogs consume keys while open (except ctrl+c, which always closes them).
97+
98+ // Confirmation owns input by default. Pressing / deliberately moves
99+ // focus to the main editor without answering the pending request, so
100+ // slash commands and the dialogs they open can run first.
101+ if i.confirmDialog.Focused() {
102+ if k.Kind == tui.KeyRune && k.Rune == '/' {
103+ i.confirmDialog.Blur()
104+ i.ed.Insert("/")
105+ i.suggest.Reset()
106+ i.invalidate()
107+ return false
108+ }
109+ if k.Kind == tui.KeyCtrlO {
110+ if i.btwDialog != nil && i.btwDialog.Active() {
111+ i.toggleBtwToolExpansion()
112+ } else {
113+ i.toggleToolExpansion()
114+ }
115+ return false
116+ }
117+ i.confirmDialog.HandleKey(k)
118+ i.invalidate()
119+ return false
120+ }
121+ if i.confirmDialog.Active() && !i.confirmChildActive() && k.Kind == tui.KeyEsc {
122+ i.ed.Clear()
123+ i.suggest.Reset()
124+ i.fileSuggest.Reset()
125+ i.confirmDialog.Focus()
126+ i.invalidate()
127+ return false
128+ }
129+ if i.dialog.Active() {
130+ if k.Kind == tui.KeyCtrlC {
131+ i.dialog.Close()
132+ if i.cfg.AuthManager != nil {
133+ i.cfg.AuthManager.CancelOAuth()
134+ }
135+ return false
136+ }
137+ act := i.dialog.HandleKey(k)
138+ if act.StartAPIKey {
139+ i.startAPIKeyFlow(act.Provider)
140+ }
141+ if act.StartOAuth {
142+ i.startOAuthFlow(act.Provider)
143+ }
144+ if act.StartManual {
145+ i.startManualOAuthFlow(act.Provider)
146+ }
147+ if act.SubmitCode != "" {
148+ i.submitManualOAuthCode(act.SubmitCode)
149+ }
150+ if act.SaveLlama {
151+ i.saveLlamaCPPLogin(act.LlamaURL, act.LlamaAPIKey)
152+ }
153+ return false
154+ }
155+ if i.modelDialog.Active() {
156+ if k.Kind == tui.KeyCtrlC {
157+ i.modelDialog.Close()
158+ i.quickModelAssign = 0
159+ return false
160+ }
161+ act := i.modelDialog.HandleKey(k)
162+ if act.Close {
163+ i.quickModelAssign = 0
164+ }
165+ if act.ReasoningChanged {
166+ i.applyReasoningSetting(act.Reasoning)
167+ }
168+ if act.Select {
169+ if i.quickModelAssign > 0 {
170+ i.applyQuickModelSelection(i.quickModelAssign, act.Provider, act.Model)
171+ i.quickModelAssign = 0
172+ } else {
173+ i.applyModelSelection(act.Provider, act.Model)
174+ }
175+ }
176+ return false
177+ }
178+ if i.llamaDialog.Active() {
179+ i.llamaDialog.HandleKey(k)
180+ i.invalidate()
181+ return false
182+ }
183+ if i.rescueDialog.Active() {
184+ if k.Kind == tui.KeyCtrlC {
185+ i.rescueDialog.Close()
186+ i.invalidate()
187+ return false
188+ }
189+ act := i.rescueDialog.HandleKey(k)
190+ if act.Select {
191+ i.applyRescueSelection(act.Provider, act.Model, act.Prompt)
192+ }
193+ i.invalidate()
194+ return false
195+ }
196+ i.mu.Lock()
197+ sessionDialogActive := i.sessionDialog.Active()
198+ i.mu.Unlock()
199+ if sessionDialogActive {
200+ if k.Kind == tui.KeyCtrlC {
201+ i.mu.Lock()
202+ i.sessionDialog.Close()
203+ i.sessionLoads = nil
204+ i.mu.Unlock()
205+ i.invalidate()
206+ return false
207+ }
208+ manualRenamePath := ""
209+ i.mu.Lock()
210+ if k.Kind == tui.KeyEnter && i.sessionDialog.renaming && core.NormalizeSessionTitle(i.sessionDialog.rename) != "" && i.sessionDialog.cursor >= 0 && i.sessionDialog.cursor < len(i.sessionDialog.sessions) && i.cfg.CurrentSessionPath != nil {
211+ manualRenamePath = i.sessionDialog.sessions[i.sessionDialog.cursor].Path
212+ }
213+ i.mu.Unlock()
214+ manualRenameCurrent := manualRenamePath != "" && i.cfg.CurrentSessionPath() == manualRenamePath
215+ if manualRenameCurrent {
216+ i.markSessionTitleSwitching()
217+ }
218+ i.mu.Lock()
219+ act := i.sessionDialog.HandleKey(k)
220+ if act.Select || act.Close {
221+ i.sessionLoads = nil
222+ }
223+ i.mu.Unlock()
224+ if act.Select {
225+ i.applySessionSelection(act.Path)
226+ }
227+ if act.Err != nil {
228+ if manualRenameCurrent {
229+ i.restoreFailedSessionTitle()
230+ }
231+ i.mu.Lock()
232+ i.statusErr = "rename: " + act.Err.Error()
233+ i.statusOK = ""
234+ i.mu.Unlock()
235+ } else if act.Renamed && act.Path != "" && i.cfg.CurrentSessionPath != nil && i.cfg.CurrentSessionPath() == act.Path {
236+ i.setManualSessionTitle(act.RenameTitle)
237+ }
238+ // Always request a redraw after handling a key here: when esc
239+ // closes the picker, the overlay-close detection in the render
240+ // pass needs to run so the tall dialog rows get repainted from
241+ // the chat (otherwise VS Code's retained scrollback leaves a
242+ // duplicate frame on screen).
243+ i.invalidate()
244+ return false
245+ }
246+ i.mu.Lock()
247+ residentDialog := i.residentSubagentsDialog
248+ residentDialogActive := residentDialog != nil && residentDialog.Active()
249+ residentSession := i.residentChildSession
250+ i.mu.Unlock()
251+ if residentDialogActive {
252+ if k.Kind == tui.KeyCtrlC || k.Kind == tui.KeyEsc {
253+ i.mu.Lock()
254+ if i.residentSubagentsDialog == residentDialog {
255+ residentDialog.Close()
256+ }
257+ i.mu.Unlock()
258+ i.invalidate()
259+ return false
260+ }
261+ i.mu.Lock()
262+ childID := residentDialog.HandleKey(k)
263+ i.mu.Unlock()
264+ if childID != "" {
265+ i.openResidentChildSession(childID)
266+ }
267+ i.invalidate()
268+ return false
269+ }
270+ if residentSession != nil {
271+ switch k.Kind {
272+ case tui.KeyCtrlC, tui.KeyEsc:
273+ i.mu.Lock()
274+ if i.residentChildSession == residentSession {
275+ i.residentChildSession = nil
276+ }
277+ i.mu.Unlock()
278+ case tui.KeyPageUp:
279+ session := residentSession
280+ if session.BeginLoad() {
281+ go func() {
282+ err := session.LoadOlder(200)
283+ if session.FinishLoad(err) {
284+ i.reloadResidentChildSession(session)
285+ }
286+ i.invalidate()
287+ }()
288+ }
289+ case tui.KeyPageDown:
290+ residentSession.FollowTail()
291+ case tui.KeyUp:
292+ residentSession.Scroll(1)
293+ case tui.KeyDown:
294+ residentSession.Scroll(-1)
295+ default:
296+ session := residentSession
297+ if prompt, submit := session.HandleKey(k); submit {
298+ go func() {
299+ err := i.cfg.ResidentManager.Resume(ctx, session.childID, prompt)
300+ session.FinishSubmission(err)
301+ i.invalidate()
302+ }()
303+ }
304+ }
305+ i.invalidate()
306+ return false
307+ }
308+ if i.logoutDialog.Active() {
309+ if k.Kind == tui.KeyCtrlC {
310+ i.logoutDialog.Close()
311+ i.invalidate()
312+ return false
313+ }
314+ act := i.logoutDialog.HandleKey(k)
315+ if act.Select {
316+ i.doLogout(act.Target)
317+ }
318+ i.invalidate()
319+ return false
320+ }
321+ if i.telegramDialog.Active() {
322+ if k.Kind == tui.KeyCtrlC {
323+ i.telegramDialog.Close()
324+ i.invalidate()
325+ return false
326+ }
327+ act := i.telegramDialog.HandleKey(k)
328+ if act.Select {
329+ i.doTelegram(act.Action)
330+ }
331+ i.invalidate()
332+ return false
333+ }
334+ if i.settingsDialog.Active() {
335+ if k.Kind == tui.KeyCtrlC {
336+ i.settingsDialog.Close()
337+ i.invalidate()
338+ return false
339+ }
340+ act := i.settingsDialog.HandleKey(k)
341+ if act.ModelShortcutSlot > 0 {
342+ i.openQuickModelPicker(act.ModelShortcutSlot)
343+ }
344+ if act.Toggle {
345+ i.applySettingChange(act)
346+ }
347+ i.invalidate()
348+ return false
349+ }
350+ if i.sessionOpsDialog.Active() {
351+ if k.Kind == tui.KeyCtrlC {
352+ i.sessionOpsDialog.Close()
353+ i.invalidate()
354+ return false
355+ }
356+ act := i.sessionOpsDialog.HandleKey(k)
357+ if act.Select {
358+ i.doSessionOp(act.Action, "")
359+ }
360+ i.invalidate()
361+ return false
362+ }
363+ if i.sessionTreeDialog.Active() {
364+ if k.Kind == tui.KeyCtrlC {
365+ i.sessionTreeDialog.Close()
366+ i.invalidate()
367+ return false
368+ }
369+ act := i.sessionTreeDialog.HandleKey(k)
370+ if act.Select {
371+ if act.Target.Role != "" || act.Target.SourcePath != "" || act.Target.IsBoundary() || act.Target.UserDraft != "" {
372+ i.applySessionTreeTarget(act.Target, act.TurnNo)
373+ } else {
374+ // Keep package embedders that still return the scalar action
375+ // source-compatible while the dialog migrates to Target.
376+ i.applySessionTreeMessageSelection(act.Path, act.MessageIdx, act.TurnNo, act.Role, act.Prompt)
377+ }
378+ }
379+ i.invalidate()
380+ return false
381+ }
382+ if i.extPanel.Active() {
383+ if k.Kind == tui.KeyCtrlC || k.Kind == tui.KeyEsc {
384+ if i.cfg.Extensions != nil {
385+ _ = i.cfg.Extensions.SendPanelClose(i.extPanel.ext, i.extPanel.id)
386+ }
387+ i.extPanel.Close()
388+ i.invalidate()
389+ return false
390+ }
391+ if i.cfg.Extensions != nil {
392+ _ = i.cfg.Extensions.SendPanelKey(i.extPanel.ext, i.extPanel.id, panelKeyName(k), panelKeyText(k))
393+ }
394+ return false
395+ }
396+ if i.jumpDialog.Active() {
397+ if k.Kind == tui.KeyCtrlC {
398+ i.jumpDialog.Close()
399+ i.pendingFork = false
400+ return false
401+ }
402+ act := i.jumpDialog.HandleKey(k)
403+ if act.Select {
404+ if i.pendingFork {
405+ i.applyForkSelection(act.MessageIdx)
406+ } else {
407+ i.applyJumpSelection(act.MessageIdx, act.TurnNo)
408+ }
409+ }
410+ // If the user dismissed the dialog without selecting, also
411+ // clear the pending-fork flag so a later plain /jump isn't
412+ // hijacked.
413+ if act.Close {
414+ i.pendingFork = false
415+ }
416+ return false
417+ }
418+ if i.btwDialog.Active() {
419+ if k.Kind == tui.KeyCtrlC {
420+ i.btwDialog.Close()
421+ i.invalidate()
422+ return false
423+ }
424+ if k.Kind == tui.KeyCtrlO {
425+ i.toggleBtwToolExpansion()
426+ return false
427+ }
428+ i.btwDialog.HandleKey(k, i.invalidate)
429+ return false
430+ }
431+ if i.skillsDialog.Active() {
432+ if k.Kind == tui.KeyCtrlC {
433+ i.skillsDialog.Close()
434+ i.invalidate()
435+ return false
436+ }
437+ i.skillsDialog.HandleKey(k)
438+ i.invalidate()
439+ return false
440+ }
441+ if i.changelogDialog.Active() {
442+ if closed := i.changelogDialog.HandleKey(k); closed {
443+ // User dismissed; let the parent persist the
444+ // LastChangelogShown marker via the close callback.
445+ if i.cfg.OnChangelogDismiss != nil {
446+ i.cfg.OnChangelogDismiss()
447+ }
448+ }
449+ i.invalidate()
450+ return false
451+ }
452+
453+ if slot := quickModelShortcutSlot(k); slot > 0 {
454+ i.applyQuickModelShortcut(slot)
455+ return false
456+ }
457+
458+ // The first eligible bare Escape is deliberately left to the existing
459+ // Escape path (which clears idle input or cancels a turn). A second tap
460+ // within the gesture window only gets first refusal while the cheap,
461+ // local eligibility rules still hold. If a turn started, a message was
462+ // queued, or the input became ineligible between taps, reset the detector
463+ // and fall through so Escape retains its normal cancellation behavior.
464+ // Once those rules still hold, the full tree gate owns the second tap:
465+ // family/read failures are fail-closed but are nevertheless consumed so
466+ // they cannot fall through into ordinary editor handling.
467+ if isUnmodifiedEscape(k) {
468+ now := i.sessionTreeEscapeNow()
469+ i.mu.Lock()
470+ consumed := i.doubleEscape.Consume(now)
471+ i.mu.Unlock()
472+ if consumed {
473+ if i.canArmSessionTreeEscape() {
474+ i.openSessionTree()
475+ return false
476+ }
477+ i.mu.Lock()
478+ i.doubleEscape.Reset()
479+ i.mu.Unlock()
480+ } else if i.canArmSessionTreeEscape() {
481+ i.mu.Lock()
482+ i.doubleEscape.Arm(now)
483+ i.mu.Unlock()
484+ }
485+ }
486+
487+ // Global keys.
488+ switch k.Kind {
489+ case tui.KeyCtrlC:
490+ i.mu.Lock()
491+ loadingSession := i.sessionLoading
492+ i.mu.Unlock()
493+ if loadingSession {
494+ i.markCtrlCExit()
495+ return true
496+ }
497+ // While busy: do NOT cancel the turn. ctrl+c during a
498+ // running turn is almost always reflex muscle memory
499+ // ("be quiet" in a shell) rather than a deliberate
500+ // decision to kill a multi-minute model call that's
501+ // already cost tokens. Use esc to interrupt a turn; use
502+ // a deliberate double-ctrl+c to exit zut entirely. First
503+ // press arms the exit hint, second press within
504+ // ctrlCExitWindow quits.
505+ if i.busy {
506+ if i.ctrlCExitArmed() {
507+ i.markCtrlCExit()
508+ return true
509+ }
510+ i.mu.Lock()
511+ i.statusOK = "press ctrl+c again to exit, esc to cancel the turn"
512+ i.statusErr = ""
513+ i.mu.Unlock()
514+ i.armCtrlCExit()
515+ return false
516+ }
517+ // Idle: first press clears the editor (and any queued
518+ // follow-up messages); a second press within ctrlCExitWindow
519+ // exits. With both an empty editor and no queue the first
520+ // press still just arms — require a deliberate double-tap.
521+ ag := i.agent
522+ pending := 0
523+ if ag != nil {
524+ pending = ag.QueuedMessageCount()
525+ }
526+ hadInput := !i.ed.IsEmpty() || len(i.queued) > 0 || pending > 0
527+ if hadInput {
528+ i.ed.Clear()
529+ i.clipboardImages = nil
530+ i.suggest.Reset()
531+ if ag != nil {
532+ ag.DrainQueuedMessages()
533+ }
534+ i.mu.Lock()
535+ i.queued = nil
536+ i.statusOK = "input cleared"
537+ i.statusErr = ""
538+ i.mu.Unlock()
539+ i.armCtrlCExit()
540+ return false
541+ }
542+ if i.ctrlCExitArmed() {
543+ i.markCtrlCExit()
544+ return true
545+ }
546+ i.mu.Lock()
547+ i.statusOK = "press ctrl+c again to exit"
548+ i.statusErr = ""
549+ i.mu.Unlock()
550+ i.armCtrlCExit()
551+ return false
552+ case tui.KeyEsc:
553+ // Esc interrupts a running turn — but only when nothing
554+ // else on screen wants to consume the key first. The slash
555+ // popup has its own Esc behaviour (close + clear editor),
556+ // and transient overlays like the /help block and extension
557+ // notes should dismiss on Esc before we even consider the
558+ // turn. Without these guards, a casual Esc press after
559+ // running /help on a busy turn rips the turn away.
560+ if i.suggest.Active(i.ed.Value()) || i.fileSuggest.Active(i.ed.Value()) {
561+ break
562+ }
563+ i.mu.Lock()
564+ hadHelp := len(i.helpBlock) > 0
565+ hadNotes := len(i.extNotes) > 0
566+ if hadHelp {
567+ i.helpBlock = nil
568+ }
569+ if hadNotes {
570+ i.extNotes = nil
571+ }
572+ i.mu.Unlock()
573+ if hadHelp || hadNotes {
574+ i.invalidate()
575+ return false
576+ }
577+ i.mu.Lock()
578+ busyCancel := i.busy && i.cancelTurn != nil
579+ cancelTurn := i.cancelTurn
580+ var restoreQueued string
581+ var handoff json.RawMessage
582+ var persistHandoff bool
583+ if busyCancel {
584+ // Keep the most recently queued follow-up as an editable draft
585+ // instead of losing it with the cancelled turn's stale queue.
586+ if i.agent != nil {
587+ restoreQueued, _ = i.agent.PopQueuedMessage()
588+ }
589+ if restoreQueued == "" && len(i.queued) > 0 {
590+ n := len(i.queued) - 1
591+ restoreQueued = i.queued[n]
592+ i.queued = i.queued[:n]
593+ }
594+ handoff, persistHandoff = i.resetCompactContinuationLocked()
595+ }
596+ i.mu.Unlock()
597+ if busyCancel {
598+ if restoreQueued != "" {
599+ i.ed.SetValue(restoreQueued)
600+ i.inputHistoryIndex = -1
601+ }
602+ i.updateActiveGoal(core.GoalPaused, "interrupted by user")
603+ cancelTurn()
604+ // If a confirm dialog is pending, refuse it so the agent
605+ // goroutine unblocks and the context cancellation can
606+ // actually take effect.
607+ i.confirmDialog.CancelAll("turn cancelled")
608+ if persistHandoff {
609+ i.persistCompactHandoff(handoff)
610+ }
611+ return false
612+ }
613+ case tui.KeyCtrlD:
614+ if i.ed.IsEmpty() && !i.busy {
615+ return true
616+ }
617+ case tui.KeyCtrlB:
618+ i.mu.Lock()
619+ i.rightBarHidden = !i.rightBarHidden
620+ i.mu.Unlock()
621+ i.requestRendererInvalidate()
622+ i.invalidate()
623+ return false
624+ case tui.KeyCtrlL:
625+ i.requestRendererClear()
626+ i.invalidate()
627+ return false
628+ case tui.KeyPasteClipboard:
629+ i.pasteClipboard(ctx)
630+ return false
631+ case tui.KeyCtrlO:
632+ i.toggleToolExpansion()
633+ return false
634+ case tui.KeyPageUp:
635+ i.scrollBy(+i.chatPage())
636+ return false
637+ case tui.KeyPageDown:
638+ i.scrollBy(-i.chatPage())
639+ return false
640+ case tui.KeyUp:
641+ // Alt/Option+Up: pop the most recently queued ("sliding in")
642+ // message back into the editor so the user can edit and
643+ // resend it. Repeated presses keep peeling messages off the
644+ // tail of the queue; each press *replaces* the editor
645+ // contents (we don't append/push). When the queue is empty
646+ // the keypress falls through to the normal scroll behavior.
647+ if k.Alt {
648+ i.mu.Lock()
649+ var text string
650+ if i.agent != nil {
651+ text, _ = i.agent.PopQueuedMessage()
652+ }
653+ if text == "" {
654+ if n := len(i.queued); n > 0 {
655+ text = i.queued[n-1]
656+ i.queued = i.queued[:n-1]
657+ }
658+ }
659+ i.mu.Unlock()
660+ if text != "" {
661+ i.ed.SetValue(text)
662+ i.inputHistoryIndex = -1
663+ i.invalidate()
664+ return false
665+ }
666+ }
667+ // In multi-line / wrapped input, Up first moves inside the editor.
668+ // At the editor's top edge, plain Up can browse input history when
669+ // history browsing is safe/active; otherwise it falls back to chat
670+ // scrolling, preserving the old single-line scroll behavior.
671+ if !i.suggest.Active(i.ed.Value()) && !i.fileSuggest.Active(i.ed.Value()) {
672+ if i.ed.MoveVertical(-1) {
673+ i.invalidate()
674+ return false
675+ }
676+ if !k.Alt && i.handleInputHistoryKey(k) {
677+ return false
678+ }
679+ i.scrollBy(+3)
680+ return false
681+ }
682+ case tui.KeyDown:
683+ if !i.suggest.Active(i.ed.Value()) && !i.fileSuggest.Active(i.ed.Value()) {
684+ if i.ed.MoveVertical(+1) {
685+ i.invalidate()
686+ return false
687+ }
688+ if !k.Alt && i.inputHistoryIndex < 0 && i.cfg.ResidentManager != nil {
689+ if _, total := i.cfg.ResidentManager.RecentSnapshotPage(0, 1); total > 0 {
690+ i.runResidentSubagents(ctx, nil)
691+ return false
692+ }
693+ }
694+ if !k.Alt && i.handleInputHistoryKey(k) {
695+ return false
696+ }
697+ if i.scrollOffset > 0 {
698+ i.scrollBy(-3)
699+ }
700+ return false
701+ }
702+ }
703+
704+ // Note: we intentionally do NOT gate the editor on i.busy here.
705+ // Typing while the agent is working is supported — submitted
706+ // messages are queued and delivered as follow-up turns when the
707+ // current turn ends. See the submit handler below.
708+
709+ if k.Kind == tui.KeyEnter && (k.Alt || k.Shift) {
710+ i.ed.HandleKey(k)
711+ return false
712+ }
713+
714+ // Slash suggestions: intercept up/down/tab/enter when the popup is visible.
715+ if i.suggest.Active(i.ed.Value()) {
716+ switch k.Kind {
717+ case tui.KeyUp:
718+ i.suggest.Up()
719+ return false
720+ case tui.KeyDown:
721+ i.suggest.Down()
722+ return false
723+ case tui.KeyPageUp:
724+ i.suggest.PageUp()
725+ return false
726+ case tui.KeyPageDown:
727+ i.suggest.PageDown()
728+ return false
729+ case tui.KeyTab:
730+ if name := i.suggest.Selection(i.ed.Value()); name != "" {
731+ i.ed.SetValue(name)
732+ i.suggest.Reset()
733+ }
734+ return false
735+ case tui.KeyEnter:
736+ // Enter on an ambiguous or partial slash prefix: complete to the
737+ // currently highlighted command and run it. That way typing
738+ // "/lo" + enter picks whichever of /login or /logout is selected
739+ // in the popup instead of submitting "/lo" as unknown. Also
740+ // clear the editor so the command doesn't linger after the
741+ // dialog opens/closes.
742+ if name := i.suggest.Selection(i.ed.Value()); name != "" {
743+ i.ed.Clear()
744+ i.suggest.Reset()
745+ return i.runSlash(ctx, name)
746+ }
747+ case tui.KeyEsc:
748+ i.ed.Clear()
749+ i.suggest.Reset()
750+ return false
751+ }
752+ }
753+
754+ // File suggestions: intercept up/down/tab/enter when the @-popup is visible.
755+ if i.fileSuggest.Active(i.ed.Value()) {
756+ switch k.Kind {
757+ case tui.KeyUp:
758+ i.fileSuggest.Up()
759+ return false
760+ case tui.KeyDown:
761+ i.fileSuggest.Down()
762+ return false
763+ case tui.KeyRight:
764+ // Open selected directory. The filter the user typed picked
765+ // that directory at the current level; once we descend it no
766+ // longer applies to the directory's contents, so clear it.
767+ // Otherwise typing "@eda" then right would re-filter inside
768+ // eda/ by "eda" and show nothing.
769+ if i.fileSuggest.Right() {
770+ i.clearFileSuggestQuery()
771+ }
772+ return false
773+ case tui.KeyLeft:
774+ // Go back to parent directory. Clear the filter for the same
775+ // reason as Right: it was scoped to the level we just left.
776+ if i.fileSuggest.Left() {
777+ i.clearFileSuggestQuery()
778+ }
779+ return false
780+ case tui.KeyEnter:
781+ if entry, ok := i.fileSuggest.SelectedEntry(i.ed.Value()); ok {
782+ var chip string
783+ if entry.isDir {
784+ chip = "[dir:" + entry.rel + "/]"
785+ } else {
786+ chip = "[file:" + entry.rel + "]"
787+ }
788+ val := i.ed.Value()
789+ if idx := strings.LastIndex(val, "@"); idx >= 0 {
790+ val = val[:idx]
791+ }
792+ i.ed.SetValue(val + chip + " ")
793+ i.fileSuggest.Reset()
794+ }
795+ return false
796+ case tui.KeyEsc:
797+ val := i.ed.Value()
798+ if idx := strings.LastIndex(val, "@"); idx >= 0 {
799+ i.ed.SetValue(val[:idx])
800+ }
801+ i.fileSuggest.Reset()
802+ return false
803+ }
804+ }
805+
806+ // Tab-complete a path token in the editor when no popup is open.
807+ // Recognises tokens that look like paths (start with ~, /, ./, ../
808+ // or contain a slash); shell-style completion expands ~, lists the
809+ // parent dir, and completes the basename to the longest common
810+ // prefix. Single match: full replace and trailing / for dirs.
811+ // No match: no-op. Plain bare words (no slash, no tilde) fall
812+ // through so Tab keeps its current no-op behaviour outside paths.
813+ if k.Kind == tui.KeyTab && !i.suggest.Active(i.ed.Value()) && !i.fileSuggest.Active(i.ed.Value()) {
814+ if i.tryPathTabComplete() {
815+ return false
816+ }
817+ }
818+
819+ if i.inputHistoryIndex >= 0 && k.Kind != tui.KeyUp && k.Kind != tui.KeyDown {
820+ i.inputHistoryIndex = -1
821+ }
822+
823+ if k.Kind == tui.KeyEsc {
824+ i.clipboardImages = nil
825+ }
826+ if submit := i.ed.HandleKey(k); submit {
827+ // SubmitValue() expands any [pasted text #N +L lines]
828+ // placeholders back into their bodies; the raw Value()
829+ // is only what the user sees on screen.
830+ text := strings.TrimRight(i.ed.SubmitValue(), "\n")
831+ // Expand [file:name] and [dir:name/] chips to full paths.
832+ text = expandFileChips(text, i.cfg.CWD)
833+ text, images := preparePromptWithClipboardImages(text, i.clipboardImages)
834+ if text == "" && len(images) == 0 {
835+ return false
836+ }
837+ clearSubmittedInput := func() {
838+ i.clipboardImages = nil
839+ i.ed.Clear()
840+ i.inputHistoryIndex = -1
841+ i.suggest.Reset()
842+ i.fileSuggest.Reset()
843+ }
844+
845+ // Shell escapes and slash commands are text-only. An image-bearing
846+ // draft remains a normal provider prompt instead of silently dropping
847+ // its attachments.
848+ if len(images) == 0 {
849+ if cmd, ok := shellEscapeCommand(text); ok {
850+ clearSubmittedInput()
851+ i.startShellEscape(ctx, cmd)
852+ return false
853+ }
854+ }
855+
856+ if len(images) == 0 && looksLikeSlashCommand(text) {
857+ clearSubmittedInput()
858+ head := text
859+ rest := ""
860+ if idx := strings.IndexAny(text, " \t"); idx >= 0 {
861+ head = text[:idx]
862+ rest = strings.TrimSpace(text[idx:])
863+ }
864+ if !isKnownSlashCommand(text) {
865+ // Try extensions before giving up. Extensions register
866+ // commands by bare name (no leading slash); strip it here.
867+ extName := strings.TrimPrefix(head, "/")
868+ if i.cfg.Extensions != nil && i.cfg.Extensions.HasCommand(extName) {
869+ go i.invokeExtensionCommand(ctx, extName, rest)
870+ return false
871+ }
872+ i.mu.Lock()
873+ i.statusErr = "unknown command " + head + " — type /help to see the list"
874+ i.statusOK = ""
875+ i.mu.Unlock()
876+ return false
877+ }
878+ // Slash commands run regardless of busy state. Commands that
879+ // would mutate the transcript or replace the agent (/clear,
880+ // /compact, /logout, /login, /model, /fork, /session fork)
881+ // cancel the active turn
882+ // first and wait for the goroutine to wind down so they don't
883+ // race with a streaming response. Safe commands (/help,
884+ // /jump, /sessions, /jail, /unjail, /exit) run immediately
885+ // without disturbing the active turn.
886+ if slashCommandCancelsTurn(text) {
887+ i.cancelAndWaitForIdle()
888+ }
889+ return i.runSlash(ctx, text)
890+ }
891+
892+ if i.agent == nil {
893+ i.mu.Lock()
894+ i.statusErr = "not logged in. type /login first."
895+ i.mu.Unlock()
896+ return false
897+ }
898+ // Mirror the user's typed prompt into the paired Telegram
899+ // chat (when the bridge is active) so the Telegram thread
900+ // stays a complete record of the session, not just the half
901+ // that originated on the phone. On a goroutine so the
902+ // network write doesn't delay the local turn.
903+ if i.telegramBridge != nil && i.telegramBridge.Active() {
904+ go i.telegramBridge.OnUserTyped(text)
905+ }
906+ i.maybeStartSessionTitle(ctx, text)
907+ // If a turn is already in flight, queue this prompt inside the
908+ // agent loop so it is delivered at the next safe model-call
909+ // boundary instead of waiting for the whole run to finish.
910+ i.mu.Lock()
911+ busy := i.busy
912+ compacting := i.compacting
913+ ag := i.agent
914+ i.mu.Unlock()
915+ if busy {
916+ if len(images) > 0 {
917+ i.mu.Lock()
918+ i.statusErr = "can't queue clipboard images while a turn is running; wait for the current turn to finish"
919+ i.mu.Unlock()
920+ i.invalidate()
921+ return false
922+ }
923+ clearSubmittedInput()
924+ var handoff json.RawMessage
925+ var persistHandoff bool
926+ if ag != nil && !compacting {
927+ i.mu.Lock()
928+ handoff, persistHandoff = i.resetCompactContinuationLocked()
929+ ag.QueueMessage(text)
930+ i.mu.Unlock()
931+ } else {
932+ i.mu.Lock()
933+ handoff, persistHandoff = i.resetCompactContinuationLocked()
934+ i.queued = append(i.queued, text)
935+ i.mu.Unlock()
936+ }
937+ if persistHandoff {
938+ i.persistCompactHandoff(handoff)
939+ }
940+ i.invalidate()
941+ return false
942+ }
943+ clearSubmittedInput()
944+ i.startTurnWithImages(ctx, text, images)
945+ }
946+ return false
947+}
948+func (i *Interactive) pasteClipboard(ctx context.Context) {
949+ image, ok, _ := tui.ReadClipboardImage(ctx)
950+ if ok {
951+ i.mu.Lock()
952+ marker := fmt.Sprintf("[clipboard image #%d]", len(i.clipboardImages)+1)
953+ i.clipboardImages = append(i.clipboardImages, clipboardImageAttachment{
954+ Marker: marker,
955+ Image: provider.ImageBlock{MimeType: image.MimeType, Data: image.Data},
956+ })
957+ i.ed.Insert(marker + " ")
958+ i.statusOK = ""
959+ i.statusErr = ""
960+ i.mu.Unlock()
961+ return
962+ }
963+ key, pastedText, err := resolveClipboardText(tui.Key{Kind: tui.KeyPasteClipboard}, tui.ReadClipboardText)
964+ i.mu.Lock()
965+ defer i.mu.Unlock()
966+ if err != nil {
967+ i.statusErr = "clipboard paste failed: " + err.Error()
968+ i.statusOK = ""
969+ return
970+ }
971+ if pastedText {
972+ i.ed.HandleKey(key)
973+ i.statusErr = ""
974+ i.statusOK = ""
975+ return
976+ }
977+ i.statusErr = "clipboard does not contain text or an image"
978+ i.statusOK = ""
979+}
980+func (i *Interactive) handleInputHistoryKey(k tui.Key) bool {
981+ if k.Kind != tui.KeyUp && k.Kind != tui.KeyDown {
982+ return false
983+ }
984+ // Do not steal normal vertical cursor movement. History browsing can only
985+ // start from an empty editor; once active, Up/Down keep walking
986+ // the ring so repeated presses work even though the editor now
987+ // contains the selected historical prompt.
988+ if i.inputHistoryIndex < 0 && !i.ed.IsEmpty() {
989+ return false
990+ }
991+ hist := i.inputHistory()
992+ if len(hist) == 0 {
993+ return false
994+ }
995+
996+ if i.inputHistoryIndex < 0 {
997+ // Start just after the newest item so Up lands on the most
998+ // recent user prompt. A lone Down from an empty editor is not
999+ // history navigation; let the caller fall through to normal UI
1000+ // behavior instead.
1001+ if k.Kind != tui.KeyUp {
1002+ return false
1003+ }
1004+ i.inputHistoryIndex = len(hist)
1005+ }
1006+
1007+ switch k.Kind {
1008+ case tui.KeyUp:
1009+ if i.inputHistoryIndex > 0 {
1010+ i.inputHistoryIndex--
1011+ }
1012+ case tui.KeyDown:
1013+ if i.inputHistoryIndex < len(hist) {
1014+ i.inputHistoryIndex++
1015+ }
1016+ }
1017+
1018+ if i.inputHistoryIndex >= len(hist) {
1019+ i.ed.Clear()
1020+ } else {
1021+ i.ed.SetValue(hist[i.inputHistoryIndex])
1022+ }
1023+ return true
1024+}
1025+func (i *Interactive) inputHistory() []string {
1026+ if i.agent == nil {
1027+ return nil
1028+ }
1029+ msgs := i.agent.Messages()
1030+ hist := make([]string, 0, len(msgs))
1031+ for _, m := range msgs {
1032+ if !isForkableUserMessage(m) {
1033+ continue
1034+ }
1035+ text := userMessageText(m)
1036+ if strings.TrimSpace(text) == "" {
1037+ continue
1038+ }
1039+ hist = append(hist, text)
1040+ }
1041+ return hist
1042+}
1043+func userMessageText(m provider.Message) string {
1044+ var sb strings.Builder
1045+ for _, c := range m.Content {
1046+ if tb, ok := c.(provider.TextBlock); ok {
1047+ if sb.Len() > 0 {
1048+ sb.WriteByte('\n')
1049+ }
1050+ sb.WriteString(tb.Text)
1051+ }
1052+ }
1053+ return sb.String()
1054+}
M packages/agent/modes/interactive_render.go +41 -0
@@ -99,3 +99,44 @@ func (s *latestFrameScheduler) stop() {
99 s.mu.Unlock()99 s.mu.Unlock()
100 <-s.done100 <-s.done
101 }101 }
102+
103+func (i *Interactive) requestRendererClear() {
104+ if scheduler := i.renderScheduler.Load(); scheduler != nil {
105+ scheduler.request(true, false)
106+ return
107+ }
108+ if i.rend != nil {
109+ i.rend.Clear()
110+ }
111+}
112+
113+func (i *Interactive) requestRendererInvalidate() {
114+ if scheduler := i.renderScheduler.Load(); scheduler != nil {
115+ scheduler.request(false, true)
116+ return
117+ }
118+ if i.rend != nil {
119+ i.rend.Invalidate()
120+ }
121+}
122+
123+func (i *Interactive) requestRendererTheme(theme tui.Theme) {
124+ if scheduler := i.renderScheduler.Load(); scheduler != nil {
125+ scheduler.requestTheme(theme)
126+ return
127+ }
128+ if i.rend != nil {
129+ i.rend.SetTheme(theme)
130+ }
131+}
132+
133+func (i *Interactive) invalidate() {
134+ i.renderRevision.Add(1)
135+ // Keep ordinary state changes on the main-loop throttle. The scheduler
136+ // owns terminal output, but scheduling it here would bypass
137+ // redrawMinInterval and paint every intermediate tool-event frame.
138+ select {
139+ case i.dirty <- struct{}{}:
140+ default:
141+ }
142+}
A packages/agent/modes/interactive_rendering.go +1234 -0
@@ -0,0 +1,1234 @@
1+package modes
2+
3+import (
4+ "fmt"
5+ "sort"
6+ "strings"
7+ "time"
8+
9+ "github.com/bnema/zut/packages/agent/extproto"
10+ "github.com/bnema/zut/packages/agent/skills"
11+ "github.com/bnema/zut/packages/provider"
12+ "github.com/bnema/zut/packages/tui"
13+)
14+
15+func (i *Interactive) cachedChatLocked(cols int) []string {
16+ // A busy frame contains mutable streaming/tool state. Keep the stable
17+ // transcript cache, but never return a full-frame cache entry that would
18+ // hide a newly arrived delta or completed result.
19+ if i.busy || i.streamOn || i.streamFlushPending {
20+ return i.buildChatLocked(cols)
21+ }
22+ key := i.chatCacheKeyLocked(cols)
23+ if i.chatCacheValid && i.chatCacheKey == key {
24+ return append([]string(nil), i.chatCache...)
25+ }
26+ chat := i.buildChatLocked(cols)
27+ key = i.chatCacheKeyLocked(cols)
28+ i.chatCache = append(i.chatCache[:0], chat...)
29+ i.chatCacheKey = key
30+ i.chatCacheValid = true
31+ return chat
32+}
33+func (i *Interactive) chatCacheKeyLocked(cols int) chatCacheKey {
34+ var rev uint64
35+ if i.agent != nil {
36+ rev = i.agent.Revision()
37+ }
38+ showVer := len(i.view.Messages) == 0 && !i.streamOn && len(i.toolOrder) == 0 && !i.welcomeStart.IsZero() && time.Since(i.welcomeStart) < welcomeVersionDuration
39+ return chatCacheKey{
40+ cols: cols,
41+ agentRev: rev,
42+ statusOK: i.statusOK,
43+ statusErr: i.statusErr,
44+ help: strings.Join(i.helpBlock, "\n"),
45+ extNotes: strings.Join(i.extNotes, "\n"),
46+ extStatuses: i.extensionStatusesKeyLocked(),
47+ extWidgets: i.extensionWidgetsKeyLocked(),
48+ reloadErrors: strings.Join(i.reloadErrors, "\n"),
49+ updateAvailable: i.updateInfo.Available,
50+ updateCurrent: i.updateInfo.Current,
51+ updateLatest: i.updateInfo.Latest,
52+ updateURL: i.updateInfo.URL,
53+ welcomeShowVer: showVer,
54+ expandAll: i.view.ExpandAll,
55+ tailLimit: i.view.TailLimit,
56+ renderedMessageCount: len(i.view.Messages),
57+ viewCacheRev: i.view.RenderCacheRevision,
58+ }
59+}
60+func sortedNestedOuterKeys[T any](groups map[string]map[string]T) []string {
61+ keys := make([]string, 0, len(groups))
62+ for key := range groups {
63+ keys = append(keys, key)
64+ }
65+ sort.Strings(keys)
66+ return keys
67+}
68+func sortedNestedInnerKeys[T any](groups map[string]map[string]T, outer string) []string {
69+ items := groups[outer]
70+ keys := make([]string, 0, len(items))
71+ for key := range items {
72+ keys = append(keys, key)
73+ }
74+ sort.Strings(keys)
75+ return keys
76+}
77+func (i *Interactive) extensionStatusesKeyLocked() string {
78+ var sb strings.Builder
79+ extNames := sortedNestedOuterKeys(i.extStatuses)
80+ for _, extName := range extNames {
81+ keys := sortedNestedInnerKeys(i.extStatuses, extName)
82+ for _, key := range keys {
83+ status := i.extStatuses[extName][key]
84+ fmt.Fprintf(&sb, "%s/%s/%s/%s\n", extName, key, status.Level, status.Text)
85+ }
86+ }
87+ return sb.String()
88+}
89+func (i *Interactive) extensionWidgetsKeyLocked() string {
90+ var sb strings.Builder
91+ extNames := sortedNestedOuterKeys(i.extWidgets)
92+ for _, extName := range extNames {
93+ ids := sortedNestedInnerKeys(i.extWidgets, extName)
94+ for _, id := range ids {
95+ widget := i.extWidgets[extName][id]
96+ fmt.Fprintf(&sb, "%s/%s/%s/%s\n", extName, id, widget.Position, widget.Title)
97+ for _, line := range widget.Lines {
98+ sb.WriteString(line)
99+ sb.WriteByte('\n')
100+ }
101+ }
102+ }
103+ return sb.String()
104+}
105+func (i *Interactive) rightBarWidgetsLocked() []tui.RightBarWidget {
106+ var out []tui.RightBarWidget
107+ for _, extName := range sortedNestedOuterKeys(i.extWidgets) {
108+ for _, id := range sortedNestedInnerKeys(i.extWidgets, extName) {
109+ widget := i.extWidgets[extName][id]
110+ if extproto.NormalizeWidgetPosition(widget.Position) != extproto.WidgetPositionRightBar {
111+ continue
112+ }
113+ out = append(out, tui.RightBarWidget{
114+ Extension: extName,
115+ ID: id,
116+ Title: widget.Title,
117+ Lines: append([]string(nil), widget.Lines...),
118+ })
119+ }
120+ }
121+ return out
122+}
123+func (i *Interactive) extensionChromeLinesLocked(cols int) []string {
124+ return i.extensionChromeLinesAtLocked(cols, false)
125+}
126+func (i *Interactive) extensionChromeLinesForLayoutLocked(cols int, rightBarActive, rightBarFallback bool) []string {
127+ lines := i.extensionChromeLinesAtLocked(cols, rightBarActive)
128+ if !rightBarFallback || len(lines) <= maxNarrowExtensionChromeRows {
129+ return lines
130+ }
131+ limit := maxNarrowExtensionChromeRows
132+ trimmed := append([]string(nil), lines[:limit-1]...)
133+ trimmed = append(trimmed, i.cfg.Theme.FGColor(i.cfg.Theme.Muted, truncateLine(" ... extension chrome truncated ...", cols)))
134+ return trimmed
135+}
136+func (i *Interactive) extensionChromeLinesAtLocked(cols int, rightBarActive bool) []string {
137+ var out []string
138+ bodyWidth := cols - 2
139+ if bodyWidth < 1 {
140+ bodyWidth = 1
141+ }
142+ widgetRows := 0
143+ widgetTruncated := false
144+ appendWidgetRow := func(line string) bool {
145+ if widgetRows >= maxExtensionWidgetRows-1 {
146+ widgetTruncated = true
147+ return false
148+ }
149+ out = append(out, line)
150+ widgetRows++
151+ return true
152+ }
153+
154+ stopWidgets := false
155+ for _, extName := range sortedNestedOuterKeys(i.extWidgets) {
156+ if stopWidgets {
157+ break
158+ }
159+ for _, id := range sortedNestedInnerKeys(i.extWidgets, extName) {
160+ widget := i.extWidgets[extName][id]
161+ if rightBarActive && extproto.NormalizeWidgetPosition(widget.Position) == extproto.WidgetPositionRightBar {
162+ continue
163+ }
164+ label := " [" + extName + "]"
165+ if widget.Title != "" {
166+ label += " " + widget.Title
167+ }
168+ if !appendWidgetRow(i.cfg.Theme.FGColor(i.cfg.Theme.Accent, truncateLine(label, cols))) {
169+ stopWidgets = true
170+ break
171+ }
172+ for _, line := range widget.Lines {
173+ if !appendWidgetRow(" " + truncateLine(line, bodyWidth)) {
174+ stopWidgets = true
175+ break
176+ }
177+ }
178+ if stopWidgets {
179+ break
180+ }
181+ }
182+ }
183+ if widgetTruncated {
184+ out = append(out, i.cfg.Theme.FGColor(i.cfg.Theme.Muted, truncateLine(" ... extension widgets truncated ...", cols)))
185+ }
186+
187+ statusRows := 0
188+ statusTruncated := false
189+ for _, extName := range sortedNestedOuterKeys(i.extStatuses) {
190+ for _, key := range sortedNestedInnerKeys(i.extStatuses, extName) {
191+ if statusRows >= maxExtensionStatusRows-1 {
192+ statusTruncated = true
193+ break
194+ }
195+ status := i.extStatuses[extName][key]
196+ color := i.cfg.Theme.Muted
197+ switch status.Level {
198+ case "warn":
199+ color = i.cfg.Theme.Warning
200+ case "error":
201+ color = i.cfg.Theme.Error
202+ case "success":
203+ color = i.cfg.Theme.Tool
204+ }
205+ label := " [" + extName + "] " + status.Text
206+ out = append(out, i.cfg.Theme.FGColor(color, truncateLine(label, cols)))
207+ statusRows++
208+ }
209+ if statusTruncated {
210+ break
211+ }
212+ }
213+ if statusTruncated {
214+ out = append(out, i.cfg.Theme.FGColor(i.cfg.Theme.Muted, truncateLine(" ... extension statuses truncated ...", cols)))
215+ }
216+ return out
217+}
218+func (i *Interactive) stableChatRowsLocked(cols int) []string {
219+ key := i.chatCacheKeyLocked(cols)
220+ if i.stableChatCacheValid && i.stableChatCacheKey == key {
221+ return append([]string(nil), i.stableChatCache...)
222+ }
223+
224+ renderView := i.view.CloneForRender()
225+ renderView.Streaming = ""
226+ renderView.StreamingActive = false
227+ renderView.ToolCalls = nil
228+ renderView.Err = ""
229+ if !i.renderOutsideLock {
230+ rows := renderView.Build(cols)
231+ i.stableChatCache = append(i.stableChatCache[:0], rows...)
232+ i.stableChatCacheKey = key
233+ i.stableChatCacheValid = true
234+ return append([]string(nil), rows...)
235+ }
236+ // Markdown, syntax highlighting, and wrapping are the expensive part of
237+ // a frame. Build the immutable snapshot without holding the interactive
238+ // mutex so key processing can continue while a cold transcript renders.
239+ i.mu.Unlock()
240+ rows := renderView.Build(cols)
241+ i.mu.Lock()
242+ if i.view.MessagesRevision == renderView.MessagesRevision &&
243+ i.view.RenderCacheRevision == renderView.RenderCacheRevision {
244+ i.view.AdoptRenderCacheFrom(renderView)
245+ }
246+
247+ i.stableChatCache = append(i.stableChatCache[:0], rows...)
248+ i.stableChatCacheKey = key
249+ i.stableChatCacheValid = true
250+ return append([]string(nil), rows...)
251+}
252+func (i *Interactive) buildChatLocked(cols int) []string {
253+ if i.agent != nil {
254+ i.view.Messages = filterHiddenTranscriptMessages(i.agent.Messages())
255+ i.view.MessagesRevision = i.agent.Revision()
256+ } else {
257+ i.view.Messages = nil
258+ i.view.MessagesRevision = 0
259+ }
260+ // Pacer flush: while the streaming pacer is still draining the
261+ // buffer (i.e. EvAssistantMessage already fired but more runes
262+ // are queued), the final assistant message is already in
263+ // i.agent.Messages() in full. Painting it in the transcript
264+ // AND the streaming block at the same time shows the user the
265+ // complete text immediately — which defeats the whole pacer.
266+ // Hide the last message until the pacer catches up; once the
267+ // flush-pending latch clears, the message is revealed (the
268+ // streaming block disappears the same frame because streamOn
269+ // flips off, so the transition is seamless).
270+ if i.streamFlushPending && len(i.view.Messages) > 0 {
271+ i.view.Messages = i.view.Messages[:len(i.view.Messages)-1]
272+ }
273+ i.view.Streaming = i.streaming.String()
274+ i.view.StreamingActive = i.streamOn
275+ // Guard against the narrow race where EvAssistantMessage has
276+ // just promoted a streaming reply into the transcript but a
277+ // render tick hasn't flipped streamOn off yet. Without the
278+ // guard, the same text would appear twice (once as the
279+ // in-flight streaming block, once as the last transcript
280+ // message). We detect the duplicate strictly: the last
281+ // assistant message's visible text must equal the streaming
282+ // buffer. Just matching on role is too broad — it also hides
283+ // the next round's typewriter streaming after a tool turn,
284+ // because the last transcript message is always assistant
285+ // (the tool-use block) until the follow-up summary lands.
286+ if i.streamOn && i.streaming.Len() > 0 {
287+ if n := len(i.view.Messages); n > 0 && i.view.Messages[n-1].Role == provider.RoleAssistant {
288+ if assistantText(i.view.Messages[n-1]) == i.streaming.String() {
289+ i.view.StreamingActive = false
290+ }
291+ }
292+ }
293+ // Live tool-call view: only shown while a turn is in flight. Once
294+ // the agent is idle, every tool call has already been folded into
295+ // the transcript (as assistant.ToolCallBlock + a tool-role message),
296+ // so showing v.ToolCalls a second time would duplicate them below
297+ // the final assistant text — which looks like the summary came
298+ // "before" the tools.
299+ i.view.ToolCalls = i.view.ToolCalls[:0]
300+ if i.busy {
301+ for _, id := range i.toolOrder {
302+ // Deterministic ordering: a tool block stays hidden until
303+ // the paced assistant text that preceded it has finished
304+ // typing out. toolOrder is append-only in arrival order,
305+ // so once one tool is still gated, every later tool is too,
306+ // so stop here to avoid showing a tool out of sequence.
307+ if !i.toolGateOpenLocked(id) {
308+ break
309+ }
310+ if tc, ok := i.toolCalls[id]; ok {
311+ i.view.ToolCalls = append(i.view.ToolCalls, *tc)
312+ }
313+ }
314+ }
315+ i.view.Err = i.statusErr
316+ // Live streaming/tool rows are appended to the chat buffer (not
317+ // hoisted into a separate live block above the editor). That keeps
318+ // the renderer's diff view append-only: when a tool finishes the
319+ // rows update in place at the end of the buffer, instead of the
320+ // whole bottom band shrinking and shifting chat lines around.
321+ i.liveBlock = nil
322+ stable := i.stableChatRowsLocked(cols)
323+ chat := append([]string(nil), stable...)
324+ chat = append(chat, i.view.BuildLive(cols)...)
325+
326+ // Welcome banner: shown at the top of the chat area when there is
327+ // no transcript yet. Disappears after the first message is sent.
328+ // The version suffix is shown for welcomeVersionDuration after
329+ // startup, then drops off automatically.
330+ if len(i.view.Messages) == 0 && !i.streamOn && len(i.toolOrder) == 0 {
331+ showVer := !i.welcomeStart.IsZero() && time.Since(i.welcomeStart) < welcomeVersionDuration
332+ chat = append(welcomeBanner(i.cfg.Theme, i.cfg.Version, showVer), chat...)
333+ }
334+
335+ // Update-available banner: prepended above everything else so it's
336+ // the first thing the user sees when opening a new zut session.
337+ // Once rendered, it stays until the user updates to a newer
338+ // version — we don't persist a "dismissed" flag because this is
339+ // cheap and re-showing it is how most users remember to update.
340+ if i.updateInfo.Available {
341+ banner := renderUpdateBanner(i.cfg.Theme, i.updateInfo, cols)
342+ chat = append(banner, chat...)
343+ }
344+
345+ // /help block: appended to the transcript so it appears at the
346+ // bottom of the chat area (right above the status bar / editor).
347+ // Prepending it would push long conversations off the top of the
348+ // viewport, which users would miss entirely.
349+ if len(i.helpBlock) > 0 {
350+ chat = append(chat, i.helpBlock...)
351+ }
352+
353+ if i.statusOK != "" {
354+ // Hard-truncate the OK line to the visible width so a long
355+ // session path ("resumed session: /Users/.../sessions/...")
356+ // doesn't overflow past the right edge and look broken on a
357+ // narrow terminal.
358+ line := "✓ " + i.statusOK
359+ if cols > 4 && len(line) > cols {
360+ line = line[:cols-3] + "..."
361+ }
362+ chat = append(chat, i.cfg.Theme.FGColor(i.cfg.Theme.Tool, line), "")
363+ }
364+
365+ // Live shell-escape output (!command / entry.pre) streams into the
366+ // transcript area while the command runs, then is replaced by the
367+ // final user-context message when it finishes.
368+ if i.shellRunning && i.shellLive != "" {
369+ chat = append(chat, strings.Split(strings.TrimRight(i.shellLive, "\n"), "\n")...)
370+ chat = append(chat, "")
371+ }
372+
373+ // Extension notes (notify / display) live just under the
374+ // transcript, above the dialog/editor band. Cleared by /clear.
375+ if len(i.extNotes) > 0 {
376+ chat = append(chat, i.extNotes...)
377+ chat = append(chat, "")
378+ }
379+
380+ // Reload failures remain in the scrolling chat until /clear. They are
381+ // host-only display state, not provider messages or persisted context.
382+ // While the latest failure is still the temporary status error, hide its
383+ // stored copy to avoid painting the same message twice.
384+ reloadErrors := i.reloadErrors
385+ if n := len(reloadErrors); n > 0 && reloadErrors[n-1] == i.statusErr {
386+ reloadErrors = reloadErrors[:n-1]
387+ }
388+ if len(reloadErrors) > 0 {
389+ chat = append(chat, renderReloadErrors(i.cfg.Theme, reloadErrors, cols)...)
390+ chat = append(chat, "")
391+ }
392+
393+ // Strip trailing blank rows so the chat content sits flush
394+ // against the new "blank above status bar" row added by the
395+ // bottom-region assembly. Build() ends every message with a
396+ // blank separator; without this trim, the final message in
397+ // the transcript would have its own trailing blank plus the
398+ // status block's leading blank, doubling the gap.
399+ for len(chat) > 0 && strings.TrimSpace(chat[len(chat)-1]) == "" {
400+ chat = chat[:len(chat)-1]
401+ }
402+ return chat
403+}
404+func (i *Interactive) sessionsRoot() string {
405+ if i.cfg.SessionsRoot != "" {
406+ return i.cfg.SessionsRoot
407+ }
408+ return i.cfg.ZutHome
409+}
410+func (i *Interactive) lastCols() int {
411+ cols, _ := i.cfg.Terminal.Size()
412+ return cols
413+}
414+func (i *Interactive) chatPage() int {
415+ _, rows := i.cfg.Terminal.Size()
416+ p := rows - 6 // rough reservation for status + editor + a dialog line
417+ if p < 4 {
418+ p = 4
419+ }
420+ return p
421+}
422+func (i *Interactive) scrollBy(delta int) {
423+ i.mu.Lock()
424+ i.scrollOffset += delta
425+ if i.scrollOffset < 0 {
426+ i.scrollOffset = 0
427+ }
428+ if i.scrollOffset == 0 {
429+ i.parkedTurn = 0
430+ i.parkedTotal = 0
431+ }
432+ if i.rend != nil {
433+ // VS Code's terminal is especially prone to leaving stray
434+ // wrapped-character fragments behind during scroll-driven
435+ // viewport changes. Force a full repaint on scroll, but
436+ // avoid a whole-screen clear because that visibly flickers.
437+ i.requestRendererInvalidate()
438+ }
439+ i.mu.Unlock()
440+ i.invalidate()
441+}
442+func anchorScrollOffset(offset, prevLen, newLen, prevRows, newRows int) int {
443+ adj := (newLen - prevLen) - (newRows - prevRows)
444+ offset += adj
445+ if offset < 0 {
446+ offset = 0
447+ }
448+ if offset > newLen {
449+ offset = newLen
450+ }
451+ return offset
452+}
453+func (i *Interactive) scrollToBottom() {
454+ i.mu.Lock()
455+ i.scrollOffset = 0
456+ i.parkedTurn = 0
457+ i.parkedTotal = 0
458+ // Reset the auto-follow baseline. scrollToBottom is the resume /
459+ // session-swap snap point, where the chat buffer changes length
460+ // wholesale. Without zeroing these, the next render's follow guard
461+ // compares the fresh transcript's length against a stale baseline
462+ // and nudges scrollOffset, which reads as a viewport jump right
463+ // after resume. See commit 43da5e5 for the same fix on new turns.
464+ i.prevChatLen = 0
465+ i.prevChatCols = 0
466+ i.requestRendererInvalidate()
467+ i.mu.Unlock()
468+ i.invalidate()
469+}
470+func (i *Interactive) redraw() {
471+ i.mu.Lock()
472+ renderRevision := i.renderRevision.Load()
473+
474+ cols, rows := i.cfg.Terminal.Size()
475+ mainCols := cols
476+ rightBarWidgets := i.rightBarWidgetsLocked()
477+ rightBarActive := false
478+ var rightBarWidth int
479+ if !i.rightBarHidden && len(rightBarWidgets) > 0 {
480+ if width, rail, ok := tui.RightBarColumns(cols); ok {
481+ mainCols = width
482+ rightBarWidth = rail
483+ rightBarActive = true
484+ }
485+ }
486+ rightBarFallback := len(rightBarWidgets) > 0 && !rightBarActive
487+ i.renderOutsideLock = true
488+ chat := i.cachedChatLocked(mainCols)
489+ i.renderOutsideLock = false
490+ if i.renderRevision.Load() != renderRevision {
491+ i.mu.Unlock()
492+ return
493+ }
494+
495+ // Dialogs (login or model picker) render between chat and the editor.
496+ var dialog []string
497+ switch {
498+ case i.dialog.Active():
499+ dialog = i.dialog.Render(i.cfg.Theme, mainCols)
500+ case i.modelDialog.Active():
501+ dialog = i.modelDialog.Render(i.cfg.Theme, mainCols)
502+ case i.llamaDialog.Active():
503+ dialog = i.llamaDialog.Render(i.cfg.Theme, mainCols)
504+ case i.rescueDialog.Active():
505+ dialog = i.rescueDialog.Render(i.cfg.Theme, mainCols)
506+ case i.sessionDialog.Active():
507+ // Reserve rows for the editor (~3), status line (1-2),
508+ // dialog chrome (header + hint + rule + indicators, ~5),
509+ // and leave the remainder for session rows. Minimum of 3
510+ // rows so even a very small terminal shows something.
511+ _, rows := i.cfg.Terminal.Size()
512+ avail := rows - 12
513+ if avail < 3 {
514+ avail = 3
515+ }
516+ i.sessionDialog.MaxRows = avail
517+ dialog = i.sessionDialog.Render(i.cfg.Theme, mainCols)
518+ case i.residentSubagentsDialog.Active():
519+ _, terminalRows := i.cfg.Terminal.Size()
520+ dialog = i.residentSubagentsDialog.Render(i.cfg.Theme, mainCols, terminalRows)
521+ case i.residentChildSession != nil:
522+ _, terminalRows := i.cfg.Terminal.Size()
523+ dialog = i.residentChildSession.Render(mainCols, terminalRows)
524+ case i.jumpDialog.Active():
525+ dialog = i.jumpDialog.Render(i.cfg.Theme, mainCols)
526+ case i.extPanel.Active() && (!i.confirmDialog.Active() || !i.confirmDialog.Focused()):
527+ dialog = i.extPanel.Render(i.cfg.Theme, mainCols)
528+ case i.confirmDialog.Active():
529+ if i.btwDialog.Active() {
530+ // Keep the side-chat transcript and its live tool preview visible
531+ // above confirmation, matching how the main transcript remains
532+ // visible while --no-yolo waits for a decision.
533+ dialog = renderBtwConfirmation(i.cfg.Theme, mainCols, i.btwDialog, i.confirmDialog)
534+ } else {
535+ dialog = i.confirmDialog.Render(i.cfg.Theme, mainCols)
536+ }
537+ case i.btwDialog.Active():
538+ dialog = i.btwDialog.Render(i.cfg.Theme, mainCols)
539+ case i.skillsDialog.Active():
540+ dialog = i.skillsDialog.Render(i.cfg.Theme, mainCols)
541+ case i.changelogDialog.Active():
542+ dialog = i.changelogDialog.Render(i.cfg.Theme, mainCols)
543+ case i.logoutDialog.Active():
544+ dialog = i.logoutDialog.Render(i.cfg.Theme, mainCols)
545+ case i.telegramDialog.Active():
546+ dialog = i.telegramDialog.Render(i.cfg.Theme, mainCols)
547+ case i.settingsDialog.Active():
548+ dialog = i.settingsDialog.Render(i.cfg.Theme, mainCols)
549+ case i.sessionOpsDialog.Active():
550+ dialog = i.sessionOpsDialog.Render(i.cfg.Theme, mainCols)
551+ case i.sessionTreeDialog.Active():
552+ // Reserve rows for the editor, status, and tree chrome while using
553+ // the same budget for rendering and PageUp/PageDown movement.
554+ _, rows := i.cfg.Terminal.Size()
555+ avail := rows - 12
556+ if avail < 3 {
557+ avail = 3
558+ }
559+ i.sessionTreeDialog.MaxRows = avail
560+ dialog = i.sessionTreeDialog.Render(i.cfg.Theme, mainCols)
561+ }
562+ if len(dialog) > 0 {
563+ dialog = padDialogFrame(dialog)
564+ }
565+ modalBackdrop := len(dialog) > 0
566+
567+ // Slash-command autocomplete: popup above the status line, only
568+ // when the editor starts with "/" and no dialog is already open.
569+ // Feed extension-registered commands into the suggester first so
570+ // they show up in tab-complete + the popup alongside the built-ins.
571+ i.suggest.SetJailed(i.cfg.Sandbox.Locked())
572+ i.suggest.SetLlamaConfigured(i.llamaConfigured)
573+ if i.cfg.Extensions != nil {
574+ catalog := i.cfg.Extensions.Commands()
575+ extra := make([]slashCommand, 0, len(catalog))
576+ for _, c := range catalog {
577+ // The popup renders extension commands under a dedicated
578+ // "── extensions ───" divider, so the description doesn't
579+ // need to repeat the source. If the description is empty,
580+ // fall back to the extension name so the row isn't blank.
581+ desc := c.Description
582+ if strings.TrimSpace(desc) == "" {
583+ desc = "(" + c.Extension + ")"
584+ }
585+ extra = append(extra, slashCommand{
586+ Name: "/" + c.Name,
587+ Desc: desc,
588+ })
589+ }
590+ i.suggest.SetExtra(extra)
591+ }
592+ var suggest []string
593+ currentInput := i.ed.Value()
594+ if i.suggest.SkillInputStarted(currentInput) {
595+ var list []*skills.Skill
596+ if i.cfg.SkillSnapshot != nil {
597+ list = i.cfg.SkillSnapshot()
598+ }
599+ i.suggest.SetSkills(list)
600+ }
601+ // Slash popup renders even while the agent is busy so the user
602+ // can queue a destructive command (/clear, /compact, /logout,
603+ // /model) or a read-only one (/help, /jump, /sessions, etc.)
604+ // without waiting for the current turn to finish. The dispatcher
605+ // in runSlash already handles the busy case per-command: safe
606+ // ones run immediately, destructive ones cancel the turn first.
607+ i.fileSuggest.SetCWD(i.cfg.CWD)
608+ mainInputFocused := len(dialog) == 0 || (i.confirmDialog.Active() && !i.confirmDialog.Focused() && !i.confirmChildActiveLocked())
609+ if mainInputFocused && i.suggest.Active(currentInput) {
610+ suggest = i.suggest.Render(currentInput, i.cfg.Theme, mainCols)
611+ } else if mainInputFocused && i.fileSuggest.Active(currentInput) {
612+ suggest = i.fileSuggest.Render(currentInput, i.cfg.Theme, mainCols)
613+ }
614+
615+ // Detect overlay close (any dialog or slash/file suggestion popup
616+ // just transitioned from open to closed). Force a full redraw so
617+ // the rows the overlay occupied are guaranteed to be repainted
618+ // from the chat below, instead of the diff path leaving stale
619+ // dialog content behind. Equivalent to the user pressing ctrl+l.
620+ overlayOpen := len(dialog) > 0 || len(suggest) > 0
621+ if i.rend != nil && i.prevOverlayOpen && !overlayOpen {
622+ // An overlay (dialog or slash/file popup) just closed, so the
623+ // bottom band shrinks. On terminals where we can drop
624+ // scrollback, a full Clear is the simplest way to guarantee
625+ // the vacated rows are repainted from the chat below.
626+ //
627+ // On VS Code's terminal closing a dialog leaves the stale
628+ // overlay rows in the retained scrollback (we can't drop them
629+ // with the quiet in-place diff). Run the same full Clear() that
630+ // Ctrl+L uses so the scrollback is purged and the conversation
631+ // is repainted clean, matching what the user expects after
632+ // dismissing a picker. Clear() is keepScrollback-aware and
633+ // emits \x1b[3J there.
634+ i.rend.Clear()
635+ }
636+ i.prevOverlayOpen = overlayOpen
637+ if len(suggest) > 0 {
638+ // One blank row above the popup so it doesn't sit flush
639+ // against the chat / welcome content above.
640+ suggest = append([]string{""}, suggest...)
641+ }
642+
643+ // Busy prefix shown at the far left of the status bar. The spinner owns
644+ // only its frame; the activity label reports the operation in progress.
645+ var busyPrefix string
646+ if i.busy {
647+ label := i.activity.label()
648+ if label == "" {
649+ label = "Preparing request"
650+ }
651+ busyPrefix = fmt.Sprintf("%s %s %s %s",
652+ i.cfg.Theme.FGColor(i.cfg.Theme.Assistant, i.spin.Frame()),
653+ i.cfg.Theme.FGColor(i.cfg.Theme.Assistant, label),
654+ i.cfg.Theme.FGColor(i.cfg.Theme.Muted, "-"),
655+ i.cfg.Theme.FGColor(i.cfg.Theme.Muted, i.spin.Elapsed().String()),
656+ )
657+ }
658+
659+ ctxMax := 0
660+ if m, err := provider.FindModel(i.cfg.Provider, i.cfg.Model); err == nil {
661+ ctxMax = m.ContextWindow
662+ }
663+ statusPosition := tui.NormalizeStatusPosition(i.cfg.TUIStatusPosition)
664+ workingPosition := tui.NormalizeWorkingPosition(i.cfg.TUIWorkingPosition)
665+ workingWithStatus := statusPosition == workingPosition
666+ statusBusyPrefix := ""
667+ if workingWithStatus {
668+ statusBusyPrefix = busyPrefix
669+ }
670+ goalStatus := string(i.goalStatus)
671+ statusLines := tui.StatusBar(tui.StatusBarParams{
672+ Theme: i.cfg.Theme,
673+ Provider: i.cfg.Provider,
674+ Model: i.cfg.Model,
675+ Reasoning: i.cfg.Reasoning,
676+ FastMode: i.cfg.FastMode != nil && *i.cfg.FastMode,
677+ Busy: i.busy,
678+ BusyPrefix: statusBusyPrefix,
679+ CWD: i.cfg.CWD,
680+ Locked: i.cfg.Sandbox.Locked(),
681+ NoYolo: i.cfg.NoYolo,
682+ GoalStatus: goalStatus,
683+ Usage: i.cumUsage,
684+ Subscription: i.cfg.AuthMethod == "oauth",
685+ ContextUsed: i.lastCtxInput,
686+ ContextMax: ctxMax,
687+ AutoCompacting: i.autoCompacting,
688+ Telegram: i.telegramBridge != nil && i.telegramBridge.Active(),
689+ Cols: mainCols,
690+ })
691+ inputStyle := tui.NormalizeInputStyle(i.cfg.TUIInputStyle)
692+ if inputStyle == tui.InputStyleLines || inputStyle == tui.InputStyleBlock {
693+ i.ed.Prompt = ""
694+ } else {
695+ i.ed.Prompt = i.cfg.Theme.AccentBar(i.cfg.Theme.Accent)
696+ }
697+ edLines, curR, curC := i.ed.Render(mainCols)
698+ dashboardActive := i.residentSubagentsDialog.Active() || i.residentChildSession != nil
699+ var allResidentSubagentLines []string
700+ residentSubagentHidden := 0
701+ if !dashboardActive && i.cfg.ResidentManager != nil {
702+ const residentIndicatorSnapshotLimit = 8
703+ snapshots, total := i.cfg.ResidentManager.ActiveSnapshotPage(residentIndicatorSnapshotLimit)
704+ allResidentSubagentLines = renderResidentSubagentActivityLines(i.cfg.Theme, i.spin.FrameAt(i.clock()), snapshots, mainCols, i.clock())
705+ residentSubagentHidden = total - len(snapshots)
706+ }
707+ var workingLines []string
708+ if busyPrefix != "" && !workingWithStatus {
709+ workingLines = []string{" " + busyPrefix}
710+ }
711+ inputCursorOffset := 0
712+ inputCursorColOffset := 0
713+ switch inputStyle {
714+ case tui.InputStyleLines:
715+ edLines = tui.InputLines(i.cfg.Theme, edLines, mainCols)
716+ inputCursorOffset = 1
717+ case tui.InputStyleBlock:
718+ edLines = tui.InputBlock(i.cfg.Theme, edLines, mainCols)
719+ inputCursorOffset = 1
720+ inputCursorColOffset = 2
721+ }
722+
723+ // "Sliding in" chips for messages the user typed while a turn is
724+ // in flight. Shown directly above the status bar so they're close
725+ // to the editor but don't push the chat around.
726+ var queue []string
727+ queued := append([]string(nil), i.queued...)
728+ if i.agent != nil {
729+ queued = append(queued, i.agent.PendingQueuedMessages()...)
730+ }
731+ if len(queued) > 0 {
732+ queue = append(queue, "")
733+ for _, q := range queued {
734+ label := i.cfg.Theme.FGColor(i.cfg.Theme.Accent, " sliding in: ")
735+ text := truncateLine(q, mainCols-17)
736+ queue = append(queue, label+i.cfg.Theme.FGColor(i.cfg.Theme.Muted, text))
737+ }
738+ // Hint row, rendered in the same muted tone as the model
739+ // info on the status bar so it reads as ambient metadata
740+ // rather than a chip. Tells the user how to recover the
741+ // most recent queued message back into the editor.
742+ hint := " Press " + slideBackChordHint() + " to slide back into input"
743+ queue = append(queue, i.cfg.Theme.FGColor(i.cfg.Theme.Muted, hint))
744+ }
745+
746+ extensionLines := i.extensionChromeLinesForLayoutLocked(mainCols, rightBarActive, rightBarFallback)
747+
748+ // Bottom-sticky sections (always visible, never scroll). Each
749+ // non-empty subsection (dialog, suggest popup, sliding-in queue)
750+ // is preceded by one blank row so it has air above the chat
751+ // content. The status block and editor get their own dedicated
752+ // blanks so spacing stays consistent whether or not a dialog or
753+ // popup is showing.
754+ composeBottom := func(residentSubagentLines []string) (bottom []string, inputStartRow int) {
755+ bottom = make([]string, 0, len(dialog)+len(suggest)+len(queue)+len(extensionLines)+len(statusLines)+len(residentSubagentLines)+len(edLines)+9)
756+ inputStartRow = -1
757+ if len(dialog) > 0 {
758+ bottom = append(bottom, "")
759+ }
760+ bottom = append(bottom, dialog...)
761+ // The resident child views own the bottom of the screen while active.
762+ if dashboardActive {
763+ return bottom, inputStartRow
764+ }
765+
766+ bottom = append(bottom, suggest...)
767+ bottom = append(bottom, queue...)
768+ lineInput := inputStyle == tui.InputStyleLines
769+ statusBelow := statusPosition == tui.StatusPositionBelowInput
770+ workingBelow := workingPosition == tui.WorkingPositionBelowInput
771+ var aboveInput []string
772+ aboveInput = append(aboveInput, extensionLines...)
773+ if !statusBelow {
774+ aboveInput = append(aboveInput, statusLines...)
775+ }
776+ if !workingBelow {
777+ aboveInput = append(aboveInput, workingLines...)
778+ }
779+ var belowInput []string
780+ // Background resident work belongs directly below the editor. It is
781+ // deliberately independent of the main turn's busy/status placement.
782+ belowInput = append(belowInput, residentSubagentLines...)
783+ if workingBelow {
784+ belowInput = append(belowInput, workingLines...)
785+ }
786+ if statusBelow {
787+ belowInput = append(belowInput, statusLines...)
788+ }
789+
790+ bottom = append(bottom, "")
791+ bottom = append(bottom, aboveInput...)
792+ needsGapAboveInput := len(aboveInput) > 0 && !lineInput
793+ if lineInput && statusBelow && !workingBelow && len(workingLines) > 0 {
794+ needsGapAboveInput = true
795+ }
796+ if needsGapAboveInput {
797+ bottom = append(bottom, "")
798+ }
799+ inputStartRow = len(bottom)
800+ bottom = append(bottom, edLines...)
801+ if len(belowInput) > 0 {
802+ // Active resident work sits immediately below the prompt. Other
803+ // below-input chrome keeps the usual breathing room.
804+ if !lineInput && len(residentSubagentLines) == 0 {
805+ bottom = append(bottom, "")
806+ }
807+ bottom = append(bottom, belowInput...)
808+ }
809+ return bottom, inputStartRow
810+ }
811+
812+ const rendererBottomMarginRows = 1
813+ maxBottomRows := rows - rendererBottomMarginRows - 1
814+ var residentSubagentLines []string
815+ var bottom []string
816+ var inputStartRow int
817+ if !dashboardActive && len(allResidentSubagentLines) > 0 {
818+ for maxRows := len(allResidentSubagentLines) + 1; maxRows >= 0; maxRows-- {
819+ candidate := limitResidentSubagentActivityLines(i.cfg.Theme, allResidentSubagentLines, residentSubagentHidden, maxRows, mainCols)
820+ candidateBottom, _ := composeBottom(candidate)
821+ if len(candidateBottom) <= maxBottomRows || maxRows == 0 {
822+ residentSubagentLines = candidate
823+ break
824+ }
825+ }
826+ }
827+ bottom, inputStartRow = composeBottom(residentSubagentLines)
828+
829+ chatRows := rows - len(bottom)
830+ if chatRows < 1 {
831+ chatRows = 1
832+ }
833+
834+ // Auto-follow guard: when the user has scrolled up (scrollOffset
835+ // > 0) and the agent appends new content below the viewport while
836+ // they're reading, compensate so the visible content stays
837+ // anchored. scrollOffset is measured from the bottom of `chat`,
838+ // so without compensation a growing buffer pushes the window
839+ // downward through the content and the lines the user was
840+ // reading scroll off the top.
841+ //
842+ // Skip compensation when the terminal width changed (a resize
843+ // reflows the whole buffer and the line-count delta no longer
844+ // corresponds to appended content) and when scrollOffset is 0
845+ // (the user is following the tail and wants new content to push
846+ // the view down as usual).
847+ //
848+ // The window the user sees starts at row
849+ // start = len(chat) - scrollOffset - chatRows
850+ // so to keep `start` fixed across a redraw we must offset by both
851+ // the buffer growth (len delta) AND the viewport-height change
852+ // (chatRows delta, e.g. the status band or sliding-in queue
853+ // appearing during a turn). Compensating only for the len delta
854+ // let a shrinking chatRows pull the window toward the tail, which
855+ // read as the viewport jumping to the bottom whenever the agent
856+ // streamed text or a tool call grew the bottom band.
857+ if i.scrollOffset > 0 && i.prevChatCols == mainCols && i.prevChatLen > 0 {
858+ i.scrollOffset = anchorScrollOffset(i.scrollOffset,
859+ i.prevChatLen, len(chat), i.prevChatRows, chatRows)
860+ }
861+ i.prevChatLen = len(chat)
862+ i.prevChatCols = mainCols
863+ i.prevChatRows = chatRows
864+
865+ // Apply scroll offset to the chat slice.
866+ maxOffset := len(chat) - chatRows
867+ if maxOffset < 0 {
868+ maxOffset = 0
869+ }
870+ // Tail-render expansion: if the user has scrolled to (or above)
871+ // the top of the currently rendered tail and there are still
872+ // truncated messages above, widen view.TailLimit and rebuild.
873+ // The chat cache is keyed on tailLimit so the next cachedChatLocked
874+ // will re-issue Build instead of returning the stale slice. We
875+ // rebuild immediately so the same redraw shows the freshly-revealed
876+ // rows; otherwise the user would have to scroll again to see them.
877+ if i.scrollOffset >= maxOffset && i.view.TailLimit > 0 && i.view.TailLimit < len(i.view.Messages) {
878+ prevLen := len(chat)
879+ i.view.TailLimit += resumeTailExpandStep
880+ if i.view.TailLimit >= len(i.view.Messages) {
881+ i.view.TailLimit = 0 // unbounded
882+ }
883+ i.chatCacheValid = false
884+ chat = i.cachedChatLocked(mainCols)
885+ for len(chat) > 0 && strings.TrimSpace(chat[len(chat)-1]) == "" {
886+ chat = chat[:len(chat)-1]
887+ }
888+ // Newly-revealed rows are older messages prepended at the top.
889+ // scrollOffset counts rows from the bottom, so to keep the
890+ // viewport visually anchored on the same content the user was
891+ // looking at we shift it up by the number of rows added. Keep
892+ // the auto-follow baseline (prevChatLen) in sync with the
893+ // post-expansion length too, so the next render's follow guard
894+ // doesn't see this growth as a synthetic delta and yank the
895+ // viewport again.
896+ if grew := len(chat) - prevLen; grew > 0 {
897+ i.scrollOffset += grew
898+ }
899+ i.prevChatLen = len(chat)
900+ i.prevChatCols = mainCols
901+ maxOffset = len(chat) - chatRows
902+ if maxOffset < 0 {
903+ maxOffset = 0
904+ }
905+ }
906+ if i.scrollOffset > maxOffset {
907+ i.scrollOffset = maxOffset
908+ }
909+ if i.scrollOffset < 0 {
910+ i.scrollOffset = 0
911+ }
912+
913+ var visibleChat []string
914+ if len(chat) <= chatRows {
915+ visibleChat = chat
916+ } else {
917+ end := len(chat) - i.scrollOffset
918+ rawStart := end - chatRows
919+ if rawStart < 0 {
920+ rawStart = 0
921+ }
922+ start := snapViewportStartToImageBlock(chat, rawStart)
923+ // If the snap pulled start upward (an image-block was atomic) while
924+ // the user is scrolling downward, the viewport would sit on the same
925+ // image until the user mashes down past every reserved row. Bump
926+ // scrollOffset past the image so one keypress always clears it.
927+ if start < rawStart && i.scrollOffset < i.prevScrollOffset {
928+ jump := rawStart - start
929+ i.scrollOffset -= jump
930+ if i.scrollOffset < 0 {
931+ i.scrollOffset = 0
932+ }
933+ end = len(chat) - i.scrollOffset
934+ rawStart = end - chatRows
935+ if rawStart < 0 {
936+ rawStart = 0
937+ }
938+ start = snapViewportStartToImageBlock(chat, rawStart)
939+ }
940+ end = start + chatRows
941+ if end > len(chat) {
942+ end = len(chat)
943+ start = end - chatRows
944+ if start < 0 {
945+ start = 0
946+ }
947+ }
948+ visibleChat = chat[start:end]
949+ }
950+ i.prevScrollOffset = i.scrollOffset
951+ visibleChat = clipBottomClippedImages(visibleChat)
952+
953+ // A tiny "scrolled up" indicator in the top-right of the chat pane
954+ // so you know you're not at the bottom. When the viewport was
955+ // parked by /jump we include the turn number so the user remembers
956+ // they're reading history rather than the live conversation.
957+ if i.scrollOffset > 0 && len(visibleChat) > 0 {
958+ var text string
959+ if i.parkedTurn > 0 && i.parkedTotal > 0 {
960+ text = fmt.Sprintf(" ↑ viewing turn %d of %d - %d lines more below (pgdn / end)",
961+ i.parkedTurn, i.parkedTotal, i.scrollOffset)
962+ } else {
963+ text = fmt.Sprintf(" ↑ %d lines more below (end to jump)", i.scrollOffset)
964+ }
965+ note := i.cfg.Theme.FGColor(i.cfg.Theme.Muted, text)
966+ visibleChat = append([]string{note}, visibleChat...)
967+ if len(visibleChat) > chatRows {
968+ visibleChat = visibleChat[:chatRows]
969+ }
970+ }
971+
972+ // A dialog is the foreground layer. Dim every other visible surface,
973+ // including the main input beneath it, without changing the dialog's
974+ // own styling. Keep the source slices untouched because chat is cached.
975+ if modalBackdrop {
976+ chat = tui.DimLines(chat)
977+ visibleChat = tui.DimLines(visibleChat)
978+ dimmedBottom := tui.DimLines(bottom)
979+ copy(dimmedBottom[1:1+len(dialog)], dialog)
980+ bottom = dimmedBottom
981+ }
982+
983+ // Default: the real terminal cursor sits on the main editor's
984+ // input position. In main-screen log mode cursor rows are relative
985+ // to the fixed bottom band, not the chat transcript.
986+ // dialogLead is 1 when the bottom region prepends a blank above
987+ // the dialog block (whenever a dialog is showing) so popup-side
988+ // cursor positions still land in the right cell.
989+ dialogLead := 0
990+ if len(dialog) > 0 {
991+ dialogLead = 1
992+ }
993+ cursorRow := inputStartRow + inputCursorOffset + curR
994+ cursorCol := curC + inputCursorColOffset
995+ cursorInDialog := false
996+ if i.btwDialog.Active() {
997+ if r, c := i.btwDialog.CursorPos(mainCols); r >= 0 {
998+ cursorRow = dialogLead + r
999+ cursorCol = c
1000+ cursorInDialog = true
1001+ }
1002+ }
1003+ if i.dialog.Active() {
1004+ if r, c := i.dialog.CursorPos(mainCols); r >= 0 {
1005+ cursorRow = dialogLead + r
1006+ cursorCol = c
1007+ cursorInDialog = true
1008+ }
1009+ }
1010+ if i.llamaDialog.Active() {
1011+ if r, c := i.llamaDialog.CursorPos(); r >= 0 {
1012+ cursorRow = dialogLead + r
1013+ cursorCol = c
1014+ cursorInDialog = true
1015+ } else {
1016+ cursorRow = -1
1017+ cursorCol = 0
1018+ }
1019+ }
1020+ if i.sessionDialog.Active() {
1021+ if r, c := i.sessionDialog.CursorPos(); r >= 0 {
1022+ cursorRow = dialogLead + r
1023+ cursorCol = c
1024+ cursorInDialog = true
1025+ }
1026+ }
1027+ if i.sessionTreeDialog.Active() {
1028+ if r, c := i.sessionTreeDialog.CursorPos(); r >= 0 {
1029+ cursorRow = dialogLead + r
1030+ cursorCol = c
1031+ cursorInDialog = true
1032+ }
1033+ }
1034+ if i.extPanel.Active() {
1035+ cursorRow = -1
1036+ cursorCol = 0
1037+ }
1038+ i.setInputCursorDimmed(modalBackdrop && !cursorInDialog)
1039+ if i.renderRevision.Load() != renderRevision {
1040+ i.mu.Unlock()
1041+ return
1042+ }
1043+ theme := i.cfg.Theme
1044+ // State assembly above is synchronized; terminal output is owned by
1045+ // this renderer goroutine and must not hold the global state mutex.
1046+ i.mu.Unlock()
1047+ if rightBarActive {
1048+ rightBar := tui.RenderRightBar(theme, rightBarWidgets, rightBarWidth, rows)
1049+ if modalBackdrop {
1050+ i.rend.DrawRightBarDimmed(visibleChat, bottom, tui.DimLines(rightBar), cursorRow, cursorCol)
1051+ } else {
1052+ i.rend.DrawRightBar(visibleChat, bottom, rightBar, cursorRow, cursorCol)
1053+ }
1054+ } else {
1055+ _ = visibleChat // maintained for legacy scroll state/indicators; DrawLog owns chat viewport.
1056+ i.rend.DrawLog(chat, bottom, cursorRow, cursorCol)
1057+ }
1058+ i.mu.Lock()
1059+ if i.pendingAlert != nil && !i.busy && !i.streamOn && !i.streamFlushPending && len(i.streamPending) == 0 {
1060+ alert := *i.pendingAlert
1061+ i.pendingAlert = nil
1062+ i.emitAlertLocked(alert)
1063+ }
1064+ i.mu.Unlock()
1065+}
1066+func hasImageEscape(line string) bool {
1067+ return strings.Contains(line, "\x1b]1337;File=") || strings.Contains(line, "\x1b_G")
1068+}
1069+func snapViewportStartToImageBlock(chat []string, start int) int {
1070+ if start <= 0 || start >= len(chat) {
1071+ return start
1072+ }
1073+ if hasImageEscape(chat[start]) || !isBoxBlankLine(chat[start]) {
1074+ return start
1075+ }
1076+ for k := start - 1; k >= 0; k-- {
1077+ line := chat[k]
1078+ if hasImageEscape(line) {
1079+ return k
1080+ }
1081+ if !isBoxBlankLine(line) {
1082+ break
1083+ }
1084+ }
1085+ return start
1086+}
1087+func filterHiddenTranscriptMessages(msgs []provider.Message) []provider.Message {
1088+ if len(msgs) == 0 {
1089+ return nil
1090+ }
1091+ out := make([]provider.Message, 0, len(msgs))
1092+ for _, m := range msgs {
1093+ if isHiddenTranscriptMessage(m) {
1094+ continue
1095+ }
1096+ out = append(out, m)
1097+ }
1098+ return out
1099+}
1100+func isHiddenTranscriptMessage(m provider.Message) bool {
1101+ if m.Meta[autoCompactContinueMetaKey] == "true" || m.Meta[goalContinueMetaKey] == "true" {
1102+ return true
1103+ }
1104+ if m.Role != provider.RoleUser || len(m.Content) == 0 {
1105+ return false
1106+ }
1107+ tb, ok := m.Content[0].(provider.TextBlock)
1108+ if !ok {
1109+ return false
1110+ }
1111+ return strings.TrimSpace(tb.Text) == hiddenOpenAIImageMirrorPrefix
1112+}
1113+func clipBottomClippedImages(lines []string) []string {
1114+ if len(lines) == 0 {
1115+ return lines
1116+ }
1117+ out := append([]string(nil), lines...)
1118+ for i, line := range out {
1119+ if !hasImageEscape(line) {
1120+ continue
1121+ }
1122+ // Image blocks render as: image escape, zero or more blank
1123+ // reservation rows, then the muted "image - ..." info line,
1124+ // then one trailing blank. If the info line isn't visible in
1125+ // the current chat slice, the image would paint down into the
1126+ // fixed status bar area. Suppress that image for this frame.
1127+ //
1128+ // When the image lives inside a tool box, the reservation rows
1129+ // are wrapped in vertical box edges ("│ ... │"); those rows
1130+ // look non-blank under a naive whitespace check but are still
1131+ // reservation rows for this scan, so treat them as blank.
1132+ foundInfo := false
1133+ for j := i + 1; j < len(out); j++ {
1134+ if strings.Contains(out[j], "image - ") {
1135+ foundInfo = true
1136+ break
1137+ }
1138+ if !isBoxBlankLine(out[j]) {
1139+ break
1140+ }
1141+ }
1142+ if !foundInfo {
1143+ out[i] = ""
1144+ }
1145+ }
1146+ return out
1147+}
1148+func isBoxBlankLine(line string) bool {
1149+ stripped := stripANSIBytes(line)
1150+ stripped = strings.TrimSpace(stripped)
1151+ stripped = strings.Trim(stripped, "│")
1152+ stripped = strings.TrimSpace(stripped)
1153+ return stripped == ""
1154+}
1155+func stripANSIBytes(s string) string {
1156+ if !strings.Contains(s, "\x1b") {
1157+ return s
1158+ }
1159+ var b strings.Builder
1160+ b.Grow(len(s))
1161+ i := 0
1162+ for i < len(s) {
1163+ if s[i] == 0x1b && i+1 < len(s) && s[i+1] == '[' {
1164+ end := i + 2
1165+ for end < len(s) {
1166+ c := s[end]
1167+ end++
1168+ if c >= 0x40 && c <= 0x7e {
1169+ break
1170+ }
1171+ }
1172+ i = end
1173+ continue
1174+ }
1175+ b.WriteByte(s[i])
1176+ i++
1177+ }
1178+ return b.String()
1179+}
1180+func panelKeyName(k tui.Key) string {
1181+ switch k.Kind {
1182+ case tui.KeyUp:
1183+ return "up"
1184+ case tui.KeyDown:
1185+ return "down"
1186+ case tui.KeyLeft:
1187+ return "left"
1188+ case tui.KeyRight:
1189+ return "right"
1190+ case tui.KeyEnter:
1191+ return "enter"
1192+ case tui.KeyEsc:
1193+ return "esc"
1194+ case tui.KeyTab:
1195+ return "tab"
1196+ case tui.KeyBackspace:
1197+ return "backspace"
1198+ case tui.KeyDelete:
1199+ return "delete"
1200+ case tui.KeyHome:
1201+ return "home"
1202+ case tui.KeyEnd:
1203+ return "end"
1204+ case tui.KeyPageUp:
1205+ return "pageup"
1206+ case tui.KeyPageDown:
1207+ return "pagedown"
1208+ case tui.KeyRune:
1209+ return "rune"
1210+ default:
1211+ return "unknown"
1212+ }
1213+}
1214+func panelKeyText(k tui.Key) string {
1215+ if k.Kind == tui.KeyRune {
1216+ return string(k.Rune)
1217+ }
1218+ return ""
1219+}
1220+func truncateLine(s string, n int) string {
1221+ if n <= 0 {
1222+ return ""
1223+ }
1224+ // Collapse newlines — chips are single line.
1225+ s = strings.ReplaceAll(s, "\n", " ↩ ")
1226+ runes := []rune(s)
1227+ if len(runes) <= n {
1228+ return s
1229+ }
1230+ if n <= 3 {
1231+ return strings.Repeat(".", n)
1232+ }
1233+ return string(runes[:n-3]) + "..."
1234+}
A packages/agent/modes/interactive_resident_subagents.go +1107 -0
@@ -0,0 +1,1107 @@
1+package modes
2+
3+import (
4+ "context"
5+ "fmt"
6+ "os"
7+ "path/filepath"
8+ "strings"
9+ "time"
10+
11+ "github.com/bnema/zut/packages/agent/internal/orchestration"
12+ "github.com/bnema/zut/packages/agent/modes/telegram"
13+ "github.com/bnema/zut/packages/agent/subagents"
14+ "github.com/bnema/zut/packages/agent/tools"
15+ "github.com/bnema/zut/packages/core"
16+ "github.com/bnema/zut/packages/provider"
17+ "github.com/bnema/zut/packages/tui"
18+)
19+
20+func (i *Interactive) TrackResidentSubagent(childID string) {
21+ if i == nil {
22+ return
23+ }
24+ i.ensureCompletionTracker().TrackResident()
25+ i.registerCoordinatorWorker(childID)
26+ i.invalidate()
27+ i.requestCompletionDelivery()
28+}
29+func (i *Interactive) ReportResidentSubagent(completion subagents.ResidentCompletion) {
30+ if i == nil {
31+ return
32+ }
33+ status, errText := "completed", ""
34+ if completion.Err != nil {
35+ status, errText = "failed", completion.Err.Error()
36+ }
37+ i.ensureCompletionTracker().Report(subagents.Completion{AgentID: completion.ChildID, Status: status, Task: completion.Task, Error: errText, Summary: completion.Summary})
38+ i.reloadOpenResidentChildSession(completion.ChildID)
39+ i.invalidate()
40+ i.requestCompletionDelivery()
41+}
42+func (i *Interactive) reloadOpenResidentChildSession(childID string) {
43+ if i == nil {
44+ return
45+ }
46+ i.mu.Lock()
47+ session := i.residentChildSession
48+ i.mu.Unlock()
49+ if session == nil || session.childID != childID || !session.RequestRecentReload() {
50+ return
51+ }
52+ i.reloadResidentChildSession(session)
53+}
54+func (i *Interactive) reloadResidentChildSession(session *residentChildSession) {
55+ if i == nil || session == nil {
56+ return
57+ }
58+ go func() {
59+ err := session.ReloadRecent(200)
60+ if session.FinishLoad(err) {
61+ if session.RequestRecentReload() {
62+ i.reloadResidentChildSession(session)
63+ }
64+ }
65+ i.invalidate()
66+ }()
67+}
68+func (i *Interactive) ensureCompletionTracker() *subagents.CompletionTracker {
69+ // Tests and lightweight embedders may construct Interactive with a struct
70+ // literal instead of NewInteractive, so retain this lazy fallback.
71+ i.completionDeliveryMu.Lock()
72+ defer i.completionDeliveryMu.Unlock()
73+ if i.completionTracker == nil {
74+ i.completionTracker = subagents.NewCompletionTracker()
75+ }
76+ return i.completionTracker
77+}
78+func (i *Interactive) ensureCoordinatorLocked() *orchestration.Coordinator {
79+ if i.turnCoordinator == nil {
80+ i.turnCoordinator = orchestration.New()
81+ }
82+ return i.turnCoordinator
83+}
84+func (i *Interactive) coordinatorAcceptsUserInput() bool {
85+ if i == nil {
86+ return false
87+ }
88+ i.completionDeliveryMu.Lock()
89+ accepts := i.ensureCoordinatorLocked().AcceptsUserInput()
90+ i.completionDeliveryMu.Unlock()
91+ return accepts
92+}
93+func (i *Interactive) coordinatorHasPendingWorkers() bool {
94+ if i == nil {
95+ return false
96+ }
97+ i.completionDeliveryMu.Lock()
98+ pending := i.ensureCoordinatorLocked().HasPendingWorkers()
99+ i.completionDeliveryMu.Unlock()
100+ return pending
101+}
102+func (i *Interactive) applyCoordinator(event orchestration.Event) []orchestration.Action {
103+ if i == nil {
104+ return nil
105+ }
106+ i.completionDeliveryMu.Lock()
107+ result := i.ensureCoordinatorLocked().Apply(event)
108+ i.completionDeliveryMu.Unlock()
109+ return result.Actions
110+}
111+func (i *Interactive) registerCoordinatorWorker(workerID string) {
112+ if i == nil || workerID == "" {
113+ return
114+ }
115+ i.completionDeliveryMu.Lock()
116+ coordinator := i.ensureCoordinatorLocked()
117+ implicitWave := i.completionDeliveryHolds == 0
118+ if implicitWave {
119+ coordinator.Apply(orchestration.Event{Kind: orchestration.EventManagerStarted})
120+ }
121+ i.coordinatorWorkerSeq++
122+ registrationID := fmt.Sprintf("%s#%d", workerID, i.coordinatorWorkerSeq)
123+ if i.coordinatorWorkerIDs == nil {
124+ i.coordinatorWorkerIDs = make(map[string][]string)
125+ }
126+ i.coordinatorWorkerIDs[workerID] = append(i.coordinatorWorkerIDs[workerID], registrationID)
127+ coordinator.Apply(orchestration.Event{Kind: orchestration.EventWorkerRegistered, WorkerID: registrationID})
128+ var actions []orchestration.Action
129+ if implicitWave {
130+ actions = coordinator.Apply(orchestration.Event{Kind: orchestration.EventManagerFinished}).Actions
131+ }
132+ i.completionDeliveryMu.Unlock()
133+ i.executeCoordinatorActions(actions)
134+}
135+func (i *Interactive) cancelCoordinator() {
136+ if i == nil {
137+ return
138+ }
139+ i.completionDeliveryMu.Lock()
140+ coordinator := i.ensureCoordinatorLocked()
141+ actions := coordinator.Apply(orchestration.Event{Kind: orchestration.EventCancelled}).Actions
142+ // A later user turn starts a fresh wave. Drop resident registrations as
143+ // well as coordinator identities so late outcomes cannot match a new child.
144+ if i.completionTracker != nil {
145+ i.completionTracker.Reset()
146+ }
147+ i.turnCoordinator = orchestration.New()
148+ i.coordinatorWorkerIDs = nil
149+ i.completionDeliveryMu.Unlock()
150+ i.executeCoordinatorActions(actions)
151+}
152+func (i *Interactive) takeCoordinatorWorkerID(agentID string) string {
153+ if i == nil || agentID == "" {
154+ return ""
155+ }
156+ i.completionDeliveryMu.Lock()
157+ defer i.completionDeliveryMu.Unlock()
158+ ids := i.coordinatorWorkerIDs[agentID]
159+ if len(ids) == 0 {
160+ return ""
161+ }
162+ workerID := ids[0]
163+ if len(ids) == 1 {
164+ delete(i.coordinatorWorkerIDs, agentID)
165+ } else {
166+ i.coordinatorWorkerIDs[agentID] = ids[1:]
167+ }
168+ return workerID
169+}
170+func (i *Interactive) executeCoordinatorActions(actions []orchestration.Action) {
171+ for _, action := range actions {
172+ if action.Kind != orchestration.ActionRunManager {
173+ continue
174+ }
175+ prompt := action.Text
176+ if len(action.Completions) != 0 {
177+ instruction := "Briefly summarise the collective outcome for the user. Reference the agents by id. If any failed, suggest a follow-up; otherwise confirm completion. Do not spawn new sub-agents unless the user asks."
178+ update := subagents.FormatCompletionUpdate(action.Completions, instruction)
179+ if prompt != "" {
180+ prompt = update + "\n\nQueued user request:\n" + prompt
181+ } else {
182+ prompt = update
183+ }
184+ }
185+ if prompt != "" {
186+ i.submitOrQueue(prompt, nil, false)
187+ } else if action.Reason == orchestration.WakeGoal {
188+ parent := i.runCtx
189+ if parent == nil {
190+ parent = context.Background()
191+ }
192+ i.requestGoalContinuationIfIdle(parent)
193+ }
194+ }
195+}
196+func (i *Interactive) requestCompletionDelivery() {
197+ if i == nil {
198+ return
199+ }
200+ i.ensureCompletionTracker()
201+ i.completionDeliveryMu.Lock()
202+ i.completionDeliveryRequest = true
203+ if i.completionDeliveryRunning || i.completionDeliveryHolds != 0 {
204+ i.completionDeliveryMu.Unlock()
205+ return
206+ }
207+ i.completionDeliveryRunning = true
208+ i.completionDeliveryMu.Unlock()
209+
210+ go i.deliverCompletionUpdates()
211+}
212+func (i *Interactive) beginCompletionDeliveryHold() func() {
213+ if i == nil {
214+ return func() {}
215+ }
216+ i.completionDeliveryMu.Lock()
217+ i.ensureCoordinatorLocked().Apply(orchestration.Event{Kind: orchestration.EventManagerStarted})
218+ i.completionDeliveryHolds++
219+ i.completionDeliveryMu.Unlock()
220+ return i.releaseCompletionDeliveryHold
221+}
222+func (i *Interactive) releaseCompletionDeliveryHold() {
223+ if i == nil {
224+ return
225+ }
226+ start := false
227+ var actions []orchestration.Action
228+ i.completionDeliveryMu.Lock()
229+ if i.completionDeliveryHolds > 0 {
230+ i.completionDeliveryHolds--
231+ }
232+ if i.completionDeliveryHolds == 0 {
233+ // The coordinator must observe the goal before sealing the wave, so a
234+ // pending worker wins the wake decision instead of a direct continuation.
235+ _, goalActive := i.goalContinuationMessage()
236+ coordinator := i.ensureCoordinatorLocked()
237+ coordinator.Apply(orchestration.Event{Kind: orchestration.EventGoalChanged, GoalActive: goalActive})
238+ actions = coordinator.Apply(orchestration.Event{Kind: orchestration.EventManagerFinished}).Actions
239+ }
240+ if i.completionDeliveryHolds == 0 && i.completionDeliveryRequest && !i.completionDeliveryRunning {
241+ i.completionDeliveryRunning = true
242+ start = true
243+ }
244+ i.completionDeliveryMu.Unlock()
245+ i.executeCoordinatorActions(actions)
246+ if start {
247+ go i.deliverCompletionUpdates()
248+ }
249+}
250+func (i *Interactive) completionWaitContext() context.Context {
251+ i.mu.Lock()
252+ ctx := i.runCtx
253+ i.mu.Unlock()
254+ if ctx == nil {
255+ return context.Background()
256+ }
257+ return ctx
258+}
259+func (i *Interactive) deliverCompletionUpdates() {
260+ for {
261+ i.completionDeliveryMu.Lock()
262+ i.completionDeliveryRequest = false
263+ tracker := i.completionTracker
264+ i.completionDeliveryMu.Unlock()
265+
266+ batch, err := tracker.WaitIdle(i.completionWaitContext())
267+ if err == nil && len(batch) != 0 {
268+ var actions []orchestration.Action
269+ for _, completion := range batch {
270+ workerID := i.takeCoordinatorWorkerID(completion.AgentID)
271+ if workerID == "" {
272+ continue
273+ }
274+ actions = append(actions, i.applyCoordinator(orchestration.Event{
275+ Kind: orchestration.EventWorkerFinished,
276+ WorkerID: workerID,
277+ Completion: completion,
278+ })...)
279+ }
280+ i.executeCoordinatorActions(actions)
281+ }
282+
283+ i.completionDeliveryMu.Lock()
284+ if i.completionDeliveryRequest {
285+ i.completionDeliveryMu.Unlock()
286+ continue
287+ }
288+ i.completionDeliveryRunning = false
289+ i.completionDeliveryMu.Unlock()
290+ return
291+ }
292+}
293+func autoSubagentsAddenda(cfg InteractiveConfig, orchestrating bool) []string {
294+ addenda := make([]string, 0, 2)
295+ if cfg.ResidentManager != nil && autoSubagentsToolAllowedConfig(cfg) {
296+ if addendum := strings.TrimSpace(cfg.SubagentsSystemAddendum); addendum != "" {
297+ addenda = append(addenda, addendum)
298+ }
299+ }
300+ if !orchestrating {
301+ if cfg.ResidentManager != nil && autoSubagentsAnyToolAllowedConfig(cfg) {
302+ if addendum := strings.TrimSpace(cfg.OnDemandSubagentsSystemAddendum); addendum != "" {
303+ addenda = append(addenda, addendum)
304+ }
305+ }
306+ return addenda
307+ }
308+
309+ if addendum := strings.TrimSpace(cfg.AutoSubagentsSystemAddendum); addendum != "" {
310+ addenda = append(addenda, addendum)
311+ }
312+ return addenda
313+}
314+func removeLastAutoSubagentsAddendum(system, addendum string) (string, bool) {
315+ idx := strings.LastIndex(system, addendum)
316+ if idx < 0 {
317+ return system, false
318+ }
319+ return system[:idx] + system[idx+len(addendum):], true
320+}
321+func (i *Interactive) applyAutoSubagentsSystemPrompt(orchestrating bool) {
322+ // i.mu is sufficient here: this path updates the existing agent's system
323+ // prompt and managed addenda in place; it does not replace the agent or
324+ // its tool registry, so agentMu is not required.
325+ i.mu.Lock()
326+ defer i.mu.Unlock()
327+ if i.agent == nil {
328+ return
329+ }
330+
331+ sys, _ := i.agent.PromptConfig()
332+ changed := false
333+ if guidance := strings.TrimSpace(i.cfg.WritingGuidance); guidance != "" {
334+ var removed bool
335+ sys, removed = removeLastAutoSubagentsAddendum(sys, guidance)
336+ changed = changed || removed
337+ }
338+ for idx := len(i.managedAutoSubagentsAddenda) - 1; idx >= 0; idx-- {
339+ var removed bool
340+ sys, removed = removeLastAutoSubagentsAddendum(sys, i.managedAutoSubagentsAddenda[idx])
341+ changed = changed || removed
342+ }
343+ i.managedAutoSubagentsAddenda = nil
344+
345+ addenda := autoSubagentsAddenda(i.cfg, orchestrating)
346+ if changed {
347+ sys = strings.TrimRight(sys, "\n")
348+ }
349+ for _, addendum := range addenda {
350+ if sys != "" {
351+ sys += "\n\n"
352+ }
353+ sys += addendum
354+ i.managedAutoSubagentsAddenda = append(i.managedAutoSubagentsAddenda, addendum)
355+ changed = true
356+ }
357+ if guidance := strings.TrimSpace(i.cfg.WritingGuidance); guidance != "" {
358+ if sys != "" {
359+ sys += "\n\n"
360+ }
361+ sys += guidance
362+ changed = true
363+ }
364+ if !changed {
365+ return
366+ }
367+ i.agent.SetSystemPrompt(sys)
368+}
369+func (i *Interactive) autoSubagentsAvailable() bool {
370+ return i.cfg.ResidentManager != nil && autoSubagentsAnyToolAllowedConfig(i.cfg)
371+}
372+func (i *Interactive) autoSubagentsUnavailableHint() string {
373+ var hints []string
374+ if i.cfg.ResidentManager == nil {
375+ hints = append(hints, "resident subagent manager is unavailable in this mode")
376+ }
377+ if !autoSubagentsAnyToolAllowedConfig(i.cfg) {
378+ hints = append(hints, "launch-time tool policy excludes subagent manager tools")
379+ }
380+ return strings.Join(hints, "; ")
381+}
382+func autoSubagentsToolAllowedConfig(cfg InteractiveConfig) bool {
383+ return cfg.AutoSubagentsToolAllowed == nil || *cfg.AutoSubagentsToolAllowed
384+}
385+func autoSubagentsStatusToolAllowedConfig(cfg InteractiveConfig) bool {
386+ if cfg.AutoSubagentsStatusToolAllowed != nil {
387+ return *cfg.AutoSubagentsStatusToolAllowed
388+ }
389+ // Preserve the old single-boolean embedding contract: when callers have
390+ // not supplied the new field, status follows the existing delegation gate.
391+ return autoSubagentsToolAllowedConfig(cfg)
392+}
393+func autoSubagentsStopToolAllowedConfig(cfg InteractiveConfig) bool {
394+ if cfg.AutoSubagentsStopToolAllowed != nil {
395+ return *cfg.AutoSubagentsStopToolAllowed
396+ }
397+ return autoSubagentsToolAllowedConfig(cfg)
398+}
399+func autoSubagentsResumeToolAllowedConfig(cfg InteractiveConfig) bool {
400+ if cfg.AutoSubagentsResumeToolAllowed != nil {
401+ return *cfg.AutoSubagentsResumeToolAllowed
402+ }
403+ return autoSubagentsToolAllowedConfig(cfg)
404+}
405+func autoSubagentsAnyToolAllowedConfig(cfg InteractiveConfig) bool {
406+ return autoSubagentsToolAllowedConfig(cfg) ||
407+ autoSubagentsStatusToolAllowedConfig(cfg) ||
408+ autoSubagentsStopToolAllowedConfig(cfg) ||
409+ autoSubagentsResumeToolAllowedConfig(cfg)
410+}
411+func (i *Interactive) autoSubagentsToolAllowed() bool {
412+ return autoSubagentsToolAllowedConfig(i.cfg)
413+}
414+func (i *Interactive) autoSubagentsStatusToolAllowed() bool {
415+ return autoSubagentsStatusToolAllowedConfig(i.cfg)
416+}
417+func (i *Interactive) autoSubagentsStopToolAllowed() bool {
418+ return autoSubagentsStopToolAllowedConfig(i.cfg)
419+}
420+func (i *Interactive) autoSubagentsResumeToolAllowed() bool {
421+ return autoSubagentsResumeToolAllowedConfig(i.cfg)
422+}
423+func (i *Interactive) autoSubagentsEnabledLocked() bool {
424+ return i.cfg.AutoSubagentsEnabled != nil && *i.cfg.AutoSubagentsEnabled && i.autoSubagentsAvailable()
425+}
426+func (i *Interactive) applyAutoSubagentsTool() {
427+ i.agentMu.Lock()
428+ defer i.agentMu.Unlock()
429+ i.mu.Lock()
430+ defer i.mu.Unlock()
431+ if i.agent == nil {
432+ return
433+ }
434+ current := i.agent.ToolsSnapshot()
435+ next := core.Registry{}
436+ for name, t := range current {
437+ if name == "subagent_spawn" || name == "subagent_status" || name == "subagent_stop" || name == "subagent_resume" {
438+ continue
439+ }
440+ next[name] = t
441+ }
442+ if i.autoSubagentsAvailable() {
443+ if i.autoSubagentsToolAllowed() {
444+ canonical := &tools.SubagentSpawnTool{
445+ ResidentManager: i.cfg.ResidentManager,
446+ BuildResidentSpec: i.cfg.BuildResidentSpec,
447+ Enabled: func() bool { return true },
448+ DefaultModel: func() string { return i.cfg.Model },
449+ DefaultProvider: func() string { return i.cfg.Provider },
450+ DefaultReasoning: func() string { return i.cfg.Reasoning },
451+ ResolveSubagent: i.cfg.ResolveSubagent,
452+ }
453+ next[canonical.Name()] = canonical
454+ }
455+ if i.autoSubagentsStatusToolAllowed() {
456+ statusTool := &tools.SubagentStatusTool{
457+ ResidentManager: i.cfg.ResidentManager,
458+ Enabled: func() bool { return true },
459+ }
460+ next[statusTool.Name()] = statusTool
461+ }
462+ if i.autoSubagentsStopToolAllowed() {
463+ stopTool := &tools.SubagentStopTool{
464+ ResidentManager: i.cfg.ResidentManager,
465+ Enabled: func() bool { return true },
466+ }
467+ next[stopTool.Name()] = stopTool
468+ }
469+ if i.autoSubagentsResumeToolAllowed() {
470+ resumeTool := &tools.SubagentResumeTool{
471+ ResidentManager: i.cfg.ResidentManager,
472+ Enabled: func() bool { return true },
473+ }
474+ next[resumeTool.Name()] = resumeTool
475+ }
476+ }
477+ i.agent.SetTools(next)
478+}
479+func (i *Interactive) applyTelegramTools(active bool) {
480+ if active {
481+ // The child ceiling must be in place even when no live main agent exists,
482+ // and before Bridge.Start can accept an external prompt.
483+ i.setWebSearchAvailable(false)
484+ }
485+ i.agentMu.Lock()
486+ defer i.agentMu.Unlock()
487+ i.mu.Lock()
488+ defer i.mu.Unlock()
489+ if i.agent == nil {
490+ return
491+ }
492+ current := i.agent.ToolsSnapshot()
493+ next := core.Registry{}
494+ for name, t := range current {
495+ if name == "telegram_send_image" || name == "telegram_send_file" {
496+ continue
497+ }
498+ if active && name == "web_search" {
499+ // External Telegram prompts have no per-request confirmation
500+ // surface, so V1 does not expose native web search while paired.
501+ continue
502+ }
503+ next[name] = t
504+ }
505+ if active {
506+ sender := telegramSenderAdapter{bridge: i.telegramBridge}
507+ next["telegram_send_image"] = &tools.TelegramSendImageTool{
508+ CWD: i.cfg.CWD, Sandbox: i.cfg.Sandbox, Sender: sender,
509+ }
510+ next["telegram_send_file"] = &tools.TelegramSendFileTool{
511+ CWD: i.cfg.CWD, Sandbox: i.cfg.Sandbox, Sender: sender,
512+ }
513+ }
514+ i.agent.SetTools(next)
515+ _, webSearchAvailable := next["web_search"]
516+ i.setWebSearchAvailable(webSearchAvailable && !active)
517+}
518+func (i *Interactive) telegramStatus() {
519+ var msg string
520+ if i.telegramBridge != nil && i.telegramBridge.Active() {
521+ s := i.telegramBridge.State()
522+ msg = "telegram: connected (tui bridge)"
523+ if s.Username != "" {
524+ msg += " as @" + s.Username
525+ }
526+ if s.PairedID != 0 {
527+ msg += fmt.Sprintf(" - paired with user %d", s.PairedID)
528+ } else {
529+ msg += " - awaiting pairing"
530+ }
531+ } else if pid, alive, _ := telegram.IsRunning(i.cfg.ZutHome); alive && pid > 0 {
532+ msg = fmt.Sprintf("telegram: background daemon running (pid %d) - /telegram connect won't work until you stop it", pid)
533+ } else {
534+ cfg, _ := telegram.LoadConfig(i.cfg.ZutHome)
535+ if cfg.BotToken == "" {
536+ msg = "telegram: not configured. run `zut telegram-bot setup` first."
537+ } else {
538+ msg = "telegram: disconnected"
539+ if cfg.BotUsername != "" {
540+ msg += " (@" + cfg.BotUsername + " ready to connect)"
541+ }
542+ }
543+ }
544+ i.mu.Lock()
545+ i.statusOK = msg
546+ i.statusErr = ""
547+ i.mu.Unlock()
548+ i.invalidate()
549+}
550+func (h *telegramHost) SubmitOrQueue(prompt string, images []provider.ImageBlock) {
551+ h.iv.SubmitOrQueue(prompt, images)
552+}
553+func (h *telegramHost) CancelTurn() { h.iv.CancelTurn() }
554+func (h *telegramHost) Status() string {
555+ h.iv.mu.Lock()
556+ providerName := h.iv.cfg.Provider
557+ model := h.iv.cfg.Model
558+ cwd := h.iv.cfg.CWD
559+ usage := h.iv.cumUsage
560+ subscription := h.iv.cfg.AuthMethod == "oauth"
561+ ctxUsed := h.iv.lastCtxInput
562+ busy := h.iv.busy
563+ queued := len(h.iv.queued)
564+ h.iv.mu.Unlock()
565+
566+ ctxMax := 0
567+ if m, err := provider.FindModel(providerName, model); err == nil {
568+ ctxMax = m.ContextWindow
569+ }
570+ return telegram.FormatStatus(telegram.StatusSnapshot{
571+ Provider: providerName,
572+ Model: model,
573+ CWD: cwd,
574+ Usage: usage,
575+ Subscription: subscription,
576+ ContextUsed: ctxUsed,
577+ ContextMax: ctxMax,
578+ Busy: busy,
579+ Queued: queued,
580+ })
581+}
582+func (h *telegramHost) Notify(level, message string) {
583+ h.iv.mu.Lock()
584+ switch level {
585+ case "error", "warn":
586+ h.iv.statusErr = message
587+ h.iv.statusOK = ""
588+ default:
589+ h.iv.statusOK = message
590+ h.iv.statusErr = ""
591+ }
592+ h.iv.mu.Unlock()
593+ h.iv.invalidate()
594+}
595+func (i *Interactive) openSessionOpsDialog() {
596+ items := []sessionOpsItem{
597+ {label: "export", action: "export", hint: "write the current session to a .zutsession file"},
598+ {label: "import", action: "import", hint: "load a .zutsession file into this directory"},
599+ {label: "fork", action: "fork", hint: "branch from a past user message into a new session"},
600+ {label: "tree", action: "tree", hint: "switch between branches in this directory"},
601+ }
602+ i.sessionOpsDialog.Open(items)
603+ i.invalidate()
604+}
605+func (i *Interactive) doSessionOp(action, arg string) {
606+ switch action {
607+ case "export":
608+ i.doSessionExport(arg)
609+ case "import":
610+ i.doSessionImport(arg)
611+ case "fork":
612+ i.doSessionFork()
613+ case "tree":
614+ i.doSessionTree()
615+ default:
616+ i.mu.Lock()
617+ i.statusErr = "unknown /session action: " + action + " (use export, import, fork, or tree)"
618+ i.mu.Unlock()
619+ i.invalidate()
620+ }
621+}
622+func (i *Interactive) doSessionExport(dst string) {
623+ src := i.currentSessionPath()
624+ if src == "" {
625+ i.mu.Lock()
626+ i.statusErr = "export: no session is active (running with --no-session?)"
627+ i.mu.Unlock()
628+ i.invalidate()
629+ return
630+ }
631+ // Persist any in-memory agent messages to the session file so
632+ // the export carries the full conversation. Without this, the
633+ // default lazy-flush-at-exit strategy leaves most of a running
634+ // session unwritten and the export ends up with only the meta.
635+ if i.cfg.FlushSession != nil {
636+ i.cfg.FlushSession()
637+ }
638+ dst = unquotePath(dst)
639+ if dst == "" {
640+ dst = defaultExportDir()
641+ } else {
642+ dst = expandTilde(dst)
643+ }
644+ out, err := core.ExportSession(src, dst)
645+ if err != nil {
646+ i.mu.Lock()
647+ i.statusErr = "export: " + err.Error()
648+ i.mu.Unlock()
649+ i.invalidate()
650+ return
651+ }
652+ i.mu.Lock()
653+ i.statusOK = "exported session to " + friendlyPath(out)
654+ i.statusErr = ""
655+ i.mu.Unlock()
656+ i.invalidate()
657+}
658+func (i *Interactive) doSessionImport(src string) {
659+ src = unquotePath(src)
660+ if src == "" {
661+ i.mu.Lock()
662+ i.statusErr = "import: pass a path — e.g. /session import ~/Downloads/work.zutsession"
663+ i.mu.Unlock()
664+ i.invalidate()
665+ return
666+ }
667+ src = expandTilde(src)
668+ if _, err := os.Stat(src); err != nil {
669+ i.mu.Lock()
670+ i.statusErr = "import: " + err.Error()
671+ i.mu.Unlock()
672+ i.invalidate()
673+ return
674+ }
675+ newPath, err := core.ImportSession(src, i.sessionsRoot(), i.cfg.CWD, i.cfg.Version)
676+ if err != nil {
677+ i.mu.Lock()
678+ i.statusErr = "import: " + err.Error()
679+ i.mu.Unlock()
680+ i.invalidate()
681+ return
682+ }
683+ if i.cfg.LoadSession == nil {
684+ i.mu.Lock()
685+ i.statusOK = "imported session at " + friendlyPath(newPath) + " (run /sessions to resume it)"
686+ i.statusErr = ""
687+ i.mu.Unlock()
688+ i.invalidate()
689+ return
690+ }
691+ i.markSessionTitleSwitching()
692+ if err := i.cfg.LoadSession(newPath); err != nil {
693+ i.restoreFailedSessionTitle()
694+ i.mu.Lock()
695+ i.statusErr = "import: load failed: " + err.Error()
696+ i.mu.Unlock()
697+ i.invalidate()
698+ return
699+ }
700+ i.restoreLoadedSessionTitle()
701+ state := i.restoreCurrentCompactHandoff()
702+ i.mu.Lock()
703+ i.statusOK = "imported and switched to session " + friendlyPath(newPath)
704+ i.statusErr = ""
705+ if i.agent != nil {
706+ i.view.Messages = filterHiddenTranscriptMessages(i.agent.Messages())
707+ i.cumUsage = i.agent.Cost()
708+ last := i.agent.LastTurnUsage()
709+ i.lastCtxInput = last.InputTokens + last.CacheReadTokens + last.CacheWriteTokens
710+ if len(i.view.Messages) > initialResumeTailLimit {
711+ i.view.TailLimit = initialResumeTailLimit
712+ } else {
713+ i.view.TailLimit = 0
714+ }
715+ i.view.InvalidateRenderCache()
716+ }
717+ i.mu.Unlock()
718+ i.invalidate()
719+ if state.reason != compactContinuationNone {
720+ i.startRestoredCompactHandoff(i.runCtx)
721+ }
722+}
723+func defaultExportDir() string {
724+ home, err := os.UserHomeDir()
725+ if err != nil || home == "" {
726+ return os.TempDir()
727+ }
728+ downloads := filepath.Join(home, "Downloads")
729+ if fi, err := os.Stat(downloads); err == nil && fi.IsDir() {
730+ return downloads
731+ }
732+ return home
733+}
734+func expandTilde(p string) string {
735+ if p == "" || p[0] != '~' {
736+ return p
737+ }
738+ home, err := os.UserHomeDir()
739+ if err != nil || home == "" {
740+ return p
741+ }
742+ if len(p) == 1 {
743+ return home
744+ }
745+ if p[1] == '/' || p[1] == filepath.Separator {
746+ return filepath.Join(home, p[2:])
747+ }
748+ return p
749+}
750+func unquotePath(p string) string {
751+ p = strings.TrimSpace(p)
752+ if len(p) >= 2 {
753+ first := p[0]
754+ last := p[len(p)-1]
755+ if (first == '\'' && last == '\'') || (first == '"' && last == '"') {
756+ p = p[1 : len(p)-1]
757+ }
758+ }
759+ return p
760+}
761+func friendlyPath(p string) string {
762+ home, err := os.UserHomeDir()
763+ if err != nil || home == "" {
764+ return p
765+ }
766+ if strings.HasPrefix(p, home+string(filepath.Separator)) {
767+ return "~" + p[len(home):]
768+ }
769+ return p
770+}
771+func (i *Interactive) doSessionFork() {
772+ path := i.currentSessionPath()
773+ if path == "" {
774+ i.mu.Lock()
775+ i.statusErr = "fork: no session is active (running with --no-session?)"
776+ i.mu.Unlock()
777+ i.invalidate()
778+ return
779+ }
780+ i.mu.Lock()
781+ busy := i.busy || i.shellRunning || i.compacting || i.autoCompacting || i.sessionLoading
782+ queued := len(i.queued) != 0
783+ ag := i.agent
784+ i.mu.Unlock()
785+ if busy || queued || ag == nil || ag.QueuedMessageCount() != 0 {
786+ i.mu.Lock()
787+ i.statusErr = "fork: wait until the current turn and queued messages finish"
788+ i.mu.Unlock()
789+ i.invalidate()
790+ return
791+ }
792+ if i.cfg.FlushSession != nil {
793+ i.cfg.FlushSession()
794+ }
795+ snapshot, err := core.ReadSessionSnapshot(path)
796+ if err != nil {
797+ i.mu.Lock()
798+ i.statusErr = "fork: " + err.Error()
799+ i.mu.Unlock()
800+ i.invalidate()
801+ return
802+ }
803+ msgs := snapshot.Messages
804+ if len(msgs) == 0 {
805+ i.mu.Lock()
806+ i.statusErr = "fork: transcript is empty; nothing to fork from"
807+ i.mu.Unlock()
808+ i.invalidate()
809+ return
810+ }
811+ i.pendingFork = true
812+ i.jumpDialog.Open(msgs, "")
813+ i.invalidate()
814+}
815+func (i *Interactive) doSessionTree() {
816+ i.openSessionTree()
817+}
818+func (i *Interactive) applySessionTreeMessageSelection(src string, msgIdx, turnNo int, role provider.Role, prompt string) {
819+ target := sessionTreeTarget{
820+ SourcePath: src,
821+ EffectiveIndex: msgIdx,
822+ SelectionBoundary: msgIdx + 1,
823+ Role: role,
824+ UserDraft: prompt,
825+ Boundary: sessionTreeMessageBoundary,
826+ }
827+ if role == provider.RoleUser {
828+ target.SelectionBoundary = msgIdx
829+ }
830+ i.applySessionTreeTarget(target, turnNo)
831+}
832+func (i *Interactive) applySessionTreeTarget(target sessionTreeTarget, turnNo int) {
833+ // Selection is a state-changing operation too. Use the selection gate so
834+ // a queued/busy turn cannot race the hidden branch or accidentally submit
835+ // a provider turn while the session is being swapped. The open gate cannot
836+ // be reused here because the tree dialog itself owns the keyboard.
837+ if !i.canCommitSessionTreeSelection() {
838+ i.setSessionTreeError("tree: session branching is not available in this build")
839+ return
840+ }
841+ src := target.SourcePath
842+ if src == "" {
843+ src = i.currentSessionPath()
844+ }
845+ if src == "" {
846+ i.setSessionTreeError("tree: no session is active")
847+ return
848+ }
849+
850+ // Read and validate the selected row before flushing or creating anything.
851+ // This gives tool-call/result pairs an atomic boundary and prevents a
852+ // malformed selection from changing the active session. Historical rows
853+ // come from a pre-compaction segment; current rows use the effective
854+ // snapshot that the running agent resumes from.
855+ var msgs []provider.Message
856+ var historicalSegment core.SessionHistorySegment
857+ if target.Historical {
858+ history, err := core.ReadSessionHistory(src)
859+ if err != nil {
860+ i.setSessionTreeError("tree: read selection: " + err.Error())
861+ return
862+ }
863+ if target.HistorySegment < 0 || target.HistorySegment >= len(history.Segments) {
864+ i.setSessionTreeError("tree: selected history segment is unavailable")
865+ return
866+ }
867+ historicalSegment = history.Segments[target.HistorySegment]
868+ msgs = historicalSegment.Messages
869+ } else {
870+ sess, current, err := core.OpenSession(src)
871+ if err != nil {
872+ i.setSessionTreeError("tree: read selection: " + err.Error())
873+ return
874+ }
875+ if err := sess.Close(); err != nil {
876+ i.setSessionTreeError("tree: close selection: " + err.Error())
877+ return
878+ }
879+ msgs = current
880+ }
881+ selection, err := sessionTreeSelection(msgs, target)
882+ if err != nil {
883+ i.setSessionTreeError("tree: " + err.Error())
884+ return
885+ }
886+ if i.cfg.FlushSession != nil {
887+ i.cfg.FlushSession()
888+ }
889+ var newPath string
890+ if target.Historical {
891+ newPath, err = core.BranchSessionHiddenFromHistory(src, i.sessionsRoot(), i.cfg.CWD, i.cfg.Version, historicalSegment, selection.upTo)
892+ } else {
893+ newPath, err = core.BranchSessionHidden(src, i.sessionsRoot(), i.cfg.CWD, i.cfg.Version, selection.upTo)
894+ }
895+ if err != nil {
896+ i.setSessionTreeError("tree: " + err.Error())
897+ return
898+ }
899+ i.markSessionTitleSwitching()
900+ if err := i.cfg.LoadSession(newPath); err != nil {
901+ i.restoreFailedSessionTitle()
902+ i.setSessionTreeError("tree: checkout failed: " + err.Error())
903+ return
904+ }
905+ state := i.restoreCurrentCompactHandoff()
906+
907+ // LoadSession is intentionally the old func(string) error callback: the
908+ // CLI and embedders already own agent/session swapping. Refresh the view
909+ // from the swapped agent here instead of requiring a new callback result.
910+ i.resetSessionTitleForFreshBranch()
911+ i.scrollToBottom()
912+ i.mu.Lock()
913+ i.lastCtxInput = 0
914+ if i.agent != nil {
915+ i.view.Messages = filterHiddenTranscriptMessages(i.agent.Messages())
916+ i.cumUsage = i.agent.Cost()
917+ last := i.agent.LastTurnUsage()
918+ i.lastCtxInput = last.InputTokens + last.CacheReadTokens + last.CacheWriteTokens
919+ i.view.InvalidateRenderCache()
920+ }
921+ i.toolCalls = map[string]*tui.ToolCallView{}
922+ i.toolOrder = nil
923+ i.toolGate = map[string]int{}
924+ i.extNotes = nil
925+ if selection.restoreDraft {
926+ i.ed.SetValue(selection.draftText)
927+ i.clipboardImages = selection.images
928+ i.statusOK = fmt.Sprintf("checked out before turn %d; edit and send to branch", turnNo)
929+ } else {
930+ i.ed.Clear()
931+ i.clipboardImages = nil
932+ i.statusOK = fmt.Sprintf("checked out turn %d into a new branch", turnNo)
933+ }
934+ i.statusErr = ""
935+ i.mu.Unlock()
936+ i.scrollToBottom()
937+ // Selection does not submit, start, or queue the restored user draft; only
938+ // a later explicit Enter does that. An inherited compact handoff may resume
939+ // its hidden continuation below when this branch has the complete effective
940+ // transcript, without submitting the restored draft.
941+ i.invalidate()
942+ if state.reason != compactContinuationNone {
943+ i.startRestoredCompactHandoff(i.runCtx)
944+ }
945+}
946+func (i *Interactive) applyForkSelection(msgIdx int) {
947+ i.pendingFork = false
948+ src := i.currentSessionPath()
949+ if src == "" {
950+ i.mu.Lock()
951+ i.statusErr = "fork: no session is active"
952+ i.mu.Unlock()
953+ i.invalidate()
954+ return
955+ }
956+ if i.cfg.FlushSession != nil {
957+ i.cfg.FlushSession()
958+ }
959+ // msgIdx is 0-indexed message position; copy msgIdx+1 rows so
960+ // the selected user message is included.
961+ upTo := msgIdx + 1
962+ newPath, err := core.BranchSession(src, i.sessionsRoot(), i.cfg.CWD, i.cfg.Version, upTo)
963+ if err != nil {
964+ i.mu.Lock()
965+ i.statusErr = "fork: " + err.Error()
966+ i.mu.Unlock()
967+ i.invalidate()
968+ return
969+ }
970+ if i.cfg.LoadSession == nil {
971+ i.mu.Lock()
972+ i.statusOK = "forked at message " + formatInt(upTo) + " (run /sessions to resume)"
973+ i.statusErr = ""
974+ i.mu.Unlock()
975+ i.invalidate()
976+ return
977+ }
978+ i.markSessionTitleSwitching()
979+ if err := i.cfg.LoadSession(newPath); err != nil {
980+ i.restoreFailedSessionTitle()
981+ i.mu.Lock()
982+ i.statusErr = "fork: switch failed: " + err.Error()
983+ i.mu.Unlock()
984+ i.invalidate()
985+ return
986+ }
987+ state := i.restoreCurrentCompactHandoff()
988+ i.resetSessionTitleForFreshBranch()
989+ i.mu.Lock()
990+ i.statusOK = "forked and switched to new branch at " + friendlyPath(newPath)
991+ i.statusErr = ""
992+ i.mu.Unlock()
993+ i.invalidate()
994+ if state.reason != compactContinuationNone {
995+ i.startRestoredCompactHandoff(i.runCtx)
996+ }
997+}
998+func formatInt(n int) string {
999+ return fmt.Sprintf("%d", n)
1000+}
1001+func assistantText(m provider.Message) string {
1002+ var sb strings.Builder
1003+ for _, c := range m.Content {
1004+ if tb, ok := c.(provider.TextBlock); ok {
1005+ if sb.Len() > 0 {
1006+ sb.WriteByte('\n')
1007+ }
1008+ sb.WriteString(tb.Text)
1009+ }
1010+ }
1011+ return sb.String()
1012+}
1013+func (i *Interactive) resetTranscriptRenderLocked() {
1014+ i.view.InvalidateRenderCache()
1015+ i.chatCacheValid = false
1016+ i.prevChatLen = 0
1017+ i.prevChatCols = 0
1018+ i.prevChatRows = 0
1019+ i.prevScrollOffset = 0
1020+ i.requestRendererInvalidate()
1021+}
1022+func (i *Interactive) resetStreamingStateLocked() {
1023+ i.streaming.Reset()
1024+ i.streamPending = i.streamPending[:0]
1025+ i.streamFlushPending = false
1026+ i.streamOn = false
1027+ i.openAllToolGatesLocked()
1028+}
1029+func (i *Interactive) openAllToolGatesLocked() {
1030+ for id := range i.toolGate {
1031+ i.toolGate[id] = 0
1032+ }
1033+}
1034+func (i *Interactive) gateToolLocked(id string) {
1035+ if _, ok := i.toolGate[id]; ok {
1036+ return
1037+ }
1038+ if !i.streamOn {
1039+ i.toolGate[id] = 0
1040+ return
1041+ }
1042+ i.toolGate[id] = i.streaming.Len() + len(i.streamPending)
1043+}
1044+func (i *Interactive) toolGateOpenLocked(id string) bool {
1045+ gate, ok := i.toolGate[id]
1046+ if !ok || gate == 0 {
1047+ return true
1048+ }
1049+ return i.streaming.Len() >= gate
1050+}
1051+func (i *Interactive) assistantMessageSideEffects(m provider.Message) {
1052+ if i.cfg.OnAssistant != nil {
1053+ i.cfg.OnAssistant(m)
1054+ }
1055+ if i.telegramBridge != nil && i.telegramBridge.Active() {
1056+ var sb strings.Builder
1057+ for _, c := range m.Content {
1058+ if tb, ok := c.(provider.TextBlock); ok {
1059+ if sb.Len() > 0 {
1060+ sb.WriteString("\n")
1061+ }
1062+ sb.WriteString(tb.Text)
1063+ }
1064+ }
1065+ if text := sb.String(); strings.TrimSpace(text) != "" {
1066+ go i.telegramBridge.OnAssistantText(text)
1067+ }
1068+ }
1069+}
1070+func (i *Interactive) runStreamPacer(ctx context.Context) {
1071+ t := time.NewTicker(paintPaceInterval)
1072+ defer t.Stop()
1073+ for {
1074+ select {
1075+ case <-ctx.Done():
1076+ return
1077+ case <-t.C:
1078+ i.mu.Lock()
1079+ if len(i.streamPending) == 0 {
1080+ // EvAssistantMessage already fired but the pacer
1081+ // was still draining a tick ago. Everything is now
1082+ // painted; clear the streaming flags so the next
1083+ // redraw shows the finalised transcript message
1084+ // and hides the streaming overlay.
1085+ if i.streamFlushPending {
1086+ i.streamFlushPending = false
1087+ i.streaming.Reset()
1088+ i.streamOn = false
1089+ i.openAllToolGatesLocked()
1090+ i.mu.Unlock()
1091+ i.invalidate()
1092+ continue
1093+ }
1094+ i.mu.Unlock()
1095+ continue
1096+ }
1097+ n := paintPaceRate
1098+ if n > len(i.streamPending) {
1099+ n = len(i.streamPending)
1100+ }
1101+ i.streaming.WriteString(string(i.streamPending[:n]))
1102+ i.streamPending = i.streamPending[n:]
1103+ i.mu.Unlock()
1104+ i.invalidate()
1105+ }
1106+ }
1107+}
A packages/agent/modes/interactive_settings.go +1237 -0
@@ -0,0 +1,1237 @@
1+package modes
2+
3+import (
4+ "context"
5+ "fmt"
6+ "runtime"
7+ "strconv"
8+ "strings"
9+
10+ "github.com/bnema/zut/packages/provider"
11+ "github.com/bnema/zut/packages/tui"
12+)
13+
14+func effectiveImageProtocol(override *bool) tui.ImageProtocol {
15+ detected := tui.DetectImageProtocol()
16+ if override == nil {
17+ return detected
18+ }
19+ if !*override {
20+ return tui.ImageProtocolNone
21+ }
22+ return detected
23+}
24+func imageProtocolName(p tui.ImageProtocol) string {
25+ switch p {
26+ case tui.ImageProtocolKitty:
27+ return "kitty/ghostty"
28+ case tui.ImageProtocolITerm2:
29+ return "iTerm2"
30+ default:
31+ return "none"
32+ }
33+}
34+func onOff(v bool) string {
35+ if v {
36+ return "enabled"
37+ }
38+ return "disabled"
39+}
40+func (i *Interactive) applyInputCursorColor() {
41+ if i == nil || i.cfg.Terminal == nil {
42+ return
43+ }
44+ color := tui.Color256(15)
45+ if i.cursorDimmed {
46+ color = i.cfg.Theme.DimColor(color, modalBackdropDimPercent)
47+ }
48+ _, _ = i.cfg.Terminal.Write([]byte(tui.CursorColor(color) + tui.CursorShapeBlock()))
49+}
50+func (i *Interactive) setInputCursorDimmed(dimmed bool) {
51+ if i == nil || i.cursorDimmed == dimmed {
52+ return
53+ }
54+ i.cursorDimmed = dimmed
55+ i.applyInputCursorColor()
56+}
57+func reasoningSettingOptions(levels []string) []settingsOption {
58+ descriptions := map[string]string{
59+ "": "no reasoning",
60+ "minimum": "very brief (~1k tokens)",
61+ "low": "light (~2k tokens)",
62+ "medium": "moderate (~8k tokens)",
63+ "high": "deep (~16k tokens)",
64+ "xhigh": "extra-high effort",
65+ "max": "unconstrained effort",
66+ }
67+ options := make([]settingsOption, 0, len(levels))
68+ for _, level := range levels {
69+ label := level
70+ if label == "" {
71+ label = "off"
72+ }
73+ options = append(options, settingsOption{value: level, label: label, desc: descriptions[level]})
74+ }
75+ return options
76+}
77+func (i *Interactive) reasoningSettingItem() settingsItem {
78+ levels := []string{"", "minimum", "low", "medium", "high", "xhigh", "max"}
79+ reasoning := provider.NormalizeReasoning(i.cfg.Reasoning)
80+ desc := "reasoning depth for reasoning-capable models"
81+ hint := ""
82+ if m, err := provider.FindModel(i.cfg.Provider, i.cfg.Model); err == nil {
83+ levels = provider.AvailableReasoningLevels(m)
84+ reasoning = provider.ClampReasoningForModel(m, reasoning)
85+ if !m.Reasoning {
86+ hint = "current model does not support reasoning"
87+ desc += "; current model does not support reasoning"
88+ } else if len(levels) == 1 {
89+ hint = "current model has no adjustable reasoning levels"
90+ desc += "; current model has no adjustable reasoning levels"
91+ } else {
92+ desc += "; only levels supported by the current model are shown"
93+ }
94+ }
95+ options := reasoningSettingOptions(levels)
96+ choice := 0
97+ for idx, opt := range options {
98+ if opt.value == reasoning {
99+ choice = idx
100+ break
101+ }
102+ }
103+
104+ return settingsItem{
105+ key: "reasoning",
106+ label: "reasoning level",
107+ desc: desc,
108+ options: options,
109+ choice: choice,
110+ hint: hint,
111+ }
112+}
113+func (i *Interactive) openReasoningDialog() {
114+ i.settingsDialog.OpenDirectOption(i.reasoningSettingItem())
115+}
116+func (i *Interactive) openSettingsDialog() {
117+ detected := tui.DetectImageProtocol()
118+ imgEnabled := detected != tui.ImageProtocolNone
119+ if i.cfg.InlineImagesEnabled != nil {
120+ imgEnabled = *i.cfg.InlineImagesEnabled
121+ }
122+ imgDisabled := detected == tui.ImageProtocolNone
123+ imgHint := ""
124+ if imgDisabled {
125+ imgEnabled = false
126+ imgHint = "this terminal does not support inline images"
127+ } else {
128+ imgHint = "terminal supports " + imageProtocolName(detected)
129+ }
130+
131+ terminalAlerts := terminalAlertsEnabled(i.cfg.TerminalAlertsEnabled)
132+ terminalTitles := terminalTitleEnabled(i.cfg.TerminalTitleEnabled)
133+ autoSubagents := false
134+ if i.cfg.AutoSubagentsEnabled != nil {
135+ autoSubagents = *i.cfg.AutoSubagentsEnabled
136+ }
137+ autoSubagentsDisabled := !i.autoSubagentsAvailable()
138+ autoSubagentsHint := i.autoSubagentsUnavailableHint()
139+ if autoSubagentsDisabled {
140+ autoSubagents = false
141+ }
142+
143+ ponytailEnabled := i.ponytailEnabled()
144+ ponytailDisabled := !i.ponytailSettingsAvailable()
145+ ponytailHint := ""
146+ if ponytailDisabled {
147+ ponytailHint = "requires persistent settings and live prompt refresh support"
148+ }
149+
150+ webSearchEnabled := i.webSearchEffectiveEnabled()
151+ webSearchDisabled := !i.webSearchSettingsAvailable()
152+ webSearchHint := i.webSearchUnavailableHint()
153+
154+ fastMode := i.cfg.FastMode != nil && *i.cfg.FastMode
155+ fastModeHint := "OpenAI service tier"
156+ if !provider.SupportsFastMode(i.cfg.Provider) {
157+ fastModeHint = "only supported for OpenAI providers"
158+ }
159+ lspEnabled := i.cfg.LSPEnabled == nil || *i.cfg.LSPEnabled
160+ subagentLSPEnabled := i.cfg.SubagentLSPEnabled == nil || *i.cfg.SubagentLSPEnabled
161+
162+ jailByDefault := i.cfg.JailByDefault != nil && *i.cfg.JailByDefault
163+ recursiveFiles := i.cfg.RecursiveFileSuggest != nil && *i.cfg.RecursiveFileSuggest
164+ respectGitignore := i.cfg.RespectGitignore == nil || *i.cfg.RespectGitignore
165+ compactMode := i.compactModeEnabled()
166+ showInstructions := i.cfg.ShowInstructionsAtStartup != nil && *i.cfg.ShowInstructionsAtStartup
167+ inputStyle := tui.NormalizeInputStyle(i.cfg.TUIInputStyle)
168+ statusPosition := tui.NormalizeStatusPosition(i.cfg.TUIStatusPosition)
169+ workingPosition := tui.NormalizeWorkingPosition(i.cfg.TUIWorkingPosition)
170+ quickItems := i.quickModelSettingItems()
171+
172+ autoCompactOptions := []settingsOption{
173+ {value: "0", label: "off", desc: "disable context-percentage triggers; payload-too-large recovery stays enabled"},
174+ {value: "70", label: "70%", desc: "compact earlier to keep more context headroom"},
175+ {value: "80", label: "80%", desc: "compact with moderate context headroom"},
176+ {value: "85", label: "85%", desc: "default balance between history and headroom"},
177+ {value: "90", label: "90%", desc: "retain more history before compacting"},
178+ {value: "95", label: "95%", desc: "retain most history before compacting"},
179+ }
180+ autoCompactThreshold := NormalizeAutoCompactThreshold(i.cfg.AutoCompactThreshold)
181+ autoCompactChoice := 0
182+ for idx, opt := range autoCompactOptions {
183+ if opt.value == strconv.Itoa(autoCompactThreshold) {
184+ autoCompactChoice = idx
185+ break
186+ }
187+ }
188+
189+ reasoningItem := i.reasoningSettingItem()
190+
191+ themeName := i.cfg.ThemeName
192+ if themeName == "" {
193+ themeName = "auto"
194+ }
195+ if themeName != "auto" && !tui.ThemeExists(i.cfg.ZutHome, themeName) {
196+ themeName = "auto"
197+ i.cfg.ThemeName = ""
198+ if i.cfg.SettingsStore != nil {
199+ _ = i.cfg.SettingsStore.SetTheme("auto")
200+ }
201+ i.applyThemeNow("auto")
202+ }
203+ themeOptions := []settingsOption{}
204+ themeChoice := 0
205+ availableThemes := tui.AvailableThemes(i.cfg.ZutHome)
206+ if i.cfg.ExtensionThemes != nil {
207+ availableThemes = append(availableThemes, i.cfg.ExtensionThemes()...)
208+ }
209+ for idx, opt := range availableThemes {
210+ themeOptions = append(themeOptions, settingsOption{value: opt.Value, label: opt.Label, desc: opt.Description})
211+ if opt.Value == themeName {
212+ themeChoice = idx
213+ }
214+ }
215+
216+ inputStyleOptions := []settingsOption{
217+ {value: tui.InputStylePlain, label: "plain", desc: "render the input as the normal prompt line"},
218+ {value: tui.InputStyleLines, label: "lines", desc: "draw separator lines above and below the input"},
219+ {value: tui.InputStyleBlock, label: "block", desc: "render the input as a user-bubble-style block"},
220+ }
221+ inputStyleChoice := 0
222+ for idx, opt := range inputStyleOptions {
223+ if opt.value == inputStyle {
224+ inputStyleChoice = idx
225+ break
226+ }
227+ }
228+ statusPositionOptions := []settingsOption{
229+ {value: tui.StatusPositionAboveInput, label: "above input", desc: "show model, usage, and working directory above the input"},
230+ {value: tui.StatusPositionBelowInput, label: "below input", desc: "show model, usage, and working directory below the input"},
231+ }
232+ statusPositionChoice := 0
233+ for idx, opt := range statusPositionOptions {
234+ if opt.value == statusPosition {
235+ statusPositionChoice = idx
236+ break
237+ }
238+ }
239+
240+ workingPositionOptions := []settingsOption{
241+ {value: tui.WorkingPositionAboveInput, label: "above input", desc: "show the working spinner above the input"},
242+ {value: tui.WorkingPositionBelowInput, label: "below input", desc: "show the working spinner below the input"},
243+ }
244+ workingPositionChoice := 0
245+ for idx, opt := range workingPositionOptions {
246+ if opt.value == workingPosition {
247+ workingPositionChoice = idx
248+ break
249+ }
250+ }
251+
252+ items := []settingsItem{
253+ {
254+ key: "inline_images_enabled",
255+ label: "render images when supported",
256+ desc: "draw screenshots inline instead of showing a text placeholder",
257+ value: imgEnabled,
258+ disabled: imgDisabled,
259+ hint: imgHint,
260+ },
261+ {
262+ key: "terminal_alerts_enabled",
263+ label: "terminal alerts",
264+ desc: "ring the terminal when the main session needs attention or an extension raises an alert",
265+ value: terminalAlerts,
266+ },
267+ {
268+ key: "terminal_title_enabled",
269+ label: "AI terminal titles",
270+ desc: "ask the active model for a concise title after the first prompt; uses one extra hidden request",
271+ value: terminalTitles,
272+ },
273+ {
274+ key: "auto_subagents_enabled",
275+ label: "Subagent Orchestrator",
276+ desc: "automatically delegate coding work to sub-agents; tools stay available when disabled",
277+ value: autoSubagents,
278+ disabled: autoSubagentsDisabled,
279+ hint: autoSubagentsHint,
280+ },
281+ {
282+ key: "ponytail_enabled",
283+ label: "ponytail coding mode",
284+ desc: "apply compact coding guidance that favors small, validated, maintainable changes",
285+ value: ponytailEnabled,
286+ disabled: ponytailDisabled,
287+ hint: ponytailHint,
288+ },
289+ {
290+ key: "web_search_enabled",
291+ label: "web search",
292+ desc: "search DuckDuckGo for bounded public-web sources; sends queries to the public backend",
293+ value: webSearchEnabled,
294+ disabled: webSearchDisabled,
295+ hint: webSearchHint,
296+ },
297+ {
298+ key: "fast_mode",
299+ label: "fast mode",
300+ desc: "request the provider's fast tier where supported; unsupported providers return an error",
301+ value: fastMode,
302+ hint: fastModeHint,
303+ },
304+ {
305+ key: "lsp_enabled",
306+ label: "lsp in main session",
307+ desc: "enable the lsp tool and code diagnostics for the main agent",
308+ value: lspEnabled,
309+ },
310+ {
311+ key: "subagent_lsp_enabled",
312+ label: "lsp in sub-agents",
313+ desc: "allow newly spawned background sub-agents to use the lsp tool",
314+ value: subagentLSPEnabled,
315+ },
316+ {
317+ key: "auto_compact_threshold",
318+ label: "auto-compact threshold",
319+ desc: "choose how full the model context can get before zut condenses conversation history",
320+ options: autoCompactOptions,
321+ choice: autoCompactChoice,
322+ },
323+ {
324+ key: "jail_by_default",
325+ label: "jail new sessions by default",
326+ desc: "confine tools to the session working directory unless /unjail is used",
327+ value: jailByDefault,
328+ },
329+ {
330+ key: "recursive_file_suggest",
331+ label: "recursive @-file search",
332+ desc: "fuzzy-search the whole project tree when picking files with @ instead of browsing one directory at a time",
333+ value: recursiveFiles,
334+ },
335+ {
336+ key: "respect_gitignore",
337+ label: "hide gitignored files in @-picker",
338+ desc: "skip files and directories matched by the project's root .gitignore (and .git) when picking files with @",
339+ value: respectGitignore,
340+ },
341+ {
342+ key: "compact_mode",
343+ label: "compact transcript rendering",
344+ desc: "reduce visual chrome by rendering tool calls without boxes and sent messages without padded bubbles",
345+ value: compactMode,
346+ },
347+ {
348+ key: "show_instructions_at_startup",
349+ label: "show loaded resources at startup",
350+ desc: "list loaded context files, extensions, and user-installed skills above the transcript",
351+ value: showInstructions,
352+ },
353+ {
354+ key: "tui_settings",
355+ label: "tui settings",
356+ desc: "choose input chrome in its order around the prompt",
357+ children: []settingsItem{
358+ {
359+ key: "tui_status_position",
360+ label: "status position",
361+ desc: "place model, usage, and working directory above or below the input",
362+ options: statusPositionOptions,
363+ choice: statusPositionChoice,
364+ },
365+ {
366+ key: "tui_working_position",
367+ label: "working spinner position",
368+ desc: "place the working spinner above or below the input",
369+ options: workingPositionOptions,
370+ choice: workingPositionChoice,
371+ },
372+ {
373+ key: "tui_input_style",
374+ label: "input style",
375+ desc: "choose between the plain prompt, lines, and a block input area",
376+ options: inputStyleOptions,
377+ choice: inputStyleChoice,
378+ },
379+ },
380+ },
381+ reasoningItem,
382+ {
383+ key: "theme",
384+ label: "color theme",
385+ desc: "choose auto, inherited terminal colors, a built-in dark/light theme, or a loaded theme file",
386+ options: themeOptions,
387+ choice: themeChoice,
388+ },
389+ }
390+ if len(quickItems) > 0 {
391+ items = append(items, settingsItem{
392+ key: "quick_models",
393+ label: "model shortcuts",
394+ desc: "configure " + quickModelShortcutPrefix() + "+1 through " + quickModelShortcutPrefix() + "+9 quick model switches",
395+ children: quickItems,
396+ })
397+ }
398+ i.settingsDialog.Open(items)
399+}
400+func (i *Interactive) applySettingChange(act settingsAction) {
401+ switch {
402+ case strings.HasPrefix(act.Key, "quick_model_"):
403+ i.applyQuickModelSetting(act.Key, act.StringValue)
404+ case act.Key == "reasoning":
405+ i.applyReasoningSetting(act.StringValue)
406+ case act.Key == "theme":
407+ i.applyThemeSetting(act.StringValue)
408+ case act.Key == "auto_compact_threshold":
409+ i.applyAutoCompactThresholdSetting(act.StringValue)
410+ case act.Key == "tui_input_style":
411+ i.applyTUIInputStyleSetting(act.StringValue)
412+ case act.Key == "tui_status_position":
413+ i.applyTUIStatusPositionSetting(act.StringValue)
414+ case act.Key == "tui_working_position":
415+ i.applyTUIWorkingPositionSetting(act.StringValue)
416+ default:
417+ i.applySettingToggle(act.Key, act.Value)
418+ }
419+}
420+func (i *Interactive) applyAutoCompactThresholdSetting(value string) {
421+ threshold, err := strconv.Atoi(value)
422+ if err != nil {
423+ return
424+ }
425+ threshold = NormalizeAutoCompactThreshold(&threshold)
426+ if store, ok := i.cfg.SettingsStore.(autoCompactThresholdSettingsStore); ok {
427+ if err := store.SetAutoCompactThreshold(threshold); err != nil {
428+ i.mu.Lock()
429+ i.statusErr = "settings: " + err.Error()
430+ i.mu.Unlock()
431+ return
432+ }
433+ }
434+ i.mu.Lock()
435+ i.cfg.AutoCompactThreshold = &threshold
436+ if threshold == 0 {
437+ i.statusOK = "auto-compact threshold off"
438+ } else {
439+ i.statusOK = fmt.Sprintf("auto-compact threshold %d%%", threshold)
440+ }
441+ i.statusErr = ""
442+ i.mu.Unlock()
443+ i.invalidate()
444+}
445+func (i *Interactive) quickModelSettingItems() []settingsItem {
446+ if len(i.cfg.QuickModelShortcuts) < 9 {
447+ next := make([]QuickModelShortcut, 9)
448+ copy(next, i.cfg.QuickModelShortcuts)
449+ i.cfg.QuickModelShortcuts = next
450+ }
451+ items := make([]settingsItem, 0, 9)
452+ for slot := 1; slot <= 9; slot++ {
453+ items = append(items, i.quickModelSettingItem(slot))
454+ }
455+ return items
456+}
457+func (i *Interactive) quickModelSettingItem(slot int) settingsItem {
458+ current := QuickModelShortcut{}
459+ if slot >= 1 && len(i.cfg.QuickModelShortcuts) >= slot {
460+ current = i.cfg.QuickModelShortcuts[slot-1]
461+ }
462+ hint := "not assigned"
463+ if current.Provider != "" && current.Model != "" {
464+ hint = current.Provider + " / " + current.Model
465+ }
466+ return settingsItem{
467+ key: "quick_model_" + strconv.Itoa(slot),
468+ label: "model " + strconv.Itoa(slot),
469+ desc: quickModelShortcutLabel(slot) + " switches to this model. Enter opens the /model selector, Backspace clears.",
470+ picker: true,
471+ hint: hint,
472+ }
473+}
474+func (i *Interactive) compactModeEnabled() bool {
475+ return i.cfg.CompactMode != nil && *i.cfg.CompactMode
476+}
477+func quickModelShortcutSlot(k tui.Key) int {
478+ if k.Kind != tui.KeyRune || k.Rune < '1' || k.Rune > '9' {
479+ return 0
480+ }
481+ if runtime.GOOS == "darwin" {
482+ if !k.Super && !k.Ctrl {
483+ return 0
484+ }
485+ } else if !k.Ctrl {
486+ return 0
487+ }
488+ return int(k.Rune - '0')
489+}
490+func quickModelShortcutPrefix() string {
491+ return "Ctrl"
492+}
493+func quickModelShortcutLabel(slot int) string {
494+ return quickModelShortcutPrefix() + "+" + strconv.Itoa(slot)
495+}
496+func (i *Interactive) openQuickModelPicker(slot int) {
497+ if slot < 1 || slot > 9 {
498+ return
499+ }
500+ i.quickModelAssign = slot
501+ current := i.cfg.Model
502+ if len(i.cfg.QuickModelShortcuts) >= slot && i.cfg.QuickModelShortcuts[slot-1].Model != "" {
503+ current = i.cfg.QuickModelShortcuts[slot-1].Model
504+ }
505+ var loggedIn []string
506+ if i.cfg.LoggedInProviders != nil {
507+ loggedIn = i.cfg.LoggedInProviders()
508+ }
509+ i.modelDialog.Open(current, loggedIn, i.cfg.Reasoning)
510+}
511+func (i *Interactive) applyQuickModelSelection(slot int, providerName, model string) {
512+ i.setQuickModelShortcut(slot, providerName, model)
513+}
514+func (i *Interactive) applyQuickModelShortcut(slot int) {
515+ if slot < 1 || slot > 9 {
516+ return
517+ }
518+ if i.busy {
519+ i.mu.Lock()
520+ i.statusErr = "cannot switch model while a turn is running"
521+ i.statusOK = ""
522+ i.mu.Unlock()
523+ i.invalidate()
524+ return
525+ }
526+ if len(i.cfg.QuickModelShortcuts) < slot {
527+ i.mu.Lock()
528+ i.statusErr = quickModelShortcutLabel(slot) + " is not assigned"
529+ i.statusOK = ""
530+ i.mu.Unlock()
531+ i.invalidate()
532+ return
533+ }
534+ shortcut := i.cfg.QuickModelShortcuts[slot-1]
535+ if shortcut.Provider == "" || shortcut.Model == "" {
536+ i.mu.Lock()
537+ i.statusErr = quickModelShortcutLabel(slot) + " is not assigned"
538+ i.statusOK = ""
539+ i.mu.Unlock()
540+ i.invalidate()
541+ return
542+ }
543+ i.swapModel(shortcut.Provider, shortcut.Model, i.cfg.BuildAgentFor, false)
544+ i.invalidate()
545+}
546+func (i *Interactive) applyQuickModelSetting(key, value string) {
547+ slotText := strings.TrimPrefix(key, "quick_model_")
548+ slot, err := strconv.Atoi(slotText)
549+ if err != nil || slot < 1 || slot > 9 {
550+ return
551+ }
552+ providerName, model := "", ""
553+ if value != "" {
554+ parts := strings.SplitN(value, "\t", 2)
555+ if len(parts) == 2 {
556+ providerName, model = parts[0], parts[1]
557+ }
558+ }
559+ i.setQuickModelShortcut(slot, providerName, model)
560+}
561+func (i *Interactive) setQuickModelShortcut(slot int, providerName, model string) {
562+ if len(i.cfg.QuickModelShortcuts) < slot {
563+ next := make([]QuickModelShortcut, slot)
564+ copy(next, i.cfg.QuickModelShortcuts)
565+ i.cfg.QuickModelShortcuts = next
566+ }
567+ i.cfg.QuickModelShortcuts[slot-1] = QuickModelShortcut{Provider: providerName, Model: model}
568+ if i.cfg.SettingsStore != nil {
569+ if err := i.cfg.SettingsStore.SetQuickModelShortcut(slot, providerName, model); err != nil {
570+ i.mu.Lock()
571+ i.statusErr = "settings: " + err.Error()
572+ i.mu.Unlock()
573+ return
574+ }
575+ }
576+ i.mu.Lock()
577+ if model == "" {
578+ i.statusOK = quickModelShortcutLabel(slot) + " cleared"
579+ } else {
580+ i.statusOK = quickModelShortcutLabel(slot) + " set to " + providerName + " / " + model
581+ }
582+ i.statusErr = ""
583+ i.mu.Unlock()
584+ i.refreshQuickModelSettingsItem(slot)
585+ i.invalidate()
586+}
587+func (i *Interactive) refreshQuickModelSettingsItem(slot int) {
588+ if i.settingsDialog == nil || !i.settingsDialog.Active() || len(i.settingsDialog.items) == 0 {
589+ return
590+ }
591+ key := "quick_model_" + strconv.Itoa(slot)
592+ for idx, it := range i.settingsDialog.items {
593+ if it.key == key {
594+ i.settingsDialog.items[idx] = i.quickModelSettingItem(slot)
595+ return
596+ }
597+ }
598+}
599+func (i *Interactive) resetSettingsToggle(key string, value bool) {
600+ if i.settingsDialog == nil {
601+ return
602+ }
603+ for idx := range i.settingsDialog.items {
604+ if i.settingsDialog.items[idx].key == key {
605+ i.settingsDialog.items[idx].value = value
606+ return
607+ }
608+ }
609+}
610+func (i *Interactive) ponytailEnabled() bool {
611+ return i.cfg.PonytailEnabled == nil || *i.cfg.PonytailEnabled
612+}
613+func (i *Interactive) ponytailSettingsAvailable() bool {
614+ _, persistent := i.cfg.SettingsStore.(ponytailSettingsStore)
615+ return persistent && i.cfg.RefreshPrompt != nil
616+}
617+func (i *Interactive) webSearchEnabled() bool {
618+ return i.cfg.WebSearchEnabled == nil || *i.cfg.WebSearchEnabled
619+}
620+func (i *Interactive) webSearchToolAllowed() bool {
621+ return i.cfg.WebSearchToolAllowed == nil || *i.cfg.WebSearchToolAllowed
622+}
623+func (i *Interactive) webSearchEffectiveEnabled() bool {
624+ return i.webSearchToolAllowed() && (i.cfg.WebSearchInvocationOverride || i.webSearchEnabled())
625+}
626+func (i *Interactive) webSearchSettingsAvailable() bool {
627+ _, persistent := i.cfg.SettingsStore.(webSearchSettingsStore)
628+ return i.webSearchToolAllowed() && !i.cfg.WebSearchInvocationOverride && persistent && i.cfg.RefreshTools != nil
629+}
630+func (i *Interactive) webSearchUnavailableHint() string {
631+ if !i.webSearchToolAllowed() {
632+ return "unavailable in this session due to the launch-time tool capability ceiling"
633+ }
634+ if i.cfg.WebSearchInvocationOverride {
635+ return "enabled for this session by explicit --tools web_search; the persisted setting applies to invocations without --tools"
636+ }
637+ if !i.webSearchSettingsAvailable() {
638+ return "requires persistent settings and live tool refresh support"
639+ }
640+ return ""
641+}
642+func (i *Interactive) setWebSearchAvailable(available bool) {
643+ // Advance before publishing the new gate/child ceiling. A resolver that
644+ // captured the prior generation can no longer commit after this transition.
645+ // Callers may hold agentMu and i.mu; the callback must complete without
646+ // calling back into Interactive or requiring either lock.
647+ i.webSearchPolicyGeneration.Add(1)
648+ if i.cfg.SetWebSearchAvailable != nil {
649+ i.cfg.SetWebSearchAvailable(available)
650+ }
651+}
652+func (i *Interactive) applyWebSearchSettingToggle(key string, value bool) {
653+ previous := i.webSearchEnabled()
654+ store, persistent := i.cfg.SettingsStore.(webSearchSettingsStore)
655+ if !i.webSearchSettingsAvailable() || !persistent {
656+ i.resetSettingsToggle(key, i.webSearchEffectiveEnabled())
657+ i.mu.Lock()
658+ i.statusOK = ""
659+ i.statusErr = "web search unavailable: " + i.webSearchUnavailableHint()
660+ i.mu.Unlock()
661+ return
662+ }
663+ if err := store.SetWebSearchEnabled(value); err != nil {
664+ i.resetSettingsToggle(key, previous)
665+ i.mu.Lock()
666+ i.statusOK = ""
667+ i.statusErr = "settings: " + err.Error()
668+ i.mu.Unlock()
669+ return
670+ }
671+ val := value
672+ i.cfg.WebSearchEnabled = &val
673+ // Revoke and advance the policy generation for both directions. Enabling
674+ // remains fail-closed until the freshly resolved registry commits, while a
675+ // stale resolve from before this persisted transition is rejected.
676+ i.stripWebSearchTool()
677+ if err := i.cfg.RefreshTools(); err != nil {
678+ // Refresh failure leaves the old registry's state unknown. Revoke both
679+ // live execution and generic-child inheritance before rollback.
680+ i.stripWebSearchTool()
681+ errMsg := "settings: web search tool refresh: " + err.Error()
682+ if rollbackErr := store.SetWebSearchEnabled(previous); rollbackErr != nil {
683+ failClosed := false
684+ i.cfg.WebSearchEnabled = &failClosed
685+ i.resetSettingsToggle(key, false)
686+ errMsg += "; rollback persistence: " + rollbackErr.Error()
687+ } else {
688+ previousVal := previous
689+ i.cfg.WebSearchEnabled = &previousVal
690+ i.resetSettingsToggle(key, previous)
691+ if refreshErr := i.cfg.RefreshTools(); refreshErr != nil {
692+ errMsg += "; rollback refresh: " + refreshErr.Error()
693+ i.stripWebSearchTool()
694+ }
695+ }
696+ i.mu.Lock()
697+ i.statusOK = ""
698+ i.statusErr = errMsg
699+ i.mu.Unlock()
700+ return
701+ }
702+ i.mu.Lock()
703+ i.statusOK = "web search " + onOff(value)
704+ i.statusErr = ""
705+ i.mu.Unlock()
706+}
707+func (i *Interactive) stripWebSearchTool() {
708+ // Revoke first so a stale tool snapshotted by core cannot execute while
709+ // the advertised registry is being replaced.
710+ i.setWebSearchAvailable(false)
711+ i.agentMu.Lock()
712+ defer i.agentMu.Unlock()
713+ i.mu.Lock()
714+ defer i.mu.Unlock()
715+ if i.agent == nil {
716+ return
717+ }
718+ tools := i.agent.ToolsSnapshot()
719+ if _, ok := tools["web_search"]; !ok {
720+ return
721+ }
722+ delete(tools, "web_search")
723+ i.agent.SetTools(tools)
724+}
725+func (i *Interactive) applySettingToggle(key string, value bool) {
726+ // Every setting toggle forces a full repaint at the end — same
727+ // effect as the user pressing Ctrl+L — so any per-setting visual
728+ // change (image rendering, status copy, future toggles) lands
729+ // immediately instead of waiting for the next diff frame.
730+ defer func() {
731+ i.requestRendererClear()
732+ i.invalidate()
733+ }()
734+ switch key {
735+ case "inline_images_enabled":
736+ val := value
737+ i.cfg.InlineImagesEnabled = &val
738+ if i.cfg.SettingsStore != nil {
739+ if err := i.cfg.SettingsStore.SetInlineImages(value); err != nil {
740+ i.mu.Lock()
741+ i.statusErr = "settings: " + err.Error()
742+ i.mu.Unlock()
743+ return
744+ }
745+ }
746+ i.mu.Lock()
747+ i.view.ImageProto = effectiveImageProtocol(i.cfg.InlineImagesEnabled)
748+ i.view.InvalidateRenderCache()
749+ i.statusOK = "inline image rendering " + onOff(value)
750+ i.statusErr = ""
751+ i.mu.Unlock()
752+ case "terminal_alerts_enabled":
753+ val := value
754+ i.mu.Lock()
755+ i.cfg.TerminalAlertsEnabled = &val
756+ i.mu.Unlock()
757+ if store, ok := i.cfg.SettingsStore.(terminalAlertsSettingsStore); ok {
758+ if err := store.SetTerminalAlertsEnabled(value); err != nil {
759+ i.mu.Lock()
760+ i.statusErr = "settings: " + err.Error()
761+ i.mu.Unlock()
762+ return
763+ }
764+ }
765+ i.mu.Lock()
766+ i.statusOK = "terminal alerts " + onOff(value)
767+ i.statusErr = ""
768+ i.mu.Unlock()
769+ case "terminal_title_enabled":
770+ val := value
771+ var cancel context.CancelFunc
772+ i.mu.Lock()
773+ i.cfg.TerminalTitleEnabled = &val
774+ if value {
775+ i.writeTerminalTitleLocked(i.sessionTitle)
776+ } else {
777+ cancel = i.titleCancel
778+ i.titleCancel = nil
779+ i.titleVersion++
780+ i.writeTerminalTitleLocked("")
781+ }
782+ i.mu.Unlock()
783+ if cancel != nil {
784+ cancel()
785+ }
786+ if store, ok := i.cfg.SettingsStore.(terminalTitleSettingsStore); ok {
787+ if err := store.SetTerminalTitleEnabled(value); err != nil {
788+ i.mu.Lock()
789+ i.statusErr = "settings: " + err.Error()
790+ i.mu.Unlock()
791+ return
792+ }
793+ }
794+ i.mu.Lock()
795+ i.statusOK = "AI terminal titles " + onOff(value)
796+ i.statusErr = ""
797+ i.mu.Unlock()
798+ case "web_search_enabled":
799+ apply := func() { i.applyWebSearchSettingToggle(key, value) }
800+ if i.cfg.SessionTransition != nil {
801+ i.cfg.SessionTransition(apply)
802+ } else {
803+ apply()
804+ }
805+ case "ponytail_enabled":
806+ previous := i.ponytailEnabled()
807+ store, available := i.cfg.SettingsStore.(ponytailSettingsStore)
808+ if !available || i.cfg.RefreshPrompt == nil {
809+ i.resetSettingsToggle(key, previous)
810+ i.mu.Lock()
811+ i.statusOK = ""
812+ i.statusErr = "ponytail coding mode unavailable: persistent settings and live prompt refresh are required"
813+ i.mu.Unlock()
814+ return
815+ }
816+ if err := store.SetPonytailEnabled(value); err != nil {
817+ i.resetSettingsToggle(key, previous)
818+ i.mu.Lock()
819+ i.statusOK = ""
820+ i.statusErr = "settings: " + err.Error()
821+ i.mu.Unlock()
822+ return
823+ }
824+ val := value
825+ i.cfg.PonytailEnabled = &val
826+ if err := i.cfg.RefreshPrompt(); err != nil {
827+ errMsg := "settings: ponytail prompt refresh: " + err.Error()
828+ if rollbackErr := store.SetPonytailEnabled(previous); rollbackErr != nil {
829+ // The first write succeeded, so keep the in-memory setting at
830+ // the value that remains durable instead of claiming that the
831+ // rollback took effect.
832+ i.resetSettingsToggle(key, value)
833+ errMsg += "; rollback persistence: " + rollbackErr.Error()
834+ if reconcileErr := i.cfg.RefreshPrompt(); reconcileErr != nil {
835+ errMsg += "; durable-state refresh: " + reconcileErr.Error()
836+ }
837+ } else {
838+ previousVal := previous
839+ i.cfg.PonytailEnabled = &previousVal
840+ i.resetSettingsToggle(key, previous)
841+ if refreshErr := i.cfg.RefreshPrompt(); refreshErr != nil {
842+ errMsg += "; rollback refresh: " + refreshErr.Error()
843+ }
844+ }
845+ i.mu.Lock()
846+ i.statusOK = ""
847+ i.statusErr = errMsg
848+ i.mu.Unlock()
849+ return
850+ }
851+ i.mu.Lock()
852+ i.statusOK = "ponytail coding mode " + onOff(value)
853+ i.statusErr = ""
854+ i.mu.Unlock()
855+ case "auto_subagents_enabled":
856+ if value && !i.autoSubagentsAvailable() {
857+ i.mu.Lock()
858+ i.statusOK = ""
859+ i.statusErr = "subagent orchestrator unavailable: " + i.autoSubagentsUnavailableHint()
860+ i.mu.Unlock()
861+ return
862+ }
863+ i.mu.Lock()
864+ previousFlag := i.cfg.AutoSubagentsEnabled
865+ previous := previousFlag != nil && *previousFlag
866+ val := value
867+ i.cfg.AutoSubagentsEnabled = &val
868+ i.mu.Unlock()
869+ if i.cfg.SettingsStore != nil {
870+ if err := i.cfg.SettingsStore.SetAutoSubagents(value); err != nil {
871+ i.mu.Lock()
872+ i.cfg.AutoSubagentsEnabled = previousFlag
873+ i.statusOK = ""
874+ i.statusErr = "settings: " + err.Error()
875+ i.mu.Unlock()
876+ i.resetSettingsToggle(key, previous)
877+ return
878+ }
879+ }
880+ // The subagent tools remain available for explicit user requests.
881+ // Swap only the system-prompt guidance between strict orchestration and
882+ // on-demand delegation.
883+ i.applyAutoSubagentsTool()
884+ // Also swap the system-prompt addendum so the model knows whether it
885+ // should orchestrate proactively or delegate only on user request.
886+ i.applyAutoSubagentsSystemPrompt(value)
887+ i.mu.Lock()
888+ i.statusOK = "subagent orchestrator " + onOff(value)
889+ i.statusErr = ""
890+ i.mu.Unlock()
891+ case "fast_mode":
892+ previous := i.cfg.FastMode != nil && *i.cfg.FastMode
893+ if err := provider.ValidateFastMode(i.cfg.Provider, value); err != nil {
894+ i.resetSettingsToggle(key, previous)
895+ i.mu.Lock()
896+ i.statusErr = "settings: " + err.Error()
897+ i.mu.Unlock()
898+ return
899+ }
900+ if store, ok := i.cfg.SettingsStore.(fastModeSettingsStore); ok {
901+ if err := store.SetFastMode(value); err != nil {
902+ i.resetSettingsToggle(key, previous)
903+ i.mu.Lock()
904+ i.statusErr = "settings: " + err.Error()
905+ i.mu.Unlock()
906+ return
907+ }
908+ }
909+ val := value
910+ i.cfg.FastMode = &val
911+ if i.agent != nil {
912+ i.agent.SetFastMode(value)
913+ }
914+ i.mu.Lock()
915+ i.statusOK = "fast mode " + onOff(value)
916+ i.statusErr = ""
917+ i.mu.Unlock()
918+ case "lsp_enabled":
919+ previous := i.cfg.LSPEnabled == nil || *i.cfg.LSPEnabled
920+ store, hasStore := i.cfg.SettingsStore.(lspSettingsStore)
921+ if hasStore {
922+ if err := store.SetLSPEnabled(value); err != nil {
923+ i.mu.Lock()
924+ i.statusErr = "settings: " + err.Error()
925+ i.mu.Unlock()
926+ return
927+ }
928+ }
929+ val := value
930+ i.cfg.LSPEnabled = &val
931+ if i.cfg.RefreshTools != nil {
932+ if err := i.cfg.RefreshTools(); err != nil {
933+ errMsg := "settings: refresh tools: " + err.Error()
934+ rollbackErr := error(nil)
935+ if hasStore {
936+ rollbackErr = store.SetLSPEnabled(previous)
937+ }
938+ if rollbackErr != nil {
939+ // The first write succeeded, so keep the in-memory setting at
940+ // the value that remains durable instead of claiming that the
941+ // rollback took effect.
942+ errMsg += "; rollback persistence: " + rollbackErr.Error()
943+ if reconcileErr := i.cfg.RefreshTools(); reconcileErr != nil {
944+ errMsg += "; durable-state refresh: " + reconcileErr.Error()
945+ }
946+ } else {
947+ previousVal := previous
948+ i.cfg.LSPEnabled = &previousVal
949+ if refreshErr := i.cfg.RefreshTools(); refreshErr != nil {
950+ errMsg += "; rollback refresh: " + refreshErr.Error()
951+ }
952+ }
953+ i.mu.Lock()
954+ i.statusOK = ""
955+ i.statusErr = errMsg
956+ i.mu.Unlock()
957+ return
958+ }
959+ }
960+ i.mu.Lock()
961+ i.statusOK = "lsp in main session " + onOff(value)
962+ i.statusErr = ""
963+ i.mu.Unlock()
964+ case "subagent_lsp_enabled":
965+ if store, ok := i.cfg.SettingsStore.(lspSettingsStore); ok {
966+ if err := store.SetSubagentLSPEnabled(value); err != nil {
967+ i.mu.Lock()
968+ i.statusErr = "settings: " + err.Error()
969+ i.mu.Unlock()
970+ return
971+ }
972+ }
973+ val := value
974+ i.cfg.SubagentLSPEnabled = &val
975+ i.mu.Lock()
976+ i.statusOK = "lsp in sub-agents " + onOff(value)
977+ i.statusErr = ""
978+ i.mu.Unlock()
979+ case "jail_by_default":
980+ val := value
981+ i.cfg.JailByDefault = &val
982+ if i.cfg.SettingsStore != nil {
983+ if err := i.cfg.SettingsStore.SetJailByDefault(value); err != nil {
984+ i.mu.Lock()
985+ i.statusErr = "settings: " + err.Error()
986+ i.mu.Unlock()
987+ return
988+ }
989+ }
990+ if i.cfg.Sandbox != nil {
991+ if value {
992+ i.cfg.Sandbox.Lock()
993+ } else {
994+ i.cfg.Sandbox.Unlock()
995+ }
996+ }
997+ i.mu.Lock()
998+ i.statusOK = "jail by default " + onOff(value)
999+ i.statusErr = ""
1000+ i.mu.Unlock()
1001+ case "recursive_file_suggest":
1002+ val := value
1003+ i.cfg.RecursiveFileSuggest = &val
1004+ if i.cfg.SettingsStore != nil {
1005+ if err := i.cfg.SettingsStore.SetRecursiveFileSuggest(value); err != nil {
1006+ i.mu.Lock()
1007+ i.statusErr = "settings: " + err.Error()
1008+ i.mu.Unlock()
1009+ return
1010+ }
1011+ }
1012+ // Flip the live picker so the next @ reflects the new mode
1013+ // without restarting zut. SetRecursive drops its cache.
1014+ i.fileSuggest.SetRecursive(value)
1015+ i.mu.Lock()
1016+ i.statusOK = "recursive @-file search " + onOff(value)
1017+ i.statusErr = ""
1018+ i.mu.Unlock()
1019+ case "respect_gitignore":
1020+ val := value
1021+ i.cfg.RespectGitignore = &val
1022+ if i.cfg.SettingsStore != nil {
1023+ if err := i.cfg.SettingsStore.SetRespectGitignore(value); err != nil {
1024+ i.mu.Lock()
1025+ i.statusErr = "settings: " + err.Error()
1026+ i.mu.Unlock()
1027+ return
1028+ }
1029+ }
1030+ i.fileSuggest.SetRespectGitignore(value)
1031+ i.mu.Lock()
1032+ i.statusOK = "hide gitignored files in @-picker " + onOff(value)
1033+ i.statusErr = ""
1034+ i.mu.Unlock()
1035+ case "compact_mode":
1036+ val := value
1037+ i.cfg.CompactMode = &val
1038+ if i.cfg.SettingsStore != nil {
1039+ if err := i.cfg.SettingsStore.SetCompactMode(value); err != nil {
1040+ i.mu.Lock()
1041+ i.statusErr = "settings: " + err.Error()
1042+ i.mu.Unlock()
1043+ return
1044+ }
1045+ }
1046+ i.mu.Lock()
1047+ i.view.CompactMode = value
1048+ i.view.InvalidateRenderCache()
1049+ i.statusOK = "compact transcript rendering " + onOff(value)
1050+ i.statusErr = ""
1051+ i.mu.Unlock()
1052+ case "show_instructions_at_startup":
1053+ val := value
1054+ i.cfg.ShowInstructionsAtStartup = &val
1055+ if store, ok := i.cfg.SettingsStore.(showInstructionsSettingsStore); ok {
1056+ if err := store.SetShowInstructionsAtStartup(value); err != nil {
1057+ i.mu.Lock()
1058+ i.statusErr = "settings: " + err.Error()
1059+ i.mu.Unlock()
1060+ return
1061+ }
1062+ }
1063+ i.mu.Lock()
1064+ i.view.StartupAgentName = ""
1065+ i.view.StartupContextPaths = nil
1066+ i.view.StartupExtensionNames = nil
1067+ i.view.StartupSkillNames = nil
1068+ i.view.InvalidateRenderCache()
1069+ if value {
1070+ i.view.StartupAgentName = i.cfg.StartupAgentName
1071+ i.view.StartupContextPaths = append(i.view.StartupContextPaths, i.cfg.StartupContextPaths...)
1072+ i.view.StartupExtensionNames = append(i.view.StartupExtensionNames, i.cfg.StartupExtensionNames...)
1073+ i.view.StartupSkillNames = append(i.view.StartupSkillNames, i.cfg.StartupSkillNames...)
1074+ }
1075+ i.statusOK = "show loaded resources at startup " + onOff(value)
1076+ i.statusErr = ""
1077+ i.mu.Unlock()
1078+ }
1079+}
1080+func (i *Interactive) applyTUIInputStyleSetting(style string) {
1081+ defer func() {
1082+ i.requestRendererClear()
1083+ i.invalidate()
1084+ }()
1085+ style = tui.NormalizeInputStyle(style)
1086+ i.cfg.TUIInputStyle = style
1087+ i.applyInputCursorColor()
1088+ if i.cfg.SettingsStore != nil {
1089+ if err := i.cfg.SettingsStore.SetTUIInputStyle(style); err != nil {
1090+ i.mu.Lock()
1091+ i.statusErr = "settings: " + err.Error()
1092+ i.mu.Unlock()
1093+ return
1094+ }
1095+ }
1096+ i.mu.Lock()
1097+ i.statusOK = "input style " + style
1098+ i.statusErr = ""
1099+ i.mu.Unlock()
1100+}
1101+func (i *Interactive) applyTUIStatusPositionSetting(position string) {
1102+ defer func() {
1103+ i.requestRendererClear()
1104+ i.invalidate()
1105+ }()
1106+ position = tui.NormalizeStatusPosition(position)
1107+ i.cfg.TUIStatusPosition = position
1108+ if i.cfg.SettingsStore != nil {
1109+ if err := i.cfg.SettingsStore.SetTUIStatusPosition(position); err != nil {
1110+ i.mu.Lock()
1111+ i.statusErr = "settings: " + err.Error()
1112+ i.mu.Unlock()
1113+ return
1114+ }
1115+ }
1116+ i.mu.Lock()
1117+ label := "above input"
1118+ if position == tui.StatusPositionBelowInput {
1119+ label = "below input"
1120+ }
1121+ i.statusOK = "status position " + label
1122+ i.statusErr = ""
1123+ i.mu.Unlock()
1124+}
1125+func (i *Interactive) applyTUIWorkingPositionSetting(position string) {
1126+ defer func() {
1127+ i.requestRendererClear()
1128+ i.invalidate()
1129+ }()
1130+ position = tui.NormalizeWorkingPosition(position)
1131+ i.cfg.TUIWorkingPosition = position
1132+ if i.cfg.SettingsStore != nil {
1133+ if err := i.cfg.SettingsStore.SetTUIWorkingPosition(position); err != nil {
1134+ i.mu.Lock()
1135+ i.statusErr = "settings: " + err.Error()
1136+ i.mu.Unlock()
1137+ return
1138+ }
1139+ }
1140+ i.mu.Lock()
1141+ label := "above input"
1142+ if position == tui.WorkingPositionBelowInput {
1143+ label = "below input"
1144+ }
1145+ i.statusOK = "working spinner position " + label
1146+ i.statusErr = ""
1147+ i.mu.Unlock()
1148+}
1149+func (i *Interactive) applyThemeSetting(name string) {
1150+ if i.cfg.SettingsStore != nil {
1151+ if err := i.cfg.SettingsStore.SetTheme(name); err != nil {
1152+ i.mu.Lock()
1153+ i.statusErr = "settings: " + err.Error()
1154+ i.mu.Unlock()
1155+ return
1156+ }
1157+ }
1158+ i.cfg.ThemeName = name
1159+ if name == "auto" {
1160+ i.cfg.ThemeName = ""
1161+ }
1162+ i.applyThemeNow(name)
1163+}
1164+func (i *Interactive) applyThemeNow(name string) {
1165+ if name == "" {
1166+ name = "auto"
1167+ }
1168+ detected := tui.Dark
1169+ if tui.IsLightTheme(i.cfg.Theme) {
1170+ detected = tui.Light
1171+ }
1172+ // Keep the startup terminal snapshot available when a user switches
1173+ // themes live; inherited mode must not issue OSC queries while the TUI
1174+ // already owns raw stdin.
1175+ detected.Terminal = i.cfg.Theme.Terminal
1176+ th, applied, err := tui.LoadThemeFromHome(i.cfg.ZutHome, name, detected)
1177+ if err != nil {
1178+ if i.cfg.SettingsStore != nil {
1179+ _ = i.cfg.SettingsStore.SetTheme("auto")
1180+ }
1181+ i.cfg.ThemeName = ""
1182+ th, _, _ = tui.LoadThemeFromHome(i.cfg.ZutHome, "auto", detected)
1183+ i.mu.Lock()
1184+ i.statusErr = "theme missing; reset to default"
1185+ i.mu.Unlock()
1186+ } else {
1187+ i.mu.Lock()
1188+ label := applied
1189+ if label == "" {
1190+ label = "auto"
1191+ }
1192+ i.statusOK = "theme " + label
1193+ i.statusErr = ""
1194+ i.mu.Unlock()
1195+ }
1196+ i.mu.Lock()
1197+ i.cfg.Theme = th
1198+ i.view.Theme = th
1199+ i.view.InvalidateRenderCache()
1200+ i.mu.Unlock()
1201+ i.ed.Prompt = th.AccentBar(th.Accent)
1202+ i.applyInputCursorColor()
1203+ i.spin.Configure(th)
1204+ i.requestRendererTheme(th)
1205+ i.requestRendererClear()
1206+ i.invalidate()
1207+}
1208+func (i *Interactive) applyReasoningSetting(level string) {
1209+ defer func() {
1210+ i.requestRendererClear()
1211+ i.invalidate()
1212+ }()
1213+ level = provider.NormalizeReasoning(level)
1214+ i.cfg.Reasoning = level
1215+ if i.cfg.SettingsStore != nil {
1216+ if err := i.cfg.SettingsStore.SetReasoning(level); err != nil {
1217+ i.mu.Lock()
1218+ i.statusErr = "settings: " + err.Error()
1219+ i.mu.Unlock()
1220+ return
1221+ }
1222+ }
1223+ if i.cfg.OnReasoningChanged != nil {
1224+ i.cfg.OnReasoningChanged(level)
1225+ }
1226+ i.mu.Lock()
1227+ if i.agent != nil {
1228+ i.agent.Reasoning = level
1229+ }
1230+ label := level
1231+ if label == "" {
1232+ label = "off"
1233+ }
1234+ i.statusOK = "reasoning level " + label
1235+ i.statusErr = ""
1236+ i.mu.Unlock()
1237+}
A packages/agent/modes/interactive_submit.go +61 -0
@@ -0,0 +1,61 @@
1+package modes
2+
3+import "context"
4+
5+// Submit feeds text through the agent loop as if the user had typed it.
6+func (i *Interactive) Submit(text string) {
7+ if cmd, ok := shellEscapeCommand(text); ok {
8+ i.startShellEscape(i.runCtx, cmd)
9+ return
10+ }
11+ parent := i.runCtx
12+ if parent == nil {
13+ parent = context.Background()
14+ }
15+ i.mu.Lock()
16+ awaitingStartupPre := i.awaitingStartupPre
17+ i.mu.Unlock()
18+ if !awaitingStartupPre {
19+ i.maybeStartSessionTitle(parent, text)
20+ }
21+ i.startTurn(parent, text)
22+}
23+
24+// completeStartupPre applies deferred InitialInput after entry.pre finishes.
25+// When AutoSubmitInitial was set, the deferred prompt is submitted; otherwise
26+// it only pre-fills the editor (CLI-supplied prompts).
27+func (i *Interactive) completeStartupPre() {
28+ i.mu.Lock()
29+ if !i.awaitingStartupPre {
30+ i.mu.Unlock()
31+ return
32+ }
33+ i.awaitingStartupPre = false
34+ deferred := i.deferredInitialInput
35+ auto := i.autoSubmitDeferred
36+ i.deferredInitialInput = ""
37+ i.autoSubmitDeferred = false
38+ onDone := i.cfg.OnStartupPreDone
39+ i.mu.Unlock()
40+ if onDone != nil {
41+ onDone()
42+ }
43+ i.startupPreDone <- startupPreResult{deferred: deferred, autoSubmit: auto}
44+ i.invalidate()
45+}
46+
47+// applyStartupPreResult runs on the TUI event loop so the editor remains
48+// single-threaded. Input entered while resources were reloading wins over the
49+// deferred prefill rather than being overwritten.
50+func (i *Interactive) applyStartupPreResult(result startupPreResult) {
51+ if result.deferred == "" {
52+ return
53+ }
54+ if result.autoSubmit {
55+ i.Submit(result.deferred)
56+ return
57+ }
58+ if i.ed.IsEmpty() {
59+ i.ed.SetValue(result.deferred)
60+ }
61+}
A packages/agent/modes/interactive_telegram.go +198 -0
@@ -0,0 +1,198 @@
1+package modes
2+
3+import (
4+ "context"
5+ "errors"
6+ "fmt"
7+
8+ "github.com/bnema/zut/packages/agent/modes/telegram"
9+)
10+
11+func (i *Interactive) openTelegramDialog() {
12+ items := i.telegramMenuItems()
13+ if len(items) == 0 {
14+ i.mu.Lock()
15+ i.statusErr = "telegram not configured. run `zut telegram-bot setup` first."
16+ i.mu.Unlock()
17+ i.invalidate()
18+ return
19+ }
20+ i.telegramDialog.Open(items)
21+ i.invalidate()
22+}
23+func (i *Interactive) telegramMenuItems() []telegramItem {
24+ cfg, err := telegram.LoadConfig(i.cfg.ZutHome)
25+ if err != nil || cfg.BotToken == "" {
26+ return nil
27+ }
28+ var items []telegramItem
29+ if i.telegramBridge != nil && i.telegramBridge.Active() {
30+ items = append(items, telegramItem{label: "disconnect", action: "disconnect", hint: "stop mirroring"})
31+ st := i.telegramBridge.State()
32+ hint := "active"
33+ if st.Username != "" {
34+ hint += " as @" + st.Username
35+ }
36+ items = append(items, telegramItem{label: "status", action: "status", hint: hint})
37+ } else {
38+ label := "connect"
39+ hint := "start mirroring dms into this session"
40+ if cfg.AllowedUserID == 0 {
41+ hint = "awaiting pairing (send /start to the bot once connected)"
42+ }
43+ items = append(items, telegramItem{label: label, action: "connect", hint: hint})
44+ items = append(items, telegramItem{label: "status", action: "status", hint: "disconnected"})
45+ }
46+ return items
47+}
48+func (i *Interactive) doTelegram(action string) {
49+ switch action {
50+ case "connect":
51+ i.telegramConnect()
52+ case "disconnect":
53+ i.telegramDisconnect()
54+ case "status":
55+ i.telegramStatus()
56+ default:
57+ i.mu.Lock()
58+ i.statusErr = "unknown telegram action: " + action + " (use connect, disconnect, or status)"
59+ i.mu.Unlock()
60+ i.invalidate()
61+ }
62+}
63+func (i *Interactive) telegramConnect() {
64+ if i.telegramBridge != nil && i.telegramBridge.Active() {
65+ i.mu.Lock()
66+ i.statusOK = "telegram already connected"
67+ i.statusErr = ""
68+ i.mu.Unlock()
69+ i.invalidate()
70+ return
71+ }
72+ cfg, err := telegram.LoadConfig(i.cfg.ZutHome)
73+ if err != nil {
74+ i.mu.Lock()
75+ i.statusErr = "telegram: " + err.Error()
76+ i.mu.Unlock()
77+ i.invalidate()
78+ return
79+ }
80+ if cfg.BotToken == "" {
81+ i.mu.Lock()
82+ i.statusErr = "telegram: no bot token configured. run `zut telegram-bot setup` first."
83+ i.mu.Unlock()
84+ i.invalidate()
85+ return
86+ }
87+ // Refuse to start when a background daemon is already polling
88+ // the same bot. Two concurrent long-poll consumers race each
89+ // update and one always loses, so DMs get half-delivered. The
90+ // user can `zut telegram-bot stop` first, then /telegram connect.
91+ if pid, alive, _ := telegram.IsRunning(i.cfg.ZutHome); alive && pid > 0 {
92+ i.mu.Lock()
93+ i.statusErr = fmt.Sprintf("telegram: bot daemon already running (pid %d). stop it with `zut telegram-bot stop` first.", pid)
94+ i.mu.Unlock()
95+ i.invalidate()
96+ return
97+ }
98+ bridge := &telegram.Bridge{
99+ Client: telegram.NewClient(cfg.BotToken),
100+ Config: cfg,
101+ Save: func(next telegram.Config) error {
102+ return telegram.SaveConfig(i.cfg.ZutHome, next)
103+ },
104+ Host: &telegramHost{iv: i},
105+ }
106+ i.mu.Lock()
107+ i.telegramBridge = bridge
108+ i.mu.Unlock()
109+ // Strip web_search before the bridge's startup handshake can accept a
110+ // Telegram update. ApplyAgentPromptConfig also keeps it stripped from
111+ // concurrent refreshes while this bridge pointer is attached.
112+ i.applyTelegramTools(true)
113+ if err := bridge.Start(i.runCtx); err != nil {
114+ i.mu.Lock()
115+ i.telegramBridge = nil
116+ i.mu.Unlock()
117+ refreshErr := i.refreshToolsAfterTelegram()
118+ i.mu.Lock()
119+ i.statusErr = "telegram connect failed: " + err.Error()
120+ if refreshErr != nil {
121+ i.statusErr += "; tool refresh: " + refreshErr.Error()
122+ }
123+ i.mu.Unlock()
124+ i.invalidate()
125+ return
126+ }
127+ state := bridge.State()
128+ label := "telegram connected"
129+ if state.Username != "" {
130+ label += " as @" + state.Username
131+ }
132+ if state.PairedID == 0 {
133+ label += " — send /start to the bot from your phone to claim it"
134+ }
135+ i.mu.Lock()
136+ i.statusOK = label
137+ i.statusErr = ""
138+ i.mu.Unlock()
139+ i.invalidate()
140+}
141+func (i *Interactive) telegramDisconnect() {
142+ if i.telegramBridge == nil || !i.telegramBridge.Active() {
143+ i.mu.Lock()
144+ i.statusOK = "telegram already disconnected"
145+ i.statusErr = ""
146+ i.mu.Unlock()
147+ i.invalidate()
148+ return
149+ }
150+ bridge := i.telegramBridge
151+ bridge.Stop()
152+ i.mu.Lock()
153+ // Clear the pointer before rebuilding the normal registry. This both
154+ // allows web_search back into the refresh and marks the bridge inactive
155+ // for any concurrent prompt-config commit.
156+ i.telegramBridge = nil
157+ i.mu.Unlock()
158+ refreshErr := i.refreshToolsAfterTelegram()
159+ i.mu.Lock()
160+ if refreshErr != nil {
161+ i.statusOK = ""
162+ i.statusErr = "telegram disconnect tool refresh: " + refreshErr.Error()
163+ } else {
164+ i.statusOK = "telegram disconnected"
165+ i.statusErr = ""
166+ }
167+ i.mu.Unlock()
168+ i.invalidate()
169+}
170+func (i *Interactive) refreshToolsAfterTelegram() error {
171+ if i.cfg.RefreshTools == nil {
172+ i.stripWebSearchTool()
173+ i.applyTelegramTools(false)
174+ return errors.New("live tool refresh is unavailable")
175+ }
176+ if err := i.cfg.RefreshTools(); err != nil {
177+ i.stripWebSearchTool()
178+ i.applyTelegramTools(false)
179+ return err
180+ }
181+ i.applyTelegramTools(false)
182+ return nil
183+}
184+func (a telegramSenderAdapter) SendImage(ctx context.Context, path, caption string) error {
185+ if a.bridge == nil {
186+ return fmt.Errorf("telegram bridge is not connected")
187+ }
188+ return a.bridge.SendImage(ctx, path, caption)
189+}
190+func (a telegramSenderAdapter) SendDocument(ctx context.Context, path, caption string) error {
191+ if a.bridge == nil {
192+ return fmt.Errorf("telegram bridge is not connected")
193+ }
194+ return a.bridge.SendDocument(ctx, path, caption)
195+}
196+func (a telegramSenderAdapter) Active() bool {
197+ return a.bridge != nil && a.bridge.Active()
198+}
A packages/agent/modes/interactive_turn.go +637 -0
@@ -0,0 +1,637 @@
1+package modes
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "strings"
7+ "unicode"
8+ "unicode/utf8"
9+
10+ "github.com/bnema/zut/packages/agent/tools"
11+ "github.com/bnema/zut/packages/core"
12+ "github.com/bnema/zut/packages/provider"
13+ "github.com/bnema/zut/packages/tui"
14+)
15+
16+func shellEscapeCommand(text string) (string, bool) {
17+ return ShellEscapeCommand(text)
18+}
19+func ShellEscapeCommand(text string) (string, bool) {
20+ trimmed := strings.TrimLeft(text, " \t")
21+ if !strings.HasPrefix(trimmed, "!") {
22+ return "", false
23+ }
24+ cmd := strings.TrimSpace(strings.TrimPrefix(trimmed, "!"))
25+ if cmd == "" {
26+ return "", false
27+ }
28+ return cmd, true
29+}
30+func (i *Interactive) startShellEscape(parent context.Context, cmd string) {
31+ i.mu.Lock()
32+ if i.busy || i.shellRunning {
33+ i.statusErr = "busy — wait for the current turn to finish before running a shell command"
34+ i.statusOK = ""
35+ i.mu.Unlock()
36+ i.invalidate()
37+ return
38+ }
39+ if parent == nil {
40+ parent = i.runCtx
41+ }
42+ if parent == nil {
43+ parent = context.Background()
44+ }
45+ ctx, cancel := context.WithCancel(parent)
46+ i.busy = true
47+ i.shellRunning = true
48+ i.shellLive = "$ " + cmd + "\n\n"
49+ i.cancelTurn = cancel
50+ i.statusErr = ""
51+ i.statusOK = ""
52+ i.spin.Start()
53+ i.activity = agentActivity{activity: activity{kind: activityRunningShellCommand}}
54+ // Clear stale extension notes the same way a new turn would so the
55+ // screen doesn't accumulate transient state.
56+ i.extNotes = nil
57+ i.scrollOffset = 0
58+ i.parkedTurn = 0
59+ i.parkedTotal = 0
60+ i.helpBlock = nil
61+ sandbox := i.cfg.Sandbox
62+ cwd := i.cfg.CWD
63+ i.mu.Unlock()
64+ i.invalidate()
65+
66+ go func() {
67+ defer cancel()
68+ raw, _ := json.Marshal(map[string]any{
69+ "command": cmd,
70+ "timeout": ShellEscapeTimeoutSeconds,
71+ })
72+ bash := &tools.BashTool{CWD: cwd, Sandbox: sandbox}
73+ progress := func(chunk string) {
74+ i.mu.Lock()
75+ i.shellLive += chunk
76+ i.mu.Unlock()
77+ i.invalidate()
78+ }
79+ res, err := bash.Execute(ctx, raw, progress)
80+
81+ var out string
82+ if err != nil {
83+ out = "$ " + cmd + "\n\n" + err.Error() + "\n\n[error]"
84+ } else {
85+ for _, c := range res.Content {
86+ if tb, ok := c.(provider.TextBlock); ok {
87+ out += tb.Text
88+ }
89+ }
90+ }
91+ cancelled := ctx.Err() != nil
92+ failed := err != nil || res.IsError || cancelled
93+ if cancelled {
94+ out += "\n\n[cancelled]"
95+ }
96+
97+ if i.agent != nil {
98+ i.agent.AppendUserContext(out, map[string]string{shellEscapeMetaKey: "true"})
99+ }
100+
101+ i.mu.Lock()
102+ i.shellRunning = false
103+ i.shellLive = ""
104+ i.busy = false
105+ i.cancelTurn = nil
106+ pendingIdleWork := i.takePendingIdleWorkLocked()
107+ awaitingPre := i.awaitingStartupPre
108+ if failed {
109+ if cancelled {
110+ i.statusErr = "shell command cancelled"
111+ } else {
112+ i.statusErr = "shell command failed"
113+ }
114+ i.statusOK = ""
115+ } else {
116+ i.statusOK = "shell command finished"
117+ i.statusErr = ""
118+ }
119+ i.mu.Unlock()
120+ runPendingIdleWork(pendingIdleWork)
121+ i.invalidate()
122+ if awaitingPre {
123+ i.completeStartupPre()
124+ }
125+ }()
126+}
127+func (i *Interactive) startTurn(parent context.Context, prompt string) {
128+ i.startTurnWithImages(parent, prompt, nil)
129+}
130+func (i *Interactive) startTurnWithImages(parent context.Context, prompt string, images []provider.ImageBlock) {
131+ i.startTurnRequest(parent, prompt, images, false, false)
132+}
133+func (i *Interactive) startTurnRequest(parent context.Context, prompt string, images []provider.ImageBlock, continueExisting, overflowRecoveryAttempted bool) {
134+ if i.agent == nil {
135+ // Text startup pre cannot run without credentials; continue so
136+ // deferred InitialInput (pre-fill or auto-submit) still applies.
137+ i.mu.Lock()
138+ awaitingPre := i.awaitingStartupPre
139+ i.mu.Unlock()
140+ if awaitingPre {
141+ i.completeStartupPre()
142+ }
143+ return
144+ }
145+ // Pre-turn safety: if the most recent context measurement is
146+ // already past the auto-compact threshold, condense before
147+ // sending so the next outbound request stays under the limit.
148+ // The condense flow re-fires the user's queued prompt for us, so
149+ // we just hand it off and exit.
150+ i.mu.Lock()
151+ var resetHandoff json.RawMessage
152+ var persistResetHandoff bool
153+ if !continueExisting {
154+ resetHandoff, persistResetHandoff = i.resetCompactContinuationLocked()
155+ }
156+ needsPreCompact := !overflowRecoveryAttempted && !i.autoCompacting && i.shouldAutoCompactLocked()
157+ if needsPreCompact {
158+ // Reserve the compaction hand-off before releasing i.mu. A completion
159+ // update arriving in the small gap before runCompact acquires the
160+ // mutex must join the host queue rather than starting a competing turn.
161+ i.busy = true
162+ i.compacting = true
163+ i.autoCompacting = true
164+ if continueExisting {
165+ i.continueAfterCompact = true
166+ } else {
167+ i.pendingCompactPrompt = prompt
168+ i.pendingCompactImages = append([]provider.ImageBlock(nil), images...)
169+ i.hasPendingCompactPrompt = true
170+ }
171+ i.statusErr = ""
172+ i.extNotes = append(i.extNotes, autoCompactNoteLine(i.cfg.Theme, "context near limit — condensing history before sending..."))
173+ i.pendingPostCompactNote = "context auto-compacted; sending your last message"
174+ i.mu.Unlock()
175+ if persistResetHandoff {
176+ i.persistCompactHandoff(resetHandoff)
177+ }
178+ i.invalidate()
179+ i.runCompact(parent, compactContinuationRequest{origin: compactOriginPreTurnThreshold})
180+ return
181+ }
182+ i.mu.Unlock()
183+ if persistResetHandoff {
184+ i.persistCompactHandoff(resetHandoff)
185+ }
186+
187+ ctx, cancel := context.WithCancel(parent)
188+ i.mu.Lock()
189+ i.busy = true
190+ i.spin.Start()
191+ i.activity = newAgentActivity(i.cfg.Provider, i.cfg.Model)
192+ i.cancelTurn = cancel
193+ i.statusErr = ""
194+ i.statusOK = ""
195+ i.streaming.Reset()
196+ i.streamOn = true
197+ i.pendingAlert = nil
198+ i.toolCalls = map[string]*tui.ToolCallView{}
199+ i.toolOrder = nil
200+ i.toolGate = map[string]int{}
201+ i.extNotes = nil // ext notes are one-shot; a new prompt clears them
202+ i.scrollOffset = 0 // jump back to the bottom on new turn
203+ // Lift the resume tail cap once the user starts interacting. The
204+ // cap is purely a first-paint optimization (don't markdown the
205+ // whole history before showing anything). Keeping it active during
206+ // a turn makes the rendered chat a sliding window: appended
207+ // messages push older ones off the TOP of the buffer, which the
208+ // renderer must treat as a change above the viewport and repaint
209+ // fully, snapping the terminal's native scrollback to the bottom on
210+ // every streamed chunk. A fresh session has no cap (append-only),
211+ // which is why the jump only shows up in resumed sessions. Dropping
212+ // the cap here makes resumed turns append-only too.
213+ i.view.TailLimit = 0
214+ // Reset the auto-follow baseline so the very next render at
215+ // interactive.go:1053 doesn't see a synthetic shrink between
216+ // "last frame had the previous turn's tool overlay" and
217+ // "this frame had it cleared above". Without this, the guard
218+ // reads delta = -(rows in cleared overlay) and decrements
219+ // scrollOffset, which on terminals that mirror zut's pane
220+ // scroll into the host scrollbar visibly yanks the viewport.
221+ // See autofollow_shrink_test.go for the exact arithmetic.
222+ i.prevChatLen = 0
223+ i.prevChatCols = 0
224+ i.parkedTurn = 0 // starting a turn clears the /jump parked state
225+ i.parkedTotal = 0
226+ i.helpBlock = nil // hide the help block once the user asks something
227+ i.mu.Unlock()
228+ i.invalidate()
229+
230+ var (
231+ lastStop provider.StopReason
232+ lastTurnErr error
233+ )
234+ sink := func(ev core.AgentEvent) {
235+ if e, ok := ev.(core.EvTurnEnd); ok {
236+ lastStop = e.Stop
237+ lastTurnErr = e.Err
238+ }
239+ i.handleEventForPresentation(ev)
240+ }
241+
242+ releaseCompletionHold := i.beginCompletionDeliveryHold()
243+ go func() {
244+ defer releaseCompletionHold()
245+ var err error
246+ if continueExisting {
247+ err = i.agent.Continue(ctx, sink)
248+ } else {
249+ err = i.agent.Prompt(ctx, prompt, images, sink)
250+ }
251+ i.mu.Lock()
252+ // Keep busy asserted through final cleanup and queue selection. A
253+ // watcher may enqueue a completion summary concurrently with the
254+ // provider's final event; publishing idle before inspecting queues can
255+ // strand that summary in the core agent queue or start an overlapping
256+ // turn.
257+ // Don't touch streamPending / streamFlushPending here — the
258+ // pacer may still be draining the final deltas and needs to
259+ // paint them even though Prompt has returned. It will reset
260+ // streamOn on its own once the buffer empties.
261+ if len(i.streamPending) == 0 {
262+ i.streamOn = false
263+ }
264+ i.cancelTurn = nil
265+ pendingIdleWork := i.takePendingIdleWorkLocked()
266+ if err != nil && ctx.Err() == nil {
267+ i.statusErr = err.Error()
268+ }
269+ // Decide whether to offer a model rescue picker for recoverable
270+ // provider failures (auth/rate/temporary). The picker opens after
271+ // the mutex is released so it can take its own locks freely.
272+ var (
273+ offer bool
274+ rescueWhy string
275+ rescueImgs []provider.ImageBlock
276+ rescueModel string
277+ rescueProv string
278+ rescueFprov string
279+ )
280+ if err != nil && ctx.Err() == nil {
281+ if ok, reason := classifyRescueError(err); ok {
282+ offer = true
283+ rescueWhy = reason
284+ rescueImgs = images
285+ rescueModel = i.cfg.Model
286+ rescueProv = i.cfg.Provider
287+ rescueFprov = extractFailedProvider(err)
288+ if rescueFprov == "" {
289+ rescueFprov = i.cfg.Provider
290+ }
291+ // Suppress the red banner — the rescue dialog already
292+ // surfaces the failure.
293+ i.statusErr = ""
294+ }
295+ }
296+ // Detect responses that reject the current context, either as an
297+ // HTTP 413 payload limit or a model context-window error. Token-
298+ // based auto-compact can miss both when metadata is stale or the
299+ // limit is measured in raw bytes. Compact once, then continue the
300+ // user message already present in the transcript.
301+ contextOverflow := err != nil && ctx.Err() == nil && provider.IsContextOverflowError(err)
302+ recoverContextOverflow := contextOverflow && !overflowRecoveryAttempted
303+ if recoverContextOverflow {
304+ i.statusErr = ""
305+ i.continueAfterCompact = true
306+ i.extNotes = append(i.extNotes, autoCompactNoteLine(i.cfg.Theme, "request was too large. condensing history before retrying ..."))
307+ i.pendingPostCompactNote = "context auto-compacted; retrying your last message"
308+ }
309+ // Persist the assistant's reply (and every tool row before
310+ // it) to the session file while the turn memory is hot.
311+ // Without this, WriteNewTranscript only fires at zut exit,
312+ // meaning a crash or ungraceful kill drops the whole
313+ // conversation. FlushSession is idempotent (it advances the
314+ // baseline so subsequent flushes only write new rows).
315+ flush := i.cfg.FlushSession
316+ i.mu.Unlock()
317+ runPendingIdleWork(pendingIdleWork)
318+ if flush != nil {
319+ flush()
320+ }
321+ terminalGoalError := ctx.Err() == nil && !offer && !recoverContextOverflow && (err != nil || lastTurnErr != nil || lastStop == provider.StopError)
322+ if terminalGoalError {
323+ i.updateActiveGoal(core.GoalBlocked, "turn ended with an error")
324+ }
325+ i.mu.Lock()
326+ awaitingPre := i.awaitingStartupPre
327+ // A newer explicit prompt may have cleared the handoff while the
328+ // completed turn was being flushed. Re-read it under the mutex before
329+ // deciding whether this continuation may spend another rescue attempt.
330+ statusRescueActive := i.compactContinuation.reason == compactContinuationStatusRescue
331+ // Pop the next queued message, if any, and relaunch.
332+ var next string
333+ var hasNext bool
334+ if !awaitingPre && len(i.queued) > 0 && ctx.Err() == nil && err == nil {
335+ next, i.queued = i.queued[0], i.queued[1:]
336+ hasNext = true
337+ }
338+ // If the turn was cancelled or errored, drop the queue so the
339+ // user isn't bombarded with stale messages after an interrupt.
340+ if ctx.Err() != nil || (err != nil && !recoverContextOverflow) {
341+ i.queued = nil
342+ if i.agent != nil {
343+ i.agent.DrainQueuedMessages()
344+ }
345+ }
346+ // Decide whether the next thing to do is an auto-compaction.
347+ // Only fires when the turn completed cleanly AND no host-side
348+ // or agent-side queued messages are waiting (otherwise a queued
349+ // message would race the condense).
350+ agentQueued := 0
351+ if i.agent != nil {
352+ agentQueued = i.agent.QueuedMessageCount()
353+ }
354+ continueQueued := !awaitingPre && !hasNext && agentQueued > 0 && err == nil && ctx.Err() == nil
355+ shouldAutoCompact := !awaitingPre && !hasNext && agentQueued == 0 && err == nil && ctx.Err() == nil && i.shouldAutoCompactLocked()
356+ continueStatusRescue := false
357+ var handoff json.RawMessage
358+ var persistHandoff bool
359+ if statusRescueActive && i.agent != nil && !awaitingPre && !hasNext && !continueQueued && !shouldAutoCompact && err == nil && ctx.Err() == nil && lastStop == provider.StopEnd && lastTurnErr == nil {
360+ followUpMessages := i.agent.Messages()
361+ reason := classifyCompactionContinuation(compactOriginManual, true, lastStop, lastTurnErr, followUpMessages)
362+ if reason == compactContinuationStatusRescue && i.compactContinuation.rescueAttempts < maxStatusRescueContinuations {
363+ handoff, persistHandoff = i.setCompactContinuationLocked(compactContinuationState{
364+ reason: compactContinuationStatusRescue,
365+ rescueAttempts: i.compactContinuation.rescueAttempts + 1,
366+ })
367+ continueStatusRescue = true
368+ } else {
369+ handoff, persistHandoff = i.resetCompactContinuationLocked()
370+ }
371+ }
372+ if !continueStatusRescue && (i.agent == nil || ctx.Err() != nil || err != nil || awaitingPre || hasNext || continueQueued || offer || recoverContextOverflow || (!shouldAutoCompact && !statusRescueActive)) {
373+ handoff, persistHandoff = i.resetCompactContinuationLocked()
374+ }
375+ // The agent run can finish before the paced final text reaches the
376+ // transcript. A compaction replaces that transcript, so it must never
377+ // race the still-live stream frame; otherwise stale deltas can repaint
378+ // after the replacement and corrupt the scrollback renderer's model.
379+ if recoverContextOverflow || shouldAutoCompact {
380+ i.resetStreamingStateLocked()
381+ }
382+ alertReason := mainAlertReason(ctx, err, lastTurnErr, lastStop, awaitingPre, hasNext || agentQueued > 0, offer, recoverContextOverflow, shouldAutoCompact)
383+ i.busy = hasNext || continueQueued || continueStatusRescue || recoverContextOverflow || shouldAutoCompact
384+ i.mu.Unlock()
385+ if persistHandoff {
386+ i.persistCompactHandoff(handoff)
387+ }
388+ if alertReason != "" {
389+ i.scheduleMainAlert(alertReason)
390+ }
391+ i.invalidate()
392+ parent := i.runCtx
393+ if parent == nil {
394+ parent = context.Background()
395+ }
396+ if awaitingPre {
397+ i.completeStartupPre()
398+ return
399+ }
400+ switch {
401+ case hasNext:
402+ i.startTurn(parent, next)
403+ case continueQueued:
404+ i.startTurnRequest(parent, "", nil, true, false)
405+ case continueStatusRescue:
406+ i.startAutoCompactContinuation(parent)
407+ case offer:
408+ i.openRescueDialog(rescueProv, rescueFprov, rescueModel, rescueWhy, prompt, rescueImgs)
409+ case recoverContextOverflow:
410+ i.runCompact(parent, compactContinuationRequest{origin: compactOriginRecovery})
411+ case shouldAutoCompact:
412+ i.runCompact(parent, compactContinuationRequest{
413+ origin: compactOriginAfterTurnThreshold,
414+ force: lastStop == provider.StopLength,
415+ lastStop: lastStop,
416+ turnError: lastTurnErr,
417+ })
418+ }
419+ }()
420+}
421+func mainAlertReason(ctx context.Context, err, turnErr error, stop provider.StopReason, awaitingPre, hasNext, rescue, recovering, autoCompacting bool) string {
422+ if awaitingPre || ctx.Err() != nil || hasNext || recovering || autoCompacting {
423+ return ""
424+ }
425+ if rescue {
426+ return "rescue_required"
427+ }
428+ if stop == provider.StopAborted {
429+ return ""
430+ }
431+ if err != nil || turnErr != nil || stop == provider.StopError {
432+ return "agent_error"
433+ }
434+ if stop == provider.StopLength {
435+ return "response_truncated"
436+ }
437+ return "agent_done"
438+}
439+func (i *Interactive) openRescueDialog(activeProvider, failedProvider, failedModel, reason, prompt string, images []provider.ImageBlock) {
440+ if i.rescueDialog == nil {
441+ return
442+ }
443+ loggedIn := []string{}
444+ if i.cfg.LoggedInProviders != nil {
445+ loggedIn = i.cfg.LoggedInProviders()
446+ }
447+ fprov := failedProvider
448+ if fprov == "" {
449+ fprov = activeProvider
450+ }
451+ i.mu.Lock()
452+ i.pendingRescuePrompt = prompt
453+ i.pendingRescueImages = images
454+ i.mu.Unlock()
455+ i.rescueDialog.Open(failedModel, loggedIn, fprov, failedModel, reason, prompt)
456+ i.invalidate()
457+}
458+func (i *Interactive) applyRescueSelection(prov, model, prompt string) {
459+ if model == "" {
460+ return
461+ }
462+ i.applyRescueModelSelection(prov, model)
463+ i.mu.Lock()
464+ images := i.pendingRescueImages
465+ if prompt == "" {
466+ prompt = i.pendingRescuePrompt
467+ }
468+ i.pendingRescuePrompt = ""
469+ i.pendingRescueImages = nil
470+ i.mu.Unlock()
471+ parent := i.runCtx
472+ if parent == nil {
473+ parent = context.Background()
474+ }
475+ i.startTurnWithImages(parent, prompt, images)
476+}
477+func stripAutoCompactNotes(notes []string) []string {
478+ if len(notes) == 0 {
479+ return notes
480+ }
481+ out := notes[:0]
482+ for _, n := range notes {
483+ if strings.Contains(n, "condensing history") {
484+ continue
485+ }
486+ out = append(out, n)
487+ }
488+ return out
489+}
490+func autoCompactNoteLine(th tui.Theme, msg string) string {
491+ return " " + th.FGColor(th.Warning, "⚠ "+msg)
492+}
493+func NormalizeAutoCompactThreshold(threshold *int) int {
494+ const defaultAutoCompactThreshold = 85
495+ if threshold == nil {
496+ return defaultAutoCompactThreshold
497+ }
498+ switch *threshold {
499+ case 0, 70, 80, 85, 90, 95:
500+ return *threshold
501+ default:
502+ return defaultAutoCompactThreshold
503+ }
504+}
505+func ShouldAutoCompact(inputTokens, contextWindow, thresholdPercent int) bool {
506+ if inputTokens <= 0 || contextWindow <= 0 || thresholdPercent <= 0 {
507+ return false
508+ }
509+ return float64(inputTokens)/float64(contextWindow) >= float64(thresholdPercent)/100
510+}
511+func classifyCompactionContinuation(origin compactContinuationOrigin, statusRescueActive bool, stop provider.StopReason, turnErr error, msgs []provider.Message) compactContinuationReason {
512+ if origin != compactOriginManual && structuralCompactionContinuation(msgs) {
513+ return compactContinuationStructuralTail
514+ }
515+ if (origin != compactOriginAfterTurnThreshold && !statusRescueActive) || stop != provider.StopEnd || turnErr != nil {
516+ return compactContinuationNone
517+ }
518+ if likelyForwardWorkStatus(msgs) {
519+ return compactContinuationStatusRescue
520+ }
521+ return compactContinuationNone
522+}
523+func structuralCompactionContinuation(msgs []provider.Message) bool {
524+ if len(msgs) == 0 {
525+ return false
526+ }
527+ last := msgs[len(msgs)-1]
528+ if last.Role != provider.RoleAssistant {
529+ return true
530+ }
531+ hasText := false
532+ for _, content := range last.Content {
533+ switch block := content.(type) {
534+ case provider.ToolCallBlock:
535+ return true
536+ case provider.TextBlock:
537+ if strings.TrimSpace(block.Text) != "" {
538+ hasText = true
539+ }
540+ case provider.ReasoningBlock:
541+ // Reasoning without a visible answer is not terminal.
542+ default:
543+ return true
544+ }
545+ }
546+ return !hasText
547+}
548+func likelyForwardWorkStatus(msgs []provider.Message) bool {
549+ if len(msgs) == 0 {
550+ return false
551+ }
552+ last := msgs[len(msgs)-1]
553+ if last.Role != provider.RoleAssistant {
554+ return false
555+ }
556+ var text strings.Builder
557+ for _, content := range last.Content {
558+ block, ok := content.(provider.TextBlock)
559+ if !ok {
560+ if _, hasTool := content.(provider.ToolCallBlock); hasTool {
561+ return false
562+ }
563+ continue
564+ }
565+ if strings.TrimSpace(block.Text) == "" {
566+ continue
567+ }
568+ if text.Len() > 0 {
569+ text.WriteByte(' ')
570+ }
571+ text.WriteString(block.Text)
572+ }
573+ if text.Len() == 0 {
574+ return false
575+ }
576+ normalized := strings.ToLower(strings.Join(strings.Fields(text.String()), " "))
577+ normalized = strings.NewReplacer("’", "'", "‘", "'", "`", "'").Replace(normalized)
578+ for _, phrase := range []string{
579+ "next i will",
580+ "next, i will",
581+ "i'll now",
582+ "i will now",
583+ "i am going to",
584+ "i need to continue",
585+ "then i will",
586+ "then, i will",
587+ "i will proceed",
588+ } {
589+ if containsFutureWorkPhrase(normalized, phrase) {
590+ return true
591+ }
592+ }
593+ return false
594+}
595+func containsFutureWorkPhrase(text, phrase string) bool {
596+ for offset := 0; offset < len(text); {
597+ index := strings.Index(text[offset:], phrase)
598+ if index < 0 {
599+ return false
600+ }
601+ index += offset
602+ end := index + len(phrase)
603+ if !wordRuneBefore(text, index) && !wordRuneAfter(text, end) {
604+ return true
605+ }
606+ offset = end
607+ }
608+ return false
609+}
610+func wordRuneBefore(text string, index int) bool {
611+ if index == 0 {
612+ return false
613+ }
614+ rune_, _ := utf8.DecodeLastRuneInString(text[:index])
615+ return unicode.IsLetter(rune_) || unicode.IsDigit(rune_) || rune_ == '_'
616+}
617+func wordRuneAfter(text string, index int) bool {
618+ if index == len(text) {
619+ return false
620+ }
621+ rune_, _ := utf8.DecodeRuneInString(text[index:])
622+ return unicode.IsLetter(rune_) || unicode.IsDigit(rune_) || rune_ == '_'
623+}
624+func (i *Interactive) shouldAutoCompactLocked() bool {
625+ if i.agent == nil {
626+ return false
627+ }
628+ if i.autoCompacting {
629+ return false
630+ }
631+ m, err := provider.FindModel(i.cfg.Provider, i.cfg.Model)
632+ if err != nil || m.ContextWindow <= 0 {
633+ return false
634+ }
635+ threshold := NormalizeAutoCompactThreshold(i.cfg.AutoCompactThreshold)
636+ return ShouldAutoCompact(i.lastCtxInput, m.ContextWindow, threshold)
637+}

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