interactive: smarter auto-compact triggers and resume scroll
- Pre-turn auto-compact: when the previous turn already pushed context past the threshold, condense before sending. The user's prompt is re-queued and fired automatically once compaction succeeds. - HTTP 413 handling: a 'payload too large' from the provider is no longer surfaced as a status_err. Instead the request is retried after a transparent auto-compact pass. - Both inline auto-compacts surface a yellow chat note above the status bar so the user sees the spinner *and* the reason; on success a status_ok like 'context auto-compacted; sending your last message' confirms the retry. - Resume picker (/sessions and startup) now scrolls to the bottom of the loaded transcript instead of parking at the last user turn, so the most recent reply is always fully visible. - Drop the VS Code mouse-capture path: native click-drag selection beats the wheel-speed boost there.
- commit
- 471fdea0c0af4c336a5782099abe191708e4dc2b
- author
- patriceckhart · · 2026-05-04 10:46 UTC
- parent
- 1a49277
- signature
- unsigned
- refs
- tag: v0.1.50
1 file · +94 -39
M internal/agent/modes/interactive.go +94 -39
| @@ -236,6 +236,11 @@ type Interactive struct { | |||
| 236 | 236 | // survive past the ctx of the key event that enqueued them. | |
| 237 | 237 | runCtx context.Context | |
| 238 | 238 | ||
| 239 | + | // pendingPostCompactNote is a status_ok message to surface after | |
| 240 | + | // a successful auto-compact pass triggered by a 413 or by the | |
| 241 | + | // pre-turn fraction guard. Cleared by runCompact once shown. | |
| 242 | + | pendingPostCompactNote string | |
| 243 | + | ||
| 239 | 244 | // autoCompacting is true while a model-triggered compaction is in | |
| 240 | 245 | // flight. Surfaced in the status bar so the user can tell a | |
| 241 | 246 | // condense pass from a regular assistant turn. | |
| @@ -359,16 +364,14 @@ func (i *Interactive) Run(ctx context.Context) error { | |||
| 359 | 364 | } | |
| 360 | 365 | }() | |
| 361 | 366 | ||
| 362 | - | mouseSeqOn := "" | |
| 363 | - | mouseSeqOff := "" | |
| 364 | - | if isVSCodeTerminal() { | |
| 365 | - | mouseSeqOn = tui.SeqMouseOn | |
| 366 | - | mouseSeqOff = tui.SeqMouseOff | |
| 367 | - | } | |
| 368 | - | ||
| 369 | - | _, _ = term.Write([]byte(tui.SeqBracketedPasteOn + mouseSeqOn)) | |
| 367 | + | // Enabling mouse reporting steals click-drag selection from the | |
| 368 | + | // host terminal (VS Code, Ghostty, iTerm). The user prefers native | |
| 369 | + | // selection over the wheel-speed boost, so we no longer turn it | |
| 370 | + | // on automatically. Wheel events fall through to the terminal's | |
| 371 | + | // own scrollback handler. | |
| 372 | + | _, _ = term.Write([]byte(tui.SeqBracketedPasteOn)) | |
| 370 | 373 | _, _ = term.Write([]byte(tui.SeqAltScreenOn)) | |
| 371 | - | defer term.Write([]byte(tui.SeqAltScreenOff + mouseSeqOff + tui.SeqBracketedPasteOff + tui.SeqShowCursor)) | |
| 374 | + | defer term.Write([]byte(tui.SeqAltScreenOff + tui.SeqBracketedPasteOff + tui.SeqShowCursor)) | |
| 372 | 375 | ||
| 373 | 376 | // Streaming pacer: drains buffered text deltas at a steady rate | |
| 374 | 377 | // so typewriter feel is identical across providers regardless of | |
| @@ -401,13 +404,14 @@ func (i *Interactive) Run(ctx context.Context) error { | |||
| 401 | 404 | time.AfterFunc(welcomeVersionDuration, i.invalidate) | |
| 402 | 405 | ||
| 403 | 406 | // If the agent was constructed with a pre-loaded transcript | |
| 404 | - | // (--continue, --resume, --session) park the viewport on the | |
| 405 | - | // most recent turn so the user lands looking at where the | |
| 406 | - | // previous session left off rather than at the bottom of an | |
| 407 | - | // already-rendered final reply. | |
| 407 | + | // (--continue, --resume, --session) pin the viewport at the | |
| 408 | + | // bottom so the most recent reply (and any prompt the user just | |
| 409 | + | // typed) is fully visible. Earlier behaviour parked the view at | |
| 410 | + | // the last user turn, which could leave the latest message clipped | |
| 411 | + | // off the bottom of the page on long sessions. | |
| 408 | 412 | if i.agent != nil { | |
| 409 | 413 | if msgs := i.agent.Messages(); len(msgs) > 0 { | |
| 410 | - | i.scrollToLastTurn(msgs) | |
| 414 | + | i.scrollToBottom() | |
| 411 | 415 | } | |
| 412 | 416 | } | |
| 413 | 417 | ||
| @@ -581,21 +585,6 @@ func (i *Interactive) invalidate() { | |||
| 581 | 585 | } | |
| 582 | 586 | } | |
| 583 | 587 | ||
| 584 | - | func isVSCodeTerminal() bool { | |
| 585 | - | return strings.EqualFold(os.Getenv("TERM_PROGRAM"), "vscode") | |
| 586 | - | } | |
| 587 | - | ||
| 588 | - | func mouseWheelScrollRows() int { | |
| 589 | - | // VS Code's integrated terminal emits relatively small wheel | |
| 590 | - | // steps compared with Ghostty's native scrolling. Mouse-wheel | |
| 591 | - | // events get a bigger delta than keyboard arrows so trackpads and | |
| 592 | - | // wheel mice feel responsive without changing Up/Down behaviour. | |
| 593 | - | if isVSCodeTerminal() { | |
| 594 | - | return 12 | |
| 595 | - | } | |
| 596 | - | return 6 | |
| 597 | - | } | |
| 598 | - | ||
| 599 | 588 | func (i *Interactive) cachedChatLocked(cols int) []string { | |
| 600 | 589 | key, cacheable := i.chatCacheKeyLocked(cols) | |
| 601 | 590 | if cacheable && i.chatCacheValid && i.chatCacheKey == key { | |
| @@ -1631,14 +1620,6 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||
| 1631 | 1620 | case tui.KeyPageDown: | |
| 1632 | 1621 | i.scrollBy(-i.chatPage()) | |
| 1633 | 1622 | return false | |
| 1634 | - | case tui.KeyMouseWheelUp: | |
| 1635 | - | i.scrollBy(+mouseWheelScrollRows()) | |
| 1636 | - | return false | |
| 1637 | - | case tui.KeyMouseWheelDown: | |
| 1638 | - | if i.scrollOffset > 0 { | |
| 1639 | - | i.scrollBy(-mouseWheelScrollRows()) | |
| 1640 | - | } | |
| 1641 | - | return false | |
| 1642 | 1623 | case tui.KeyUp: | |
| 1643 | 1624 | // Always use up/down for chat scrolling, even when the editor | |
| 1644 | 1625 | // contains text. This makes keyboard scrolling consistent with | |
| @@ -2850,11 +2831,16 @@ func (i *Interactive) runCompact(parent context.Context, auto bool) { | |||
| 2850 | 2831 | if len(msgs) > 0 && msgs[0].Meta["compaction"] == "true" { | |
| 2851 | 2832 | tokens = msgs[0].Meta["tokens_before"] | |
| 2852 | 2833 | } | |
| 2853 | - | if tokens != "" { | |
| 2834 | + | switch { | |
| 2835 | + | case i.pendingPostCompactNote != "": | |
| 2836 | + | i.statusOK = i.pendingPostCompactNote | |
| 2837 | + | case tokens != "": | |
| 2854 | 2838 | i.statusOK = fmt.Sprintf("compacted from ~%s tokens (ctrl+o to expand)", tokens) | |
| 2855 | - | } else { | |
| 2839 | + | default: | |
| 2856 | 2840 | i.statusOK = "compacted (ctrl+o to expand)" | |
| 2857 | 2841 | } | |
| 2842 | + | i.pendingPostCompactNote = "" | |
| 2843 | + | i.extNotes = stripAutoCompactNotes(i.extNotes) | |
| 2858 | 2844 | i.lastCtxInput = 0 | |
| 2859 | 2845 | i.toolCalls = map[string]*tui.ToolCallView{} | |
| 2860 | 2846 | i.toolOrder = nil | |
| @@ -2886,6 +2872,27 @@ func (i *Interactive) startTurnWithImages(parent context.Context, prompt string, | |||
| 2886 | 2872 | if i.agent == nil { | |
| 2887 | 2873 | return | |
| 2888 | 2874 | } | |
| 2875 | + | // Pre-turn safety: if the most recent context measurement is | |
| 2876 | + | // already past the auto-compact threshold, condense before | |
| 2877 | + | // sending so the next outbound request stays under the limit. | |
| 2878 | + | // The condense flow re-fires the user's queued prompt for us, so | |
| 2879 | + | // we just hand it off and exit. | |
| 2880 | + | i.mu.Lock() | |
| 2881 | + | needsPreCompact := !i.autoCompacting && i.shouldAutoCompactLocked() | |
| 2882 | + | if needsPreCompact { | |
| 2883 | + | if prompt != "" { | |
| 2884 | + | i.queued = append([]string{prompt}, i.queued...) | |
| 2885 | + | } | |
| 2886 | + | i.statusErr = "" | |
| 2887 | + | i.extNotes = append(i.extNotes, autoCompactNoteLine(i.cfg.Theme, "context near limit — condensing history before sending…")) | |
| 2888 | + | i.pendingPostCompactNote = "context auto-compacted; sending your last message" | |
| 2889 | + | i.mu.Unlock() | |
| 2890 | + | i.invalidate() | |
| 2891 | + | i.runCompact(parent, true) | |
| 2892 | + | return | |
| 2893 | + | } | |
| 2894 | + | i.mu.Unlock() | |
| 2895 | + | ||
| 2889 | 2896 | ctx, cancel := context.WithCancel(parent) | |
| 2890 | 2897 | i.mu.Lock() | |
| 2891 | 2898 | i.busy = true | |
| @@ -2924,6 +2931,18 @@ func (i *Interactive) startTurnWithImages(parent context.Context, prompt string, | |||
| 2924 | 2931 | if err != nil && ctx.Err() == nil { | |
| 2925 | 2932 | i.statusErr = err.Error() | |
| 2926 | 2933 | } | |
| 2934 | + | // Detect HTTP 413 "payload too large" responses. The provider | |
| 2935 | + | // rejected the request because the request body exceeded its | |
| 2936 | + | // per-request limit. Token-based auto-compact can miss this | |
| 2937 | + | // because the limit is on raw bytes, not tokens. Re-queue the | |
| 2938 | + | // prompt so it survives the condense pass and trigger one. | |
| 2939 | + | payloadTooLarge := err != nil && ctx.Err() == nil && isPayloadTooLargeError(err) | |
| 2940 | + | if payloadTooLarge { | |
| 2941 | + | i.statusErr = "" | |
| 2942 | + | i.queued = append([]string{prompt}, i.queued...) | |
| 2943 | + | i.extNotes = append(i.extNotes, autoCompactNoteLine(i.cfg.Theme, "request was too large — condensing history before retrying…")) | |
| 2944 | + | i.pendingPostCompactNote = "context auto-compacted; retrying your last message" | |
| 2945 | + | } | |
| 2927 | 2946 | // Persist the assistant's reply (and every tool row before | |
| 2928 | 2947 | // it) to the session file while the turn memory is hot. | |
| 2929 | 2948 | // Without this, WriteNewTranscript only fires at zot exit, | |
| @@ -2961,12 +2980,48 @@ func (i *Interactive) startTurnWithImages(parent context.Context, prompt string, | |||
| 2961 | 2980 | switch { | |
| 2962 | 2981 | case hasNext: | |
| 2963 | 2982 | i.startTurn(parent, next) | |
| 2983 | + | case payloadTooLarge: | |
| 2984 | + | i.runCompact(parent, true) | |
| 2964 | 2985 | case shouldAutoCompact: | |
| 2965 | 2986 | i.runCompact(parent, true) | |
| 2966 | 2987 | } | |
| 2967 | 2988 | }() | |
| 2968 | 2989 | } | |
| 2969 | 2990 | ||
| 2991 | + | func stripAutoCompactNotes(notes []string) []string { | |
| 2992 | + | if len(notes) == 0 { | |
| 2993 | + | return notes | |
| 2994 | + | } | |
| 2995 | + | out := notes[:0] | |
| 2996 | + | for _, n := range notes { | |
| 2997 | + | if strings.Contains(n, "condensing history") { | |
| 2998 | + | continue | |
| 2999 | + | } | |
| 3000 | + | out = append(out, n) | |
| 3001 | + | } | |
| 3002 | + | return out | |
| 3003 | + | } | |
| 3004 | + | ||
| 3005 | + | // autoCompactNoteLine returns a styled chat-area note for the | |
| 3006 | + | // inline auto-compact heads-up. Lives in extNotes so it survives | |
| 3007 | + | // the busy-spinner overwrite of the status row. | |
| 3008 | + | func autoCompactNoteLine(th tui.Theme, msg string) string { | |
| 3009 | + | return " " + th.FG256(th.Warning, "⚠ "+msg) | |
| 3010 | + | } | |
| 3011 | + | ||
| 3012 | + | // isPayloadTooLargeError matches HTTP 413 responses surfaced by the | |
| 3013 | + | // provider clients. The error formatting differs slightly between | |
| 3014 | + | // providers (anthropic and openai both prepend the status code), so | |
| 3015 | + | // we look for the canonical 413 marker as well as the conventional | |
| 3016 | + | // 'payload too large' phrase. | |
| 3017 | + | func isPayloadTooLargeError(err error) bool { | |
| 3018 | + | if err == nil { | |
| 3019 | + | return false | |
| 3020 | + | } | |
| 3021 | + | msg := strings.ToLower(err.Error()) | |
| 3022 | + | return strings.Contains(msg, "http 413") || strings.Contains(msg, " 413") || strings.HasPrefix(msg, "413 ") || strings.Contains(msg, "payload too large") || strings.Contains(msg, "request entity too large") | |
| 3023 | + | } | |
| 3024 | + | ||
| 2970 | 3025 | // autoCompactThreshold is the context-window fraction at which the | |
| 2971 | 3026 | // agent will auto-compact after a turn ends. 0.85 leaves enough | |
| 2972 | 3027 | // headroom for one more user prompt + response before we bump the | |
| @@ -236,6 +236,11 @@ type Interactive struct { | |||||
| 236 | // survive past the ctx of the key event that enqueued them. | 236 | // survive past the ctx of the key event that enqueued them. | ||
| 237 | runCtx context.Context | 237 | runCtx context.Context | ||
| 238 | 238 | ||||
| 239 | + | // pendingPostCompactNote is a status_ok message to surface after | |||
| 240 | + | // a successful auto-compact pass triggered by a 413 or by the | |||
| 241 | + | // pre-turn fraction guard. Cleared by runCompact once shown. | |||
| 242 | + | pendingPostCompactNote string | |||
| 243 | + | ||||
| 239 | // autoCompacting is true while a model-triggered compaction is in | 244 | // autoCompacting is true while a model-triggered compaction is in | ||
| 240 | // flight. Surfaced in the status bar so the user can tell a | 245 | // flight. Surfaced in the status bar so the user can tell a | ||
| 241 | // condense pass from a regular assistant turn. | 246 | // condense pass from a regular assistant turn. | ||
| @@ -359,16 +364,14 @@ func (i *Interactive) Run(ctx context.Context) error { | |||||
| 359 | } | 364 | } | ||
| 360 | }() | 365 | }() | ||
| 361 | 366 | ||||
| 362 | - | mouseSeqOn := "" | 367 | + | // Enabling mouse reporting steals click-drag selection from the |
| 363 | - | mouseSeqOff := "" | 368 | + | // host terminal (VS Code, Ghostty, iTerm). The user prefers native |
| 364 | - | if isVSCodeTerminal() { | 369 | + | // selection over the wheel-speed boost, so we no longer turn it |
| 365 | - | mouseSeqOn = tui.SeqMouseOn | 370 | + | // on automatically. Wheel events fall through to the terminal's |
| 366 | - | mouseSeqOff = tui.SeqMouseOff | 371 | + | // own scrollback handler. |
| 367 | - | } | 372 | + | _, _ = term.Write([]byte(tui.SeqBracketedPasteOn)) |
| 368 | - | ||||
| 369 | - | _, _ = term.Write([]byte(tui.SeqBracketedPasteOn + mouseSeqOn)) | |||
| 370 | _, _ = term.Write([]byte(tui.SeqAltScreenOn)) | 373 | _, _ = term.Write([]byte(tui.SeqAltScreenOn)) | ||
| 371 | - | defer term.Write([]byte(tui.SeqAltScreenOff + mouseSeqOff + tui.SeqBracketedPasteOff + tui.SeqShowCursor)) | 374 | + | defer term.Write([]byte(tui.SeqAltScreenOff + tui.SeqBracketedPasteOff + tui.SeqShowCursor)) |
| 372 | 375 | ||||
| 373 | // Streaming pacer: drains buffered text deltas at a steady rate | 376 | // Streaming pacer: drains buffered text deltas at a steady rate | ||
| 374 | // so typewriter feel is identical across providers regardless of | 377 | // so typewriter feel is identical across providers regardless of | ||
| @@ -401,13 +404,14 @@ func (i *Interactive) Run(ctx context.Context) error { | |||||
| 401 | time.AfterFunc(welcomeVersionDuration, i.invalidate) | 404 | time.AfterFunc(welcomeVersionDuration, i.invalidate) | ||
| 402 | 405 | ||||
| 403 | // If the agent was constructed with a pre-loaded transcript | 406 | // If the agent was constructed with a pre-loaded transcript | ||
| 404 | - | // (--continue, --resume, --session) park the viewport on the | 407 | + | // (--continue, --resume, --session) pin the viewport at the |
| 405 | - | // most recent turn so the user lands looking at where the | 408 | + | // bottom so the most recent reply (and any prompt the user just |
| 406 | - | // previous session left off rather than at the bottom of an | 409 | + | // typed) is fully visible. Earlier behaviour parked the view at |
| 407 | - | // already-rendered final reply. | 410 | + | // the last user turn, which could leave the latest message clipped |
| 411 | + | // off the bottom of the page on long sessions. | |||
| 408 | if i.agent != nil { | 412 | if i.agent != nil { | ||
| 409 | if msgs := i.agent.Messages(); len(msgs) > 0 { | 413 | if msgs := i.agent.Messages(); len(msgs) > 0 { | ||
| 410 | - | i.scrollToLastTurn(msgs) | 414 | + | i.scrollToBottom() |
| 411 | } | 415 | } | ||
| 412 | } | 416 | } | ||
| 413 | 417 | ||||
| @@ -581,21 +585,6 @@ func (i *Interactive) invalidate() { | |||||
| 581 | } | 585 | } | ||
| 582 | } | 586 | } | ||
| 583 | 587 | ||||
| 584 | - | func isVSCodeTerminal() bool { | |||
| 585 | - | return strings.EqualFold(os.Getenv("TERM_PROGRAM"), "vscode") | |||
| 586 | - | } | |||
| 587 | - | ||||
| 588 | - | func mouseWheelScrollRows() int { | |||
| 589 | - | // VS Code's integrated terminal emits relatively small wheel | |||
| 590 | - | // steps compared with Ghostty's native scrolling. Mouse-wheel | |||
| 591 | - | // events get a bigger delta than keyboard arrows so trackpads and | |||
| 592 | - | // wheel mice feel responsive without changing Up/Down behaviour. | |||
| 593 | - | if isVSCodeTerminal() { | |||
| 594 | - | return 12 | |||
| 595 | - | } | |||
| 596 | - | return 6 | |||
| 597 | - | } | |||
| 598 | - | ||||
| 599 | func (i *Interactive) cachedChatLocked(cols int) []string { | 588 | func (i *Interactive) cachedChatLocked(cols int) []string { | ||
| 600 | key, cacheable := i.chatCacheKeyLocked(cols) | 589 | key, cacheable := i.chatCacheKeyLocked(cols) | ||
| 601 | if cacheable && i.chatCacheValid && i.chatCacheKey == key { | 590 | if cacheable && i.chatCacheValid && i.chatCacheKey == key { | ||
| @@ -1631,14 +1620,6 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||||
| 1631 | case tui.KeyPageDown: | 1620 | case tui.KeyPageDown: | ||
| 1632 | i.scrollBy(-i.chatPage()) | 1621 | i.scrollBy(-i.chatPage()) | ||
| 1633 | return false | 1622 | return false | ||
| 1634 | - | case tui.KeyMouseWheelUp: | |||
| 1635 | - | i.scrollBy(+mouseWheelScrollRows()) | |||
| 1636 | - | return false | |||
| 1637 | - | case tui.KeyMouseWheelDown: | |||
| 1638 | - | if i.scrollOffset > 0 { | |||
| 1639 | - | i.scrollBy(-mouseWheelScrollRows()) | |||
| 1640 | - | } | |||
| 1641 | - | return false | |||
| 1642 | case tui.KeyUp: | 1623 | case tui.KeyUp: | ||
| 1643 | // Always use up/down for chat scrolling, even when the editor | 1624 | // Always use up/down for chat scrolling, even when the editor | ||
| 1644 | // contains text. This makes keyboard scrolling consistent with | 1625 | // contains text. This makes keyboard scrolling consistent with | ||
| @@ -2850,11 +2831,16 @@ func (i *Interactive) runCompact(parent context.Context, auto bool) { | |||||
| 2850 | if len(msgs) > 0 && msgs[0].Meta["compaction"] == "true" { | 2831 | if len(msgs) > 0 && msgs[0].Meta["compaction"] == "true" { | ||
| 2851 | tokens = msgs[0].Meta["tokens_before"] | 2832 | tokens = msgs[0].Meta["tokens_before"] | ||
| 2852 | } | 2833 | } | ||
| 2853 | - | if tokens != "" { | 2834 | + | switch { |
| 2835 | + | case i.pendingPostCompactNote != "": | |||
| 2836 | + | i.statusOK = i.pendingPostCompactNote | |||
| 2837 | + | case tokens != "": | |||
| 2854 | i.statusOK = fmt.Sprintf("compacted from ~%s tokens (ctrl+o to expand)", tokens) | 2838 | i.statusOK = fmt.Sprintf("compacted from ~%s tokens (ctrl+o to expand)", tokens) | ||
| 2855 | - | } else { | 2839 | + | default: |
| 2856 | i.statusOK = "compacted (ctrl+o to expand)" | 2840 | i.statusOK = "compacted (ctrl+o to expand)" | ||
| 2857 | } | 2841 | } | ||
| 2842 | + | i.pendingPostCompactNote = "" | |||
| 2843 | + | i.extNotes = stripAutoCompactNotes(i.extNotes) | |||
| 2858 | i.lastCtxInput = 0 | 2844 | i.lastCtxInput = 0 | ||
| 2859 | i.toolCalls = map[string]*tui.ToolCallView{} | 2845 | i.toolCalls = map[string]*tui.ToolCallView{} | ||
| 2860 | i.toolOrder = nil | 2846 | i.toolOrder = nil | ||
| @@ -2886,6 +2872,27 @@ func (i *Interactive) startTurnWithImages(parent context.Context, prompt string, | |||||
| 2886 | if i.agent == nil { | 2872 | if i.agent == nil { | ||
| 2887 | return | 2873 | return | ||
| 2888 | } | 2874 | } | ||
| 2875 | + | // Pre-turn safety: if the most recent context measurement is | |||
| 2876 | + | // already past the auto-compact threshold, condense before | |||
| 2877 | + | // sending so the next outbound request stays under the limit. | |||
| 2878 | + | // The condense flow re-fires the user's queued prompt for us, so | |||
| 2879 | + | // we just hand it off and exit. | |||
| 2880 | + | i.mu.Lock() | |||
| 2881 | + | needsPreCompact := !i.autoCompacting && i.shouldAutoCompactLocked() | |||
| 2882 | + | if needsPreCompact { | |||
| 2883 | + | if prompt != "" { | |||
| 2884 | + | i.queued = append([]string{prompt}, i.queued...) | |||
| 2885 | + | } | |||
| 2886 | + | i.statusErr = "" | |||
| 2887 | + | i.extNotes = append(i.extNotes, autoCompactNoteLine(i.cfg.Theme, "context near limit — condensing history before sending…")) | |||
| 2888 | + | i.pendingPostCompactNote = "context auto-compacted; sending your last message" | |||
| 2889 | + | i.mu.Unlock() | |||
| 2890 | + | i.invalidate() | |||
| 2891 | + | i.runCompact(parent, true) | |||
| 2892 | + | return | |||
| 2893 | + | } | |||
| 2894 | + | i.mu.Unlock() | |||
| 2895 | + | ||||
| 2889 | ctx, cancel := context.WithCancel(parent) | 2896 | ctx, cancel := context.WithCancel(parent) | ||
| 2890 | i.mu.Lock() | 2897 | i.mu.Lock() | ||
| 2891 | i.busy = true | 2898 | i.busy = true | ||
| @@ -2924,6 +2931,18 @@ func (i *Interactive) startTurnWithImages(parent context.Context, prompt string, | |||||
| 2924 | if err != nil && ctx.Err() == nil { | 2931 | if err != nil && ctx.Err() == nil { | ||
| 2925 | i.statusErr = err.Error() | 2932 | i.statusErr = err.Error() | ||
| 2926 | } | 2933 | } | ||
| 2934 | + | // Detect HTTP 413 "payload too large" responses. The provider | |||
| 2935 | + | // rejected the request because the request body exceeded its | |||
| 2936 | + | // per-request limit. Token-based auto-compact can miss this | |||
| 2937 | + | // because the limit is on raw bytes, not tokens. Re-queue the | |||
| 2938 | + | // prompt so it survives the condense pass and trigger one. | |||
| 2939 | + | payloadTooLarge := err != nil && ctx.Err() == nil && isPayloadTooLargeError(err) | |||
| 2940 | + | if payloadTooLarge { | |||
| 2941 | + | i.statusErr = "" | |||
| 2942 | + | i.queued = append([]string{prompt}, i.queued...) | |||
| 2943 | + | i.extNotes = append(i.extNotes, autoCompactNoteLine(i.cfg.Theme, "request was too large — condensing history before retrying…")) | |||
| 2944 | + | i.pendingPostCompactNote = "context auto-compacted; retrying your last message" | |||
| 2945 | + | } | |||
| 2927 | // Persist the assistant's reply (and every tool row before | 2946 | // Persist the assistant's reply (and every tool row before | ||
| 2928 | // it) to the session file while the turn memory is hot. | 2947 | // it) to the session file while the turn memory is hot. | ||
| 2929 | // Without this, WriteNewTranscript only fires at zot exit, | 2948 | // Without this, WriteNewTranscript only fires at zot exit, | ||
| @@ -2961,12 +2980,48 @@ func (i *Interactive) startTurnWithImages(parent context.Context, prompt string, | |||||
| 2961 | switch { | 2980 | switch { | ||
| 2962 | case hasNext: | 2981 | case hasNext: | ||
| 2963 | i.startTurn(parent, next) | 2982 | i.startTurn(parent, next) | ||
| 2983 | + | case payloadTooLarge: | |||
| 2984 | + | i.runCompact(parent, true) | |||
| 2964 | case shouldAutoCompact: | 2985 | case shouldAutoCompact: | ||
| 2965 | i.runCompact(parent, true) | 2986 | i.runCompact(parent, true) | ||
| 2966 | } | 2987 | } | ||
| 2967 | }() | 2988 | }() | ||
| 2968 | } | 2989 | } | ||
| 2969 | 2990 | ||||
| 2991 | + | func stripAutoCompactNotes(notes []string) []string { | |||
| 2992 | + | if len(notes) == 0 { | |||
| 2993 | + | return notes | |||
| 2994 | + | } | |||
| 2995 | + | out := notes[:0] | |||
| 2996 | + | for _, n := range notes { | |||
| 2997 | + | if strings.Contains(n, "condensing history") { | |||
| 2998 | + | continue | |||
| 2999 | + | } | |||
| 3000 | + | out = append(out, n) | |||
| 3001 | + | } | |||
| 3002 | + | return out | |||
| 3003 | + | } | |||
| 3004 | + | ||||
| 3005 | + | // autoCompactNoteLine returns a styled chat-area note for the | |||
| 3006 | + | // inline auto-compact heads-up. Lives in extNotes so it survives | |||
| 3007 | + | // the busy-spinner overwrite of the status row. | |||
| 3008 | + | func autoCompactNoteLine(th tui.Theme, msg string) string { | |||
| 3009 | + | return " " + th.FG256(th.Warning, "⚠ "+msg) | |||
| 3010 | + | } | |||
| 3011 | + | ||||
| 3012 | + | // isPayloadTooLargeError matches HTTP 413 responses surfaced by the | |||
| 3013 | + | // provider clients. The error formatting differs slightly between | |||
| 3014 | + | // providers (anthropic and openai both prepend the status code), so | |||
| 3015 | + | // we look for the canonical 413 marker as well as the conventional | |||
| 3016 | + | // 'payload too large' phrase. | |||
| 3017 | + | func isPayloadTooLargeError(err error) bool { | |||
| 3018 | + | if err == nil { | |||
| 3019 | + | return false | |||
| 3020 | + | } | |||
| 3021 | + | msg := strings.ToLower(err.Error()) | |||
| 3022 | + | return strings.Contains(msg, "http 413") || strings.Contains(msg, " 413") || strings.HasPrefix(msg, "413 ") || strings.Contains(msg, "payload too large") || strings.Contains(msg, "request entity too large") | |||
| 3023 | + | } | |||
| 3024 | + | ||||
| 2970 | // autoCompactThreshold is the context-window fraction at which the | 3025 | // autoCompactThreshold is the context-window fraction at which the | ||
| 2971 | // agent will auto-compact after a turn ends. 0.85 leaves enough | 3026 | // agent will auto-compact after a turn ends. 0.85 leaves enough | ||
| 2972 | // headroom for one more user prompt + response before we bump the | 3027 | // headroom for one more user prompt + response before we bump the | ||