From f0657acbe735e9c7f119cdd866c15b7277958183 Mon Sep 17 00:00:00 2001 From: Thomas Glanzmann Date: Fri, 28 Aug 2026 14:26:11 +0200 Subject: [PATCH 08/10] feat: show output in full instead of collapsing, truncating, or capping it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chat output was reduced in three independent ways: sections started collapsed behind a "… (N lines hidden) [click or space to expand]" affordance, long lines were cut at the right edge with an ellipsis, and content was capped at 120 columns regardless of terminal width. Show it in full instead. Start expanded: - AssistantMessageItem starts in thinkingFullExpanded rather than the zero-value thinkingCollapsed. - baseToolMessageItem starts with expandedContent set. This flows through ToolRenderOptions.ExpandedContent into the shared toolOutput*Content helpers, so plain text, code, diffs, multi-edit diffs, and markdown are all covered. - ShellItem starts expanded in both the pending and completed constructors. The toggle cycle is unchanged, so users can still collapse any of them. Wrap instead of cutting at the right edge: - toolOutputPlainContent hard-wraps one column short of the width to leave room for the leading space each rendered row carries. - toolOutputCodeContent hard-wraps too, giving continuation rows a blank gutter so line numbers stay aligned with their own first row. - ShellItem.RawRender wraps rather than cutting at xOffset. Nothing extends past the right edge anymore, so maxLineWidth is now always zero and horizontal scrolling is inert. Use the full terminal width: - cappedMessageWidth no longer clamps to maxTextWidth, so message content gets every column left over after the border and padding. - The agent and fetch prompt headers drop their own maxTextWidth clamp, as does toolOutputMarkdownContent. - maxTextWidth itself stays: tools.go and unified_diff.go read it as the terminal width above which diffs render side-by-side, which is a threshold rather than a cap. Its doc comment now says so. Tests that encoded the old collapsed default now set the collapsed state explicitly where that is the behaviour under test, keeping the windowing paths covered. Co-Authored-By: Claude Opus 5 --- internal/ui/chat/agent.go | 4 +- internal/ui/chat/assistant.go | 4 ++ internal/ui/chat/assistant_test.go | 32 ++++++++-------- .../ui/chat/assistant_thinking_window_test.go | 11 ++++-- internal/ui/chat/bash_test.go | 12 +++--- internal/ui/chat/messages.go | 11 ++++-- internal/ui/chat/shell.go | 31 +++++++-------- internal/ui/chat/shell_test.go | 6 +++ internal/ui/chat/tools.go | 38 ++++++++++++++++--- internal/ui/model/chat_expand_test.go | 25 ++++++------ 10 files changed, 112 insertions(+), 62 deletions(-) diff --git a/internal/ui/chat/agent.go b/internal/ui/chat/agent.go index 9e44473e..96052f18 100644 --- a/internal/ui/chat/agent.go +++ b/internal/ui/chat/agent.go @@ -149,7 +149,7 @@ func (r *AgentToolRenderContext) RenderTool(sty *styles.Styles, width int, opts taskTagWidth := lipgloss.Width(taskTag) // Calculate remaining width for prompt. - remainingWidth := min(cappedWidth-taskTagWidth-3, maxTextWidth-taskTagWidth-3) // -3 for spacing + remainingWidth := cappedWidth - taskTagWidth - 3 // -3 for spacing promptText := sty.Tool.AgentPrompt.Width(remainingWidth).Render(prompt) @@ -306,7 +306,7 @@ func (r *AgenticFetchToolRenderContext) RenderTool(sty *styles.Styles, width int promptTagWidth := lipgloss.Width(promptTag) // Calculate remaining width for prompt text. - remainingWidth := min(cappedWidth-promptTagWidth-3, maxTextWidth-promptTagWidth-3) // -3 for spacing + remainingWidth := cappedWidth - promptTagWidth - 3 // -3 for spacing promptText := sty.Tool.AgentPrompt.Width(remainingWidth).Render(prompt) diff --git a/internal/ui/chat/assistant.go b/internal/ui/chat/assistant.go index 5c8fcb50..747e76a0 100644 --- a/internal/ui/chat/assistant.go +++ b/internal/ui/chat/assistant.go @@ -242,6 +242,10 @@ func NewAssistantMessageItem(sty *styles.Styles, message *message.Message) Messa focusableMessageItem: newFocusableMessageItem(v), message: message, sty: sty, + // Thinking blocks start fully expanded so the reasoning is + // visible without a click; the toggle cycle still lets the + // user collapse them. + thinkingViewMode: thinkingFullExpanded, } a.anim = anim.New(anim.Settings{ diff --git a/internal/ui/chat/assistant_test.go b/internal/ui/chat/assistant_test.go index 5784eeff..6a9fab33 100644 --- a/internal/ui/chat/assistant_test.go +++ b/internal/ui/chat/assistant_test.go @@ -32,15 +32,15 @@ func TestAssistantMessageItemExpandable(t *testing.T) { exp, ok := any(item).(Expandable) require.True(t, ok, "AssistantMessageItem must satisfy Expandable") - require.Equal(t, thinkingCollapsed, item.thinkingViewMode, - "new items must start in the collapsed view-mode") - require.True(t, exp.ToggleExpanded(), - "first toggle of a non-empty thinking block must report expanded") require.Equal(t, thinkingFullExpanded, item.thinkingViewMode, - "short blocks must skip tail-window and land in full expansion") + "new items must start in the fully expanded view-mode") require.False(t, exp.ToggleExpanded(), - "second toggle must report collapsed (cycle closed)") + "first toggle of an expanded thinking block must report collapsed") require.Equal(t, thinkingCollapsed, item.thinkingViewMode) + require.True(t, exp.ToggleExpanded(), + "second toggle must report expanded (cycle closed)") + require.Equal(t, thinkingFullExpanded, item.thinkingViewMode, + "short blocks must skip tail-window and land in full expansion") } // TestAssistantMessageItemExpandableEmptyThinkingNoOp guards the B2 @@ -59,18 +59,18 @@ func TestAssistantMessageItemExpandableEmptyThinkingNoOp(t *testing.T) { exp, ok := any(item).(Expandable) require.True(t, ok, "AssistantMessageItem must satisfy Expandable") - require.Equal(t, thinkingCollapsed, item.thinkingViewMode) - require.False(t, exp.ToggleExpanded(), - "empty thinking must report current (collapsed) state without flipping") - require.Equal(t, thinkingCollapsed, item.thinkingViewMode, + require.Equal(t, thinkingFullExpanded, item.thinkingViewMode) + require.True(t, exp.ToggleExpanded(), + "empty thinking must report current (expanded) state without flipping") + require.Equal(t, thinkingFullExpanded, item.thinkingViewMode, "empty-thinking toggle must not mutate thinkingViewMode") // Whitespace-only thinking is still effectively empty. item.message.Parts = []message.ContentPart{ message.ReasoningContent{Thinking: " \n\n\t ", StartedAt: testStartedAt}, } - require.False(t, exp.ToggleExpanded()) - require.Equal(t, thinkingCollapsed, item.thinkingViewMode) + require.True(t, exp.ToggleExpanded()) + require.Equal(t, thinkingFullExpanded, item.thinkingViewMode) } // TestAssistantMessageItemTailWindowBoundary guards the B1 fix: the @@ -126,17 +126,19 @@ func TestAssistantMessageItemHandleMouseClick(t *testing.T) { item := NewAssistantMessageItem(&sty, msg).(*AssistantMessageItem) item.thinkingBoxHeight = 5 + before := item.thinkingViewMode + // Click inside the thinking box signals handled but must not mutate // the view-mode state. require.True(t, item.HandleMouseClick(ansi.MouseLeft, 0, 2)) - require.Equal(t, thinkingCollapsed, item.thinkingViewMode, + require.Equal(t, before, item.thinkingViewMode, "HandleMouseClick must not toggle expansion on its own") // Click outside the thinking box is ignored entirely. require.False(t, item.HandleMouseClick(ansi.MouseLeft, 0, 10)) - require.Equal(t, thinkingCollapsed, item.thinkingViewMode) + require.Equal(t, before, item.thinkingViewMode) // Non-left button is ignored. require.False(t, item.HandleMouseClick(ansi.MouseRight, 0, 2)) - require.Equal(t, thinkingCollapsed, item.thinkingViewMode) + require.Equal(t, before, item.thinkingViewMode) } diff --git a/internal/ui/chat/assistant_thinking_window_test.go b/internal/ui/chat/assistant_thinking_window_test.go index 3b685847..dbba7906 100644 --- a/internal/ui/chat/assistant_thinking_window_test.go +++ b/internal/ui/chat/assistant_thinking_window_test.go @@ -81,7 +81,7 @@ func renderedThinkingHeight(t *testing.T, item *AssistantMessageItem, width int) // TestThinkingWindow_CollapsedCapPreserved guards that F5 did not // regress the existing collapsed-mode behaviour: a 5000-line -// thinking block in the default (collapsed) state still renders at +// thinking block in the collapsed state still renders at // most a small bounded height — the last `maxCollapsedThinkingHeight` // lines plus the truncation hint. The thinking message keeps // IsThinking() == true, so the optional "Thought for" footer is @@ -93,8 +93,9 @@ func TestThinkingWindow_CollapsedCapPreserved(t *testing.T) { msg := thinkingMessageWithLines("collapsed", 5000) item := NewAssistantMessageItem(&sty, msg).(*AssistantMessageItem) - // Default state must be collapsed. - require.Equal(t, thinkingCollapsed, item.thinkingViewMode) + // New items start fully expanded; drive the collapsed state + // directly since that is what this test covers. + item.thinkingViewMode = thinkingCollapsed // Unique odd width avoids sharing the glamour renderer cache with // any other parallel test (the renderer instance is memoized per @@ -127,6 +128,7 @@ func TestThinkingWindow_ExpandedShortSkipsTailWindow(t *testing.T) { "this test relies on the source being well under the tail cap") msg := thinkingMessageWithLines("short", lines) item := NewAssistantMessageItem(&sty, msg).(*AssistantMessageItem) + item.thinkingViewMode = thinkingCollapsed require.True(t, item.ToggleExpanded(), "first toggle should report expanded") @@ -175,6 +177,7 @@ func TestThinkingWindow_TailWindowed(t *testing.T) { // Tail-windowed render. tailMsg := thinkingMessageWithLines("tail", total) tailItem := NewAssistantMessageItem(&sty, tailMsg).(*AssistantMessageItem) + tailItem.thinkingViewMode = thinkingCollapsed require.True(t, tailItem.ToggleExpanded(), "first toggle should report expanded") require.Equal(t, thinkingTailWindow, tailItem.thinkingViewMode, "a long block must enter tail-window after the first toggle") @@ -244,6 +247,7 @@ func TestThinkingWindow_PromoteToFull(t *testing.T) { const total = 1500 msg := thinkingMessageWithLines("promote", total) item := NewAssistantMessageItem(&sty, msg).(*AssistantMessageItem) + item.thinkingViewMode = thinkingCollapsed const width = 97 @@ -411,6 +415,7 @@ func TestThinkingWindow_ToggleInvalidatesOnlyThinking(t *testing.T) { } item := NewAssistantMessageItem(&sty, build()).(*AssistantMessageItem) + item.thinkingViewMode = thinkingCollapsed const width = 101 _ = item.RawRender(width) diff --git a/internal/ui/chat/bash_test.go b/internal/ui/chat/bash_test.go index 39f98fdb..920d52da 100644 --- a/internal/ui/chat/bash_test.go +++ b/internal/ui/chat/bash_test.go @@ -70,9 +70,9 @@ func TestBashToolMessageItem_LongCommandNotTruncated(t *testing.T) { require.Contains(t, out, longCmd[strings.LastIndex(longCmd, "a")-10:], "end of command must be visible") } -// TestBashToolMessageItem_OutputDefaultsCollapsed verifies that bash -// output is truncated when collapsed and fully shown when expanded. -func TestBashToolMessageItem_OutputDefaultsCollapsed(t *testing.T) { +// TestBashToolMessageItem_OutputDefaultsExpanded verifies that bash +// output starts fully shown and is truncated only once collapsed. +func TestBashToolMessageItem_OutputDefaultsExpanded(t *testing.T) { t.Parallel() sty := styles.CharmtonePantera() @@ -91,14 +91,16 @@ func TestBashToolMessageItem_OutputDefaultsCollapsed(t *testing.T) { item := NewBashToolMessageItem(&sty, tc, result, false, "") bash, ok := item.(*BashToolMessageItem) require.True(t, ok, "NewBashToolMessageItem must return a *BashToolMessageItem") - require.False(t, bash.expandedContent, "bash items must default to collapsed") + require.True(t, bash.expandedContent, "bash items must default to expanded") + require.Contains(t, ansi.Strip(bash.RawRender(120)), "line20") + require.False(t, bash.ToggleExpanded(), "first toggle should report collapsed") renderCollapsed := ansi.Strip(bash.RawRender(120)) require.Contains(t, renderCollapsed, "line10") require.NotContains(t, renderCollapsed, "line20") require.Contains(t, renderCollapsed, "… (10 lines hidden)") - require.True(t, bash.ToggleExpanded(), "first toggle should report expanded") + require.True(t, bash.ToggleExpanded(), "second toggle should report expanded") require.Contains(t, ansi.Strip(bash.RawRender(120)), "line20") } diff --git a/internal/ui/chat/messages.go b/internal/ui/chat/messages.go index 0b13b31d..6390844c 100644 --- a/internal/ui/chat/messages.go +++ b/internal/ui/chat/messages.go @@ -17,10 +17,12 @@ import ( ) // MessageLeftPaddingTotal is the total width that is taken up by the border + -// padding. We also cap the width so text is readable to the maxTextWidth(120). +// padding. const MessageLeftPaddingTotal = 2 -// maxTextWidth is the maximum width text messages can be +// maxTextWidth is the terminal width above which diffs are rendered +// side-by-side instead of stacked. Message content itself is not capped — +// it uses the full available width. const maxTextWidth = 120 // Identifiable is an interface for items that can provide a unique identifier. @@ -392,9 +394,10 @@ func (a *AssistantInfoItem) renderContent(width int) string { return common.Section(a.sty, assistant, width) } -// cappedMessageWidth returns the maximum width for message content for readability. +// cappedMessageWidth returns the width available to message content, i.e. +// everything left over once the border + padding is accounted for. func cappedMessageWidth(availableWidth int) int { - return min(availableWidth-MessageLeftPaddingTotal, maxTextWidth) + return availableWidth - MessageLeftPaddingTotal } // prismSavingsSuffix returns the styled Prism savings suffix for the diff --git a/internal/ui/chat/shell.go b/internal/ui/chat/shell.go index 5edc31b6..a9da3dae 100644 --- a/internal/ui/chat/shell.go +++ b/internal/ui/chat/shell.go @@ -63,6 +63,9 @@ func NewShellItem(sty *styles.Styles, command, output string, exitCode int) Mess command: command, exitCode: exitCode, sty: sty, + // Shell output starts expanded so results are visible without a + // click; the toggle still lets the user collapse them. + expandedContent: true, } s.replaceOutput(output) return s @@ -82,6 +85,9 @@ func NewPendingShellItem(sty *styles.Styles, command string) *ShellItem { command: command, sty: sty, pending: true, + // Shell output starts expanded so results are visible without a + // click; the toggle still lets the user collapse them. + expandedContent: true, } s.anim = anim.New(anim.Settings{ ID: id, @@ -262,15 +268,10 @@ func (s *ShellItem) RawRender(width int) string { output := common.RemapANSI16(raw, s.sty.ANSI) lines := strings.Split(output, "\n") - // Compute max line width for scroll clamping. - maxW := 0 - for _, ln := range lines { - w := ansi.StringWidth(ln) - if w > maxW { - maxW = w - } - } - s.maxLineWidth = max(0, maxW-cappedWidth) + // Long lines wrap rather than being cut at the right edge, so no + // output extends past cappedWidth and there is nothing left to + // scroll horizontally. + s.maxLineWidth = 0 var body strings.Builder @@ -284,13 +285,13 @@ func (s *ShellItem) RawRender(width int) string { } for _, ln := range lines { - scrolled := ansi.GraphemeWidth.Cut(ln, s.xOffset, len(ln)) - truncated := ansi.Truncate(scrolled, cappedWidth, "…") - if s.xOffset > 0 && strings.TrimSpace(truncated) != "" { - truncated = "…" + truncated + if ansi.StringWidth(ln) > cappedWidth { + ln = ansi.Hardwrap(ln, cappedWidth, false) + } + for wl := range strings.SplitSeq(ln, "\n") { + body.WriteString(s.sty.Messages.ShellOutput.Render(wl)) + body.WriteString("\n") } - body.WriteString(s.sty.Messages.ShellOutput.Render(truncated)) - body.WriteString("\n") } // When finished, hidden lines are below, so show the notice after. diff --git a/internal/ui/chat/shell_test.go b/internal/ui/chat/shell_test.go index 7a5e3ca0..d1be3dc7 100644 --- a/internal/ui/chat/shell_test.go +++ b/internal/ui/chat/shell_test.go @@ -43,6 +43,8 @@ func TestPendingShellItemShowsTail(t *testing.T) { sty := styles.CharmtonePantera() item := NewPendingShellItem(&sty, "seq 30") + // Items start expanded; this test covers the collapsed window. + item.expandedContent = false for i := 1; i <= 30; i++ { item.AppendOutput(strconv.Itoa(i) + "\n") @@ -94,6 +96,8 @@ func TestPendingShellItemPreservesANSIBoundaryInCollapsedTail(t *testing.T) { sty := styles.CharmtonePantera() item := NewPendingShellItem(&sty, "seq 12") + // Items start expanded; this test covers the collapsed window. + item.expandedContent = false item.AppendOutput("1\n2\n\x1b[") item.AppendOutput("31m3\x1b[0m\n4\n5\n6\n7\n8\n9\n10\n11\n12\n") @@ -108,6 +112,8 @@ func TestCompletedShellItemShowsHeadAndAccurateCount(t *testing.T) { sty := styles.CharmtonePantera() item := NewPendingShellItem(&sty, "seq 12") + // Items start expanded; this test covers the collapsed window. + item.expandedContent = false item.Complete("1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n11\n12\n\x1b[0m\n", 0) rendered := ansi.Strip(item.RawRender(80)) diff --git a/internal/ui/chat/tools.go b/internal/ui/chat/tools.go index 8116ca72..9c400657 100644 --- a/internal/ui/chat/tools.go +++ b/internal/ui/chat/tools.go @@ -200,6 +200,9 @@ func newBaseToolMessageItem( result: result, status: status, hasCappedWidth: hasCappedWidth, + // Tool output starts expanded so results are visible without a + // click; the toggle still lets the user collapse them. + expandedContent: true, } t.anim = anim.New(anim.Settings{ ID: toolCall.ID, @@ -694,6 +697,15 @@ func toolOutputPlainContent(sty *styles.Styles, content string, width int, expan if i >= maxLines { break } + if expanded { + // Wrap rather than cutting at the right edge so no output is + // hidden. Wrap one column short of the width to leave room + // for the leading space every rendered line carries. + for wl := range strings.SplitSeq(ansi.Hardwrap(ln, max(1, width-1), false), "\n") { + out = append(out, sty.Tool.ContentLine.Width(width).Render(" "+wl)) + } + continue + } ln = " " + ln if lipgloss.Width(ln) > width { ln = ansi.Truncate(ln, width, "…") @@ -741,11 +753,30 @@ func toolOutputCodeContent(sty *styles.Styles, path, content string, offset, wid codeWidth := bodyWidth - maxDigits var out []string + availWidth := codeWidth - sty.Tool.ContentCodeLine.GetHorizontalPadding() + blankNum := sty.Tool.ContentLineNumber.Render(strings.Repeat(" ", maxDigits)) for i, ln := range highlightedLines { lineNum := sty.Tool.ContentLineNumber.Render(fmt.Sprintf(numFmt, i+1+offset)) + if expanded { + // Wrap rather than cutting at the right edge so no code is + // hidden. Continuation lines get a blank gutter so the line + // numbers stay aligned with their own first row. + for j, wl := range strings.Split(ansi.Hardwrap(ln, max(1, availWidth), false), "\n") { + gutter := lineNum + if j > 0 { + gutter = blankNum + } + codeLine := sty.Tool.ContentCodeLine. + Width(codeWidth). + Render(wl) + out = append(out, lipgloss.JoinHorizontal(lipgloss.Left, gutter, codeLine)) + } + continue + } + // Truncate accounting for padding that will be added. - ln = ansi.Truncate(ln, codeWidth-sty.Tool.ContentCodeLine.GetHorizontalPadding(), "…") + ln = ansi.Truncate(ln, availWidth, "…") codeLine := sty.Tool.ContentCodeLine. Width(codeWidth). @@ -1102,11 +1133,6 @@ func roundedEnumerator(lPadding, width int) tree.Enumerator { func toolOutputMarkdownContent(sty *styles.Styles, content string, width int, expanded bool) string { content = stringext.NormalizeSpace(content) - // Cap width for readability. - if width > maxTextWidth { - width = maxTextWidth - } - renderer := common.QuietMarkdownRenderer(sty, width) mu := common.LockMarkdownRenderer(renderer) mu.Lock() diff --git a/internal/ui/model/chat_expand_test.go b/internal/ui/model/chat_expand_test.go index a58c2ec6..5d31f99b 100644 --- a/internal/ui/model/chat_expand_test.go +++ b/internal/ui/model/chat_expand_test.go @@ -37,19 +37,20 @@ func TestChatToggleExpandedSelectedItem_AssistantMessage(t *testing.T) { u.chat.SetMessages(item) u.chat.SetSelected(0) - // First keyboard toggle should expand. Immediately follow with a - // direct ToggleExpanded: it flips the now-expanded item back to - // collapsed and returns false. If the keyboard path had silently - // skipped the item (the bug), the item would still be collapsed and - // the direct toggle would return true. + // Items start fully expanded, so the first keyboard toggle should + // collapse. Immediately follow with a direct ToggleExpanded: it + // flips the now-collapsed item back to expanded and returns true. + // If the keyboard path had silently skipped the item (the bug), the + // item would still be expanded and the direct toggle would return + // false. u.chat.ToggleExpandedSelectedItem() - require.False(t, exp.ToggleExpanded(), - "keyboard toggle did not expand the assistant thinking block") + require.True(t, exp.ToggleExpanded(), + "keyboard toggle did not collapse the assistant thinking block") - // Second keyboard toggle against the re-collapsed item should expand - // it again. Direct ToggleExpanded then returns false because it - // re-collapses. + // Second keyboard toggle against the re-expanded item should collapse + // it again. Direct ToggleExpanded then returns true because it + // re-expands. u.chat.ToggleExpandedSelectedItem() - require.False(t, exp.ToggleExpanded(), - "second keyboard toggle did not re-expand the assistant thinking block") + require.True(t, exp.ToggleExpanded(), + "second keyboard toggle did not re-collapse the assistant thinking block") } -- 2.53.0