detached daaa062

feat(compact): silent compaction with status line and orphan repair

Compaction no longer streams the summary into the chat. The spinner shows while compacting and users can queue prompts that fire after completion. The compaction info appears in the status line with ctrl+o to expand. Orphaned tool_result blocks in the preserved tail are stripped to prevent Anthropic rejection.
commit
daaa062c68b18c304d1bb3f73ea58d126589e329
author
patriceckhart · · 2026-04-23 12:20 UTC
parent
b6529cf
signature
unsigned
4 files · +146 -19
M internal/agent/modes/interactive.go +41 -16
@@ -2363,33 +2363,36 @@ func (i *Interactive) runCompact(parent context.Context, auto bool) {
23632363 if auto {
23642364 i.spin.StartFixed("condensing history")
23652365 i.autoCompacting = true
2366- i.statusOK = "condensing history… (esc to cancel)"
23672366 } else {
2368- i.spin.Start()
2369- i.statusOK = "compacting..."
2367+ i.spin.StartFixed("compacting")
23702368 }
23712369 i.cancelTurn = cancel
23722370 i.statusErr = ""
2373- i.streaming.Reset()
2374- i.streamOn = true
2371+ i.statusOK = ""
2372+ // Do NOT set streamOn: the summary text should not be visible
2373+ // in the chat while compacting. The user just sees the spinner
2374+ // and can keep typing / queue prompts.
23752375 i.scrollOffset = 0
23762376 i.helpBlock = nil
23772377 i.mu.Unlock()
23782378 i.invalidate()
23792379
23802380 go func() {
2381- sink := func(delta string) {
2382- i.mu.Lock()
2383- i.streaming.WriteString(delta)
2384- i.mu.Unlock()
2385- i.invalidate()
2386- }
2381+ // Sink discards deltas — we don't stream the summary to the UI.
2382+ sink := func(delta string) {}
23872383 summary, err := i.agent.Compact(ctx, 4, sink)
2384+ _ = summary
23882385 i.mu.Lock()
23892386 i.busy = false
23902387 i.resetStreamingStateLocked()
23912388 i.cancelTurn = nil
23922389 i.autoCompacting = false
2390+
2391+ // Drain the queue: if the user typed a prompt while compacting,
2392+ // fire it now that the transcript is clean.
2393+ var next string
2394+ var hasNext bool
2395+
23932396 switch {
23942397 case err != nil && ctx.Err() != nil:
23952398 i.statusErr = ""
@@ -2398,22 +2401,44 @@ func (i *Interactive) runCompact(parent context.Context, auto bool) {
23982401 } else {
23992402 i.statusOK = "compaction cancelled"
24002403 }
2404+ i.queued = nil // drop queue on cancel
24012405 case err != nil:
24022406 i.statusErr = "compaction failed: " + err.Error()
24032407 i.statusOK = ""
2408+ i.queued = nil // drop queue on error
24042409 default:
24052410 i.statusErr = ""
2406- i.statusOK = fmt.Sprintf("compacted transcript (%d chars of summary)", len(summary))
2407- i.lastCtxInput = 0 // reset; next turn will get a fresh measurement
2411+ // Read token count from the compaction message meta.
2412+ tokens := ""
2413+ msgs := i.agent.Messages()
2414+ if len(msgs) > 0 && msgs[0].Meta["compaction"] == "true" {
2415+ tokens = msgs[0].Meta["tokens_before"]
2416+ }
2417+ if tokens != "" {
2418+ i.statusOK = fmt.Sprintf("compacted from ~%s tokens (ctrl+o to expand)", tokens)
2419+ } else {
2420+ i.statusOK = "compacted (ctrl+o to expand)"
2421+ }
2422+ i.lastCtxInput = 0
24082423 i.toolCalls = map[string]*tui.ToolCallView{}
24092424 i.toolOrder = nil
2410- // Transcript was rewritten in place — purge the per-message
2411- // render cache so stale entries keyed on the old messages
2412- // don't linger.
24132425 i.view.InvalidateRenderCache()
2426+ // Pop queued prompt if any.
2427+ if len(i.queued) > 0 {
2428+ next, i.queued = i.queued[0], i.queued[1:]
2429+ hasNext = true
2430+ }
24142431 }
24152432 i.mu.Unlock()
24162433 i.invalidate()
2434+
2435+ if hasNext {
2436+ p := i.runCtx
2437+ if p == nil {
2438+ p = context.Background()
2439+ }
2440+ i.startTurn(p, next)
2441+ }
24172442 }()
24182443 }
24192444
@@ -2363,33 +2363,36 @@ func (i *Interactive) runCompact(parent context.Context, auto bool) {
2363 if auto {2363 if auto {
2364 i.spin.StartFixed("condensing history")2364 i.spin.StartFixed("condensing history")
2365 i.autoCompacting = true2365 i.autoCompacting = true
2366- i.statusOK = "condensing history… (esc to cancel)"
2367 } else {2366 } else {
2368- i.spin.Start()2367+ i.spin.StartFixed("compacting")
2369- i.statusOK = "compacting..."
2370 }2368 }
2371 i.cancelTurn = cancel2369 i.cancelTurn = cancel
2372 i.statusErr = ""2370 i.statusErr = ""
2373- i.streaming.Reset()2371+ i.statusOK = ""
2374- i.streamOn = true2372+ // Do NOT set streamOn: the summary text should not be visible
2373+ // in the chat while compacting. The user just sees the spinner
2374+ // and can keep typing / queue prompts.
2375 i.scrollOffset = 02375 i.scrollOffset = 0
2376 i.helpBlock = nil2376 i.helpBlock = nil
2377 i.mu.Unlock()2377 i.mu.Unlock()
2378 i.invalidate()2378 i.invalidate()
2379 2379
2380 go func() {2380 go func() {
2381- sink := func(delta string) {2381+ // Sink discards deltas — we don't stream the summary to the UI.
2382- i.mu.Lock()2382+ sink := func(delta string) {}
2383- i.streaming.WriteString(delta)
2384- i.mu.Unlock()
2385- i.invalidate()
2386- }
2387 summary, err := i.agent.Compact(ctx, 4, sink)2383 summary, err := i.agent.Compact(ctx, 4, sink)
2384+ _ = summary
2388 i.mu.Lock()2385 i.mu.Lock()
2389 i.busy = false2386 i.busy = false
2390 i.resetStreamingStateLocked()2387 i.resetStreamingStateLocked()
2391 i.cancelTurn = nil2388 i.cancelTurn = nil
2392 i.autoCompacting = false2389 i.autoCompacting = false
2390+
2391+ // Drain the queue: if the user typed a prompt while compacting,
2392+ // fire it now that the transcript is clean.
2393+ var next string
2394+ var hasNext bool
2395+
2393 switch {2396 switch {
2394 case err != nil && ctx.Err() != nil:2397 case err != nil && ctx.Err() != nil:
2395 i.statusErr = ""2398 i.statusErr = ""
@@ -2398,22 +2401,44 @@ func (i *Interactive) runCompact(parent context.Context, auto bool) {
2398 } else {2401 } else {
2399 i.statusOK = "compaction cancelled"2402 i.statusOK = "compaction cancelled"
2400 }2403 }
2404+ i.queued = nil // drop queue on cancel
2401 case err != nil:2405 case err != nil:
2402 i.statusErr = "compaction failed: " + err.Error()2406 i.statusErr = "compaction failed: " + err.Error()
2403 i.statusOK = ""2407 i.statusOK = ""
2408+ i.queued = nil // drop queue on error
2404 default:2409 default:
2405 i.statusErr = ""2410 i.statusErr = ""
2406- i.statusOK = fmt.Sprintf("compacted transcript (%d chars of summary)", len(summary))2411+ // Read token count from the compaction message meta.
2407- i.lastCtxInput = 0 // reset; next turn will get a fresh measurement2412+ tokens := ""
2413+ msgs := i.agent.Messages()
2414+ if len(msgs) > 0 && msgs[0].Meta["compaction"] == "true" {
2415+ tokens = msgs[0].Meta["tokens_before"]
2416+ }
2417+ if tokens != "" {
2418+ i.statusOK = fmt.Sprintf("compacted from ~%s tokens (ctrl+o to expand)", tokens)
2419+ } else {
2420+ i.statusOK = "compacted (ctrl+o to expand)"
2421+ }
2422+ i.lastCtxInput = 0
2408 i.toolCalls = map[string]*tui.ToolCallView{}2423 i.toolCalls = map[string]*tui.ToolCallView{}
2409 i.toolOrder = nil2424 i.toolOrder = nil
2410- // Transcript was rewritten in place — purge the per-message
2411- // render cache so stale entries keyed on the old messages
2412- // don't linger.
2413 i.view.InvalidateRenderCache()2425 i.view.InvalidateRenderCache()
2426+ // Pop queued prompt if any.
2427+ if len(i.queued) > 0 {
2428+ next, i.queued = i.queued[0], i.queued[1:]
2429+ hasNext = true
2430+ }
2414 }2431 }
2415 i.mu.Unlock()2432 i.mu.Unlock()
2416 i.invalidate()2433 i.invalidate()
2434+
2435+ if hasNext {
2436+ p := i.runCtx
2437+ if p == nil {
2438+ p = context.Background()
2439+ }
2440+ i.startTurn(p, next)
2441+ }
2417 }()2442 }()
2418 }2443 }
2419 2444
M internal/core/compact.go +52 -0
@@ -3,6 +3,7 @@ package core
33 import (
44 "context"
55 "fmt"
6+ "strconv"
67 "strings"
78 "time"
89
@@ -82,6 +83,9 @@ func (a *Agent) Compact(ctx context.Context, keepTail int, sink func(delta strin
8283 return "", fmt.Errorf("empty summary from model")
8384 }
8485
86+ // Estimate token count before compaction (rough: 1 token ~ 4 chars).
87+ tokensBefore := len(transcript) / 4
88+
8589 // Replace transcript: one synthetic user message with the summary,
8690 // followed by the preserved tail (if any).
8791 synthetic := provider.Message{
@@ -90,9 +94,19 @@ func (a *Agent) Compact(ctx context.Context, keepTail int, sink func(delta strin
9094 provider.TextBlock{Text: "## Context Summary (compacted)\n\n" + summary},
9195 },
9296 Time: time.Now(),
97+ Meta: map[string]string{
98+ "compaction": "true",
99+ "tokens_before": strconv.Itoa(tokensBefore),
100+ },
93101 }
94102
95103 tail := msgs[len(msgs)-keepTail:]
104+ // Repair the tail: remove orphaned tool_result blocks whose
105+ // matching tool_use was in the compacted (now-removed) portion.
106+ // Anthropic rejects transcripts where a tool_result references
107+ // a tool_use ID that doesn't exist.
108+ tail = repairOrphanedToolResults(tail)
109+
96110 next := make([]provider.Message, 0, 1+len(tail))
97111 next = append(next, synthetic)
98112 next = append(next, tail...)
@@ -104,6 +118,44 @@ func (a *Agent) Compact(ctx context.Context, keepTail int, sink func(delta strin
104118 return summary, nil
105119 }
106120
121+// repairOrphanedToolResults removes tool_result content blocks (and
122+// entire messages that become empty) when the matching tool_use ID
123+// does not appear anywhere in the given messages. This happens after
124+// compaction when the tail preserves a tool_result but the tool_use
125+// that produced it was summarized away.
126+func repairOrphanedToolResults(msgs []provider.Message) []provider.Message {
127+ // Collect all tool_use IDs present in the messages.
128+ useIDs := map[string]bool{}
129+ for _, m := range msgs {
130+ for _, c := range m.Content {
131+ if tc, ok := c.(provider.ToolCallBlock); ok {
132+ useIDs[tc.ID] = true
133+ }
134+ }
135+ }
136+
137+ // Filter out tool_result blocks referencing missing tool_use IDs.
138+ out := make([]provider.Message, 0, len(msgs))
139+ for _, m := range msgs {
140+ var filtered []provider.Content
141+ for _, c := range m.Content {
142+ if tr, ok := c.(provider.ToolResultBlock); ok {
143+ if !useIDs[tr.CallID] {
144+ continue // orphaned
145+ }
146+ }
147+ filtered = append(filtered, c)
148+ }
149+ if len(filtered) > 0 {
150+ copy := m
151+ copy.Content = filtered
152+ out = append(out, copy)
153+ }
154+ // Drop messages that became empty after filtering.
155+ }
156+ return out
157+}
158+
107159 // serializeTranscript renders a list of provider.Message into a plain
108160 // text transcript the summarization model can read without trying to
109161 // continue the conversation.
@@ -3,6 +3,7 @@ package core
3 import (3 import (
4 "context"4 "context"
5 "fmt"5 "fmt"
6+ "strconv"
6 "strings"7 "strings"
7 "time"8 "time"
8 9
@@ -82,6 +83,9 @@ func (a *Agent) Compact(ctx context.Context, keepTail int, sink func(delta strin
82 return "", fmt.Errorf("empty summary from model")83 return "", fmt.Errorf("empty summary from model")
83 }84 }
84 85
86+ // Estimate token count before compaction (rough: 1 token ~ 4 chars).
87+ tokensBefore := len(transcript) / 4
88+
85 // Replace transcript: one synthetic user message with the summary,89 // Replace transcript: one synthetic user message with the summary,
86 // followed by the preserved tail (if any).90 // followed by the preserved tail (if any).
87 synthetic := provider.Message{91 synthetic := provider.Message{
@@ -90,9 +94,19 @@ func (a *Agent) Compact(ctx context.Context, keepTail int, sink func(delta strin
90 provider.TextBlock{Text: "## Context Summary (compacted)\n\n" + summary},94 provider.TextBlock{Text: "## Context Summary (compacted)\n\n" + summary},
91 },95 },
92 Time: time.Now(),96 Time: time.Now(),
97+ Meta: map[string]string{
98+ "compaction": "true",
99+ "tokens_before": strconv.Itoa(tokensBefore),
100+ },
93 }101 }
94 102
95 tail := msgs[len(msgs)-keepTail:]103 tail := msgs[len(msgs)-keepTail:]
104+ // Repair the tail: remove orphaned tool_result blocks whose
105+ // matching tool_use was in the compacted (now-removed) portion.
106+ // Anthropic rejects transcripts where a tool_result references
107+ // a tool_use ID that doesn't exist.
108+ tail = repairOrphanedToolResults(tail)
109+
96 next := make([]provider.Message, 0, 1+len(tail))110 next := make([]provider.Message, 0, 1+len(tail))
97 next = append(next, synthetic)111 next = append(next, synthetic)
98 next = append(next, tail...)112 next = append(next, tail...)
@@ -104,6 +118,44 @@ func (a *Agent) Compact(ctx context.Context, keepTail int, sink func(delta strin
104 return summary, nil118 return summary, nil
105 }119 }
106 120
121+// repairOrphanedToolResults removes tool_result content blocks (and
122+// entire messages that become empty) when the matching tool_use ID
123+// does not appear anywhere in the given messages. This happens after
124+// compaction when the tail preserves a tool_result but the tool_use
125+// that produced it was summarized away.
126+func repairOrphanedToolResults(msgs []provider.Message) []provider.Message {
127+ // Collect all tool_use IDs present in the messages.
128+ useIDs := map[string]bool{}
129+ for _, m := range msgs {
130+ for _, c := range m.Content {
131+ if tc, ok := c.(provider.ToolCallBlock); ok {
132+ useIDs[tc.ID] = true
133+ }
134+ }
135+ }
136+
137+ // Filter out tool_result blocks referencing missing tool_use IDs.
138+ out := make([]provider.Message, 0, len(msgs))
139+ for _, m := range msgs {
140+ var filtered []provider.Content
141+ for _, c := range m.Content {
142+ if tr, ok := c.(provider.ToolResultBlock); ok {
143+ if !useIDs[tr.CallID] {
144+ continue // orphaned
145+ }
146+ }
147+ filtered = append(filtered, c)
148+ }
149+ if len(filtered) > 0 {
150+ copy := m
151+ copy.Content = filtered
152+ out = append(out, copy)
153+ }
154+ // Drop messages that became empty after filtering.
155+ }
156+ return out
157+}
158+
107 // serializeTranscript renders a list of provider.Message into a plain159 // serializeTranscript renders a list of provider.Message into a plain
108 // text transcript the summarization model can read without trying to160 // text transcript the summarization model can read without trying to
109 // continue the conversation.161 // continue the conversation.
M internal/provider/provider.go +4 -3
@@ -62,9 +62,10 @@ func (ToolResultBlock) isContent() {}
6262
6363 // Message is a single turn in the conversation.
6464 type Message struct {
65- Role Role `json:"role"`
66- Content []Content `json:"content"`
67- Time time.Time `json:"time"`
65+ Role Role `json:"role"`
66+ Content []Content `json:"content"`
67+ Time time.Time `json:"time"`
68+ Meta map[string]string `json:"meta,omitempty"`
6869 }
6970
7071 // Tool is a tool definition advertised to the LLM.
@@ -62,9 +62,10 @@ func (ToolResultBlock) isContent() {}
62 62
63 // Message is a single turn in the conversation.63 // Message is a single turn in the conversation.
64 type Message struct {64 type Message struct {
65- Role Role `json:"role"`65+ Role Role `json:"role"`
66- Content []Content `json:"content"`66+ Content []Content `json:"content"`
67- Time time.Time `json:"time"`67+ Time time.Time `json:"time"`
68+ Meta map[string]string `json:"meta,omitempty"`
68 }69 }
69 70
70 // Tool is a tool definition advertised to the LLM.71 // Tool is a tool definition advertised to the LLM.
M internal/tui/view.go +49 -0
@@ -403,6 +403,17 @@ func fnv64aWrite(h uint64, p []byte) uint64 {
403403
404404 func (v *View) renderMessage(m provider.Message, width int) []string {
405405 var lines []string
406+
407+ // Compaction summary: render as a single muted line at the end
408+ // of the chat instead of as a user message.
409+ if m.Meta["compaction"] == "true" {
410+ if v.ExpandAll {
411+ return v.renderCompactionBlock(m, width)
412+ }
413+ // Collapsed: skip entirely. The status bar shows the info.
414+ return nil
415+ }
416+
406417 switch m.Role {
407418 case provider.RoleUser:
408419 header := v.Theme.FG256(v.Theme.User, "▍ you")
@@ -1279,6 +1290,44 @@ func truncateLines(s string, n int) string {
12791290 return strings.Join(lines[:n], "\n") + "\n … (" + fmt.Sprintf("%d", len(lines)-n) + " more)"
12801291 }
12811292
1293+// renderCompactionBlock renders a compaction summary as a distinct
1294+// visual block in the chat. When collapsed it shows a one-line label
1295+// with the pre-compaction token count; when expanded (ctrl+o) it
1296+// shows the full summary text.
1297+func (v *View) renderCompactionBlock(m provider.Message, width int) []string {
1298+ th := v.Theme
1299+ const indent = " "
1300+
1301+ tokens := m.Meta["tokens_before"]
1302+ if tokens == "" {
1303+ tokens = "?"
1304+ }
1305+
1306+ if v.ExpandAll {
1307+ var lines []string
1308+ header := th.FG256(th.Muted, fmt.Sprintf("compacted from ~%s tokens", tokens))
1309+ lines = append(lines, indent+header)
1310+ lines = append(lines, "")
1311+ for _, c := range m.Content {
1312+ if tb, ok := c.(provider.TextBlock); ok {
1313+ text := tb.Text
1314+ if idx := strings.Index(text, "\n\n"); idx >= 0 && strings.HasPrefix(text, "## Context Summary") {
1315+ text = text[idx+2:]
1316+ }
1317+ md := RenderMarkdown(text, th, width-4)
1318+ for _, l := range strings.Split(md, "\n") {
1319+ lines = append(lines, indent+l)
1320+ }
1321+ }
1322+ }
1323+ return lines
1324+ }
1325+
1326+ // Collapsed: single line, no banner.
1327+ line := th.FG256(th.Muted, fmt.Sprintf("compacted from ~%s tokens (ctrl+o to expand)", tokens))
1328+ return []string{indent + line}
1329+}
1330+
12821331 // StatusBarParams groups the many bits of state the status bar needs.
12831332 // Grew from a flat argument list once we settled on the layout.
12841333 type StatusBarParams struct {
@@ -403,6 +403,17 @@ func fnv64aWrite(h uint64, p []byte) uint64 {
403 403
404 func (v *View) renderMessage(m provider.Message, width int) []string {404 func (v *View) renderMessage(m provider.Message, width int) []string {
405 var lines []string405 var lines []string
406+
407+ // Compaction summary: render as a single muted line at the end
408+ // of the chat instead of as a user message.
409+ if m.Meta["compaction"] == "true" {
410+ if v.ExpandAll {
411+ return v.renderCompactionBlock(m, width)
412+ }
413+ // Collapsed: skip entirely. The status bar shows the info.
414+ return nil
415+ }
416+
406 switch m.Role {417 switch m.Role {
407 case provider.RoleUser:418 case provider.RoleUser:
408 header := v.Theme.FG256(v.Theme.User, "▍ you")419 header := v.Theme.FG256(v.Theme.User, "▍ you")
@@ -1279,6 +1290,44 @@ func truncateLines(s string, n int) string {
1279 return strings.Join(lines[:n], "\n") + "\n … (" + fmt.Sprintf("%d", len(lines)-n) + " more)"1290 return strings.Join(lines[:n], "\n") + "\n … (" + fmt.Sprintf("%d", len(lines)-n) + " more)"
1280 }1291 }
1281 1292
1293+// renderCompactionBlock renders a compaction summary as a distinct
1294+// visual block in the chat. When collapsed it shows a one-line label
1295+// with the pre-compaction token count; when expanded (ctrl+o) it
1296+// shows the full summary text.
1297+func (v *View) renderCompactionBlock(m provider.Message, width int) []string {
1298+ th := v.Theme
1299+ const indent = " "
1300+
1301+ tokens := m.Meta["tokens_before"]
1302+ if tokens == "" {
1303+ tokens = "?"
1304+ }
1305+
1306+ if v.ExpandAll {
1307+ var lines []string
1308+ header := th.FG256(th.Muted, fmt.Sprintf("compacted from ~%s tokens", tokens))
1309+ lines = append(lines, indent+header)
1310+ lines = append(lines, "")
1311+ for _, c := range m.Content {
1312+ if tb, ok := c.(provider.TextBlock); ok {
1313+ text := tb.Text
1314+ if idx := strings.Index(text, "\n\n"); idx >= 0 && strings.HasPrefix(text, "## Context Summary") {
1315+ text = text[idx+2:]
1316+ }
1317+ md := RenderMarkdown(text, th, width-4)
1318+ for _, l := range strings.Split(md, "\n") {
1319+ lines = append(lines, indent+l)
1320+ }
1321+ }
1322+ }
1323+ return lines
1324+ }
1325+
1326+ // Collapsed: single line, no banner.
1327+ line := th.FG256(th.Muted, fmt.Sprintf("compacted from ~%s tokens (ctrl+o to expand)", tokens))
1328+ return []string{indent + line}
1329+}
1330+
1282 // StatusBarParams groups the many bits of state the status bar needs.1331 // StatusBarParams groups the many bits of state the status bar needs.
1283 // Grew from a flat argument list once we settled on the layout.1332 // Grew from a flat argument list once we settled on the layout.
1284 type StatusBarParams struct {1333 type StatusBarParams struct {

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