1package modes
2
3import (
4 "context"
5 "encoding/json"
6 "errors"
7 "strings"
8 "unicode"
9 "unicode/utf8"
10
11 "github.com/bnema/zut/packages/agent/tools"
12 "github.com/bnema/zut/packages/core"
13 "github.com/bnema/zut/packages/provider"
14 "github.com/bnema/zut/packages/tui"
15)
16
17func shellEscapeCommand(text string) (string, bool) {
18 return ShellEscapeCommand(text)
19}
20func ShellEscapeCommand(text string) (string, bool) {
21 trimmed := strings.TrimLeft(text, " \t")
22 if !strings.HasPrefix(trimmed, "!") {
23 return "", false
24 }
25 cmd := strings.TrimSpace(strings.TrimPrefix(trimmed, "!"))
26 if cmd == "" {
27 return "", false
28 }
29 return cmd, true
30}
31func (i *Interactive) startShellEscape(parent context.Context, cmd string) {
32 i.mu.Lock()
33 if i.busy || i.shellRunning {
34 i.statusErr = "busy — wait for the current turn to finish before running a shell command"
35 i.statusOK = ""
36 i.mu.Unlock()
37 i.invalidate()
38 return
39 }
40 if parent == nil {
41 parent = i.runCtx
42 }
43 if parent == nil {
44 parent = context.Background()
45 }
46 ctx, cancel := context.WithCancel(parent)
47 i.busy = true
48 i.shellRunning = true
49 i.shellLive = "$ " + cmd + "\n\n"
50 i.cancelTurn = cancel
51 i.statusErr = ""
52 i.statusOK = ""
53 i.spin.Start()
54 i.activity = agentActivity{activity: activity{kind: activityRunningShellCommand}}
55 // Clear stale extension notes the same way a new turn would so the
56 // screen doesn't accumulate transient state.
57 i.extNotes = nil
58 i.scrollOffset = 0
59 i.parkedTurn = 0
60 i.parkedTotal = 0
61 i.helpBlock = nil
62 sandbox := i.cfg.Sandbox
63 cwd := i.cfg.CWD
64 i.mu.Unlock()
65 i.invalidate()
66
67 go func() {
68 defer cancel()
69 raw, _ := json.Marshal(map[string]any{
70 "command": cmd,
71 "timeout": ShellEscapeTimeoutSeconds,
72 })
73 bash := &tools.BashTool{CWD: cwd, Sandbox: sandbox}
74 progress := func(chunk string) {
75 i.mu.Lock()
76 i.shellLive += chunk
77 i.mu.Unlock()
78 i.invalidate()
79 }
80 res, err := bash.Execute(ctx, raw, progress)
81
82 var out string
83 if err != nil {
84 out = "$ " + cmd + "\n\n" + err.Error() + "\n\n[error]"
85 } else {
86 for _, c := range res.Content {
87 if tb, ok := c.(provider.TextBlock); ok {
88 out += tb.Text
89 }
90 }
91 }
92 cancelled := ctx.Err() != nil
93 failed := err != nil || res.IsError || cancelled
94 if cancelled {
95 out += "\n\n[cancelled]"
96 }
97
98 if i.agent != nil {
99 i.agent.AppendUserContext(out, map[string]string{shellEscapeMetaKey: "true"})
100 }
101
102 i.mu.Lock()
103 i.shellRunning = false
104 i.shellLive = ""
105 i.busy = false
106 i.cancelTurn = nil
107 pendingIdleWork := i.takePendingIdleWorkLocked()
108 awaitingPre := i.awaitingStartupPre
109 if failed {
110 if cancelled {
111 i.statusErr = "shell command cancelled"
112 } else {
113 i.statusErr = "shell command failed"
114 }
115 i.statusOK = ""
116 } else {
117 i.statusOK = "shell command finished"
118 i.statusErr = ""
119 }
120 i.mu.Unlock()
121 runPendingIdleWork(pendingIdleWork)
122 i.invalidate()
123 if awaitingPre {
124 i.completeStartupPre()
125 }
126 }()
127}
128func (i *Interactive) startTurn(parent context.Context, prompt string) {
129 i.startTurnWithImages(parent, prompt, nil)
130}
131func (i *Interactive) startTurnWithImages(parent context.Context, prompt string, images []provider.ImageBlock) {
132 i.startTurnRequest(parent, prompt, images, false, false)
133}
134
135// startQueuedTurn keeps host provenance in the agent queue until injection,
136// including when the next turn first needs pre-turn compaction.
137func (i *Interactive) startQueuedTurn(parent context.Context, message core.QueuedMessage) {
138 if message.HostEvent && i.agent != nil {
139 i.agent.QueuePrompt(message)
140 i.startTurnRequest(parent, "", nil, true, false)
141 return
142 }
143 i.startTurnWithImages(parent, message.Text, message.Images)
144}
145func (i *Interactive) startTurnRequest(parent context.Context, prompt string, images []provider.ImageBlock, continueExisting, overflowRecoveryAttempted bool) {
146 if !continueExisting && i.agent != nil {
147 i.agent.ResetRepetitionGuard()
148 }
149 if i.agent == nil {
150 // Text startup pre cannot run without credentials; continue so
151 // deferred InitialInput (pre-fill or auto-submit) still applies.
152 i.mu.Lock()
153 awaitingPre := i.awaitingStartupPre
154 i.mu.Unlock()
155 if awaitingPre {
156 i.completeStartupPre()
157 }
158 return
159 }
160 // Pre-turn safety: if the most recent context measurement is
161 // already past the auto-compact threshold, condense before
162 // sending so the next outbound request stays under the limit.
163 // The condense flow re-fires the user's queued prompt for us, so
164 // we just hand it off and exit.
165 i.mu.Lock()
166 var resetHandoff json.RawMessage
167 var persistResetHandoff bool
168 if !continueExisting {
169 resetHandoff, persistResetHandoff = i.resetCompactContinuationLocked()
170 }
171 needsPreCompact := !overflowRecoveryAttempted && !i.autoCompacting && i.shouldAutoCompactLocked()
172 if needsPreCompact {
173 // Reserve the compaction hand-off before releasing i.mu. A completion
174 // update arriving in the small gap before runCompact acquires the
175 // mutex must join the host queue rather than starting a competing turn.
176 i.busy = true
177 i.compacting = true
178 i.autoCompacting = true
179 if continueExisting {
180 i.continueAfterCompact = true
181 } else {
182 i.pendingCompactPrompt = prompt
183 i.pendingCompactImages = append([]provider.ImageBlock(nil), images...)
184 i.hasPendingCompactPrompt = true
185 }
186 i.statusErr = ""
187 i.extNotes = append(i.extNotes, autoCompactNoteLine(i.cfg.Theme, "context near limit — condensing history before sending..."))
188 i.pendingPostCompactNote = "context auto-compacted; sending your last message"
189 i.mu.Unlock()
190 if persistResetHandoff {
191 i.persistCompactHandoff(resetHandoff)
192 }
193 i.invalidate()
194 i.runCompact(parent, compactContinuationRequest{origin: compactOriginPreTurnThreshold})
195 return
196 }
197 i.mu.Unlock()
198 if persistResetHandoff {
199 i.persistCompactHandoff(resetHandoff)
200 }
201 // Bind a pending Esc interruption to the ordinary user turn that is
202 // actually starting. Queued-while-busy prompts do not consume it; only
203 // this start path does, so the hidden instruction precedes the prompt
204 // that owns the single automatic goal return.
205 if !continueExisting {
206 i.consumeInterruptedGoalPrompt()
207 }
208
209 ctx, cancel := context.WithCancel(parent)
210 i.mu.Lock()
211 i.busy = true
212 i.spin.Start()
213 i.activity = newAgentActivity(i.cfg.Provider, i.cfg.Model)
214 i.cancelTurn = cancel
215 i.statusErr = ""
216 i.statusOK = ""
217 i.pendingAlert = nil
218 i.toolCalls = map[string]*tui.ToolCallView{}
219 i.toolOrder = nil
220 i.stream.Reset()
221 i.extNotes = nil // ext notes are one-shot; a new prompt clears them
222 i.scrollOffset = 0 // jump back to the bottom on new turn
223 // Lift the resume tail cap once the user starts interacting. The
224 // cap is purely a first-paint optimization (don't markdown the
225 // whole history before showing anything). Keeping it active during
226 // a turn makes the rendered chat a sliding window: appended
227 // messages push older ones off the TOP of the buffer, which the
228 // renderer must treat as a change above the viewport and repaint
229 // fully, snapping the terminal's native scrollback to the bottom on
230 // every streamed chunk. A fresh session has no cap (append-only),
231 // which is why the jump only shows up in resumed sessions. Dropping
232 // the cap here makes resumed turns append-only too.
233 i.view.TailLimit = 0
234 // Reset the auto-follow baseline so the very next render at
235 // interactive.go:1053 doesn't see a synthetic shrink between
236 // "last frame had the previous turn's tool overlay" and
237 // "this frame had it cleared above". Without this, the guard
238 // reads delta = -(rows in cleared overlay) and decrements
239 // scrollOffset, which on terminals that mirror zut's pane
240 // scroll into the host scrollbar visibly yanks the viewport.
241 // See autofollow_shrink_test.go for the exact arithmetic.
242 i.prevChatLen = 0
243 i.prevChatCols = 0
244 i.parkedTurn = 0 // starting a turn clears the /jump parked state
245 i.parkedTotal = 0
246 i.helpBlock = nil // hide the help block once the user asks something
247 i.mu.Unlock()
248 i.invalidate()
249
250 var (
251 lastStop provider.StopReason
252 lastTurnErr error
253 )
254 sink := func(ev core.AgentEvent) {
255 if e, ok := ev.(core.EvTurnEnd); ok {
256 lastStop = e.Stop
257 lastTurnErr = e.Err
258 }
259 i.observeGoalRun(ev)
260 i.handleEventForPresentation(ev)
261 }
262
263 releaseCompletionHold := i.beginCompletionDeliveryHold()
264 go func() {
265 defer releaseCompletionHold()
266 var err error
267 if continueExisting {
268 err = i.agent.Continue(ctx, sink)
269 } else {
270 err = i.agent.Prompt(ctx, prompt, images, sink)
271 }
272 i.mu.Lock()
273 // Keep busy asserted through final cleanup and queue selection. A
274 // watcher may enqueue a completion summary concurrently with the
275 // provider's final event; publishing idle before inspecting queues can
276 // strand that summary in the core agent queue or start an overlapping
277 // turn.
278 // On success, let the pacer finish painting the final deltas; it
279 // returns the presenter to idle when done. A cancelled turn stops
280 // typing at once.
281 if ctx.Err() != nil {
282 i.stream.Reset()
283 } else {
284 i.stream.Finish()
285 }
286 i.cancelTurn = nil
287 pendingIdleWork := i.takePendingIdleWorkLocked()
288 if err != nil && ctx.Err() == nil {
289 i.statusErr = err.Error()
290 }
291 // Decide whether to offer a model rescue picker for recoverable
292 // provider failures (auth/rate/temporary). The picker opens after
293 // the mutex is released so it can take its own locks freely.
294 var (
295 offer bool
296 rescueWhy string
297 rescueImgs []provider.ImageBlock
298 rescueModel string
299 rescueProv string
300 rescueFprov string
301 )
302 if err != nil && ctx.Err() == nil {
303 if ok, reason := classifyRescueError(err); ok {
304 offer = true
305 rescueWhy = reason
306 rescueImgs = images
307 rescueModel = i.cfg.Model
308 rescueProv = i.cfg.Provider
309 rescueFprov = extractFailedProvider(err)
310 if rescueFprov == "" {
311 rescueFprov = i.cfg.Provider
312 }
313 // Suppress the red banner — the rescue dialog already
314 // surfaces the failure.
315 i.statusErr = ""
316 }
317 }
318 // Detect responses that reject the current context, either as an
319 // HTTP 413 payload limit or a model context-window error. Token-
320 // based auto-compact can miss both when metadata is stale or the
321 // limit is measured in raw bytes. Compact once, then continue the
322 // user message already present in the transcript.
323 contextOverflow := err != nil && ctx.Err() == nil && provider.IsContextOverflowError(err)
324 recoverContextOverflow := contextOverflow && !overflowRecoveryAttempted
325 if recoverContextOverflow {
326 i.statusErr = ""
327 i.continueAfterCompact = true
328 i.extNotes = append(i.extNotes, autoCompactNoteLine(i.cfg.Theme, "request was too large. condensing history before retrying ..."))
329 i.pendingPostCompactNote = "context auto-compacted; retrying your last message"
330 }
331 // Persist the assistant's reply (and every tool row before
332 // it) to the session file while the turn memory is hot.
333 // Without this, WriteNewTranscript only fires at zut exit,
334 // meaning a crash or ungraceful kill drops the whole
335 // conversation. FlushSession is idempotent (it advances the
336 // baseline so subsequent flushes only write new rows).
337 flush := i.cfg.FlushSession
338 goalRun := i.goalRun
339 i.mu.Unlock()
340 runPendingIdleWork(pendingIdleWork)
341 if flush != nil {
342 flush()
343 }
344 stalledGoal := false
345 if errors.Is(err, core.ErrRepetitiveLoop) {
346 stalledGoal = i.stallGoalRun(goalRun, err.Error())
347 }
348 terminalGoalError := ctx.Err() == nil && !offer && !recoverContextOverflow && (err != nil || lastTurnErr != nil || lastStop == provider.StopError)
349 if terminalGoalError && !stalledGoal {
350 status, reason := core.GoalBlocked, "turn ended with an error"
351 if errors.Is(err, core.ErrRepetitiveLoop) {
352 status, reason = core.GoalStalled, err.Error()
353 }
354 i.updateActiveGoal(status, reason)
355 }
356 // A cancelled or failed turn unwinds without scheduling autonomous
357 // work below. Preserve a freshly armed Esc return for the next
358 // ordinary prompt; drop only a return already bound to this turn.
359 if ctx.Err() != nil || err != nil {
360 i.mu.Lock()
361 if state := i.interruptedGoalReturn; state != nil && state.returnAfterTurn {
362 i.interruptedGoalReturn = nil
363 }
364 i.mu.Unlock()
365 }
366 goalContextLimited := lastStop == provider.StopLength
367 // Cancellation is user action, not model inaction: unwind the lease
368 // without consuming the no-progress allowance or stalling the goal.
369 continueGoal := i.finishGoalRunCancelled(goalContextLimited, ctx.Err() != nil)
370 if errors.Is(err, core.ErrRepetitiveLoop) {
371 continueGoal = false
372 }
373 i.mu.Lock()
374 awaitingPre := i.awaitingStartupPre
375 // A newer explicit prompt may have cleared the handoff while the
376 // completed turn was being flushed. Re-read it under the mutex before
377 // deciding whether this continuation may spend another rescue attempt.
378 statusRescueActive := i.compactContinuation.reason == compactContinuationStatusRescue
379 // A fresh user prompt typed during a repetitive loop is an explicit
380 // request, not an automatic retry. Preserve it and start a new Prompt.
381 if errors.Is(err, core.ErrRepetitiveLoop) {
382 pending := i.takeQueuedMessagesLocked()
383 for _, message := range pending {
384 if message.HostEvent {
385 if i.agent != nil {
386 i.agent.QueuePrompt(message)
387 } else {
388 i.queued = append(i.queued, message)
389 }
390 continue
391 }
392 i.queued = append(i.queued, message)
393 }
394 }
395 // Pop the next queued message, if any, and relaunch.
396 var next core.QueuedMessage
397 var hasNext bool
398 if !awaitingPre && len(i.queued) > 0 && ctx.Err() == nil && (err == nil || errors.Is(err, core.ErrRepetitiveLoop)) {
399 next, i.queued = i.queued[0], i.queued[1:]
400 hasNext = true
401 }
402 // If the turn was cancelled or errored, drop ordinary input so the
403 // user isn't bombarded with stale messages after an interrupt.
404 // Scheduler follow-ups deliberately remain: they are distinct turns
405 // whose due time must not let them steer this failed turn.
406 if ctx.Err() != nil || (err != nil && !recoverContextOverflow && !errors.Is(err, core.ErrRepetitiveLoop)) {
407 i.discardQueuedMessagesLocked(ctx.Err() == nil)
408 }
409 var scheduled scheduledFollowUp
410 var hasScheduled bool
411 if errors.Is(err, core.ErrRepetitiveLoop) {
412 for _, followUp := range i.scheduled {
413 followUp.accepted <- err
414 }
415 i.scheduled = nil
416 } else if !awaitingPre && !hasNext && len(i.scheduled) > 0 {
417 scheduled, i.scheduled = i.scheduled[0], i.scheduled[1:]
418 hasScheduled = true
419 }
420 // Decide whether the next thing to do is an auto-compaction.
421 // Only fires when the turn completed cleanly AND no host-side
422 // or agent-side queued messages are waiting (otherwise a queued
423 // message would race the condense).
424 agentQueued := 0
425 if i.agent != nil {
426 agentQueued = i.agent.QueuedMessageCount()
427 }
428 continueQueued := !awaitingPre && !hasNext && agentQueued > 0 && err == nil && ctx.Err() == nil
429 // A truncated autonomous goal needs fresh execution capacity even when
430 // context-usage metadata is absent or below the configured threshold.
431 // Route it through compaction rather than immediately reusing the same
432 // exhausted transcript.
433 goalNeedsFreshContext := continueGoal && goalContextLimited
434 shouldAutoCompact := !awaitingPre && !hasNext && agentQueued == 0 && err == nil && ctx.Err() == nil && (goalNeedsFreshContext || i.shouldAutoCompactLocked())
435 continueStatusRescue := false
436 var handoff json.RawMessage
437 var persistHandoff bool
438 if goalNeedsFreshContext {
439 // Persist ownership before compaction starts. If the process exits
440 // while condensing, session resume repeats compaction before it
441 // provisions a fresh leased goal turn.
442 handoff, persistHandoff = i.setCompactContinuationLocked(compactContinuationState{reason: compactContinuationGoalCompactionPending})
443 }
444 if statusRescueActive && i.agent != nil && !awaitingPre && !hasNext && !continueQueued && !shouldAutoCompact && err == nil && ctx.Err() == nil && lastStop == provider.StopEnd && lastTurnErr == nil {
445 followUpMessages := i.agent.Messages()
446 reason := classifyCompactionContinuation(compactOriginManual, true, lastStop, lastTurnErr, followUpMessages)
447 if reason == compactContinuationStatusRescue && i.compactContinuation.rescueAttempts < maxStatusRescueContinuations {
448 handoff, persistHandoff = i.setCompactContinuationLocked(compactContinuationState{
449 reason: compactContinuationStatusRescue,
450 rescueAttempts: i.compactContinuation.rescueAttempts + 1,
451 })
452 continueStatusRescue = true
453 } else {
454 handoff, persistHandoff = i.resetCompactContinuationLocked()
455 }
456 }
457 if !continueStatusRescue && (i.agent == nil || ctx.Err() != nil || err != nil || awaitingPre || hasNext || hasScheduled || continueQueued || offer || recoverContextOverflow || (!shouldAutoCompact && !statusRescueActive)) {
458 handoff, persistHandoff = i.resetCompactContinuationLocked()
459 }
460 // The agent run can finish before the paced final text reaches the
461 // transcript. A compaction replaces that transcript, so it must never
462 // race the still-live stream frame; otherwise stale deltas can repaint
463 // after the replacement and corrupt the scrollback renderer's model.
464 if recoverContextOverflow || shouldAutoCompact {
465 i.stream.Reset()
466 }
467 // A clean ordinary turn that owns a pending Esc return resumes the
468 // same still-active goal exactly once through the existing scheduler.
469 // It yields to every existing priority above; terminal, paused, or
470 // replaced goals never resume because the match check fails.
471 interruptedReturn := false
472 if err == nil && ctx.Err() == nil && !awaitingPre && !hasNext && !hasScheduled && !continueQueued && !offer && !recoverContextOverflow && !shouldAutoCompact && !continueStatusRescue && !continueGoal {
473 state := i.interruptedGoalReturn
474 if state != nil && state.returnAfterTurn && i.interruptedGoalMatchesLocked() {
475 // Consume only after matching: the match helper reads the
476 // state being consumed.
477 i.interruptedGoalReturn = nil
478 interruptedReturn = true
479 }
480 } else if ctx.Err() == nil && err == nil {
481 // Another follow-up won this turn: keep an armed-but-unbound
482 // interruption pending without consuming the one-shot return.
483 // A bound return that lost priority is inert until replaced.
484 if state := i.interruptedGoalReturn; state != nil && state.returnAfterTurn {
485 i.interruptedGoalReturn = nil
486 }
487 }
488 alertReason := mainAlertReason(ctx, err, lastTurnErr, lastStop, awaitingPre, hasNext || hasScheduled || agentQueued > 0 || continueGoal || interruptedReturn, offer, recoverContextOverflow, shouldAutoCompact)
489 // interruptedReturn already reserves the turn slot (like the
490 // compact handoff path below): the reserved continuation owns
491 // busy and must not re-enter the idle gate.
492 i.busy = hasNext || hasScheduled || continueQueued || continueStatusRescue || recoverContextOverflow || shouldAutoCompact || interruptedReturn
493 i.mu.Unlock()
494 if persistHandoff {
495 i.persistCompactHandoff(handoff)
496 }
497 if alertReason != "" {
498 i.scheduleMainAlert(alertReason)
499 }
500 i.invalidate()
501 parent := i.runCtx
502 if parent == nil {
503 parent = context.Background()
504 }
505 if awaitingPre {
506 i.completeStartupPre()
507 return
508 }
509 switch {
510 case errors.Is(err, core.ErrRepetitiveLoop):
511 // Keep the interactive session open and honor a user prompt queued
512 // during the stopped run, but do not resume autonomous work.
513 if hasNext {
514 i.startQueuedTurn(parent, next)
515 }
516 case hasNext:
517 i.startQueuedTurn(parent, next)
518 case hasScheduled:
519 // The scheduler holds the session transition read lock until this
520 // acknowledgement, so accepting here binds the prompt to the agent
521 // that just completed rather than a subsequently loaded session.
522 scheduled.accepted <- nil
523 i.startTurn(parent, scheduled.text)
524 case continueQueued:
525 i.startTurnRequest(parent, "", nil, true, false)
526 case continueStatusRescue:
527 i.startAutoCompactContinuation(parent)
528 case offer:
529 i.openRescueDialog(rescueProv, rescueFprov, rescueModel, rescueWhy, prompt, rescueImgs)
530 case recoverContextOverflow:
531 i.runCompact(parent, compactContinuationRequest{origin: compactOriginRecovery})
532 case shouldAutoCompact:
533 i.runCompact(parent, compactContinuationRequest{
534 origin: compactOriginAfterTurnThreshold,
535 force: lastStop == provider.StopLength,
536 lastStop: lastStop,
537 turnError: lastTurnErr,
538 })
539 case continueGoal:
540 i.requestGoalContinuationIfIdle(parent)
541 case interruptedReturn:
542 // busy is already reserved above; use the reserved entry point
543 // so the one-shot return cannot fail the idle gate and leak
544 // the reservation.
545 i.startReservedGoalContinuation(parent)
546 }
547 }()
548}
549func mainAlertReason(ctx context.Context, err, turnErr error, stop provider.StopReason, awaitingPre, hasNext, rescue, recovering, autoCompacting bool) string {
550 if awaitingPre || ctx.Err() != nil || hasNext || recovering || autoCompacting {
551 return ""
552 }
553 if rescue {
554 return "rescue_required"
555 }
556 if stop == provider.StopAborted {
557 return ""
558 }
559 if err != nil || turnErr != nil || stop == provider.StopError {
560 return "agent_error"
561 }
562 if stop == provider.StopLength {
563 return "response_truncated"
564 }
565 return "agent_done"
566}
567func (i *Interactive) openRescueDialog(activeProvider, failedProvider, failedModel, reason, prompt string, images []provider.ImageBlock) {
568 if i.rescueDialog == nil {
569 return
570 }
571 loggedIn := []string{}
572 if i.cfg.LoggedInProviders != nil {
573 loggedIn = i.cfg.LoggedInProviders()
574 }
575 fprov := failedProvider
576 if fprov == "" {
577 fprov = activeProvider
578 }
579 i.mu.Lock()
580 i.pendingRescuePrompt = prompt
581 i.pendingRescueImages = images
582 i.mu.Unlock()
583 i.rescueDialog.Open(failedModel, loggedIn, fprov, failedModel, reason, prompt)
584 i.invalidate()
585}
586func (i *Interactive) applyRescueSelection(prov, model, prompt string) {
587 if model == "" {
588 return
589 }
590 i.applyRescueModelSelection(prov, model)
591 i.mu.Lock()
592 images := i.pendingRescueImages
593 if prompt == "" {
594 prompt = i.pendingRescuePrompt
595 }
596 i.pendingRescuePrompt = ""
597 i.pendingRescueImages = nil
598 i.mu.Unlock()
599 parent := i.runCtx
600 if parent == nil {
601 parent = context.Background()
602 }
603 i.startTurnWithImages(parent, prompt, images)
604}
605func stripAutoCompactNotes(notes []string) []string {
606 if len(notes) == 0 {
607 return notes
608 }
609 out := notes[:0]
610 for _, n := range notes {
611 if strings.Contains(n, "condensing history") {
612 continue
613 }
614 out = append(out, n)
615 }
616 return out
617}
618func autoCompactNoteLine(th tui.Theme, msg string) string {
619 return " " + th.FGColor(th.Warning, "⚠ "+msg)
620}
621func NormalizeAutoCompactThreshold(threshold *int) int {
622 const defaultAutoCompactThreshold = 85
623 if threshold == nil {
624 return defaultAutoCompactThreshold
625 }
626 switch *threshold {
627 case 0, 70, 80, 85, 90, 95:
628 return *threshold
629 default:
630 return defaultAutoCompactThreshold
631 }
632}
633func ShouldAutoCompact(inputTokens, contextWindow, thresholdPercent int) bool {
634 if inputTokens <= 0 || contextWindow <= 0 || thresholdPercent <= 0 {
635 return false
636 }
637 return float64(inputTokens)/float64(contextWindow) >= float64(thresholdPercent)/100
638}
639func classifyCompactionContinuation(origin compactContinuationOrigin, statusRescueActive bool, stop provider.StopReason, turnErr error, msgs []provider.Message) compactContinuationReason {
640 if origin != compactOriginManual && structuralCompactionContinuation(msgs) {
641 return compactContinuationStructuralTail
642 }
643 if (origin != compactOriginAfterTurnThreshold && !statusRescueActive) || stop != provider.StopEnd || turnErr != nil {
644 return compactContinuationNone
645 }
646 if likelyForwardWorkStatus(msgs) {
647 return compactContinuationStatusRescue
648 }
649 return compactContinuationNone
650}
651func structuralCompactionContinuation(msgs []provider.Message) bool {
652 if len(msgs) == 0 {
653 return false
654 }
655 last := msgs[len(msgs)-1]
656 if last.Role != provider.RoleAssistant {
657 return true
658 }
659 hasText := false
660 for _, content := range last.Content {
661 switch block := content.(type) {
662 case provider.ToolCallBlock:
663 return true
664 case provider.TextBlock:
665 if strings.TrimSpace(block.Text) != "" {
666 hasText = true
667 }
668 case provider.ReasoningBlock:
669 // Reasoning without a visible answer is not terminal.
670 default:
671 return true
672 }
673 }
674 return !hasText
675}
676func likelyForwardWorkStatus(msgs []provider.Message) bool {
677 if len(msgs) == 0 {
678 return false
679 }
680 last := msgs[len(msgs)-1]
681 if last.Role != provider.RoleAssistant {
682 return false
683 }
684 var text strings.Builder
685 for _, content := range last.Content {
686 block, ok := content.(provider.TextBlock)
687 if !ok {
688 if _, hasTool := content.(provider.ToolCallBlock); hasTool {
689 return false
690 }
691 continue
692 }
693 if strings.TrimSpace(block.Text) == "" {
694 continue
695 }
696 if text.Len() > 0 {
697 text.WriteByte(' ')
698 }
699 text.WriteString(block.Text)
700 }
701 if text.Len() == 0 {
702 return false
703 }
704 normalized := strings.ToLower(strings.Join(strings.Fields(text.String()), " "))
705 normalized = strings.NewReplacer("’", "'", "‘", "'", "`", "'").Replace(normalized)
706 for _, phrase := range []string{
707 "next i will",
708 "next, i will",
709 "i'll now",
710 "i will now",
711 "i am going to",
712 "i need to continue",
713 "then i will",
714 "then, i will",
715 "i will proceed",
716 } {
717 if containsFutureWorkPhrase(normalized, phrase) {
718 return true
719 }
720 }
721 return false
722}
723func containsFutureWorkPhrase(text, phrase string) bool {
724 for offset := 0; offset < len(text); {
725 index := strings.Index(text[offset:], phrase)
726 if index < 0 {
727 return false
728 }
729 index += offset
730 end := index + len(phrase)
731 if !wordRuneBefore(text, index) && !wordRuneAfter(text, end) {
732 return true
733 }
734 offset = end
735 }
736 return false
737}
738func wordRuneBefore(text string, index int) bool {
739 if index == 0 {
740 return false
741 }
742 rune_, _ := utf8.DecodeLastRuneInString(text[:index])
743 return unicode.IsLetter(rune_) || unicode.IsDigit(rune_) || rune_ == '_'
744}
745func wordRuneAfter(text string, index int) bool {
746 if index == len(text) {
747 return false
748 }
749 rune_, _ := utf8.DecodeRuneInString(text[index:])
750 return unicode.IsLetter(rune_) || unicode.IsDigit(rune_) || rune_ == '_'
751}
752func (i *Interactive) shouldAutoCompactLocked() bool {
753 if i.agent == nil {
754 return false
755 }
756 if i.autoCompacting {
757 return false
758 }
759 m, err := provider.FindModel(i.cfg.Provider, i.cfg.Model)
760 if err != nil || m.ContextWindow <= 0 {
761 return false
762 }
763 threshold := NormalizeAutoCompactThreshold(i.cfg.AutoCompactThreshold)
764 return ShouldAutoCompact(i.lastCtxInput, m.ContextWindow, threshold)
765}