tui: keep live output outside scrollback
- commit
- aceaffdec2477ae03d1ba6a0b7a97df673d16958
- author
- patriceckhart · · 2026-05-04 13:05 UTC
- parent
- 76ca012
- signature
- unsigned
2 files · +108 -11
M internal/agent/modes/interactive.go +34 -6
| @@ -198,6 +198,7 @@ type Interactive struct { | |||
| 198 | 198 | toolOrder []string | |
| 199 | 199 | statusErr string | |
| 200 | 200 | statusOK string | |
| 201 | + | liveBlock []string // live streaming/tool progress rendered outside scrollback | |
| 201 | 202 | helpBlock []string // rendered above the chat when /help was typed | |
| 202 | 203 | cumUsage provider.Usage | |
| 203 | 204 | lastCtxInput int // input_tokens of the most recent turn — approximates current context size | |
| @@ -686,8 +687,21 @@ func (i *Interactive) buildChatLocked(cols int) []string { | |||
| 686 | 687 | } | |
| 687 | 688 | } | |
| 688 | 689 | i.view.Err = i.statusErr | |
| 689 | - | ||
| 690 | + | i.liveBlock = i.view.BuildLive(cols) | |
| 691 | + | ||
| 692 | + | streamingActive := i.view.StreamingActive | |
| 693 | + | streaming := i.view.Streaming | |
| 694 | + | toolCalls := i.view.ToolCalls | |
| 695 | + | errLine := i.view.Err | |
| 696 | + | i.view.StreamingActive = false | |
| 697 | + | i.view.Streaming = "" | |
| 698 | + | i.view.ToolCalls = nil | |
| 699 | + | i.view.Err = "" | |
| 690 | 700 | chat := i.view.Build(cols) | |
| 701 | + | i.view.StreamingActive = streamingActive | |
| 702 | + | i.view.Streaming = streaming | |
| 703 | + | i.view.ToolCalls = toolCalls | |
| 704 | + | i.view.Err = errLine | |
| 691 | 705 | ||
| 692 | 706 | // Welcome banner: shown at the top of the chat area when there is | |
| 693 | 707 | // no transcript yet. Disappears after the first message is sent. | |
| @@ -950,7 +964,15 @@ func (i *Interactive) redraw() { | |||
| 950 | 964 | // content. The status block and editor get their own dedicated | |
| 951 | 965 | // blanks so spacing stays consistent whether or not a dialog or | |
| 952 | 966 | // popup is showing. | |
| 953 | - | bottom := make([]string, 0, len(dialog)+len(suggest)+len(queue)+len(edLines)+8) | |
| 967 | + | bottom := make([]string, 0, len(i.liveBlock)+len(dialog)+len(suggest)+len(queue)+len(edLines)+9) | |
| 968 | + | if len(i.liveBlock) > 0 { | |
| 969 | + | // Live streaming/tool rows are rendered outside the immutable | |
| 970 | + | // transcript so native scrollback stays stable while the agent | |
| 971 | + | // works. Add the same breathing row the finalized transcript path | |
| 972 | + | // gets between adjacent messages. | |
| 973 | + | bottom = append(bottom, "") | |
| 974 | + | bottom = append(bottom, i.liveBlock...) | |
| 975 | + | } | |
| 954 | 976 | if len(dialog) > 0 { | |
| 955 | 977 | bottom = append(bottom, "") | |
| 956 | 978 | } | |
| @@ -1079,23 +1101,29 @@ func (i *Interactive) redraw() { | |||
| 1079 | 1101 | // statusLines and edLines (input breathing room). Without | |
| 1080 | 1102 | // these the rendered cursor would land on a blank instead of | |
| 1081 | 1103 | // inside the editor row. | |
| 1082 | - | cursorRow := dialogLead + len(dialog) + len(suggest) + len(queue) + 1 + len(statusLines) + 1 + curR | |
| 1104 | + | liveRows := len(i.liveBlock) | |
| 1105 | + | if liveRows > 0 { | |
| 1106 | + | // Account for the leading separator row inserted before live | |
| 1107 | + | // streaming/tool content in the bottom block. | |
| 1108 | + | liveRows++ | |
| 1109 | + | } | |
| 1110 | + | cursorRow := liveRows + dialogLead + len(dialog) + len(suggest) + len(queue) + 1 + len(statusLines) + 1 + curR | |
| 1083 | 1111 | cursorCol := curC | |
| 1084 | 1112 | if i.btwDialog.Active() { | |
| 1085 | 1113 | if r, c := i.btwDialog.CursorPos(cols); r >= 0 { | |
| 1086 | - | cursorRow = dialogLead + r | |
| 1114 | + | cursorRow = liveRows + dialogLead + r | |
| 1087 | 1115 | cursorCol = c | |
| 1088 | 1116 | } | |
| 1089 | 1117 | } | |
| 1090 | 1118 | if i.dialog.Active() { | |
| 1091 | 1119 | if r, c := i.dialog.CursorPos(cols); r >= 0 { | |
| 1092 | - | cursorRow = dialogLead + r | |
| 1120 | + | cursorRow = liveRows + dialogLead + r | |
| 1093 | 1121 | cursorCol = c | |
| 1094 | 1122 | } | |
| 1095 | 1123 | } | |
| 1096 | 1124 | if i.sessionDialog.Active() { | |
| 1097 | 1125 | if r, c := i.sessionDialog.CursorPos(); r >= 0 { | |
| 1098 | - | cursorRow = dialogLead + r | |
| 1126 | + | cursorRow = liveRows + dialogLead + r | |
| 1099 | 1127 | cursorCol = c | |
| 1100 | 1128 | } | |
| 1101 | 1129 | } | |
| @@ -198,6 +198,7 @@ type Interactive struct { | |||||
| 198 | toolOrder []string | 198 | toolOrder []string | ||
| 199 | statusErr string | 199 | statusErr string | ||
| 200 | statusOK string | 200 | statusOK string | ||
| 201 | + | liveBlock []string // live streaming/tool progress rendered outside scrollback | |||
| 201 | helpBlock []string // rendered above the chat when /help was typed | 202 | helpBlock []string // rendered above the chat when /help was typed | ||
| 202 | cumUsage provider.Usage | 203 | cumUsage provider.Usage | ||
| 203 | lastCtxInput int // input_tokens of the most recent turn — approximates current context size | 204 | lastCtxInput int // input_tokens of the most recent turn — approximates current context size | ||
| @@ -686,8 +687,21 @@ func (i *Interactive) buildChatLocked(cols int) []string { | |||||
| 686 | } | 687 | } | ||
| 687 | } | 688 | } | ||
| 688 | i.view.Err = i.statusErr | 689 | i.view.Err = i.statusErr | ||
| 689 | - | 690 | + | i.liveBlock = i.view.BuildLive(cols) | |
| 691 | + | ||||
| 692 | + | streamingActive := i.view.StreamingActive | |||
| 693 | + | streaming := i.view.Streaming | |||
| 694 | + | toolCalls := i.view.ToolCalls | |||
| 695 | + | errLine := i.view.Err | |||
| 696 | + | i.view.StreamingActive = false | |||
| 697 | + | i.view.Streaming = "" | |||
| 698 | + | i.view.ToolCalls = nil | |||
| 699 | + | i.view.Err = "" | |||
| 690 | chat := i.view.Build(cols) | 700 | chat := i.view.Build(cols) | ||
| 701 | + | i.view.StreamingActive = streamingActive | |||
| 702 | + | i.view.Streaming = streaming | |||
| 703 | + | i.view.ToolCalls = toolCalls | |||
| 704 | + | i.view.Err = errLine | |||
| 691 | 705 | ||||
| 692 | // Welcome banner: shown at the top of the chat area when there is | 706 | // Welcome banner: shown at the top of the chat area when there is | ||
| 693 | // no transcript yet. Disappears after the first message is sent. | 707 | // no transcript yet. Disappears after the first message is sent. | ||
| @@ -950,7 +964,15 @@ func (i *Interactive) redraw() { | |||||
| 950 | // content. The status block and editor get their own dedicated | 964 | // content. The status block and editor get their own dedicated | ||
| 951 | // blanks so spacing stays consistent whether or not a dialog or | 965 | // blanks so spacing stays consistent whether or not a dialog or | ||
| 952 | // popup is showing. | 966 | // popup is showing. | ||
| 953 | - | bottom := make([]string, 0, len(dialog)+len(suggest)+len(queue)+len(edLines)+8) | 967 | + | bottom := make([]string, 0, len(i.liveBlock)+len(dialog)+len(suggest)+len(queue)+len(edLines)+9) |
| 968 | + | if len(i.liveBlock) > 0 { | |||
| 969 | + | // Live streaming/tool rows are rendered outside the immutable | |||
| 970 | + | // transcript so native scrollback stays stable while the agent | |||
| 971 | + | // works. Add the same breathing row the finalized transcript path | |||
| 972 | + | // gets between adjacent messages. | |||
| 973 | + | bottom = append(bottom, "") | |||
| 974 | + | bottom = append(bottom, i.liveBlock...) | |||
| 975 | + | } | |||
| 954 | if len(dialog) > 0 { | 976 | if len(dialog) > 0 { | ||
| 955 | bottom = append(bottom, "") | 977 | bottom = append(bottom, "") | ||
| 956 | } | 978 | } | ||
| @@ -1079,23 +1101,29 @@ func (i *Interactive) redraw() { | |||||
| 1079 | // statusLines and edLines (input breathing room). Without | 1101 | // statusLines and edLines (input breathing room). Without | ||
| 1080 | // these the rendered cursor would land on a blank instead of | 1102 | // these the rendered cursor would land on a blank instead of | ||
| 1081 | // inside the editor row. | 1103 | // inside the editor row. | ||
| 1082 | - | cursorRow := dialogLead + len(dialog) + len(suggest) + len(queue) + 1 + len(statusLines) + 1 + curR | 1104 | + | liveRows := len(i.liveBlock) |
| 1105 | + | if liveRows > 0 { | |||
| 1106 | + | // Account for the leading separator row inserted before live | |||
| 1107 | + | // streaming/tool content in the bottom block. | |||
| 1108 | + | liveRows++ | |||
| 1109 | + | } | |||
| 1110 | + | cursorRow := liveRows + dialogLead + len(dialog) + len(suggest) + len(queue) + 1 + len(statusLines) + 1 + curR | |||
| 1083 | cursorCol := curC | 1111 | cursorCol := curC | ||
| 1084 | if i.btwDialog.Active() { | 1112 | if i.btwDialog.Active() { | ||
| 1085 | if r, c := i.btwDialog.CursorPos(cols); r >= 0 { | 1113 | if r, c := i.btwDialog.CursorPos(cols); r >= 0 { | ||
| 1086 | - | cursorRow = dialogLead + r | 1114 | + | cursorRow = liveRows + dialogLead + r |
| 1087 | cursorCol = c | 1115 | cursorCol = c | ||
| 1088 | } | 1116 | } | ||
| 1089 | } | 1117 | } | ||
| 1090 | if i.dialog.Active() { | 1118 | if i.dialog.Active() { | ||
| 1091 | if r, c := i.dialog.CursorPos(cols); r >= 0 { | 1119 | if r, c := i.dialog.CursorPos(cols); r >= 0 { | ||
| 1092 | - | cursorRow = dialogLead + r | 1120 | + | cursorRow = liveRows + dialogLead + r |
| 1093 | cursorCol = c | 1121 | cursorCol = c | ||
| 1094 | } | 1122 | } | ||
| 1095 | } | 1123 | } | ||
| 1096 | if i.sessionDialog.Active() { | 1124 | if i.sessionDialog.Active() { | ||
| 1097 | if r, c := i.sessionDialog.CursorPos(); r >= 0 { | 1125 | if r, c := i.sessionDialog.CursorPos(); r >= 0 { | ||
| 1098 | - | cursorRow = dialogLead + r | 1126 | + | cursorRow = liveRows + dialogLead + r |
| 1099 | cursorCol = c | 1127 | cursorCol = c | ||
| 1100 | } | 1128 | } | ||
| 1101 | } | 1129 | } | ||
M internal/tui/view.go +74 -5
| @@ -183,6 +183,47 @@ func (v *View) Build(width int) []string { | |||
| 183 | 183 | return lines | |
| 184 | 184 | } | |
| 185 | 185 | ||
| 186 | + | // BuildLive renders only in-flight assistant/tool/error state. Main-screen | |
| 187 | + | // scrollback renderers can keep these rows outside the immutable transcript | |
| 188 | + | // so native scrolling stays stable while a turn streams. | |
| 189 | + | func (v *View) BuildLive(width int) []string { | |
| 190 | + | var out []string | |
| 191 | + | if v.StreamingActive && strings.TrimSpace(v.Streaming) != "" { | |
| 192 | + | const indent = " " | |
| 193 | + | inner := assistantBodyWidth(width - len(indent)) | |
| 194 | + | md := RenderMarkdown(v.Streaming, v.Theme, inner) | |
| 195 | + | for _, l := range strings.Split(md, "\n") { | |
| 196 | + | for _, w := range wrapANSILine(l, inner) { | |
| 197 | + | out = append(out, indent+w) | |
| 198 | + | } | |
| 199 | + | } | |
| 200 | + | out = append(out, "") | |
| 201 | + | } | |
| 202 | + | finalised := map[string]bool{} | |
| 203 | + | for _, m := range v.Messages { | |
| 204 | + | for _, c := range m.Content { | |
| 205 | + | switch b := c.(type) { | |
| 206 | + | case provider.ToolCallBlock: | |
| 207 | + | finalised[b.ID] = true | |
| 208 | + | case provider.ToolResultBlock: | |
| 209 | + | finalised[b.CallID] = true | |
| 210 | + | } | |
| 211 | + | } | |
| 212 | + | } | |
| 213 | + | for _, tc := range v.ToolCalls { | |
| 214 | + | if finalised[tc.ID] { | |
| 215 | + | continue | |
| 216 | + | } | |
| 217 | + | out = append(out, v.renderToolCall(tc, width)...) | |
| 218 | + | out = append(out, "") | |
| 219 | + | } | |
| 220 | + | if v.Err != "" { | |
| 221 | + | out = append(out, v.Theme.FG256(v.Theme.Error, "✖ "+v.Err)) | |
| 222 | + | out = append(out, "") | |
| 223 | + | } | |
| 224 | + | return out | |
| 225 | + | } | |
| 226 | + | ||
| 186 | 227 | // BuildWithAnchors is like Build but additionally reports the first | |
| 187 | 228 | // row occupied by each message in v.Messages. Callers that need to | |
| 188 | 229 | // scroll to a specific turn (the /jump dialog) use the anchor slice | |
| @@ -646,7 +687,7 @@ func (v *View) renderToolCall(tc ToolCallView, width int) []string { | |||
| 646 | 687 | if tc.Error { | |
| 647 | 688 | color = v.Theme.Error | |
| 648 | 689 | } | |
| 649 | - | body := toolResultBlock(v.Theme, tc.Result, width, color) | |
| 690 | + | body := toolResultBlock(v.Theme, tc.Result, toolBoxBodyRenderWidth(width), color) | |
| 650 | 691 | for _, l := range v.collapseToolBody(body, false) { | |
| 651 | 692 | imgCells, stripped := parseImageFootprint(l) | |
| 652 | 693 | if hasImageEscapeLine(stripped) { | |
| @@ -748,6 +789,7 @@ const toolBoxOuterMargin = 2 | |||
| 748 | 789 | // of the line so the right corner sits at column width-1, matching the | |
| 749 | 790 | // closing edge. | |
| 750 | 791 | func toolBoxTop(th Theme, label string, width int) string { | |
| 792 | + | label = oneLineToolLabel(label) | |
| 751 | 793 | w := width - 2*toolBoxOuterMargin | |
| 752 | 794 | if w < 12 { | |
| 753 | 795 | w = 12 | |
| @@ -782,6 +824,10 @@ func toolBoxTop(th Theme, label string, width int) string { | |||
| 782 | 824 | return margin + th.FG256(th.Muted, prefix) + th.FG256(th.FG, name) + th.FG256(th.Muted, rest+suffix+fillStr+"┐") + margin | |
| 783 | 825 | } | |
| 784 | 826 | ||
| 827 | + | func oneLineToolLabel(label string) string { | |
| 828 | + | return strings.Join(strings.Fields(label), " ") | |
| 829 | + | } | |
| 830 | + | ||
| 785 | 831 | func splitToolLabel(label string) (name, rest string) { | |
| 786 | 832 | label = strings.TrimLeft(label, " ") | |
| 787 | 833 | if label == "" { | |
| @@ -861,6 +907,25 @@ func parseImageFootprint(s string) (int, string) { | |||
| 861 | 907 | // drifting too far right. | |
| 862 | 908 | const toolBoxBodyTrimLeft = 2 | |
| 863 | 909 | ||
| 910 | + | // toolBoxBodyRenderWidth is the width body renderers should target before | |
| 911 | + | // their rows are wrapped by toolBoxSide. Most body renderers emit a four-cell | |
| 912 | + | // indent; toolBoxSide trims toolBoxBodyTrimLeft cells from that indent and | |
| 913 | + | // then adds the box edge/padding. Passing the full terminal width lets long | |
| 914 | + | // bash/heredoc lines overrun the inner box and makes terminals soft-wrap, | |
| 915 | + | // visually breaking the right edge. This returns the maximum renderer width | |
| 916 | + | // whose post-trim visible width fits inside the box. | |
| 917 | + | func toolBoxBodyRenderWidth(width int) int { | |
| 918 | + | w := width - 2*toolBoxOuterMargin | |
| 919 | + | if w < 12 { | |
| 920 | + | w = 12 | |
| 921 | + | } | |
| 922 | + | inner := w - 2 - 2*toolBoxInnerPad | |
| 923 | + | if inner < 1 { | |
| 924 | + | inner = 1 | |
| 925 | + | } | |
| 926 | + | return inner + toolBoxBodyTrimLeft | |
| 927 | + | } | |
| 928 | + | ||
| 864 | 929 | // toolBoxSide wraps a single body line with vertical box edges: | |
| 865 | 930 | // | |
| 866 | 931 | // │ foo bar baz │ | |
| @@ -982,7 +1047,8 @@ func (v *View) renderToolResultContent(blocks []provider.Content, width, color i | |||
| 982 | 1047 | for _, b := range blocks { | |
| 983 | 1048 | switch bb := b.(type) { | |
| 984 | 1049 | case provider.TextBlock: | |
| 985 | - | body = append(body, v.renderToolText(bb.Text, width, color, sourcePath, startLine)...) | |
| 1050 | + | bodyWidth := toolBoxBodyRenderWidth(width) | |
| 1051 | + | body = append(body, v.renderToolText(bb.Text, bodyWidth, color, sourcePath, startLine)...) | |
| 986 | 1052 | case provider.ImageBlock: | |
| 987 | 1053 | hasImage = true | |
| 988 | 1054 | body = append(body, v.renderImageBlock(bb, width)...) | |
| @@ -1399,7 +1465,9 @@ func (v *View) renderBashResult(lines []string, width, defaultColor int) []strin | |||
| 1399 | 1465 | out = append(out, " "+v.Theme.FG256(v.Theme.Accent, w)) | |
| 1400 | 1466 | } | |
| 1401 | 1467 | case i == footerIdx: | |
| 1402 | - | out = append(out, " "+v.Theme.FG256(v.Theme.Muted, l)) | |
| 1468 | + | for _, w := range wrapLine(l, width-4, " ") { | |
| 1469 | + | out = append(out, " "+v.Theme.FG256(v.Theme.Muted, w)) | |
| 1470 | + | } | |
| 1403 | 1471 | default: | |
| 1404 | 1472 | for _, w := range wrapLine(l, width-4, " ") { | |
| 1405 | 1473 | out = append(out, " "+v.Theme.FG256(defaultColor, w)) | |
| @@ -1586,7 +1654,7 @@ func ShortArgs(tool string, raw json.RawMessage) string { | |||
| 1586 | 1654 | x, ok := v.(map[string]any) | |
| 1587 | 1655 | if !ok { | |
| 1588 | 1656 | b, _ := json.Marshal(v) | |
| 1589 | - | s := string(b) | |
| 1657 | + | s := oneLineToolLabel(string(b)) | |
| 1590 | 1658 | if len(s) > 60 { | |
| 1591 | 1659 | s = s[:57] + "..." | |
| 1592 | 1660 | } | |
| @@ -1601,12 +1669,13 @@ func ShortArgs(tool string, raw json.RawMessage) string { | |||
| 1601 | 1669 | } | |
| 1602 | 1670 | if primary == "" { | |
| 1603 | 1671 | b, _ := json.Marshal(v) | |
| 1604 | - | s := string(b) | |
| 1672 | + | s := oneLineToolLabel(string(b)) | |
| 1605 | 1673 | if len(s) > 60 { | |
| 1606 | 1674 | s = s[:57] + "..." | |
| 1607 | 1675 | } | |
| 1608 | 1676 | return s | |
| 1609 | 1677 | } | |
| 1678 | + | primary = oneLineToolLabel(primary) | |
| 1610 | 1679 | ||
| 1611 | 1680 | // Tool-specific decoration. Only the read tool gets a range | |
| 1612 | 1681 | // suffix for now; other tools just truncate the primary arg. | |
| @@ -183,6 +183,47 @@ func (v *View) Build(width int) []string { | |||||
| 183 | return lines | 183 | return lines | ||
| 184 | } | 184 | } | ||
| 185 | 185 | ||||
| 186 | + | // BuildLive renders only in-flight assistant/tool/error state. Main-screen | |||
| 187 | + | // scrollback renderers can keep these rows outside the immutable transcript | |||
| 188 | + | // so native scrolling stays stable while a turn streams. | |||
| 189 | + | func (v *View) BuildLive(width int) []string { | |||
| 190 | + | var out []string | |||
| 191 | + | if v.StreamingActive && strings.TrimSpace(v.Streaming) != "" { | |||
| 192 | + | const indent = " " | |||
| 193 | + | inner := assistantBodyWidth(width - len(indent)) | |||
| 194 | + | md := RenderMarkdown(v.Streaming, v.Theme, inner) | |||
| 195 | + | for _, l := range strings.Split(md, "\n") { | |||
| 196 | + | for _, w := range wrapANSILine(l, inner) { | |||
| 197 | + | out = append(out, indent+w) | |||
| 198 | + | } | |||
| 199 | + | } | |||
| 200 | + | out = append(out, "") | |||
| 201 | + | } | |||
| 202 | + | finalised := map[string]bool{} | |||
| 203 | + | for _, m := range v.Messages { | |||
| 204 | + | for _, c := range m.Content { | |||
| 205 | + | switch b := c.(type) { | |||
| 206 | + | case provider.ToolCallBlock: | |||
| 207 | + | finalised[b.ID] = true | |||
| 208 | + | case provider.ToolResultBlock: | |||
| 209 | + | finalised[b.CallID] = true | |||
| 210 | + | } | |||
| 211 | + | } | |||
| 212 | + | } | |||
| 213 | + | for _, tc := range v.ToolCalls { | |||
| 214 | + | if finalised[tc.ID] { | |||
| 215 | + | continue | |||
| 216 | + | } | |||
| 217 | + | out = append(out, v.renderToolCall(tc, width)...) | |||
| 218 | + | out = append(out, "") | |||
| 219 | + | } | |||
| 220 | + | if v.Err != "" { | |||
| 221 | + | out = append(out, v.Theme.FG256(v.Theme.Error, "✖ "+v.Err)) | |||
| 222 | + | out = append(out, "") | |||
| 223 | + | } | |||
| 224 | + | return out | |||
| 225 | + | } | |||
| 226 | + | ||||
| 186 | // BuildWithAnchors is like Build but additionally reports the first | 227 | // BuildWithAnchors is like Build but additionally reports the first | ||
| 187 | // row occupied by each message in v.Messages. Callers that need to | 228 | // row occupied by each message in v.Messages. Callers that need to | ||
| 188 | // scroll to a specific turn (the /jump dialog) use the anchor slice | 229 | // scroll to a specific turn (the /jump dialog) use the anchor slice | ||
| @@ -646,7 +687,7 @@ func (v *View) renderToolCall(tc ToolCallView, width int) []string { | |||||
| 646 | if tc.Error { | 687 | if tc.Error { | ||
| 647 | color = v.Theme.Error | 688 | color = v.Theme.Error | ||
| 648 | } | 689 | } | ||
| 649 | - | body := toolResultBlock(v.Theme, tc.Result, width, color) | 690 | + | body := toolResultBlock(v.Theme, tc.Result, toolBoxBodyRenderWidth(width), color) |
| 650 | for _, l := range v.collapseToolBody(body, false) { | 691 | for _, l := range v.collapseToolBody(body, false) { | ||
| 651 | imgCells, stripped := parseImageFootprint(l) | 692 | imgCells, stripped := parseImageFootprint(l) | ||
| 652 | if hasImageEscapeLine(stripped) { | 693 | if hasImageEscapeLine(stripped) { | ||
| @@ -748,6 +789,7 @@ const toolBoxOuterMargin = 2 | |||||
| 748 | // of the line so the right corner sits at column width-1, matching the | 789 | // of the line so the right corner sits at column width-1, matching the | ||
| 749 | // closing edge. | 790 | // closing edge. | ||
| 750 | func toolBoxTop(th Theme, label string, width int) string { | 791 | func toolBoxTop(th Theme, label string, width int) string { | ||
| 792 | + | label = oneLineToolLabel(label) | |||
| 751 | w := width - 2*toolBoxOuterMargin | 793 | w := width - 2*toolBoxOuterMargin | ||
| 752 | if w < 12 { | 794 | if w < 12 { | ||
| 753 | w = 12 | 795 | w = 12 | ||
| @@ -782,6 +824,10 @@ func toolBoxTop(th Theme, label string, width int) string { | |||||
| 782 | return margin + th.FG256(th.Muted, prefix) + th.FG256(th.FG, name) + th.FG256(th.Muted, rest+suffix+fillStr+"┐") + margin | 824 | return margin + th.FG256(th.Muted, prefix) + th.FG256(th.FG, name) + th.FG256(th.Muted, rest+suffix+fillStr+"┐") + margin | ||
| 783 | } | 825 | } | ||
| 784 | 826 | ||||
| 827 | + | func oneLineToolLabel(label string) string { | |||
| 828 | + | return strings.Join(strings.Fields(label), " ") | |||
| 829 | + | } | |||
| 830 | + | ||||
| 785 | func splitToolLabel(label string) (name, rest string) { | 831 | func splitToolLabel(label string) (name, rest string) { | ||
| 786 | label = strings.TrimLeft(label, " ") | 832 | label = strings.TrimLeft(label, " ") | ||
| 787 | if label == "" { | 833 | if label == "" { | ||
| @@ -861,6 +907,25 @@ func parseImageFootprint(s string) (int, string) { | |||||
| 861 | // drifting too far right. | 907 | // drifting too far right. | ||
| 862 | const toolBoxBodyTrimLeft = 2 | 908 | const toolBoxBodyTrimLeft = 2 | ||
| 863 | 909 | ||||
| 910 | + | // toolBoxBodyRenderWidth is the width body renderers should target before | |||
| 911 | + | // their rows are wrapped by toolBoxSide. Most body renderers emit a four-cell | |||
| 912 | + | // indent; toolBoxSide trims toolBoxBodyTrimLeft cells from that indent and | |||
| 913 | + | // then adds the box edge/padding. Passing the full terminal width lets long | |||
| 914 | + | // bash/heredoc lines overrun the inner box and makes terminals soft-wrap, | |||
| 915 | + | // visually breaking the right edge. This returns the maximum renderer width | |||
| 916 | + | // whose post-trim visible width fits inside the box. | |||
| 917 | + | func toolBoxBodyRenderWidth(width int) int { | |||
| 918 | + | w := width - 2*toolBoxOuterMargin | |||
| 919 | + | if w < 12 { | |||
| 920 | + | w = 12 | |||
| 921 | + | } | |||
| 922 | + | inner := w - 2 - 2*toolBoxInnerPad | |||
| 923 | + | if inner < 1 { | |||
| 924 | + | inner = 1 | |||
| 925 | + | } | |||
| 926 | + | return inner + toolBoxBodyTrimLeft | |||
| 927 | + | } | |||
| 928 | + | ||||
| 864 | // toolBoxSide wraps a single body line with vertical box edges: | 929 | // toolBoxSide wraps a single body line with vertical box edges: | ||
| 865 | // | 930 | // | ||
| 866 | // │ foo bar baz │ | 931 | // │ foo bar baz │ | ||
| @@ -982,7 +1047,8 @@ func (v *View) renderToolResultContent(blocks []provider.Content, width, color i | |||||
| 982 | for _, b := range blocks { | 1047 | for _, b := range blocks { | ||
| 983 | switch bb := b.(type) { | 1048 | switch bb := b.(type) { | ||
| 984 | case provider.TextBlock: | 1049 | case provider.TextBlock: | ||
| 985 | - | body = append(body, v.renderToolText(bb.Text, width, color, sourcePath, startLine)...) | 1050 | + | bodyWidth := toolBoxBodyRenderWidth(width) |
| 1051 | + | body = append(body, v.renderToolText(bb.Text, bodyWidth, color, sourcePath, startLine)...) | |||
| 986 | case provider.ImageBlock: | 1052 | case provider.ImageBlock: | ||
| 987 | hasImage = true | 1053 | hasImage = true | ||
| 988 | body = append(body, v.renderImageBlock(bb, width)...) | 1054 | body = append(body, v.renderImageBlock(bb, width)...) | ||
| @@ -1399,7 +1465,9 @@ func (v *View) renderBashResult(lines []string, width, defaultColor int) []strin | |||||
| 1399 | out = append(out, " "+v.Theme.FG256(v.Theme.Accent, w)) | 1465 | out = append(out, " "+v.Theme.FG256(v.Theme.Accent, w)) | ||
| 1400 | } | 1466 | } | ||
| 1401 | case i == footerIdx: | 1467 | case i == footerIdx: | ||
| 1402 | - | out = append(out, " "+v.Theme.FG256(v.Theme.Muted, l)) | 1468 | + | for _, w := range wrapLine(l, width-4, " ") { |
| 1469 | + | out = append(out, " "+v.Theme.FG256(v.Theme.Muted, w)) | |||
| 1470 | + | } | |||
| 1403 | default: | 1471 | default: | ||
| 1404 | for _, w := range wrapLine(l, width-4, " ") { | 1472 | for _, w := range wrapLine(l, width-4, " ") { | ||
| 1405 | out = append(out, " "+v.Theme.FG256(defaultColor, w)) | 1473 | out = append(out, " "+v.Theme.FG256(defaultColor, w)) | ||
| @@ -1586,7 +1654,7 @@ func ShortArgs(tool string, raw json.RawMessage) string { | |||||
| 1586 | x, ok := v.(map[string]any) | 1654 | x, ok := v.(map[string]any) | ||
| 1587 | if !ok { | 1655 | if !ok { | ||
| 1588 | b, _ := json.Marshal(v) | 1656 | b, _ := json.Marshal(v) | ||
| 1589 | - | s := string(b) | 1657 | + | s := oneLineToolLabel(string(b)) |
| 1590 | if len(s) > 60 { | 1658 | if len(s) > 60 { | ||
| 1591 | s = s[:57] + "..." | 1659 | s = s[:57] + "..." | ||
| 1592 | } | 1660 | } | ||
| @@ -1601,12 +1669,13 @@ func ShortArgs(tool string, raw json.RawMessage) string { | |||||
| 1601 | } | 1669 | } | ||
| 1602 | if primary == "" { | 1670 | if primary == "" { | ||
| 1603 | b, _ := json.Marshal(v) | 1671 | b, _ := json.Marshal(v) | ||
| 1604 | - | s := string(b) | 1672 | + | s := oneLineToolLabel(string(b)) |
| 1605 | if len(s) > 60 { | 1673 | if len(s) > 60 { | ||
| 1606 | s = s[:57] + "..." | 1674 | s = s[:57] + "..." | ||
| 1607 | } | 1675 | } | ||
| 1608 | return s | 1676 | return s | ||
| 1609 | } | 1677 | } | ||
| 1678 | + | primary = oneLineToolLabel(primary) | |||
| 1610 | 1679 | ||||
| 1611 | // Tool-specific decoration. Only the read tool gets a range | 1680 | // Tool-specific decoration. Only the read tool gets a range | ||
| 1612 | // suffix for now; other tools just truncate the primary arg. | 1681 | // suffix for now; other tools just truncate the primary arg. | ||