detached 6b4f716

Merge branch 'fix/dialog-response-wrap'

commit
6b4f7168e4f8b08a7c7e07f849994bd142b6491c
author
patriceckhart · · 2026-07-14 12:10 UTC
merge of
efd311e + acdf6ef · diff against the first parent
signature
unsigned
refs
tag: v0.2.85
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 {
330330 out = append(out, btwUserBubbleRows(th, t.User, width-2, d.compactMode)...)
331331 if t.Assistant != "" {
332332 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)...)
340334 }
341335 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)...)
343337 }
344338 }
345339
@@ -405,10 +399,10 @@ func (d *btwDialog) CursorPos(width int) (row, col int) {
405399 editorOffset += len(btwUserBubbleRows(d.theme, t.User, width-2, d.compactMode))
406400 if t.Assistant != "" {
407401 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))
409403 }
410404 if t.Err != "" {
411- editorOffset++
405+ editorOffset += len(wrapDialogTextRows(d.theme.FG256(d.theme.Error, "✖ "+t.Err), width))
412406 }
413407 }
414408 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++ // blank401 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
12921292 // inner width is width-4 for markdown and width-2 for the
12931293 // user-bubble (the bubble already pads itself).
12941294 var out []string
1295- innerMD := width - 4
1296- if innerMD < 20 {
1297- innerMD = 20
1298- }
12991295 bubbleWidth := width - 2
13001296 if bubbleWidth < 10 {
13011297 bubbleWidth = 10
@@ -1311,20 +1307,14 @@ func renderSwarmTranscriptBlocks(lines []string, th tui.Theme, width int, compac
13111307 out = append(out, btwUserBubbleRows(th, text, bubbleWidth, compactMode)...)
13121308 case kindAssistant:
13131309 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)...)
13211311 case kindStderr:
13221312 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)...)
13241314 }
13251315 case kindError:
13261316 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)...)
13281318 }
13291319 }
13301320 }
@@ -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 the1292 // 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 []string1294 var out []string
1295- innerMD := width - 4
1296- if innerMD < 20 {
1297- innerMD = 20
1298- }
1299 bubbleWidth := width - 21295 bubbleWidth := width - 2
1300 if bubbleWidth < 10 {1296 if bubbleWidth < 10 {
1301 bubbleWidth = 101297 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 }

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