detached 471fdea

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 {
236236 // survive past the ctx of the key event that enqueued them.
237237 runCtx context.Context
238238
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+
239244 // autoCompacting is true while a model-triggered compaction is in
240245 // flight. Surfaced in the status bar so the user can tell a
241246 // condense pass from a regular assistant turn.
@@ -359,16 +364,14 @@ func (i *Interactive) Run(ctx context.Context) error {
359364 }
360365 }()
361366
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))
370373 _, _ = 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))
372375
373376 // Streaming pacer: drains buffered text deltas at a steady rate
374377 // so typewriter feel is identical across providers regardless of
@@ -401,13 +404,14 @@ func (i *Interactive) Run(ctx context.Context) error {
401404 time.AfterFunc(welcomeVersionDuration, i.invalidate)
402405
403406 // 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.
408412 if i.agent != nil {
409413 if msgs := i.agent.Messages(); len(msgs) > 0 {
410- i.scrollToLastTurn(msgs)
414+ i.scrollToBottom()
411415 }
412416 }
413417
@@ -581,21 +585,6 @@ func (i *Interactive) invalidate() {
581585 }
582586 }
583587
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-
599588 func (i *Interactive) cachedChatLocked(cols int) []string {
600589 key, cacheable := i.chatCacheKeyLocked(cols)
601590 if cacheable && i.chatCacheValid && i.chatCacheKey == key {
@@ -1631,14 +1620,6 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
16311620 case tui.KeyPageDown:
16321621 i.scrollBy(-i.chatPage())
16331622 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
16421623 case tui.KeyUp:
16431624 // Always use up/down for chat scrolling, even when the editor
16441625 // contains text. This makes keyboard scrolling consistent with
@@ -2850,11 +2831,16 @@ func (i *Interactive) runCompact(parent context.Context, auto bool) {
28502831 if len(msgs) > 0 && msgs[0].Meta["compaction"] == "true" {
28512832 tokens = msgs[0].Meta["tokens_before"]
28522833 }
2853- if tokens != "" {
2834+ switch {
2835+ case i.pendingPostCompactNote != "":
2836+ i.statusOK = i.pendingPostCompactNote
2837+ case tokens != "":
28542838 i.statusOK = fmt.Sprintf("compacted from ~%s tokens (ctrl+o to expand)", tokens)
2855- } else {
2839+ default:
28562840 i.statusOK = "compacted (ctrl+o to expand)"
28572841 }
2842+ i.pendingPostCompactNote = ""
2843+ i.extNotes = stripAutoCompactNotes(i.extNotes)
28582844 i.lastCtxInput = 0
28592845 i.toolCalls = map[string]*tui.ToolCallView{}
28602846 i.toolOrder = nil
@@ -2886,6 +2872,27 @@ func (i *Interactive) startTurnWithImages(parent context.Context, prompt string,
28862872 if i.agent == nil {
28872873 return
28882874 }
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+
28892896 ctx, cancel := context.WithCancel(parent)
28902897 i.mu.Lock()
28912898 i.busy = true
@@ -2924,6 +2931,18 @@ func (i *Interactive) startTurnWithImages(parent context.Context, prompt string,
29242931 if err != nil && ctx.Err() == nil {
29252932 i.statusErr = err.Error()
29262933 }
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+ }
29272946 // Persist the assistant's reply (and every tool row before
29282947 // it) to the session file while the turn memory is hot.
29292948 // Without this, WriteNewTranscript only fires at zot exit,
@@ -2961,12 +2980,48 @@ func (i *Interactive) startTurnWithImages(parent context.Context, prompt string,
29612980 switch {
29622981 case hasNext:
29632982 i.startTurn(parent, next)
2983+ case payloadTooLarge:
2984+ i.runCompact(parent, true)
29642985 case shouldAutoCompact:
29652986 i.runCompact(parent, true)
29662987 }
29672988 }()
29682989 }
29692990
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+
29703025 // autoCompactThreshold is the context-window fraction at which the
29713026 // agent will auto-compact after a turn ends. 0.85 leaves enough
29723027 // 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.Context237 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 in244 // autoCompacting is true while a model-triggered compaction is in
240 // flight. Surfaced in the status bar so the user can tell a245 // 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.SeqMouseOn370+ // on automatically. Wheel events fall through to the terminal's
366- mouseSeqOff = tui.SeqMouseOff371+ // 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 rate376 // Streaming pacer: drains buffered text deltas at a steady rate
374 // so typewriter feel is identical across providers regardless of377 // 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 transcript406 // If the agent was constructed with a pre-loaded transcript
404- // (--continue, --resume, --session) park the viewport on the407+ // (--continue, --resume, --session) pin the viewport at the
405- // most recent turn so the user lands looking at where the408+ // bottom so the most recent reply (and any prompt the user just
406- // previous session left off rather than at the bottom of an409+ // 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 false1622 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 editor1624 // Always use up/down for chat scrolling, even when the editor
1644 // contains text. This makes keyboard scrolling consistent with1625 // 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 = 02844 i.lastCtxInput = 0
2859 i.toolCalls = map[string]*tui.ToolCallView{}2845 i.toolCalls = map[string]*tui.ToolCallView{}
2860 i.toolOrder = nil2846 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 return2873 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 = true2898 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 before2946 // 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 the3025 // autoCompactThreshold is the context-window fraction at which the
2971 // agent will auto-compact after a turn ends. 0.85 leaves enough3026 // agent will auto-compact after a turn ends. 0.85 leaves enough
2972 // headroom for one more user prompt + response before we bump the3027 // headroom for one more user prompt + response before we bump the

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close