From 08bc3842f04b85d57a722f6e8a51832221ffe33a Mon Sep 17 00:00:00 2001 From: Djordje Lukic Date: Fri, 15 May 2026 10:20:26 +0200 Subject: [PATCH] Stop hiding the thinking process Seeing the thinking process of an LLM is actually quite useful, I want to be able to steer it while it's thinking but I can't if I don't see what the agent is doing while thinking. Signed-off-by: Djordje Lukic --- .../reasoningblock/reasoningblock.go | 382 +------------ .../reasoningblock/reasoningblock_test.go | 504 +++--------------- pkg/tui/components/tool/editfile/editfile.go | 68 +-- pkg/tui/components/tool/editfile/render.go | 55 -- pkg/tui/components/toolcommon/base.go | 27 - pkg/tui/core/layout/layout.go | 6 - 6 files changed, 99 insertions(+), 943 deletions(-) diff --git a/pkg/tui/components/reasoningblock/reasoningblock.go b/pkg/tui/components/reasoningblock/reasoningblock.go index 83bcb3fa1..a3dfe629b 100644 --- a/pkg/tui/components/reasoningblock/reasoningblock.go +++ b/pkg/tui/components/reasoningblock/reasoningblock.go @@ -1,11 +1,7 @@ package reasoningblock import ( - "fmt" - "math" - "strconv" "strings" - "sync" "time" tea "charm.land/bubbletea/v2" @@ -17,61 +13,15 @@ import ( "github.com/docker/docker-agent/pkg/tui/components/markdown" "github.com/docker/docker-agent/pkg/tui/components/tool" "github.com/docker/docker-agent/pkg/tui/core/layout" - "github.com/docker/docker-agent/pkg/tui/messages" "github.com/docker/docker-agent/pkg/tui/service" "github.com/docker/docker-agent/pkg/tui/styles" "github.com/docker/docker-agent/pkg/tui/types" ) -const ( - // previewLines is the number of reasoning lines to show when collapsed. - previewLines = 3 - // completedToolVisibleDuration is how long a completed tool remains fully visible before fading. - completedToolVisibleDuration = 1500 * time.Millisecond - // completedToolFadeDuration is how long the fade-out effect lasts before hiding. - completedToolFadeDuration = 1000 * time.Millisecond - // fadeSteps is the number of discrete quantized fade levels. - fadeSteps = 20 -) - -// fadeStyles is a pre-computed table of lipgloss styles for discrete fade levels. -// Index 0 = no fade (normal), index fadeSteps = fully faded. -var ( - fadeStyles [fadeSteps + 1]lipgloss.Style - fadeStylesOnce sync.Once -) - -func initFadeStyles() { - for i := range fadeSteps + 1 { - progress := float64(i) / float64(fadeSteps) - startR, startG, startB := 128, 128, 128 - endR, endG, endB := 48, 48, 56 - r := int(float64(startR) + progress*float64(endR-startR)) - g := int(float64(startG) + progress*float64(endG-startG)) - b := int(float64(startB) + progress*float64(endB-startB)) - c := lipgloss.Color(fmt.Sprintf("#%02X%02X%02X", r, g, b)) - fadeStyles[i] = lipgloss.NewStyle().Foreground(c) - } -} - -// fadeStyleForProgress returns the pre-computed style for the given fade progress. -func fadeStyleForProgress(progress float64) lipgloss.Style { - fadeStylesOnce.Do(initFadeStyles) - idx := min(max(int(progress*fadeSteps), 0), fadeSteps) - return fadeStyles[idx] -} - -// nowFunc is the time function used to get the current time. -// Tests can override this for deterministic behavior. -var nowFunc = time.Now - // toolEntry holds a tool call message and its view. type toolEntry struct { - msg *types.Message - view layout.Model - collapsedVisibleUntil time.Time // Zero means no grace period (hide immediately when completed) - fadeProgress float64 // 0.0 = not fading, 0.0-1.0 = fading (higher = more faded) - strippedCollapsed string // Cached ANSI-stripped collapsed view (set once on completion) + msg *types.Message + view layout.Model } // contentItemKind identifies the type of content item. @@ -89,16 +39,7 @@ type contentItem struct { toolIndex int // Index into toolEntries when kind is contentItemTool } -// renderCache holds cached markdown rendering results to avoid re-rendering on every View() call. -// Invalidated when reasoning content or width changes. -type renderCache struct { - width int // width used for rendering - reasoningVersion int // version of reasoning content when cached - lines []string // all rendered lines (ANSI stripped) - hasExtra bool // whether there's extra content beyond preview -} - -// Model represents a collapsible reasoning + tool calls block. +// Model represents a reasoning + tool calls block. type Model struct { id string agentName string @@ -109,9 +50,7 @@ type Model struct { width int height int sessionState *service.SessionState - reasoningVersion int // increments when reasoning content changes - cache *renderCache // cached rendering results - animationRegistered bool // whether we're registered with animation coordinator + animationRegistered bool // whether we're registered with animation coordinator } // New creates a new reasoning block. @@ -119,7 +58,7 @@ func New(id, agentName string, sessionState *service.SessionState) *Model { return &Model{ id: id, agentName: agentName, - expanded: false, + expanded: true, width: 80, sessionState: sessionState, } @@ -138,8 +77,6 @@ func (m *Model) AgentName() string { // SetReasoning sets reasoning content (replaces all content items with a single reasoning item). func (m *Model) SetReasoning(content string) { m.contentItems = []contentItem{{kind: contentItemReasoning, reasoning: content}} - m.reasoningVersion++ - m.cache = nil // invalidate cache } // AppendReasoning appends to the reasoning content. @@ -149,9 +86,6 @@ func (m *Model) AppendReasoning(content string) { return } - m.reasoningVersion++ - m.cache = nil // invalidate cache - // If no items yet or last item was a tool, create new reasoning item if len(m.contentItems) == 0 { m.contentItems = append(m.contentItems, contentItem{kind: contentItemReasoning, reasoning: content}) @@ -229,50 +163,17 @@ func (m *Model) UpdateToolResult(toolCallID, content string, status types.ToolSt if entry.msg.ToolCall.ID != toolCallID { continue } - // Check if this is a transition from in-progress to completed/error - wasInProgress := entry.msg.ToolStatus == types.ToolStatusPending || - entry.msg.ToolStatus == types.ToolStatusRunning - isCompleted := status == types.ToolStatusCompleted || status == types.ToolStatusError entry.msg.Content = strings.ReplaceAll(content, "\t", " ") entry.msg.ToolStatus = status entry.msg.ToolResult = result - // Set grace period if transitioning from in-progress to completed - // Total visible time = completedToolVisibleDuration + completedToolFadeDuration - // Fade animation is driven by global animation tick - var animCmd tea.Cmd - if wasInProgress && isCompleted { - totalDuration := completedToolVisibleDuration + completedToolFadeDuration - entry.collapsedVisibleUntil = nowFunc().Add(totalDuration) - entry.fadeProgress = 0 - // Register with animation coordinator if not already - if !m.animationRegistered { - animCmd = animation.StartTickIfFirst() - m.animationRegistered = true - } - } - - // Recreate view to pick up new state view := tool.New(entry.msg, m.sessionState) view.SetSize(m.contentWidth(), 0) m.toolEntries[i] = entry m.toolEntries[i].view = view - // Pre-cache the ANSI-stripped collapsed view for fade rendering - if wasInProgress && isCompleted { - if cv, ok := view.(layout.CollapsedViewer); ok { - m.toolEntries[i].strippedCollapsed = ansi.Strip(cv.CollapsedView()) - } else { - m.toolEntries[i].strippedCollapsed = ansi.Strip(view.View()) - } - } - - initCmd := view.Init() - if animCmd != nil { - return tea.Batch(initCmd, animCmd) - } - return initCmd + return view.Init() } return nil } @@ -287,73 +188,20 @@ func (m *Model) HasToolCall(toolCallID string) bool { return false } -// computeFadeProgressAt computes the fade progress for all tools based on elapsed time. -// This makes fade progress time-based (tick-rate independent) - the tick only affects smoothness. -// A tool should fade if it's past its fade start time (collapsedVisibleUntil - completedToolFadeDuration). -func (m *Model) computeFadeProgressAt(now time.Time) { - for i, entry := range m.toolEntries { - if entry.collapsedVisibleUntil.IsZero() { - continue // No grace period set - } - // Compute when fade should start - fadeStartTime := entry.collapsedVisibleUntil.Add(-completedToolFadeDuration) - if now.Before(fadeStartTime) { - m.toolEntries[i].fadeProgress = 0 // Not time to fade yet - continue - } - // Compute fade progress as a fraction of the fade duration (0.0 to 1.0) - elapsed := now.Sub(fadeStartTime) - progress := float64(elapsed) / float64(completedToolFadeDuration) - m.toolEntries[i].fadeProgress = math.Min(progress, 1.0) - } -} - -// hasFadingTools returns true if any tools are still within their visibility/fade window. -// This must match the condition in getVisibleToolsCollapsed to avoid unregistering -// the animation while tools are still visible. -// Uses fadeProgress (computed just before this is called) for consistency. -func (m *Model) hasFadingTools() bool { - for _, entry := range m.toolEntries { - if entry.collapsedVisibleUntil.IsZero() { - continue - } - // Tool needs ticks while fade hasn't completed - if entry.fadeProgress < 1.0 { - return true - } - } - return false -} - // NeedsTick returns true if this reasoning block requires animation tick updates. -// This is true when: -// - Any tool is pending/running (needs spinner animation) -// - Any tool is still fading (fadeProgress < 1.0) -// -// The messages list uses this to decide whether to invalidate its render cache on ticks. -// Use fadeProgress (updated on ticks) to stay consistent with renderCollapsed/hasFadingTools. +// This is true when any tool is pending/running and needs spinner animation. func (m *Model) NeedsTick() bool { for _, entry := range m.toolEntries { - // Check for in-progress tools (need spinner) if entry.msg.ToolStatus == types.ToolStatusPending || entry.msg.ToolStatus == types.ToolStatusRunning { return true } - // Check for tools within visibility/fade window - if !entry.collapsedVisibleUntil.IsZero() && entry.fadeProgress < 1.0 { - return true - } } return false } -// GetToolFadeProgress returns the fade progress for a tool (0.0 = not fading, 0.0-1.0 = fading). -func (m *Model) GetToolFadeProgress(toolCallID string) float64 { - for _, entry := range m.toolEntries { - if entry.msg.ToolCall.ID == toolCallID { - return entry.fadeProgress - } - } +// GetToolFadeProgress returns 0 because completed tools are kept visible instead of fading out. +func (m *Model) GetToolFadeProgress(string) float64 { return 0 } @@ -367,15 +215,12 @@ func (m *Model) IsExpanded() bool { return m.expanded } -// Toggle switches between expanded and collapsed state. -func (m *Model) Toggle() { - m.expanded = !m.expanded -} +// Toggle is retained for the message interface; reasoning blocks are always expanded. +func (m *Model) Toggle() {} -// SetExpanded sets the expanded state directly. -func (m *Model) SetExpanded(expanded bool) { - m.expanded = expanded -} +// SetExpanded is retained for tests and callers that used to control collapse state. +// Reasoning blocks stay expanded so thinking and tool calls remain visible. +func (m *Model) SetExpanded(bool) {} // SetSelected sets the selected state for visual highlighting. func (m *Model) SetSelected(selected bool) { @@ -403,21 +248,6 @@ func (m *Model) Init() tea.Cmd { // Update handles messages. func (m *Model) Update(msg tea.Msg) (layout.Model, tea.Cmd) { - switch msg.(type) { - case messages.ThemeChangedMsg: - // Theme changed - invalidate cached rendering - m.cache = nil - case animation.TickMsg: - // Compute fade levels based on elapsed time (tick-rate independent) - m.computeFadeProgressAt(nowFunc()) - // Unregister if no more fading tools (uses fadeProgress computed above) - if m.animationRegistered && !m.hasFadingTools() { - m.animationRegistered = false - animation.Unregister() - } - // Continue to forward tick to tool views for their spinners - } - // Forward updates to all tool views (for spinners, etc.) var cmds []tea.Cmd for i, entry := range m.toolEntries { @@ -432,17 +262,11 @@ func (m *Model) Update(msg tea.Msg) (layout.Model, tea.Cmd) { // View renders the block. func (m *Model) View() string { - if m.expanded { - return m.renderExpanded() - } - return m.renderCollapsed() + return m.renderExpanded() } // SetSize sets the component dimensions. func (m *Model) SetSize(width, height int) tea.Cmd { - if m.width != width { - m.cache = nil // invalidate cache on width change - } m.width = width m.height = height contentWidth := m.contentWidth() @@ -452,37 +276,6 @@ func (m *Model) SetSize(width, height int) tea.Cmd { return nil } -// ensureCache computes and caches the rendered reasoning lines if needed. -// Returns the cached result. Safe to call repeatedly; only re-renders when content or width changes. -func (m *Model) ensureCache() *renderCache { - contentWidth := m.contentWidth() - - // Return existing cache if still valid - if m.cache != nil && m.cache.width == contentWidth && m.cache.reasoningVersion == m.reasoningVersion { - return m.cache - } - - // Compute fresh cache - reasoning := m.Reasoning() - var lines []string - if reasoning != "" { - rendered, err := markdown.NewRenderer(contentWidth).Render(reasoning) - if err != nil { - rendered = reasoning - } - clean := strings.TrimRight(ansi.Strip(rendered), "\n\r\t ") - lines = strings.Split(clean, "\n") - } - - m.cache = &renderCache{ - width: contentWidth, - reasoningVersion: m.reasoningVersion, - lines: lines, - hasExtra: len(m.toolEntries) > 0 || len(lines) > previewLines, - } - return m.cache -} - // GetSize returns the current dimensions. func (m *Model) GetSize() (int, int) { return m.width, m.height @@ -502,7 +295,7 @@ func (m *Model) contentWidth() int { func (m *Model) renderExpanded() string { var parts []string - // Header with collapse affordance + // Header header := m.renderHeader(true) parts = append(parts, header) @@ -536,100 +329,10 @@ func (m *Model) renderExpanded() string { return strings.Join(parts, "\n") } -// renderCollapsed renders the compact preview. -func (m *Model) renderCollapsed() string { - var parts []string - - // Header with expand affordance - header := m.renderHeader(false) - parts = append(parts, header) - - // Last N lines of reasoning - if m.Reasoning() != "" { - preview, _ := m.renderReasoningPreviewWithTruncationInfo() - if preview != "" { - parts = append(parts, preview) - } - } - - // Show in-progress tools and recently completed tools (within grace period) - visibleTools := m.getVisibleToolsCollapsed() - if len(visibleTools) > 0 { - parts = append(parts, "") // blank line before tools - for i, entry := range visibleTools { - // Prefer CollapsedView() for simplified rendering in collapsed state - var toolView string - if cv, ok := entry.view.(layout.CollapsedViewer); ok { - toolView = cv.CollapsedView() - } else { - toolView = entry.view.View() - } - if entry.fadeProgress > 0 { - // Use cached stripped text (populated once on tool completion) - stripped := visibleTools[i].strippedCollapsed - if stripped == "" { - stripped = ansi.Strip(toolView) - } - toolView = fadeStyleForProgress(entry.fadeProgress).Render(stripped) - } - parts = append(parts, toolView) - } - } - - return strings.Join(parts, "\n") -} - -// getVisibleToolsCollapsed returns tool entries that should be visible in collapsed view. -// This includes in-progress tools (pending/running) and recently completed tools that haven't fully faded. -// Must use the same logic as hasFadingTools to avoid unregistering animation while tools are still visible. -func (m *Model) getVisibleToolsCollapsed() []toolEntry { - var visible []toolEntry - for _, entry := range m.toolEntries { - // Show in-progress tools - if entry.msg.ToolStatus == types.ToolStatusPending || - entry.msg.ToolStatus == types.ToolStatusRunning { - visible = append(visible, entry) - continue - } - // For completed tools: visible if fade hasn't completed - // This matches hasFadingTools() to ensure consistency - if !entry.collapsedVisibleUntil.IsZero() && entry.fadeProgress < 1.0 { - visible = append(visible, entry) - } - } - return visible -} - -// hasExtraContent returns true if there's content that would be shown when expanded -// but is hidden when collapsed (truncated reasoning or completed tool calls). -func (m *Model) hasExtraContent() bool { - return m.ensureCache().hasExtra -} - -// renderHeader renders the header line with toggle affordance. -func (m *Model) renderHeader(expanded bool) string { +// renderHeader renders the header line. +func (m *Model) renderHeader(bool) string { badge := styles.ThinkingBadgeStyle.Render("Thinking") - - // Use [+] to expand and [-] to collapse - var indicator string - switch { - case expanded: - indicator = styles.MutedStyle.Bold(true).Render(" [-]") - case m.hasExtraContent(): - indicator = styles.MutedStyle.Bold(true).Render(" [+]") - } - - // Add tool count indicator when collapsed - var toolInfo string - if !expanded && len(m.toolEntries) > 0 { - if len(m.toolEntries) == 1 { - toolInfo = styles.MutedStyle.Render(" (1 tool)") - } else { - toolInfo = styles.MutedStyle.Render(" (" + strconv.Itoa(len(m.toolEntries)) + " tools)") - } - } - - return m.messageStyle().Render(badge + indicator + toolInfo) + return m.messageStyle().Render(badge) } // renderReasoningChunk renders a single reasoning chunk with styling. @@ -647,46 +350,6 @@ func (m *Model) renderReasoningChunk(text string) string { return m.messageStyle().Render(styled) } -// renderReasoningPreviewWithTruncationInfo renders the last N lines of reasoning -// and returns whether the content was truncated. -func (m *Model) renderReasoningPreviewWithTruncationInfo() (string, bool) { - cache := m.ensureCache() - if len(cache.lines) == 0 { - return "", false - } - - // Filter empty lines for preview - var lines []string - for _, line := range cache.lines { - if strings.TrimSpace(line) != "" { - lines = append(lines, line) - } - } - - // Take last N lines - start := 0 - reasoningTruncated := false - if len(lines) > previewLines { - start = len(lines) - previewLines - reasoningTruncated = true - } - previewLinesContent := lines[start:] - - // Style each line - dim the first line more if there's content above (truncated) - var styledLines []string - for i, line := range previewLinesContent { - if i == 0 && reasoningTruncated { - // Extra dim first line to indicate more content above - styledLines = append(styledLines, styles.MutedStyle.Italic(true).Faint(true).Render(line)) - } else { - styledLines = append(styledLines, styles.MutedStyle.Italic(true).Render(line)) - } - } - - preview := strings.Join(styledLines, "\n") - return m.messageStyle().Render(preview), reasoningTruncated -} - // StopAnimation stops all animation subscriptions for this reasoning block. // This must be called when the block is removed from the UI to avoid leaked animation subscriptions. func (m *Model) StopAnimation() { @@ -717,8 +380,7 @@ func (m *Model) IsHeaderLine(lineIdx int) bool { return lineIdx == 0 } -// IsToggleLine returns true if clicking this line should toggle the block. -// Only the header is toggleable. -func (m *Model) IsToggleLine(lineIdx int) bool { - return m.IsHeaderLine(lineIdx) && (m.expanded || m.hasExtraContent()) +// IsToggleLine returns false because reasoning blocks stay expanded. +func (m *Model) IsToggleLine(int) bool { + return false } diff --git a/pkg/tui/components/reasoningblock/reasoningblock_test.go b/pkg/tui/components/reasoningblock/reasoningblock_test.go index 03da2261b..ba50ddd99 100644 --- a/pkg/tui/components/reasoningblock/reasoningblock_test.go +++ b/pkg/tui/components/reasoningblock/reasoningblock_test.go @@ -3,264 +3,100 @@ package reasoningblock import ( "strconv" "testing" - "time" "github.com/charmbracelet/x/ansi" "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" "github.com/docker/docker-agent/pkg/tools" - "github.com/docker/docker-agent/pkg/tui/animation" "github.com/docker/docker-agent/pkg/tui/service" "github.com/docker/docker-agent/pkg/tui/types" ) -func TestReasoningBlockCollapsed(t *testing.T) { +func TestReasoningBlockShowsAllReasoningByDefault(t *testing.T) { t.Parallel() - sessionState := &service.SessionState{} - block := New("test-1", "root", sessionState) + block := New("test-1", "root", &service.SessionState{}) block.SetSize(80, 24) + block.SetReasoning("Line 1\nLine 2\nLine 3\nLine 4\nLine 5\nLine 6") - reasoning := "Let me think about this problem carefully." - block.SetReasoning(reasoning) - - // Block starts collapsed - assert.False(t, block.IsExpanded()) - - view := block.View() - stripped := ansi.Strip(view) - - // Should contain "Thinking" header - assert.Contains(t, stripped, "Thinking") - // Short content should NOT show "click to expand" (no extra content to show) - assert.NotContains(t, stripped, "click to expand") - // Should contain some reasoning content - assert.Contains(t, stripped, "think") -} - -func TestReasoningBlockCollapsedWithLongContent(t *testing.T) { - t.Parallel() - - sessionState := &service.SessionState{} - block := New("test-1", "root", sessionState) - block.SetSize(80, 24) - - // Long reasoning that definitely exceeds previewLines (4 lines) after rendering - // Using markdown list format to ensure line breaks are preserved - reasoning := `1. First point about the problem -2. Second point to consider -3. Third important aspect -4. Fourth consideration here -5. Fifth point for analysis -6. Final conclusion drawn` - block.SetReasoning(reasoning) - - // Block starts collapsed - assert.False(t, block.IsExpanded()) - - view := block.View() - stripped := ansi.Strip(view) - - // Should contain "Thinking" header with expand indicator ([+]) - assert.Contains(t, stripped, "Thinking [+]") -} - -func TestReasoningBlockExpanded(t *testing.T) { - t.Parallel() - - sessionState := &service.SessionState{} - block := New("test-1", "root", sessionState) - block.SetSize(80, 24) - - reasoning := "Line 1\nLine 2\nLine 3\nLine 4\nLine 5\nLine 6" - block.SetReasoning(reasoning) - - // Expand the block - block.Toggle() assert.True(t, block.IsExpanded()) - view := block.View() - stripped := ansi.Strip(view) - - // Should contain "Thinking" header with collapse indicator ([-]) - assert.Contains(t, stripped, "Thinking [-]") - // Should show all lines + stripped := ansi.Strip(block.View()) + assert.Contains(t, stripped, "Thinking") assert.Contains(t, stripped, "Line 1") assert.Contains(t, stripped, "Line 6") + assert.NotContains(t, stripped, "[+]") + assert.NotContains(t, stripped, "[-]") } -func TestReasoningBlockWithToolCall(t *testing.T) { +func TestReasoningBlockAlwaysShowsToolCalls(t *testing.T) { t.Parallel() - sessionState := &service.SessionState{} - block := New("test-1", "root", sessionState) + block := New("test-1", "root", &service.SessionState{}) block.SetSize(80, 24) - - block.SetReasoning("Let me think...") - - // Add a running tool call (in-progress tools show in collapsed view) - toolMsg := types.ToolCallMessage("root", tools.ToolCall{ - ID: "call-1", - Function: tools.FunctionCall{Name: "read_file", Arguments: `{"path": "/tmp/test.txt"}`}, - }, tools.Tool{Name: "read_file", Description: "Read a file"}, types.ToolStatusRunning) - block.AddToolCall(toolMsg) - - assert.Equal(t, 1, block.ToolCount()) - assert.True(t, block.HasToolCall("call-1")) - assert.False(t, block.HasToolCall("call-2")) - - // Collapsed view should show in-progress tool - view := block.View() - stripped := ansi.Strip(view) - assert.Contains(t, stripped, "read_file") - assert.Contains(t, stripped, "1 tool") -} - -func TestReasoningBlockCollapsedShowsToolViews(t *testing.T) { - t.Parallel() - - sessionState := &service.SessionState{} - block := New("test-1", "root", sessionState) - block.SetSize(80, 24) - block.SetReasoning("Thinking...") - // Add a running tool call (in-progress tools show in collapsed view) - toolMsg := types.ToolCallMessage("root", tools.ToolCall{ - ID: "call-1", - Function: tools.FunctionCall{Name: "edit_file", Arguments: `{"path": "/tmp/test.txt"}`}, - }, tools.Tool{Name: "edit_file", Description: "Edit a file"}, types.ToolStatusRunning) - block.AddToolCall(toolMsg) - - // Block is collapsed by default - assert.False(t, block.IsExpanded()) - - view := block.View() - stripped := ansi.Strip(view) + for i, status := range []types.ToolStatus{ + types.ToolStatusRunning, + types.ToolStatusCompleted, + types.ToolStatusError, + } { + name := "tool_" + strconv.Itoa(i) + toolMsg := types.ToolCallMessage("root", tools.ToolCall{ + ID: "call-" + strconv.Itoa(i), + Function: tools.FunctionCall{Name: name, Arguments: "{}"}, + }, tools.Tool{Name: name}, status) + block.AddToolCall(toolMsg) + } - // Should show the in-progress tool name - assert.Contains(t, stripped, "edit_file") + stripped := ansi.Strip(block.View()) + assert.Contains(t, stripped, "Thinking...") + assert.Contains(t, stripped, "tool_0") + assert.Contains(t, stripped, "tool_1") + assert.Contains(t, stripped, "tool_2") + assert.NotContains(t, stripped, "3 tools") } -func TestReasoningBlockCollapsedHidesCompletedTools(t *testing.T) { +func TestReasoningBlockShowsCompletedToolAddedFromHistory(t *testing.T) { t.Parallel() - sessionState := &service.SessionState{} - block := New("test-1", "root", sessionState) + block := New("test-1", "root", &service.SessionState{}) block.SetSize(80, 24) - block.SetReasoning("Thinking...") - // Add a completed tool call (should NOT show in collapsed view) toolMsg := types.ToolCallMessage("root", tools.ToolCall{ ID: "call-1", - Function: tools.FunctionCall{Name: "completed_tool", Arguments: `{}`}, - }, tools.Tool{Name: "completed_tool", Description: "A tool"}, types.ToolStatusCompleted) + Function: tools.FunctionCall{Name: "restored_tool", Arguments: `{}`}, + }, tools.Tool{Name: "restored_tool", Description: "A restored tool"}, types.ToolStatusCompleted) block.AddToolCall(toolMsg) - // Block is collapsed by default - assert.False(t, block.IsExpanded()) - - view := block.View() - stripped := ansi.Strip(view) - - // Completed tool should NOT show in collapsed view - assert.NotContains(t, stripped, "completed_tool") - // Header should still show tool count - assert.Contains(t, stripped, "1 tool") - - // When expanded, should show the completed tool - block.Toggle() - assert.True(t, block.IsExpanded()) - expandedView := block.View() - expandedStripped := ansi.Strip(expandedView) - assert.Contains(t, expandedStripped, "completed_tool") + stripped := ansi.Strip(block.View()) + assert.Contains(t, stripped, "restored_tool") } -func TestReasoningBlockToggle(t *testing.T) { +func TestReasoningBlockToggleIsDisabled(t *testing.T) { t.Parallel() - sessionState := &service.SessionState{} - block := New("test-1", "root", sessionState) + block := New("test-1", "root", &service.SessionState{}) block.SetSize(80, 24) block.SetReasoning("Some reasoning") - // Initially collapsed - assert.False(t, block.IsExpanded()) - - // Toggle to expanded - block.Toggle() assert.True(t, block.IsExpanded()) + assert.False(t, block.IsToggleLine(0)) - // Toggle back to collapsed block.Toggle() - assert.False(t, block.IsExpanded()) -} - -func TestReasoningBlockHeaderFooterLineDetection(t *testing.T) { - t.Parallel() - - sessionState := &service.SessionState{} - block := New("test-1", "root", sessionState) - block.SetSize(80, 24) - // Use markdown list to ensure content exceeds previewLines (4) after rendering - block.SetReasoning(`1. First point -2. Second point -3. Third point -4. Fourth point -5. Fifth point -6. Sixth point`) - - // When collapsed with extra content, header is toggleable - assert.True(t, block.IsHeaderLine(0)) - assert.True(t, block.IsToggleLine(0)) - assert.False(t, block.IsToggleLine(1)) // Body line - - // When expanded, header is still toggleable - block.SetExpanded(true) - - assert.True(t, block.IsHeaderLine(0)) - assert.True(t, block.IsToggleLine(0)) - assert.False(t, block.IsToggleLine(1)) // Body line -} - -func TestReasoningBlockMultipleToolCalls(t *testing.T) { - t.Parallel() - - sessionState := &service.SessionState{} - block := New("test-1", "root", sessionState) - block.SetSize(80, 24) - - block.SetReasoning("Planning steps...") - - // Add multiple running tool calls (in-progress tools show in collapsed view) - for i := range 3 { - toolMsg := types.ToolCallMessage("root", tools.ToolCall{ - ID: "call-" + strconv.Itoa(i), - Function: tools.FunctionCall{Name: "tool_" + strconv.Itoa(i), Arguments: "{}"}, - }, tools.Tool{Name: "tool_" + strconv.Itoa(i)}, types.ToolStatusRunning) - block.AddToolCall(toolMsg) - } - - assert.Equal(t, 3, block.ToolCount()) - - // Header should show count - view := block.View() - stripped := ansi.Strip(view) - assert.Contains(t, stripped, "3 tools") + assert.True(t, block.IsExpanded()) + assert.Contains(t, ansi.Strip(block.View()), "Some reasoning") - // Collapsed should show all in-progress tools - assert.Contains(t, stripped, "tool_0") - assert.Contains(t, stripped, "tool_1") - assert.Contains(t, stripped, "tool_2") + block.SetExpanded(false) + assert.True(t, block.IsExpanded()) + assert.Contains(t, ansi.Strip(block.View()), "Some reasoning") } func TestReasoningBlockAppendReasoning(t *testing.T) { t.Parallel() - sessionState := &service.SessionState{} - block := New("test-1", "root", sessionState) + block := New("test-1", "root", &service.SessionState{}) block.SetSize(80, 24) block.SetReasoning("First part") @@ -270,288 +106,100 @@ func TestReasoningBlockAppendReasoning(t *testing.T) { assert.Equal(t, "First part second part", block.Reasoning()) } -func TestReasoningBlockEmptyReasoning(t *testing.T) { +func TestReasoningBlockInterleavesReasoningAndTools(t *testing.T) { t.Parallel() - sessionState := &service.SessionState{} - block := New("test-1", "root", sessionState) + block := New("test-1", "root", &service.SessionState{}) block.SetSize(80, 24) - // Add running tool call without reasoning (in-progress tools show) + block.SetReasoning("Before tool") toolMsg := types.ToolCallMessage("root", tools.ToolCall{ ID: "call-1", - Function: tools.FunctionCall{Name: "test_tool", Arguments: "{}"}, - }, tools.Tool{Name: "test_tool"}, types.ToolStatusRunning) + Function: tools.FunctionCall{Name: "read_file", Arguments: `{}`}, + }, tools.Tool{Name: "read_file"}, types.ToolStatusCompleted) block.AddToolCall(toolMsg) + block.AppendReasoning("After tool") - view := block.View() - stripped := ansi.Strip(view) - - // Should still render header and in-progress tool - assert.Contains(t, stripped, "Thinking") - assert.Contains(t, stripped, "test_tool") + stripped := ansi.Strip(block.View()) + beforeIdx := assert.Contains(t, stripped, "Before tool") + toolIdx := assert.Contains(t, stripped, "read_file") + afterIdx := assert.Contains(t, stripped, "After tool") + assert.True(t, beforeIdx && toolIdx && afterIdx) + assert.Less(t, indexOf(stripped, "Before tool"), indexOf(stripped, "read_file")) + assert.Less(t, indexOf(stripped, "read_file"), indexOf(stripped, "After tool")) } func TestReasoningBlockUpdateToolCall(t *testing.T) { t.Parallel() - sessionState := &service.SessionState{} - block := New("test-1", "root", sessionState) + block := New("test-1", "root", &service.SessionState{}) block.SetSize(80, 24) - // Add a pending tool call toolMsg := types.ToolCallMessage("root", tools.ToolCall{ ID: "call-1", Function: tools.FunctionCall{Name: "test_tool", Arguments: "{}"}, }, tools.Tool{Name: "test_tool"}, types.ToolStatusPending) block.AddToolCall(toolMsg) - // Update to completed block.UpdateToolCall("call-1", types.ToolStatusCompleted, `{"result": "done"}`) - // Verify update assert.True(t, block.HasToolCall("call-1")) + assert.Contains(t, ansi.Strip(block.View()), "test_tool") } func TestReasoningBlockUpdateToolResult(t *testing.T) { t.Parallel() - sessionState := &service.SessionState{} - block := New("test-1", "root", sessionState) + block := New("test-1", "root", &service.SessionState{}) block.SetSize(80, 24) - // Add a running tool call toolMsg := types.ToolCallMessage("root", tools.ToolCall{ ID: "call-1", Function: tools.FunctionCall{Name: "test_tool", Arguments: "{}"}, }, tools.Tool{Name: "test_tool"}, types.ToolStatusRunning) block.AddToolCall(toolMsg) - // Update with result result := &tools.ToolCallResult{Output: "Success!"} block.UpdateToolResult("call-1", "Success!", types.ToolStatusCompleted, result) - // Verify the tool is still tracked assert.True(t, block.HasToolCall("call-1")) + stripped := ansi.Strip(block.View()) + assert.Contains(t, stripped, "test_tool") } -func TestReasoningBlockCompletedToolGracePeriod(t *testing.T) { - // Not parallel - modifies package-level nowFunc - - // Save original nowFunc and restore after test - originalNowFunc := nowFunc - t.Cleanup(func() { nowFunc = originalNowFunc }) - - // Set up a fixed "now" time - fakeNow := time.Date(2025, 1, 1, 12, 0, 0, 0, time.UTC) - nowFunc = func() time.Time { return fakeNow } +func TestReasoningBlockNeedsTickOnlyForActiveTools(t *testing.T) { + t.Parallel() - sessionState := &service.SessionState{} - block := New("test-1", "root", sessionState) + block := New("test-1", "root", &service.SessionState{}) block.SetSize(80, 24) - block.SetReasoning("Thinking...") - // Add a running tool call - toolMsg := types.ToolCallMessage("root", tools.ToolCall{ - ID: "call-1", - Function: tools.FunctionCall{Name: "grace_tool", Arguments: `{}`}, - }, tools.Tool{Name: "grace_tool", Description: "A tool"}, types.ToolStatusRunning) - block.AddToolCall(toolMsg) + assert.False(t, block.NeedsTick()) - // Verify tool is visible while running - view := block.View() - stripped := ansi.Strip(view) - require.Contains(t, stripped, "grace_tool", "Running tool should be visible in collapsed view") - - // Complete the tool - this should set the grace period - result := &tools.ToolCallResult{Output: "Done!"} - block.UpdateToolResult("call-1", "Done!", types.ToolStatusCompleted, result) - - // Tool should still be visible immediately after completion (within visible period) - view = block.View() - stripped = ansi.Strip(view) - assert.Contains(t, stripped, "grace_tool", "Completed tool should be visible during visible period") - assert.InDelta(t, 0.0, block.GetToolFadeProgress("call-1"), 0.001, "Tool should not be fading yet") - - // Advance time past the total grace period (visible + fade) - totalDuration := completedToolVisibleDuration + completedToolFadeDuration - fakeNow = fakeNow.Add(totalDuration + time.Second) - - // Send a tick to update fade progress (this is what happens in production) - block.Update(animation.TickMsg{Frame: 1}) - - // Now the tool should be hidden - view = block.View() - stripped = ansi.Strip(view) - assert.NotContains(t, stripped, "grace_tool", "Completed tool should be hidden after grace period") - - // Header should still show tool count - assert.Contains(t, stripped, "1 tool") -} - -func TestReasoningBlockFadingState(t *testing.T) { - // Not parallel - modifies package-level nowFunc - - // Save original nowFunc and restore after test - originalNowFunc := nowFunc - t.Cleanup(func() { nowFunc = originalNowFunc }) - - completionTime := time.Date(2025, 1, 1, 12, 0, 0, 0, time.UTC) - fakeNow := completionTime - nowFunc = func() time.Time { return fakeNow } - - sessionState := &service.SessionState{} - block := New("test-1", "root", sessionState) - block.SetSize(80, 24) - - block.SetReasoning("Thinking...") - - // Add a running tool call toolMsg := types.ToolCallMessage("root", tools.ToolCall{ ID: "call-1", - Function: tools.FunctionCall{Name: "fade_tool", Arguments: `{}`}, - }, tools.Tool{Name: "fade_tool", Description: "A tool"}, types.ToolStatusRunning) + Function: tools.FunctionCall{Name: "test_tool", Arguments: `{}`}, + }, tools.Tool{Name: "test_tool"}, types.ToolStatusRunning) block.AddToolCall(toolMsg) + assert.True(t, block.NeedsTick()) - // Complete the tool result := &tools.ToolCallResult{Output: "Done!"} block.UpdateToolResult("call-1", "Done!", types.ToolStatusCompleted, result) - - // Initially not fading (progress 0) - we're in the visible period - assert.InDelta(t, 0.0, block.GetToolFadeProgress("call-1"), 0.001, "Tool should not be fading immediately after completion") - - // Capture view before fading - viewBeforeFade := block.View() - - // Advance time to just past the visible duration (fade starts) - fadeStartTime := completionTime.Add(completedToolVisibleDuration) - fakeNow = fadeStartTime.Add(time.Millisecond) - - // Send animation tick to compute fade progress based on elapsed time - block.Update(animation.TickMsg{Frame: 1}) - assert.Greater(t, block.GetToolFadeProgress("call-1"), 0.0, "Tool should have non-zero fade progress just after fade starts") - - // Capture view after fading started - viewAfterFade := block.View() - - // Tool should still be visible during fade (within total grace period) - stripped := ansi.Strip(viewAfterFade) - assert.Contains(t, stripped, "fade_tool", "Fading tool should still be visible") - - // The raw view (with ANSI codes) should be different due to faded color - assert.NotEqual(t, viewBeforeFade, viewAfterFade, "View should change when fading starts") - - // Test time-based fade progress at specific timestamps (tick-rate independent) - // The fade progress is computed from elapsed time, not from tick count - testCases := []struct { - name string - elapsed time.Duration // time since fade start - expectedProgress float64 - }{ - {"25% through fade", completedToolFadeDuration / 4, 0.25}, - {"50% through fade", completedToolFadeDuration / 2, 0.5}, - {"75% through fade", completedToolFadeDuration * 3 / 4, 0.75}, - {"100% through fade", completedToolFadeDuration, 1.0}, - } - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - fakeNow = fadeStartTime.Add(tc.elapsed) - block.Update(animation.TickMsg{Frame: 99}) // Frame number doesn't matter for time-based fade - assert.InDelta(t, tc.expectedProgress, block.GetToolFadeProgress("call-1"), 0.001, - "Fade progress should be %v at %v elapsed", tc.expectedProgress, tc.elapsed) - }) - } -} - -func TestReasoningBlockCompletedToolNoGracePeriodWhenAddedAsCompleted(t *testing.T) { - t.Parallel() - - // This test verifies that tools added as already-completed (e.g., from session restore) - // do NOT get a grace period and are hidden immediately in collapsed view. - - sessionState := &service.SessionState{} - block := New("test-1", "root", sessionState) - block.SetSize(80, 24) - - block.SetReasoning("Thinking...") - - // Add a tool that is already completed (simulates session restore) - toolMsg := types.ToolCallMessage("root", tools.ToolCall{ - ID: "call-1", - Function: tools.FunctionCall{Name: "restored_tool", Arguments: `{}`}, - }, tools.Tool{Name: "restored_tool", Description: "A restored tool"}, types.ToolStatusCompleted) - block.AddToolCall(toolMsg) - - // Tool should NOT be visible in collapsed view (no grace period for pre-completed tools) - view := block.View() - stripped := ansi.Strip(view) - assert.NotContains(t, stripped, "restored_tool", "Pre-completed tool should not be visible in collapsed view") - - // But it should be visible when expanded - block.Toggle() - view = block.View() - stripped = ansi.Strip(view) - assert.Contains(t, stripped, "restored_tool", "Pre-completed tool should be visible in expanded view") + assert.False(t, block.NeedsTick()) } func TestReasoningBlockID(t *testing.T) { t.Parallel() - sessionState := &service.SessionState{} - block := New("test-block-123", "root", sessionState) - block.SetSize(80, 24) - - // Verify the block ID is correct + block := New("test-block-123", "root", &service.SessionState{}) assert.Equal(t, "test-block-123", block.ID()) } -func TestReasoningBlockNeedsTick(t *testing.T) { - // Not parallel - modifies package-level nowFunc - - // Save original nowFunc and restore after test - originalNowFunc := nowFunc - t.Cleanup(func() { nowFunc = originalNowFunc }) - - completionTime := time.Date(2025, 1, 1, 12, 0, 0, 0, time.UTC) - fakeNow := completionTime - nowFunc = func() time.Time { return fakeNow } - - sessionState := &service.SessionState{} - block := New("test-1", "root", sessionState) - block.SetSize(80, 24) - - block.SetReasoning("Thinking...") - - // Block with no tools doesn't need tick - assert.False(t, block.NeedsTick(), "Block with no tools should not need tick") - - // Add a running tool call - needs tick for spinner - toolMsg := types.ToolCallMessage("root", tools.ToolCall{ - ID: "call-1", - Function: tools.FunctionCall{Name: "test_tool", Arguments: `{}`}, - }, tools.Tool{Name: "test_tool"}, types.ToolStatusRunning) - block.AddToolCall(toolMsg) - assert.True(t, block.NeedsTick(), "Block with running tool should need tick") - - // Complete the tool - still needs tick during visibility/fade window - result := &tools.ToolCallResult{Output: "Done!"} - block.UpdateToolResult("call-1", "Done!", types.ToolStatusCompleted, result) - assert.True(t, block.NeedsTick(), "Block with completed tool in grace period should need tick") - - // During visible period - still needs tick - fakeNow = completionTime.Add(completedToolVisibleDuration / 2) - block.Update(animation.TickMsg{Frame: 1}) - assert.True(t, block.NeedsTick(), "Block should need tick during visible period") - - // During fade period - still needs tick - fakeNow = completionTime.Add(completedToolVisibleDuration + completedToolFadeDuration/2) - block.Update(animation.TickMsg{Frame: 2}) - assert.True(t, block.NeedsTick(), "Block should need tick during fade period") - - // After grace period ends - no longer needs tick - fakeNow = completionTime.Add(completedToolVisibleDuration + completedToolFadeDuration + time.Second) - block.Update(animation.TickMsg{Frame: 3}) - assert.False(t, block.NeedsTick(), "Block should not need tick after grace period ends") +func indexOf(s, substr string) int { + for i := range s { + if len(s[i:]) >= len(substr) && s[i:i+len(substr)] == substr { + return i + } + } + return -1 } diff --git a/pkg/tui/components/tool/editfile/editfile.go b/pkg/tui/components/tool/editfile/editfile.go index c5440f370..dbe81a5ff 100644 --- a/pkg/tui/components/tool/editfile/editfile.go +++ b/pkg/tui/components/tool/editfile/editfile.go @@ -16,13 +16,10 @@ type ToggleDiffViewMsg struct{} // New creates the edit_file tool UI model. func New(msg *types.Message, sessionState service.SessionStateReader) layout.Model { - return toolcommon.NewBaseWithCollapsed(msg, sessionState, render, renderCollapsed) + return toolcommon.NewBase(msg, sessionState, render) } // render displays the edit_file tool output in the TUI. -// It prioritizes the agent-provided friendly header when available, -// hides results when collapsed by the user, and renders tool errors -// in a single-line error style consistent with other tools. func render( msg *types.Message, s spinner.Spinner, @@ -33,21 +30,14 @@ func render( // Parse tool arguments to extract the file path for display. args, err := filesystem.ParseEditFileArgs([]byte(msg.ToolCall.Function.Arguments)) if err != nil { - // If arguments cannot be parsed, fail silently to avoid breaking the TUI. return "" } - // When the tool failed, render a single-line error header - // consistent with other tool error renderings. if msg.ToolStatus == types.ToolStatusError { if msg.Content == "" { return "" } - // Render everything on a single line: - // - error icon - // - tool name in error style - // - rejection/error message line := fmt.Sprintf( "%s%s %s", toolcommon.Icon(msg, s), @@ -55,14 +45,11 @@ func render( styles.ToolErrorMessageStyle.Render(msg.Content), ) - // Truncate to terminal width to avoid wrapping return styles.BaseStyle. MaxWidth(width). Render(line) } - // ---- Normal (non-error) rendering ---- - // Check for friendly description first var content string if header, ok := toolcommon.RenderFriendlyHeader(msg, s); ok { @@ -76,16 +63,11 @@ func render( ) } - // Tool results are hidden when the user collapses them. if sessionState.HideToolResults() { return content } - // Successful (or pending/confirmation) execution: - // render the diff output inside the ToolCallResult container. if msg.ToolCall.Function.Arguments != "" { - // Calculate available width for diff rendering, accounting for - // ToolCallResult frame padding. contentWidth := width - styles.ToolCallResult.GetHorizontalFrameSize() content += "\n" + styles.ToolCallResult.Render( @@ -100,51 +82,3 @@ func render( return content } - -// renderCollapsed renders a simplified view for collapsed reasoning blocks. -// Shows only the file path and +N / -M line counts. -func renderCollapsed( - msg *types.Message, - s spinner.Spinner, - _ service.SessionStateReader, - width, - _ int, -) string { - args, err := filesystem.ParseEditFileArgs([]byte(msg.ToolCall.Function.Arguments)) - if err != nil { - return "" - } - - // Error state - if msg.ToolStatus == types.ToolStatusError { - if msg.Content == "" { - return "" - } - line := fmt.Sprintf( - "%s%s %s", - toolcommon.Icon(msg, s), - styles.ToolNameError.Render(msg.ToolDefinition.DisplayName()), - styles.ToolErrorMessageStyle.Render(msg.Content), - ) - return styles.BaseStyle.MaxWidth(width).Render(line) - } - - // Count added/removed lines - added, removed := countDiffLines(msg.ToolCall, msg.ToolStatus) - var diffSummary string - if added > 0 || removed > 0 { - addStr := styles.DiffAddStyle.Render(fmt.Sprintf("+%d", added)) - remStr := styles.DiffRemoveStyle.Render(fmt.Sprintf("-%d", removed)) - diffSummary = fmt.Sprintf(" %s / %s", addStr, remStr) - } - - line := fmt.Sprintf( - "%s%s %s%s", - toolcommon.Icon(msg, s), - styles.ToolName.Render(msg.ToolDefinition.DisplayName()), - styles.ToolMessageStyle.Render(toolcommon.ShortenPath(args.Path)), - diffSummary, - ) - - return styles.BaseStyle.MaxWidth(width).Render(line) -} diff --git a/pkg/tui/components/tool/editfile/render.go b/pkg/tui/components/tool/editfile/render.go index e7d80fe28..51b1553c2 100644 --- a/pkg/tui/components/tool/editfile/render.go +++ b/pkg/tui/components/tool/editfile/render.go @@ -27,11 +27,6 @@ const ( ) type toolRenderCache struct { - // Line counts - computed once, never change - added int - removed int - lineCounted bool - // Rendered output - invalidated when width/splitView/status changes rendered string renderCached bool @@ -143,56 +138,6 @@ func renderEditFileUncached(toolCall tools.ToolCall, width int, splitView bool, return output.String() } -// countDiffLines returns the number of added and removed lines for the edit. -// Results are cached per tool call since arguments are immutable. -func countDiffLines(toolCall tools.ToolCall, _ types.ToolStatus) (added, removed int) { - c := getOrCreateCache(toolCall.ID) - - cacheMu.RLock() - if c.lineCounted { - added, removed = c.added, c.removed - cacheMu.RUnlock() - return added, removed - } - cacheMu.RUnlock() - - added, removed = countDiffLinesUncached(toolCall) - - cacheMu.Lock() - c.added = added - c.removed = removed - c.lineCounted = true - cacheMu.Unlock() - - return added, removed -} - -func countDiffLinesUncached(toolCall tools.ToolCall) (added, removed int) { - args, err := filesystem.ParseEditFileArgs([]byte(toolCall.Function.Arguments)) - if err != nil { - return 0, 0 - } - - for _, edit := range args.Edits { - edits := udiff.Strings(edit.OldText, edit.NewText) - diff, err := udiff.ToUnifiedDiff("old", "new", edit.OldText, edits, 0) - if err != nil { - continue - } - for _, hunk := range diff.Hunks { - for _, line := range hunk.Lines { - switch line.Kind { - case udiff.Insert: - added++ - case udiff.Delete: - removed++ - } - } - } - } - return added, removed -} - func computeDiff(path, oldText, newText string, toolStatus types.ToolStatus) []*udiff.Hunk { currentContent, err := os.ReadFile(path) if err != nil { diff --git a/pkg/tui/components/toolcommon/base.go b/pkg/tui/components/toolcommon/base.go index a6618e433..1dcd9dd6b 100644 --- a/pkg/tui/components/toolcommon/base.go +++ b/pkg/tui/components/toolcommon/base.go @@ -15,9 +15,6 @@ import ( // Note: Uses SessionStateReader interface for read-only access to session state. type Renderer func(msg *types.Message, s spinner.Spinner, sessionState service.SessionStateReader, width, height int) string -// CollapsedRenderer is a function that renders a simplified view for collapsed reasoning blocks. -type CollapsedRenderer func(msg *types.Message, s spinner.Spinner, sessionState service.SessionStateReader, width, height int) string - // Base provides common boilerplate for tool components. // It handles spinner management, sizing, and delegates rendering to a custom function. type Base struct { @@ -27,7 +24,6 @@ type Base struct { height int sessionState service.SessionStateReader // read-only access to session state render Renderer - collapsedRenderer CollapsedRenderer spinnerRegistered bool // tracks whether spinner is registered with coordinator } @@ -44,20 +40,6 @@ func NewBase(msg *types.Message, sessionState service.SessionStateReader, render } } -// NewBaseWithCollapsed creates a new base tool component with both regular and collapsed renderers. -// Accepts SessionStateReader for read-only access (also accepts *SessionState which implements it). -func NewBaseWithCollapsed(msg *types.Message, sessionState service.SessionStateReader, render Renderer, collapsedRender CollapsedRenderer) *Base { - return &Base{ - message: msg, - spinner: spinner.New(spinner.ModeSpinnerOnly, styles.SpinnerDotsAccentStyle), - width: 80, - height: 1, - sessionState: sessionState, - render: render, - collapsedRenderer: collapsedRender, - } -} - func (b *Base) SetSize(width, height int) tea.Cmd { b.width = width b.height = height @@ -102,15 +84,6 @@ func (b *Base) View() string { return b.render(b.message, b.spinner, b.sessionState, b.width, b.height) } -// CollapsedView returns a simplified view for use in collapsed reasoning blocks. -// Falls back to the regular View() if no collapsed renderer is provided. -func (b *Base) CollapsedView() string { - if b.collapsedRenderer != nil { - return b.collapsedRenderer(b.message, b.spinner, b.sessionState, b.width, b.height) - } - return b.View() -} - // StopAnimation stops the spinner animation and unregisters from the animation coordinator. // This must be called when the view is removed from the UI to avoid leaked animation subscriptions. func (b *Base) StopAnimation() { diff --git a/pkg/tui/core/layout/layout.go b/pkg/tui/core/layout/layout.go index b00a91e6b..d10423c8f 100644 --- a/pkg/tui/core/layout/layout.go +++ b/pkg/tui/core/layout/layout.go @@ -34,9 +34,3 @@ type Model interface { View() string Sizeable } - -// CollapsedViewer is implemented by components that provide a simplified view -// for use in collapsed reasoning blocks. -type CollapsedViewer interface { - CollapsedView() string -}