Merge pull request #127 from bnema/refactor/interactive-mode-files
refactor(modes): split interactive responsibilities
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.done | 100 | <-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 | + | } | |||