fix: wrap expanded compaction summaries
- commit
- aed6edbacb715c5139d3a30ecb349ea6496e620b
- author
- patriceckhart · · 2026-09-17 13:15 UTC
- parent
- 5f8ce64
- signature
- unsigned
- refs
- tag: v0.3.78
3 files · +61 -5
M README.md +1 -1
| @@ -450,7 +450,7 @@ Opens a picker listing every discovered SKILL.md file, built-ins hidden. Each ro | |||
| 450 | 450 | ||
| 451 | 451 | ### `/compact` | |
| 452 | 452 | ||
| 453 | - | Sends the current transcript through the model with a structured summarization prompt. The returned summary replaces the transcript as one synthetic user message, with the last few exchanges kept verbatim for continuity. The status bar's context meter resets. Use it when the context meter creeps past ~80%. | |
| 453 | + | Sends the current transcript through the model with a structured summarization prompt. The returned summary replaces the transcript as one synthetic user message, with the last few exchanges kept verbatim for continuity. The status bar's context meter resets. Use it when the context meter creeps past ~80%. Press `ctrl+o` to expand the summary. Expanded text wraps to the current terminal width, including after resizing. | |
| 454 | 454 | ||
| 455 | 455 | zot also auto-compacts in the background: after any turn that reaches the configured context threshold, the agent kicks off a condense pass on its own. Choose `off`, `70%`, `80%`, `85%` (default), or `90%` under `/settings` → **auto-compact threshold**. You'll see `condensing history, esc to cancel` above the status bar and an `(auto)` tag next to the context percentage; `esc` aborts it without touching the transcript. Turning the percentage trigger off does not disable automatic compaction and retry after a context-window or payload-too-large response. | |
| 456 | 456 | ||
| @@ -450,7 +450,7 @@ Opens a picker listing every discovered SKILL.md file, built-ins hidden. Each ro | |||||
| 450 | 450 | ||||
| 451 | ### `/compact` | 451 | ### `/compact` | ||
| 452 | 452 | ||||
| 453 | - | Sends the current transcript through the model with a structured summarization prompt. The returned summary replaces the transcript as one synthetic user message, with the last few exchanges kept verbatim for continuity. The status bar's context meter resets. Use it when the context meter creeps past ~80%. | 453 | + | Sends the current transcript through the model with a structured summarization prompt. The returned summary replaces the transcript as one synthetic user message, with the last few exchanges kept verbatim for continuity. The status bar's context meter resets. Use it when the context meter creeps past ~80%. Press `ctrl+o` to expand the summary. Expanded text wraps to the current terminal width, including after resizing. |
| 454 | 454 | ||||
| 455 | zot also auto-compacts in the background: after any turn that reaches the configured context threshold, the agent kicks off a condense pass on its own. Choose `off`, `70%`, `80%`, `85%` (default), or `90%` under `/settings` → **auto-compact threshold**. You'll see `condensing history, esc to cancel` above the status bar and an `(auto)` tag next to the context percentage; `esc` aborts it without touching the transcript. Turning the percentage trigger off does not disable automatic compaction and retry after a context-window or payload-too-large response. | 455 | zot also auto-compacts in the background: after any turn that reaches the configured context threshold, the agent kicks off a condense pass on its own. Choose `off`, `70%`, `80%`, `85%` (default), or `90%` under `/settings` → **auto-compact threshold**. You'll see `condensing history, esc to cancel` above the status bar and an `(auto)` tag next to the context percentage; `esc` aborts it without touching the transcript. Turning the percentage trigger off does not disable automatic compaction and retry after a context-window or payload-too-large response. | ||
| 456 | 456 | ||||
M packages/tui/view.go +13 -4
| @@ -2454,7 +2454,12 @@ func truncateLines(s string, n int) string { | |||
| 2454 | 2454 | // shows the full summary text. | |
| 2455 | 2455 | func (v *View) renderCompactionBlock(m provider.Message, width int) []string { | |
| 2456 | 2456 | th := v.Theme | |
| 2457 | - | const indent = " " | |
| 2457 | + | indent := " " | |
| 2458 | + | // Leave room for a wide Unicode character on very narrow terminals. | |
| 2459 | + | if width < len(indent)+2 { | |
| 2460 | + | indent = "" | |
| 2461 | + | } | |
| 2462 | + | inner := max(1, width-len(indent)) | |
| 2458 | 2463 | ||
| 2459 | 2464 | tokens := m.Meta["tokens_before"] | |
| 2460 | 2465 | if tokens == "" { | |
| @@ -2464,7 +2469,9 @@ func (v *View) renderCompactionBlock(m provider.Message, width int) []string { | |||
| 2464 | 2469 | if v.ExpandAll { | |
| 2465 | 2470 | var lines []string | |
| 2466 | 2471 | header := th.FG256(th.Muted, fmt.Sprintf("compacted from ~%s tokens", tokens)) | |
| 2467 | - | lines = append(lines, indent+header) | |
| 2472 | + | for _, line := range wrapANSILine(header, inner) { | |
| 2473 | + | lines = append(lines, indent+line) | |
| 2474 | + | } | |
| 2468 | 2475 | lines = append(lines, "") | |
| 2469 | 2476 | for _, c := range m.Content { | |
| 2470 | 2477 | if tb, ok := c.(provider.TextBlock); ok { | |
| @@ -2472,12 +2479,14 @@ func (v *View) renderCompactionBlock(m provider.Message, width int) []string { | |||
| 2472 | 2479 | if idx := strings.Index(text, "\n\n"); idx >= 0 && strings.HasPrefix(text, "## Context Summary") { | |
| 2473 | 2480 | text = text[idx+2:] | |
| 2474 | 2481 | } | |
| 2475 | - | md := RenderMarkdown(text, th, width-4) | |
| 2482 | + | md := RenderMarkdown(text, th, inner) | |
| 2476 | 2483 | for _, l := range strings.Split(md, "\n") { | |
| 2477 | 2484 | if len(l) > 0 && l[0] == FlushLeftSentinel { | |
| 2478 | 2485 | l = l[1:] | |
| 2479 | 2486 | } | |
| 2480 | - | lines = append(lines, indent+l) | |
| 2487 | + | for _, line := range wrapANSILine(l, inner) { | |
| 2488 | + | lines = append(lines, indent+line) | |
| 2489 | + | } | |
| 2481 | 2490 | } | |
| 2482 | 2491 | } | |
| 2483 | 2492 | } | |
| @@ -2454,7 +2454,12 @@ func truncateLines(s string, n int) string { | |||||
| 2454 | // shows the full summary text. | 2454 | // shows the full summary text. | ||
| 2455 | func (v *View) renderCompactionBlock(m provider.Message, width int) []string { | 2455 | func (v *View) renderCompactionBlock(m provider.Message, width int) []string { | ||
| 2456 | th := v.Theme | 2456 | th := v.Theme | ||
| 2457 | - | const indent = " " | 2457 | + | indent := " " |
| 2458 | + | // Leave room for a wide Unicode character on very narrow terminals. | |||
| 2459 | + | if width < len(indent)+2 { | |||
| 2460 | + | indent = "" | |||
| 2461 | + | } | |||
| 2462 | + | inner := max(1, width-len(indent)) | |||
| 2458 | 2463 | ||||
| 2459 | tokens := m.Meta["tokens_before"] | 2464 | tokens := m.Meta["tokens_before"] | ||
| 2460 | if tokens == "" { | 2465 | if tokens == "" { | ||
| @@ -2464,7 +2469,9 @@ func (v *View) renderCompactionBlock(m provider.Message, width int) []string { | |||||
| 2464 | if v.ExpandAll { | 2469 | if v.ExpandAll { | ||
| 2465 | var lines []string | 2470 | var lines []string | ||
| 2466 | header := th.FG256(th.Muted, fmt.Sprintf("compacted from ~%s tokens", tokens)) | 2471 | header := th.FG256(th.Muted, fmt.Sprintf("compacted from ~%s tokens", tokens)) | ||
| 2467 | - | lines = append(lines, indent+header) | 2472 | + | for _, line := range wrapANSILine(header, inner) { |
| 2473 | + | lines = append(lines, indent+line) | |||
| 2474 | + | } | |||
| 2468 | lines = append(lines, "") | 2475 | lines = append(lines, "") | ||
| 2469 | for _, c := range m.Content { | 2476 | for _, c := range m.Content { | ||
| 2470 | if tb, ok := c.(provider.TextBlock); ok { | 2477 | if tb, ok := c.(provider.TextBlock); ok { | ||
| @@ -2472,12 +2479,14 @@ func (v *View) renderCompactionBlock(m provider.Message, width int) []string { | |||||
| 2472 | if idx := strings.Index(text, "\n\n"); idx >= 0 && strings.HasPrefix(text, "## Context Summary") { | 2479 | if idx := strings.Index(text, "\n\n"); idx >= 0 && strings.HasPrefix(text, "## Context Summary") { | ||
| 2473 | text = text[idx+2:] | 2480 | text = text[idx+2:] | ||
| 2474 | } | 2481 | } | ||
| 2475 | - | md := RenderMarkdown(text, th, width-4) | 2482 | + | md := RenderMarkdown(text, th, inner) |
| 2476 | for _, l := range strings.Split(md, "\n") { | 2483 | for _, l := range strings.Split(md, "\n") { | ||
| 2477 | if len(l) > 0 && l[0] == FlushLeftSentinel { | 2484 | if len(l) > 0 && l[0] == FlushLeftSentinel { | ||
| 2478 | l = l[1:] | 2485 | l = l[1:] | ||
| 2479 | } | 2486 | } | ||
| 2480 | - | lines = append(lines, indent+l) | 2487 | + | for _, line := range wrapANSILine(l, inner) { |
| 2488 | + | lines = append(lines, indent+line) | |||
| 2489 | + | } | |||
| 2481 | } | 2490 | } | ||
| 2482 | } | 2491 | } | ||
| 2483 | } | 2492 | } | ||
A packages/tui/view_compaction_test.go +47 -0
| @@ -0,0 +1,47 @@ | |||
| 1 | + | package tui | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "strings" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | "github.com/patriceckhart/zot/packages/provider" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | func TestExpandedCompactionWrapsAfterResize(t *testing.T) { | |
| 11 | + | for _, text := range []string{ | |
| 12 | + | strings.Repeat("summary words ", 20), | |
| 13 | + | "**" + strings.Repeat("styled text ", 20) + "**", | |
| 14 | + | "```go\n" + strings.Repeat("longIdentifier", 20) + "\n```", | |
| 15 | + | strings.Repeat("日本語 Grüße ", 20), | |
| 16 | + | } { | |
| 17 | + | v := View{ | |
| 18 | + | Theme: Dark, | |
| 19 | + | Messages: []provider.Message{{ | |
| 20 | + | Role: provider.RoleUser, | |
| 21 | + | Meta: map[string]string{"compaction": "true", "tokens_before": "12345"}, | |
| 22 | + | Content: []provider.Content{provider.TextBlock{ | |
| 23 | + | Text: "## Context Summary\n\n" + text, | |
| 24 | + | }}, | |
| 25 | + | }}, | |
| 26 | + | } | |
| 27 | + | if got := strings.TrimSpace(strings.Join(v.Build(120), "")); got != "" { | |
| 28 | + | t.Fatalf("collapsed summary should be hidden: %q", got) | |
| 29 | + | } | |
| 30 | + | v.ExpandAll = true | |
| 31 | + | canonical := func(s string) string { | |
| 32 | + | return strings.Join(strings.Fields(stripANSI(s)), "") | |
| 33 | + | } | |
| 34 | + | want := canonical("compacted from ~12345 tokens" + RenderMarkdown(text, Dark, 116)) | |
| 35 | + | for _, width := range []int{120, 40, 12, 4, 2, 120} { | |
| 36 | + | lines := v.Build(width) | |
| 37 | + | for _, line := range lines { | |
| 38 | + | if got := visibleWidth(line); got > width { | |
| 39 | + | t.Fatalf("row width %d exceeds terminal width %d: %q", got, width, stripANSI(line)) | |
| 40 | + | } | |
| 41 | + | } | |
| 42 | + | if got := canonical(strings.Join(lines, "")); got != want { | |
| 43 | + | t.Fatalf("width %d lost summary content\ngot: %s\nwant: %s", width, got, want) | |
| 44 | + | } | |
| 45 | + | } | |
| 46 | + | } | |
| 47 | + | } | |
| @@ -0,0 +1,47 @@ | |||||
| 1 | + | package tui | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "strings" | |||
| 5 | + | "testing" | |||
| 6 | + | ||||
| 7 | + | "github.com/patriceckhart/zot/packages/provider" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | func TestExpandedCompactionWrapsAfterResize(t *testing.T) { | |||
| 11 | + | for _, text := range []string{ | |||
| 12 | + | strings.Repeat("summary words ", 20), | |||
| 13 | + | "**" + strings.Repeat("styled text ", 20) + "**", | |||
| 14 | + | "```go\n" + strings.Repeat("longIdentifier", 20) + "\n```", | |||
| 15 | + | strings.Repeat("日本語 Grüße ", 20), | |||
| 16 | + | } { | |||
| 17 | + | v := View{ | |||
| 18 | + | Theme: Dark, | |||
| 19 | + | Messages: []provider.Message{{ | |||
| 20 | + | Role: provider.RoleUser, | |||
| 21 | + | Meta: map[string]string{"compaction": "true", "tokens_before": "12345"}, | |||
| 22 | + | Content: []provider.Content{provider.TextBlock{ | |||
| 23 | + | Text: "## Context Summary\n\n" + text, | |||
| 24 | + | }}, | |||
| 25 | + | }}, | |||
| 26 | + | } | |||
| 27 | + | if got := strings.TrimSpace(strings.Join(v.Build(120), "")); got != "" { | |||
| 28 | + | t.Fatalf("collapsed summary should be hidden: %q", got) | |||
| 29 | + | } | |||
| 30 | + | v.ExpandAll = true | |||
| 31 | + | canonical := func(s string) string { | |||
| 32 | + | return strings.Join(strings.Fields(stripANSI(s)), "") | |||
| 33 | + | } | |||
| 34 | + | want := canonical("compacted from ~12345 tokens" + RenderMarkdown(text, Dark, 116)) | |||
| 35 | + | for _, width := range []int{120, 40, 12, 4, 2, 120} { | |||
| 36 | + | lines := v.Build(width) | |||
| 37 | + | for _, line := range lines { | |||
| 38 | + | if got := visibleWidth(line); got > width { | |||
| 39 | + | t.Fatalf("row width %d exceeds terminal width %d: %q", got, width, stripANSI(line)) | |||
| 40 | + | } | |||
| 41 | + | } | |||
| 42 | + | if got := canonical(strings.Join(lines, "")); got != want { | |||
| 43 | + | t.Fatalf("width %d lost summary content\ngot: %s\nwant: %s", width, got, want) | |||
| 44 | + | } | |||
| 45 | + | } | |||
| 46 | + | } | |||
| 47 | + | } | |||