Fix dialog response wrapping
- commit
- acdf6ef405552d77420bd1464fe0aa92ba63e955
- author
- patriceckhart · · 2026-07-14 12:10 UTC
- parent
- efd311e
- signature
- unsigned
4 files · +133 -23
M packages/agent/modes/btw_dialog.go +4 -10
| @@ -330,16 +330,10 @@ func (d *btwDialog) Render(th tui.Theme, width int) []string { | |||
| 330 | 330 | out = append(out, btwUserBubbleRows(th, t.User, width-2, d.compactMode)...) | |
| 331 | 331 | if t.Assistant != "" { | |
| 332 | 332 | out = append(out, "") | |
| 333 | - | md := tui.RenderMarkdown(t.Assistant, th, width-4) | |
| 334 | - | for _, line := range strings.Split(md, "\n") { | |
| 335 | - | if len(line) > 0 && line[0] == tui.FlushLeftSentinel { | |
| 336 | - | line = line[1:] | |
| 337 | - | } | |
| 338 | - | out = append(out, " "+line) | |
| 339 | - | } | |
| 333 | + | out = append(out, renderDialogMarkdownRows(t.Assistant, th, width)...) | |
| 340 | 334 | } | |
| 341 | 335 | if t.Err != "" { | |
| 342 | - | out = append(out, " "+th.FG256(th.Error, "✖ "+t.Err)) | |
| 336 | + | out = append(out, wrapDialogTextRows(th.FG256(th.Error, "✖ "+t.Err), width)...) | |
| 343 | 337 | } | |
| 344 | 338 | } | |
| 345 | 339 | ||
| @@ -405,10 +399,10 @@ func (d *btwDialog) CursorPos(width int) (row, col int) { | |||
| 405 | 399 | editorOffset += len(btwUserBubbleRows(d.theme, t.User, width-2, d.compactMode)) | |
| 406 | 400 | if t.Assistant != "" { | |
| 407 | 401 | editorOffset++ // blank | |
| 408 | - | editorOffset += len(strings.Split(tui.RenderMarkdown(t.Assistant, d.theme, width-4), "\n")) | |
| 402 | + | editorOffset += len(renderDialogMarkdownRows(t.Assistant, d.theme, width)) | |
| 409 | 403 | } | |
| 410 | 404 | if t.Err != "" { | |
| 411 | - | editorOffset++ | |
| 405 | + | editorOffset += len(wrapDialogTextRows(d.theme.FG256(d.theme.Error, "✖ "+t.Err), width)) | |
| 412 | 406 | } | |
| 413 | 407 | } | |
| 414 | 408 | if d.loading { | |
| @@ -330,16 +330,10 @@ func (d *btwDialog) Render(th tui.Theme, width int) []string { | |||||
| 330 | out = append(out, btwUserBubbleRows(th, t.User, width-2, d.compactMode)...) | 330 | out = append(out, btwUserBubbleRows(th, t.User, width-2, d.compactMode)...) | ||
| 331 | if t.Assistant != "" { | 331 | if t.Assistant != "" { | ||
| 332 | out = append(out, "") | 332 | out = append(out, "") | ||
| 333 | - | md := tui.RenderMarkdown(t.Assistant, th, width-4) | 333 | + | out = append(out, renderDialogMarkdownRows(t.Assistant, th, width)...) |
| 334 | - | for _, line := range strings.Split(md, "\n") { | |||
| 335 | - | if len(line) > 0 && line[0] == tui.FlushLeftSentinel { | |||
| 336 | - | line = line[1:] | |||
| 337 | - | } | |||
| 338 | - | out = append(out, " "+line) | |||
| 339 | - | } | |||
| 340 | } | 334 | } | ||
| 341 | if t.Err != "" { | 335 | if t.Err != "" { | ||
| 342 | - | out = append(out, " "+th.FG256(th.Error, "✖ "+t.Err)) | 336 | + | out = append(out, wrapDialogTextRows(th.FG256(th.Error, "✖ "+t.Err), width)...) |
| 343 | } | 337 | } | ||
| 344 | } | 338 | } | ||
| 345 | 339 | ||||
| @@ -405,10 +399,10 @@ func (d *btwDialog) CursorPos(width int) (row, col int) { | |||||
| 405 | editorOffset += len(btwUserBubbleRows(d.theme, t.User, width-2, d.compactMode)) | 399 | editorOffset += len(btwUserBubbleRows(d.theme, t.User, width-2, d.compactMode)) | ||
| 406 | if t.Assistant != "" { | 400 | if t.Assistant != "" { | ||
| 407 | editorOffset++ // blank | 401 | editorOffset++ // blank | ||
| 408 | - | editorOffset += len(strings.Split(tui.RenderMarkdown(t.Assistant, d.theme, width-4), "\n")) | 402 | + | editorOffset += len(renderDialogMarkdownRows(t.Assistant, d.theme, width)) |
| 409 | } | 403 | } | ||
| 410 | if t.Err != "" { | 404 | if t.Err != "" { | ||
| 411 | - | editorOffset++ | 405 | + | editorOffset += len(wrapDialogTextRows(d.theme.FG256(d.theme.Error, "✖ "+t.Err), width)) |
| 412 | } | 406 | } | ||
| 413 | } | 407 | } | ||
| 414 | if d.loading { | 408 | if d.loading { | ||
A packages/agent/modes/dialog_text.go +40 -0
| @@ -0,0 +1,40 @@ | |||
| 1 | + | package modes | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "strings" | |
| 5 | + | ||
| 6 | + | "github.com/patriceckhart/zot/packages/tui" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | const dialogTextIndent = " " | |
| 10 | + | ||
| 11 | + | // renderDialogMarkdownRows renders assistant prose and wraps every styled row | |
| 12 | + | // to the width available after the dialog's left indent. | |
| 13 | + | func renderDialogMarkdownRows(text string, th tui.Theme, width int) []string { | |
| 14 | + | innerWidth := dialogTextWidth(width) | |
| 15 | + | return wrapDialogTextRows(tui.RenderMarkdown(text, th, innerWidth), width) | |
| 16 | + | } | |
| 17 | + | ||
| 18 | + | // wrapDialogTextRows wraps text that has already been styled, preserving ANSI | |
| 19 | + | // sequences while keeping every returned row within the dialog width. | |
| 20 | + | func wrapDialogTextRows(text string, width int) []string { | |
| 21 | + | innerWidth := dialogTextWidth(width) | |
| 22 | + | var rows []string | |
| 23 | + | for _, line := range strings.Split(text, "\n") { | |
| 24 | + | if len(line) > 0 && line[0] == tui.FlushLeftSentinel { | |
| 25 | + | line = line[1:] | |
| 26 | + | } | |
| 27 | + | for _, wrapped := range tui.WrapANSILine(line, innerWidth) { | |
| 28 | + | rows = append(rows, dialogTextIndent+wrapped) | |
| 29 | + | } | |
| 30 | + | } | |
| 31 | + | return rows | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | func dialogTextWidth(width int) int { | |
| 35 | + | width -= len(dialogTextIndent) | |
| 36 | + | if width < 1 { | |
| 37 | + | return 1 | |
| 38 | + | } | |
| 39 | + | return width | |
| 40 | + | } | |
| @@ -0,0 +1,40 @@ | |||||
| 1 | + | package modes | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "strings" | |||
| 5 | + | ||||
| 6 | + | "github.com/patriceckhart/zot/packages/tui" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | const dialogTextIndent = " " | |||
| 10 | + | ||||
| 11 | + | // renderDialogMarkdownRows renders assistant prose and wraps every styled row | |||
| 12 | + | // to the width available after the dialog's left indent. | |||
| 13 | + | func renderDialogMarkdownRows(text string, th tui.Theme, width int) []string { | |||
| 14 | + | innerWidth := dialogTextWidth(width) | |||
| 15 | + | return wrapDialogTextRows(tui.RenderMarkdown(text, th, innerWidth), width) | |||
| 16 | + | } | |||
| 17 | + | ||||
| 18 | + | // wrapDialogTextRows wraps text that has already been styled, preserving ANSI | |||
| 19 | + | // sequences while keeping every returned row within the dialog width. | |||
| 20 | + | func wrapDialogTextRows(text string, width int) []string { | |||
| 21 | + | innerWidth := dialogTextWidth(width) | |||
| 22 | + | var rows []string | |||
| 23 | + | for _, line := range strings.Split(text, "\n") { | |||
| 24 | + | if len(line) > 0 && line[0] == tui.FlushLeftSentinel { | |||
| 25 | + | line = line[1:] | |||
| 26 | + | } | |||
| 27 | + | for _, wrapped := range tui.WrapANSILine(line, innerWidth) { | |||
| 28 | + | rows = append(rows, dialogTextIndent+wrapped) | |||
| 29 | + | } | |||
| 30 | + | } | |||
| 31 | + | return rows | |||
| 32 | + | } | |||
| 33 | + | ||||
| 34 | + | func dialogTextWidth(width int) int { | |||
| 35 | + | width -= len(dialogTextIndent) | |||
| 36 | + | if width < 1 { | |||
| 37 | + | return 1 | |||
| 38 | + | } | |||
| 39 | + | return width | |||
| 40 | + | } | |||
A packages/agent/modes/dialog_text_test.go +86 -0
| @@ -0,0 +1,86 @@ | |||
| 1 | + | package modes | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "strings" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | "github.com/mattn/go-runewidth" | |
| 8 | + | ||
| 9 | + | "github.com/patriceckhart/zot/packages/agent/swarm" | |
| 10 | + | "github.com/patriceckhart/zot/packages/tui" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | func assertRowsFitWidth(t *testing.T, rows []string, width int) { | |
| 14 | + | t.Helper() | |
| 15 | + | for i, row := range rows { | |
| 16 | + | if got := runewidth.StringWidth(stripANSIBytes(row)); got > width { | |
| 17 | + | t.Errorf("row %d width = %d, want <= %d: %q", i, got, width, stripANSIBytes(row)) | |
| 18 | + | } | |
| 19 | + | } | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | func TestBtwDialogWrapsAssistantAndKeepsCursorAligned(t *testing.T) { | |
| 23 | + | const width = 80 | |
| 24 | + | response := "This response is deliberately long enough to require several wrapped display rows without losing any words." | |
| 25 | + | d := &btwDialog{ | |
| 26 | + | active: true, | |
| 27 | + | lineInput: true, | |
| 28 | + | turns: []btwTurn{{ | |
| 29 | + | User: "check wrapping", | |
| 30 | + | Assistant: response, | |
| 31 | + | }}, | |
| 32 | + | editor: tui.NewEditor(""), | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | rows := d.Render(tui.Theme{}, width) | |
| 36 | + | assertRowsFitWidth(t, rows, width) | |
| 37 | + | plain := strings.Join(rows, "\n") | |
| 38 | + | for _, word := range strings.Fields(response) { | |
| 39 | + | if !strings.Contains(plain, word) { | |
| 40 | + | t.Fatalf("wrapped response lost word %q:\n%s", word, plain) | |
| 41 | + | } | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | cursorRow, _ := d.CursorPos(width) | |
| 45 | + | if want := len(rows) - 3; cursorRow != want { | |
| 46 | + | t.Fatalf("cursor row = %d, want editor row %d", cursorRow, want) | |
| 47 | + | } | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | func TestSwarmTranscriptWrapsAssistantAndDiagnosticRows(t *testing.T) { | |
| 51 | + | const width = 80 | |
| 52 | + | long := strings.Repeat("long response text ", 8) | |
| 53 | + | rows := renderSwarmTranscriptBlocks([]string{ | |
| 54 | + | long, | |
| 55 | + | "stderr: " + long, | |
| 56 | + | "error: " + long, | |
| 57 | + | }, tui.Theme{}, width, false) | |
| 58 | + | ||
| 59 | + | assertRowsFitWidth(t, rows, width) | |
| 60 | + | plain := strings.Join(rows, "\n") | |
| 61 | + | for _, want := range []string{"long", "stderr", "✖"} { | |
| 62 | + | if !strings.Contains(plain, want) { | |
| 63 | + | t.Fatalf("wrapped transcript missing %q:\n%s", want, plain) | |
| 64 | + | } | |
| 65 | + | } | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | func TestSwarmDialogViewingWrapsSpawnedAgentResponse(t *testing.T) { | |
| 69 | + | const width = 80 | |
| 70 | + | response := strings.Repeat("spawned agent response ", 6) | |
| 71 | + | rows := []swarm.AgentSnapshot{{ | |
| 72 | + | ID: "agent-1", | |
| 73 | + | Status: swarm.StatusDone, | |
| 74 | + | Lines: []string{response}, | |
| 75 | + | }} | |
| 76 | + | d := newSwarmDialog() | |
| 77 | + | d.Open(staticSnapshots(rows...), nil, nil, nil, nil, nil, "") | |
| 78 | + | _ = d.Render(tui.Theme{}, width) | |
| 79 | + | d.HandleKey(tui.Key{Kind: tui.KeyEnter}) | |
| 80 | + | ||
| 81 | + | rendered := d.Render(tui.Theme{}, width) | |
| 82 | + | assertRowsFitWidth(t, rendered, width) | |
| 83 | + | if !strings.Contains(strings.Join(rendered, "\n"), "response") { | |
| 84 | + | t.Fatal("spawned agent response missing from transcript") | |
| 85 | + | } | |
| 86 | + | } | |
| @@ -0,0 +1,86 @@ | |||||
| 1 | + | package modes | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "strings" | |||
| 5 | + | "testing" | |||
| 6 | + | ||||
| 7 | + | "github.com/mattn/go-runewidth" | |||
| 8 | + | ||||
| 9 | + | "github.com/patriceckhart/zot/packages/agent/swarm" | |||
| 10 | + | "github.com/patriceckhart/zot/packages/tui" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | func assertRowsFitWidth(t *testing.T, rows []string, width int) { | |||
| 14 | + | t.Helper() | |||
| 15 | + | for i, row := range rows { | |||
| 16 | + | if got := runewidth.StringWidth(stripANSIBytes(row)); got > width { | |||
| 17 | + | t.Errorf("row %d width = %d, want <= %d: %q", i, got, width, stripANSIBytes(row)) | |||
| 18 | + | } | |||
| 19 | + | } | |||
| 20 | + | } | |||
| 21 | + | ||||
| 22 | + | func TestBtwDialogWrapsAssistantAndKeepsCursorAligned(t *testing.T) { | |||
| 23 | + | const width = 80 | |||
| 24 | + | response := "This response is deliberately long enough to require several wrapped display rows without losing any words." | |||
| 25 | + | d := &btwDialog{ | |||
| 26 | + | active: true, | |||
| 27 | + | lineInput: true, | |||
| 28 | + | turns: []btwTurn{{ | |||
| 29 | + | User: "check wrapping", | |||
| 30 | + | Assistant: response, | |||
| 31 | + | }}, | |||
| 32 | + | editor: tui.NewEditor(""), | |||
| 33 | + | } | |||
| 34 | + | ||||
| 35 | + | rows := d.Render(tui.Theme{}, width) | |||
| 36 | + | assertRowsFitWidth(t, rows, width) | |||
| 37 | + | plain := strings.Join(rows, "\n") | |||
| 38 | + | for _, word := range strings.Fields(response) { | |||
| 39 | + | if !strings.Contains(plain, word) { | |||
| 40 | + | t.Fatalf("wrapped response lost word %q:\n%s", word, plain) | |||
| 41 | + | } | |||
| 42 | + | } | |||
| 43 | + | ||||
| 44 | + | cursorRow, _ := d.CursorPos(width) | |||
| 45 | + | if want := len(rows) - 3; cursorRow != want { | |||
| 46 | + | t.Fatalf("cursor row = %d, want editor row %d", cursorRow, want) | |||
| 47 | + | } | |||
| 48 | + | } | |||
| 49 | + | ||||
| 50 | + | func TestSwarmTranscriptWrapsAssistantAndDiagnosticRows(t *testing.T) { | |||
| 51 | + | const width = 80 | |||
| 52 | + | long := strings.Repeat("long response text ", 8) | |||
| 53 | + | rows := renderSwarmTranscriptBlocks([]string{ | |||
| 54 | + | long, | |||
| 55 | + | "stderr: " + long, | |||
| 56 | + | "error: " + long, | |||
| 57 | + | }, tui.Theme{}, width, false) | |||
| 58 | + | ||||
| 59 | + | assertRowsFitWidth(t, rows, width) | |||
| 60 | + | plain := strings.Join(rows, "\n") | |||
| 61 | + | for _, want := range []string{"long", "stderr", "✖"} { | |||
| 62 | + | if !strings.Contains(plain, want) { | |||
| 63 | + | t.Fatalf("wrapped transcript missing %q:\n%s", want, plain) | |||
| 64 | + | } | |||
| 65 | + | } | |||
| 66 | + | } | |||
| 67 | + | ||||
| 68 | + | func TestSwarmDialogViewingWrapsSpawnedAgentResponse(t *testing.T) { | |||
| 69 | + | const width = 80 | |||
| 70 | + | response := strings.Repeat("spawned agent response ", 6) | |||
| 71 | + | rows := []swarm.AgentSnapshot{{ | |||
| 72 | + | ID: "agent-1", | |||
| 73 | + | Status: swarm.StatusDone, | |||
| 74 | + | Lines: []string{response}, | |||
| 75 | + | }} | |||
| 76 | + | d := newSwarmDialog() | |||
| 77 | + | d.Open(staticSnapshots(rows...), nil, nil, nil, nil, nil, "") | |||
| 78 | + | _ = d.Render(tui.Theme{}, width) | |||
| 79 | + | d.HandleKey(tui.Key{Kind: tui.KeyEnter}) | |||
| 80 | + | ||||
| 81 | + | rendered := d.Render(tui.Theme{}, width) | |||
| 82 | + | assertRowsFitWidth(t, rendered, width) | |||
| 83 | + | if !strings.Contains(strings.Join(rendered, "\n"), "response") { | |||
| 84 | + | t.Fatal("spawned agent response missing from transcript") | |||
| 85 | + | } | |||
| 86 | + | } | |||
M packages/agent/modes/swarm_dialog.go +3 -13
| @@ -1292,10 +1292,6 @@ func renderSwarmTranscriptBlocks(lines []string, th tui.Theme, width int, compac | |||
| 1292 | 1292 | // inner width is width-4 for markdown and width-2 for the | |
| 1293 | 1293 | // user-bubble (the bubble already pads itself). | |
| 1294 | 1294 | var out []string | |
| 1295 | - | innerMD := width - 4 | |
| 1296 | - | if innerMD < 20 { | |
| 1297 | - | innerMD = 20 | |
| 1298 | - | } | |
| 1299 | 1295 | bubbleWidth := width - 2 | |
| 1300 | 1296 | if bubbleWidth < 10 { | |
| 1301 | 1297 | bubbleWidth = 10 | |
| @@ -1311,20 +1307,14 @@ func renderSwarmTranscriptBlocks(lines []string, th tui.Theme, width int, compac | |||
| 1311 | 1307 | out = append(out, btwUserBubbleRows(th, text, bubbleWidth, compactMode)...) | |
| 1312 | 1308 | case kindAssistant: | |
| 1313 | 1309 | text := strings.Join(b.body, "\n") | |
| 1314 | - | md := tui.RenderMarkdown(strings.TrimLeft(text, "\n"), th, innerMD) | |
| 1315 | - | for _, line := range strings.Split(md, "\n") { | |
| 1316 | - | if len(line) > 0 && line[0] == tui.FlushLeftSentinel { | |
| 1317 | - | line = line[1:] | |
| 1318 | - | } | |
| 1319 | - | out = append(out, " "+line) | |
| 1320 | - | } | |
| 1310 | + | out = append(out, renderDialogMarkdownRows(strings.TrimLeft(text, "\n"), th, width)...) | |
| 1321 | 1311 | case kindStderr: | |
| 1322 | 1312 | for _, line := range b.body { | |
| 1323 | - | out = append(out, " "+th.FG256(th.Muted, "stderr "+line)) | |
| 1313 | + | out = append(out, wrapDialogTextRows(th.FG256(th.Muted, "stderr "+line), width)...) | |
| 1324 | 1314 | } | |
| 1325 | 1315 | case kindError: | |
| 1326 | 1316 | for _, line := range b.body { | |
| 1327 | - | out = append(out, " "+th.FG256(th.Error, "✖ "+line)) | |
| 1317 | + | out = append(out, wrapDialogTextRows(th.FG256(th.Error, "✖ "+line), width)...) | |
| 1328 | 1318 | } | |
| 1329 | 1319 | } | |
| 1330 | 1320 | } | |
| @@ -1292,10 +1292,6 @@ func renderSwarmTranscriptBlocks(lines []string, th tui.Theme, width int, compac | |||||
| 1292 | // inner width is width-4 for markdown and width-2 for the | 1292 | // inner width is width-4 for markdown and width-2 for the | ||
| 1293 | // user-bubble (the bubble already pads itself). | 1293 | // user-bubble (the bubble already pads itself). | ||
| 1294 | var out []string | 1294 | var out []string | ||
| 1295 | - | innerMD := width - 4 | |||
| 1296 | - | if innerMD < 20 { | |||
| 1297 | - | innerMD = 20 | |||
| 1298 | - | } | |||
| 1299 | bubbleWidth := width - 2 | 1295 | bubbleWidth := width - 2 | ||
| 1300 | if bubbleWidth < 10 { | 1296 | if bubbleWidth < 10 { | ||
| 1301 | bubbleWidth = 10 | 1297 | bubbleWidth = 10 | ||
| @@ -1311,20 +1307,14 @@ func renderSwarmTranscriptBlocks(lines []string, th tui.Theme, width int, compac | |||||
| 1311 | out = append(out, btwUserBubbleRows(th, text, bubbleWidth, compactMode)...) | 1307 | out = append(out, btwUserBubbleRows(th, text, bubbleWidth, compactMode)...) | ||
| 1312 | case kindAssistant: | 1308 | case kindAssistant: | ||
| 1313 | text := strings.Join(b.body, "\n") | 1309 | text := strings.Join(b.body, "\n") | ||
| 1314 | - | md := tui.RenderMarkdown(strings.TrimLeft(text, "\n"), th, innerMD) | 1310 | + | out = append(out, renderDialogMarkdownRows(strings.TrimLeft(text, "\n"), th, width)...) |
| 1315 | - | for _, line := range strings.Split(md, "\n") { | |||
| 1316 | - | if len(line) > 0 && line[0] == tui.FlushLeftSentinel { | |||
| 1317 | - | line = line[1:] | |||
| 1318 | - | } | |||
| 1319 | - | out = append(out, " "+line) | |||
| 1320 | - | } | |||
| 1321 | case kindStderr: | 1311 | case kindStderr: | ||
| 1322 | for _, line := range b.body { | 1312 | for _, line := range b.body { | ||
| 1323 | - | out = append(out, " "+th.FG256(th.Muted, "stderr "+line)) | 1313 | + | out = append(out, wrapDialogTextRows(th.FG256(th.Muted, "stderr "+line), width)...) |
| 1324 | } | 1314 | } | ||
| 1325 | case kindError: | 1315 | case kindError: | ||
| 1326 | for _, line := range b.body { | 1316 | for _, line := range b.body { | ||
| 1327 | - | out = append(out, " "+th.FG256(th.Error, "✖ "+line)) | 1317 | + | out = append(out, wrapDialogTextRows(th.FG256(th.Error, "✖ "+line), width)...) |
| 1328 | } | 1318 | } | ||
| 1329 | } | 1319 | } | ||
| 1330 | } | 1320 | } | ||