refactor(modes): split interactive responsibilities
- commit
- cf1ed1b78ed8717a8262667ee2e3d86d5200eb1b
- author
- brice · · 2026-08-14 03:49 UTC
- committer
- brice · · 2026-08-14 03:57 UTC
- parent
- 06af173
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
11 files · +8017 -8017
M packages/agent/modes/interactive.go +160 -8017 large diff (8177 lines changed): collapsed
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_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 | + | } | |
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_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 | + | } | |