1package modes
2
3import (
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
16func shellEscapeCommand(text string) (string, bool) {
17 return ShellEscapeCommand(text)
18}
19func 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}
30func (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}
127func (i *Interactive) startTurn(parent context.Context, prompt string) {
128 i.startTurnWithImages(parent, prompt, nil)
129}
130func (i *Interactive) startTurnWithImages(parent context.Context, prompt string, images []provider.ImageBlock) {
131 i.startTurnRequest(parent, prompt, images, false, false)
132}
133
134// startQueuedTurn keeps host provenance in the agent queue until injection,
135// including when the next turn first needs pre-turn compaction.
136func (i *Interactive) startQueuedTurn(parent context.Context, message core.QueuedMessage) {
137 if message.HostEvent && i.agent != nil {
138 i.agent.QueuePrompt(message)
139 i.startTurnRequest(parent, "", nil, true, false)
140 return
141 }
142 i.startTurnWithImages(parent, message.Text, message.Images)
143}
144func (i *Interactive) startTurnRequest(parent context.Context, prompt string, images []provider.ImageBlock, continueExisting, overflowRecoveryAttempted bool) {
145 if i.agent == nil {
146 // Text startup pre cannot run without credentials; continue so
147 // deferred InitialInput (pre-fill or auto-submit) still applies.
148 i.mu.Lock()
149 awaitingPre := i.awaitingStartupPre
150 i.mu.Unlock()
151 if awaitingPre {
152 i.completeStartupPre()
153 }
154 return
155 }
156 // Pre-turn safety: if the most recent context measurement is
157 // already past the auto-compact threshold, condense before
158 // sending so the next outbound request stays under the limit.
159 // The condense flow re-fires the user's queued prompt for us, so
160 // we just hand it off and exit.
161 i.mu.Lock()
162 var resetHandoff json.RawMessage
163 var persistResetHandoff bool
164 if !continueExisting {
165 resetHandoff, persistResetHandoff = i.resetCompactContinuationLocked()
166 }
167 needsPreCompact := !overflowRecoveryAttempted && !i.autoCompacting && i.shouldAutoCompactLocked()
168 if needsPreCompact {
169 // Reserve the compaction hand-off before releasing i.mu. A completion
170 // update arriving in the small gap before runCompact acquires the
171 // mutex must join the host queue rather than starting a competing turn.
172 i.busy = true
173 i.compacting = true
174 i.autoCompacting = true
175 if continueExisting {
176 i.continueAfterCompact = true
177 } else {
178 i.pendingCompactPrompt = prompt
179 i.pendingCompactImages = append([]provider.ImageBlock(nil), images...)
180 i.hasPendingCompactPrompt = true
181 }
182 i.statusErr = ""
183 i.extNotes = append(i.extNotes, autoCompactNoteLine(i.cfg.Theme, "context near limit — condensing history before sending..."))
184 i.pendingPostCompactNote = "context auto-compacted; sending your last message"
185 i.mu.Unlock()
186 if persistResetHandoff {
187 i.persistCompactHandoff(resetHandoff)
188 }
189 i.invalidate()
190 i.runCompact(parent, compactContinuationRequest{origin: compactOriginPreTurnThreshold})
191 return
192 }
193 i.mu.Unlock()
194 if persistResetHandoff {
195 i.persistCompactHandoff(resetHandoff)
196 }
197 // Bind a pending Esc interruption to the ordinary user turn that is
198 // actually starting. Queued-while-busy prompts do not consume it; only
199 // this start path does, so the hidden instruction precedes the prompt
200 // that owns the single automatic goal return.
201 if !continueExisting {
202 i.consumeInterruptedGoalPrompt()
203 }
204
205 ctx, cancel := context.WithCancel(parent)
206 i.mu.Lock()
207 i.busy = true
208 i.spin.Start()
209 i.activity = newAgentActivity(i.cfg.Provider, i.cfg.Model)
210 i.cancelTurn = cancel
211 i.statusErr = ""
212 i.statusOK = ""
213 i.streaming.Reset()
214 i.streamOn = true
215 i.pendingAlert = nil
216 i.toolCalls = map[string]*tui.ToolCallView{}
217 i.toolOrder = nil
218 i.toolGate = map[string]int{}
219 i.planCurrent = 0
220 i.planTotal = 0
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 // Don't touch streamPending / streamFlushPending here — the
279 // pacer may still be draining the final deltas and needs to
280 // paint them even though Prompt has returned. It will reset
281 // streamOn on its own once the buffer empties.
282 if len(i.streamPending) == 0 {
283 i.streamOn = false
284 }
285 i.cancelTurn = nil
286 pendingIdleWork := i.takePendingIdleWorkLocked()
287 if err != nil && ctx.Err() == nil {
288 i.statusErr = err.Error()
289 }
290 // Decide whether to offer a model rescue picker for recoverable
291 // provider failures (auth/rate/temporary). The picker opens after
292 // the mutex is released so it can take its own locks freely.
293 var (
294 offer bool
295 rescueWhy string
296 rescueImgs []provider.ImageBlock
297 rescueModel string
298 rescueProv string
299 rescueFprov string
300 )
301 if err != nil && ctx.Err() == nil {
302 if ok, reason := classifyRescueError(err); ok {
303 offer = true
304 rescueWhy = reason
305 rescueImgs = images
306 rescueModel = i.cfg.Model
307 rescueProv = i.cfg.Provider
308 rescueFprov = extractFailedProvider(err)
309 if rescueFprov == "" {
310 rescueFprov = i.cfg.Provider
311 }
312 // Suppress the red banner — the rescue dialog already
313 // surfaces the failure.
314 i.statusErr = ""
315 }
316 }
317 // Detect responses that reject the current context, either as an
318 // HTTP 413 payload limit or a model context-window error. Token-
319 // based auto-compact can miss both when metadata is stale or the
320 // limit is measured in raw bytes. Compact once, then continue the
321 // user message already present in the transcript.
322 contextOverflow := err != nil && ctx.Err() == nil && provider.IsContextOverflowError(err)
323 recoverContextOverflow := contextOverflow && !overflowRecoveryAttempted
324 if recoverContextOverflow {
325 i.statusErr = ""
326 i.continueAfterCompact = true
327 i.extNotes = append(i.extNotes, autoCompactNoteLine(i.cfg.Theme, "request was too large. condensing history before retrying ..."))
328 i.pendingPostCompactNote = "context auto-compacted; retrying your last message"
329 }
330 // Persist the assistant's reply (and every tool row before
331 // it) to the session file while the turn memory is hot.
332 // Without this, WriteNewTranscript only fires at zut exit,
333 // meaning a crash or ungraceful kill drops the whole
334 // conversation. FlushSession is idempotent (it advances the
335 // baseline so subsequent flushes only write new rows).
336 flush := i.cfg.FlushSession
337 i.mu.Unlock()
338 runPendingIdleWork(pendingIdleWork)
339 if flush != nil {
340 flush()
341 }
342 terminalGoalError := ctx.Err() == nil && !offer && !recoverContextOverflow && (err != nil || lastTurnErr != nil || lastStop == provider.StopError)
343 if terminalGoalError {
344 i.updateActiveGoal(core.GoalBlocked, "turn ended with an error")
345 }
346 // A cancelled or failed turn unwinds without scheduling autonomous
347 // work below. Preserve a freshly armed Esc return for the next
348 // ordinary prompt; drop only a return already bound to this turn.
349 if ctx.Err() != nil || err != nil {
350 i.mu.Lock()
351 if state := i.interruptedGoalReturn; state != nil && state.returnAfterTurn {
352 i.interruptedGoalReturn = nil
353 }
354 i.mu.Unlock()
355 }
356 goalContextLimited := lastStop == provider.StopLength
357 // Cancellation is user action, not model inaction: unwind the lease
358 // without consuming the no-progress allowance or stalling the goal.
359 continueGoal := i.finishGoalRunCancelled(goalContextLimited, ctx.Err() != nil)
360 i.mu.Lock()
361 awaitingPre := i.awaitingStartupPre
362 // A newer explicit prompt may have cleared the handoff while the
363 // completed turn was being flushed. Re-read it under the mutex before
364 // deciding whether this continuation may spend another rescue attempt.
365 statusRescueActive := i.compactContinuation.reason == compactContinuationStatusRescue
366 // Pop the next queued message, if any, and relaunch.
367 var next core.QueuedMessage
368 var hasNext bool
369 if !awaitingPre && len(i.queued) > 0 && ctx.Err() == nil && err == nil {
370 next, i.queued = i.queued[0], i.queued[1:]
371 hasNext = true
372 }
373 // If the turn was cancelled or errored, drop ordinary input so the
374 // user isn't bombarded with stale messages after an interrupt.
375 // Scheduler follow-ups deliberately remain: they are distinct turns
376 // whose due time must not let them steer this failed turn.
377 if ctx.Err() != nil || (err != nil && !recoverContextOverflow) {
378 i.discardQueuedMessagesLocked(ctx.Err() == nil)
379 }
380 var scheduled scheduledFollowUp
381 var hasScheduled bool
382 if !awaitingPre && !hasNext && len(i.scheduled) > 0 {
383 scheduled, i.scheduled = i.scheduled[0], i.scheduled[1:]
384 hasScheduled = true
385 }
386 // Decide whether the next thing to do is an auto-compaction.
387 // Only fires when the turn completed cleanly AND no host-side
388 // or agent-side queued messages are waiting (otherwise a queued
389 // message would race the condense).
390 agentQueued := 0
391 if i.agent != nil {
392 agentQueued = i.agent.QueuedMessageCount()
393 }
394 continueQueued := !awaitingPre && !hasNext && agentQueued > 0 && err == nil && ctx.Err() == nil
395 // A truncated autonomous goal needs fresh execution capacity even when
396 // context-usage metadata is absent or below the configured threshold.
397 // Route it through compaction rather than immediately reusing the same
398 // exhausted transcript.
399 goalNeedsFreshContext := continueGoal && goalContextLimited
400 shouldAutoCompact := !awaitingPre && !hasNext && agentQueued == 0 && err == nil && ctx.Err() == nil && (goalNeedsFreshContext || i.shouldAutoCompactLocked())
401 continueStatusRescue := false
402 var handoff json.RawMessage
403 var persistHandoff bool
404 if goalNeedsFreshContext {
405 // Persist ownership before compaction starts. If the process exits
406 // while condensing, session resume repeats compaction before it
407 // provisions a fresh leased goal turn.
408 handoff, persistHandoff = i.setCompactContinuationLocked(compactContinuationState{reason: compactContinuationGoalCompactionPending})
409 }
410 if statusRescueActive && i.agent != nil && !awaitingPre && !hasNext && !continueQueued && !shouldAutoCompact && err == nil && ctx.Err() == nil && lastStop == provider.StopEnd && lastTurnErr == nil {
411 followUpMessages := i.agent.Messages()
412 reason := classifyCompactionContinuation(compactOriginManual, true, lastStop, lastTurnErr, followUpMessages)
413 if reason == compactContinuationStatusRescue && i.compactContinuation.rescueAttempts < maxStatusRescueContinuations {
414 handoff, persistHandoff = i.setCompactContinuationLocked(compactContinuationState{
415 reason: compactContinuationStatusRescue,
416 rescueAttempts: i.compactContinuation.rescueAttempts + 1,
417 })
418 continueStatusRescue = true
419 } else {
420 handoff, persistHandoff = i.resetCompactContinuationLocked()
421 }
422 }
423 if !continueStatusRescue && (i.agent == nil || ctx.Err() != nil || err != nil || awaitingPre || hasNext || hasScheduled || continueQueued || offer || recoverContextOverflow || (!shouldAutoCompact && !statusRescueActive)) {
424 handoff, persistHandoff = i.resetCompactContinuationLocked()
425 }
426 // The agent run can finish before the paced final text reaches the
427 // transcript. A compaction replaces that transcript, so it must never
428 // race the still-live stream frame; otherwise stale deltas can repaint
429 // after the replacement and corrupt the scrollback renderer's model.
430 if recoverContextOverflow || shouldAutoCompact {
431 i.resetStreamingStateLocked()
432 }
433 // A clean ordinary turn that owns a pending Esc return resumes the
434 // same still-active goal exactly once through the existing scheduler.
435 // It yields to every existing priority above; terminal, paused, or
436 // replaced goals never resume because the match check fails.
437 interruptedReturn := false
438 if err == nil && ctx.Err() == nil && !awaitingPre && !hasNext && !hasScheduled && !continueQueued && !offer && !recoverContextOverflow && !shouldAutoCompact && !continueStatusRescue && !continueGoal {
439 state := i.interruptedGoalReturn
440 if state != nil && state.returnAfterTurn && i.interruptedGoalMatchesLocked() {
441 // Consume only after matching: the match helper reads the
442 // state being consumed.
443 i.interruptedGoalReturn = nil
444 interruptedReturn = true
445 }
446 } else if ctx.Err() == nil && err == nil {
447 // Another follow-up won this turn: keep an armed-but-unbound
448 // interruption pending without consuming the one-shot return.
449 // A bound return that lost priority is inert until replaced.
450 if state := i.interruptedGoalReturn; state != nil && state.returnAfterTurn {
451 i.interruptedGoalReturn = nil
452 }
453 }
454 alertReason := mainAlertReason(ctx, err, lastTurnErr, lastStop, awaitingPre, hasNext || hasScheduled || agentQueued > 0 || continueGoal || interruptedReturn, offer, recoverContextOverflow, shouldAutoCompact)
455 i.planCurrent = 0
456 i.planTotal = 0
457 // interruptedReturn already reserves the turn slot (like the
458 // compact handoff path below): the reserved continuation owns
459 // busy and must not re-enter the idle gate.
460 i.busy = hasNext || hasScheduled || continueQueued || continueStatusRescue || recoverContextOverflow || shouldAutoCompact || interruptedReturn
461 i.mu.Unlock()
462 if persistHandoff {
463 i.persistCompactHandoff(handoff)
464 }
465 if alertReason != "" {
466 i.scheduleMainAlert(alertReason)
467 }
468 i.invalidate()
469 parent := i.runCtx
470 if parent == nil {
471 parent = context.Background()
472 }
473 if awaitingPre {
474 i.completeStartupPre()
475 return
476 }
477 switch {
478 case hasNext:
479 i.startQueuedTurn(parent, next)
480 case hasScheduled:
481 // The scheduler holds the session transition read lock until this
482 // acknowledgement, so accepting here binds the prompt to the agent
483 // that just completed rather than a subsequently loaded session.
484 scheduled.accepted <- nil
485 i.startTurn(parent, scheduled.text)
486 case continueQueued:
487 i.startTurnRequest(parent, "", nil, true, false)
488 case continueStatusRescue:
489 i.startAutoCompactContinuation(parent)
490 case offer:
491 i.openRescueDialog(rescueProv, rescueFprov, rescueModel, rescueWhy, prompt, rescueImgs)
492 case recoverContextOverflow:
493 i.runCompact(parent, compactContinuationRequest{origin: compactOriginRecovery})
494 case shouldAutoCompact:
495 i.runCompact(parent, compactContinuationRequest{
496 origin: compactOriginAfterTurnThreshold,
497 force: lastStop == provider.StopLength,
498 lastStop: lastStop,
499 turnError: lastTurnErr,
500 })
501 case continueGoal:
502 i.requestGoalContinuationIfIdle(parent)
503 case interruptedReturn:
504 // busy is already reserved above; use the reserved entry point
505 // so the one-shot return cannot fail the idle gate and leak
506 // the reservation.
507 i.startReservedGoalContinuation(parent)
508 }
509 }()
510}
511func mainAlertReason(ctx context.Context, err, turnErr error, stop provider.StopReason, awaitingPre, hasNext, rescue, recovering, autoCompacting bool) string {
512 if awaitingPre || ctx.Err() != nil || hasNext || recovering || autoCompacting {
513 return ""
514 }
515 if rescue {
516 return "rescue_required"
517 }
518 if stop == provider.StopAborted {
519 return ""
520 }
521 if err != nil || turnErr != nil || stop == provider.StopError {
522 return "agent_error"
523 }
524 if stop == provider.StopLength {
525 return "response_truncated"
526 }
527 return "agent_done"
528}
529func (i *Interactive) openRescueDialog(activeProvider, failedProvider, failedModel, reason, prompt string, images []provider.ImageBlock) {
530 if i.rescueDialog == nil {
531 return
532 }
533 loggedIn := []string{}
534 if i.cfg.LoggedInProviders != nil {
535 loggedIn = i.cfg.LoggedInProviders()
536 }
537 fprov := failedProvider
538 if fprov == "" {
539 fprov = activeProvider
540 }
541 i.mu.Lock()
542 i.pendingRescuePrompt = prompt
543 i.pendingRescueImages = images
544 i.mu.Unlock()
545 i.rescueDialog.Open(failedModel, loggedIn, fprov, failedModel, reason, prompt)
546 i.invalidate()
547}
548func (i *Interactive) applyRescueSelection(prov, model, prompt string) {
549 if model == "" {
550 return
551 }
552 i.applyRescueModelSelection(prov, model)
553 i.mu.Lock()
554 images := i.pendingRescueImages
555 if prompt == "" {
556 prompt = i.pendingRescuePrompt
557 }
558 i.pendingRescuePrompt = ""
559 i.pendingRescueImages = nil
560 i.mu.Unlock()
561 parent := i.runCtx
562 if parent == nil {
563 parent = context.Background()
564 }
565 i.startTurnWithImages(parent, prompt, images)
566}
567func stripAutoCompactNotes(notes []string) []string {
568 if len(notes) == 0 {
569 return notes
570 }
571 out := notes[:0]
572 for _, n := range notes {
573 if strings.Contains(n, "condensing history") {
574 continue
575 }
576 out = append(out, n)
577 }
578 return out
579}
580func autoCompactNoteLine(th tui.Theme, msg string) string {
581 return " " + th.FGColor(th.Warning, "⚠ "+msg)
582}
583func NormalizeAutoCompactThreshold(threshold *int) int {
584 const defaultAutoCompactThreshold = 85
585 if threshold == nil {
586 return defaultAutoCompactThreshold
587 }
588 switch *threshold {
589 case 0, 70, 80, 85, 90, 95:
590 return *threshold
591 default:
592 return defaultAutoCompactThreshold
593 }
594}
595func ShouldAutoCompact(inputTokens, contextWindow, thresholdPercent int) bool {
596 if inputTokens <= 0 || contextWindow <= 0 || thresholdPercent <= 0 {
597 return false
598 }
599 return float64(inputTokens)/float64(contextWindow) >= float64(thresholdPercent)/100
600}
601func classifyCompactionContinuation(origin compactContinuationOrigin, statusRescueActive bool, stop provider.StopReason, turnErr error, msgs []provider.Message) compactContinuationReason {
602 if origin != compactOriginManual && structuralCompactionContinuation(msgs) {
603 return compactContinuationStructuralTail
604 }
605 if (origin != compactOriginAfterTurnThreshold && !statusRescueActive) || stop != provider.StopEnd || turnErr != nil {
606 return compactContinuationNone
607 }
608 if likelyForwardWorkStatus(msgs) {
609 return compactContinuationStatusRescue
610 }
611 return compactContinuationNone
612}
613func structuralCompactionContinuation(msgs []provider.Message) bool {
614 if len(msgs) == 0 {
615 return false
616 }
617 last := msgs[len(msgs)-1]
618 if last.Role != provider.RoleAssistant {
619 return true
620 }
621 hasText := false
622 for _, content := range last.Content {
623 switch block := content.(type) {
624 case provider.ToolCallBlock:
625 return true
626 case provider.TextBlock:
627 if strings.TrimSpace(block.Text) != "" {
628 hasText = true
629 }
630 case provider.ReasoningBlock:
631 // Reasoning without a visible answer is not terminal.
632 default:
633 return true
634 }
635 }
636 return !hasText
637}
638func likelyForwardWorkStatus(msgs []provider.Message) bool {
639 if len(msgs) == 0 {
640 return false
641 }
642 last := msgs[len(msgs)-1]
643 if last.Role != provider.RoleAssistant {
644 return false
645 }
646 var text strings.Builder
647 for _, content := range last.Content {
648 block, ok := content.(provider.TextBlock)
649 if !ok {
650 if _, hasTool := content.(provider.ToolCallBlock); hasTool {
651 return false
652 }
653 continue
654 }
655 if strings.TrimSpace(block.Text) == "" {
656 continue
657 }
658 if text.Len() > 0 {
659 text.WriteByte(' ')
660 }
661 text.WriteString(block.Text)
662 }
663 if text.Len() == 0 {
664 return false
665 }
666 normalized := strings.ToLower(strings.Join(strings.Fields(text.String()), " "))
667 normalized = strings.NewReplacer("’", "'", "‘", "'", "`", "'").Replace(normalized)
668 for _, phrase := range []string{
669 "next i will",
670 "next, i will",
671 "i'll now",
672 "i will now",
673 "i am going to",
674 "i need to continue",
675 "then i will",
676 "then, i will",
677 "i will proceed",
678 } {
679 if containsFutureWorkPhrase(normalized, phrase) {
680 return true
681 }
682 }
683 return false
684}
685func containsFutureWorkPhrase(text, phrase string) bool {
686 for offset := 0; offset < len(text); {
687 index := strings.Index(text[offset:], phrase)
688 if index < 0 {
689 return false
690 }
691 index += offset
692 end := index + len(phrase)
693 if !wordRuneBefore(text, index) && !wordRuneAfter(text, end) {
694 return true
695 }
696 offset = end
697 }
698 return false
699}
700func wordRuneBefore(text string, index int) bool {
701 if index == 0 {
702 return false
703 }
704 rune_, _ := utf8.DecodeLastRuneInString(text[:index])
705 return unicode.IsLetter(rune_) || unicode.IsDigit(rune_) || rune_ == '_'
706}
707func wordRuneAfter(text string, index int) bool {
708 if index == len(text) {
709 return false
710 }
711 rune_, _ := utf8.DecodeRuneInString(text[index:])
712 return unicode.IsLetter(rune_) || unicode.IsDigit(rune_) || rune_ == '_'
713}
714func (i *Interactive) shouldAutoCompactLocked() bool {
715 if i.agent == nil {
716 return false
717 }
718 if i.autoCompacting {
719 return false
720 }
721 m, err := provider.FindModel(i.cfg.Provider, i.cfg.Model)
722 if err != nil || m.ContextWindow <= 0 {
723 return false
724 }
725 threshold := NormalizeAutoCompactThreshold(i.cfg.AutoCompactThreshold)
726 return ShouldAutoCompact(i.lastCtxInput, m.ContextWindow, threshold)
727}