diff --git a/docs/features/tui/index.md b/docs/features/tui/index.md
index 51d61a63e..900de7b74 100644
--- a/docs/features/tui/index.md
+++ b/docs/features/tui/index.md
@@ -61,6 +61,20 @@ Type `/` during a session to see available commands, or press Ctrl+ 0 && m.contentItems[i-1].kind == contentItemReasoning) {
parts = append(parts, "")
}
- parts = append(parts, m.toolEntries[item.toolIndex].view.View())
+ parts = append(parts, m.renderToolExpanded(m.toolEntries[item.toolIndex]))
// Blank line after last tool in a consecutive group (next is reasoning or end)
isLastItem := i == len(m.contentItems)-1
nextIsReasoning := !isLastItem && m.contentItems[i+1].kind == contentItemReasoning
@@ -536,6 +540,13 @@ func (m *Model) renderExpanded() string {
return strings.Join(parts, "\n")
}
+func (m *Model) renderToolExpanded(entry toolEntry) string {
+ if view, ok := entry.view.(expandedToolView); ok {
+ return view.ExpandedView()
+ }
+ return entry.view.View()
+}
+
// renderCollapsed renders the compact preview.
func (m *Model) renderCollapsed() string {
var parts []string
diff --git a/pkg/tui/components/reasoningblock/reasoningblock_test.go b/pkg/tui/components/reasoningblock/reasoningblock_test.go
index 03da2261b..d4f45967d 100644
--- a/pkg/tui/components/reasoningblock/reasoningblock_test.go
+++ b/pkg/tui/components/reasoningblock/reasoningblock_test.go
@@ -1,6 +1,8 @@
package reasoningblock
import (
+ "os"
+ "path/filepath"
"strconv"
"testing"
"time"
@@ -9,12 +11,44 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
+ "github.com/docker/docker-agent/pkg/session"
"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 TestReasoningBlockCollapsedByDefaultFromSessionState(t *testing.T) {
+ t.Parallel()
+
+ sessionState := service.NewSessionState(&session.Session{})
+ block := New("test-default-collapsed", "root", sessionState)
+ block.SetSize(80, 24)
+ longReasoning := `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(longReasoning)
+
+ assert.False(t, block.IsExpanded())
+ assert.Contains(t, ansi.Strip(block.View()), "Thinking [+]")
+}
+
+func TestReasoningBlockCanDefaultExpandedFromSessionState(t *testing.T) {
+ t.Parallel()
+
+ sessionState := service.NewSessionState(&session.Session{})
+ sessionState.SetExpandThinking(true)
+ block := New("test-default-expanded", "root", sessionState)
+ block.SetSize(80, 24)
+ block.SetReasoning("Line 1\nLine 2\nLine 3\nLine 4\nLine 5\nLine 6")
+
+ assert.True(t, block.IsExpanded())
+ assert.Contains(t, ansi.Strip(block.View()), "Thinking [-]")
+}
+
func TestReasoningBlockCollapsed(t *testing.T) {
t.Parallel()
@@ -117,6 +151,63 @@ func TestReasoningBlockWithToolCall(t *testing.T) {
assert.Contains(t, stripped, "1 tool")
}
+func TestReasoningBlockExpandedShowsFullToolRenderer(t *testing.T) {
+ t.Parallel()
+
+ tmpDir := t.TempDir()
+ path := filepath.Join(tmpDir, "test.txt")
+ require.NoError(t, os.WriteFile(path, []byte("old line\n"), 0o644))
+
+ sessionState := &service.SessionState{}
+ block := New("test-expanded-tool-renderer", "root", sessionState)
+ block.SetSize(100, 24)
+ block.SetExpanded(true)
+ block.SetReasoning("Need to edit the file.")
+
+ toolMsg := types.ToolCallMessage("root", tools.ToolCall{
+ ID: "call-1",
+ Function: tools.FunctionCall{
+ Name: "edit_file",
+ Arguments: `{"path":` + strconv.Quote(path) + `,"edits":[{"oldText":"old line\n","newText":"new line\n"}]}`,
+ },
+ }, tools.Tool{Name: "edit_file", Annotations: tools.ToolAnnotations{Title: "Edit"}}, types.ToolStatusConfirmation)
+ block.AddToolCall(toolMsg)
+
+ stripped := ansi.Strip(block.View())
+ assert.Contains(t, stripped, "Edit")
+ assert.Contains(t, stripped, path)
+ assert.Contains(t, stripped, "old line")
+ assert.Contains(t, stripped, "new line")
+}
+
+func TestReasoningBlockCollapsedUsesCollapsedToolRenderer(t *testing.T) {
+ t.Parallel()
+
+ tmpDir := t.TempDir()
+ path := filepath.Join(tmpDir, "test.txt")
+ require.NoError(t, os.WriteFile(path, []byte("old line\n"), 0o644))
+
+ sessionState := &service.SessionState{}
+ block := New("test-collapsed-tool-renderer", "root", sessionState)
+ block.SetSize(100, 24)
+ block.SetExpanded(false)
+ block.SetReasoning("Need to edit the file.")
+
+ toolMsg := types.ToolCallMessage("root", tools.ToolCall{
+ ID: "call-1",
+ Function: tools.FunctionCall{
+ Name: "edit_file",
+ Arguments: `{"path":` + strconv.Quote(path) + `,"edits":[{"oldText":"old line\n","newText":"new line\n"}]}`,
+ },
+ }, tools.Tool{Name: "edit_file", Annotations: tools.ToolAnnotations{Title: "Edit"}}, types.ToolStatusRunning)
+ block.AddToolCall(toolMsg)
+
+ stripped := ansi.Strip(block.View())
+ assert.Contains(t, stripped, "Edit")
+ assert.NotContains(t, stripped, "old line")
+ assert.NotContains(t, stripped, "new line")
+}
+
func TestReasoningBlockCollapsedShowsToolViews(t *testing.T) {
t.Parallel()
diff --git a/pkg/tui/components/toolcommon/base.go b/pkg/tui/components/toolcommon/base.go
index a6618e433..5e832caf8 100644
--- a/pkg/tui/components/toolcommon/base.go
+++ b/pkg/tui/components/toolcommon/base.go
@@ -102,6 +102,11 @@ func (b *Base) View() string {
return b.render(b.message, b.spinner, b.sessionState, b.width, b.height)
}
+// ExpandedView returns the regular, full tool renderer.
+func (b *Base) ExpandedView() string {
+ return b.View()
+}
+
// 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 {
diff --git a/pkg/tui/service/sessionstate.go b/pkg/tui/service/sessionstate.go
index 77d4e1d95..82fceeb91 100644
--- a/pkg/tui/service/sessionstate.go
+++ b/pkg/tui/service/sessionstate.go
@@ -13,6 +13,7 @@ import (
// rather than the full SessionState, following the principle of least privilege.
type SessionStateReader interface {
SplitDiffView() bool
+ ExpandThinking() bool
YoloMode() bool
HideToolResults() bool
CurrentAgentName() string
@@ -30,6 +31,7 @@ var _ SessionStateReader = (*SessionState)(nil)
// accessible by multiple components.
type SessionState struct {
splitDiffView bool
+ expandThinking bool
yoloMode bool
hideToolResults bool
sessionTitle string
@@ -40,18 +42,34 @@ type SessionState struct {
}
func NewSessionState(s *session.Session) *SessionState {
- return &SessionState{
- splitDiffView: userconfig.Get().GetSplitDiffView(),
- yoloMode: s.ToolsApproved,
- hideToolResults: s.HideToolResults,
- sessionTitle: s.Title,
+ settings := userconfig.Get()
+ state := &SessionState{
+ splitDiffView: settings.GetSplitDiffView(),
+ expandThinking: settings.GetExpandThinking(),
}
+ if s != nil {
+ state.yoloMode = s.ToolsApproved
+ state.hideToolResults = s.HideToolResults
+ state.sessionTitle = s.Title
+ }
+ return state
}
func (s *SessionState) SplitDiffView() bool {
return s.splitDiffView
}
+func (s *SessionState) ExpandThinking() bool {
+ if s == nil {
+ return true
+ }
+ return s.expandThinking
+}
+
+func (s *SessionState) SetExpandThinking(expandThinking bool) {
+ s.expandThinking = expandThinking
+}
+
func (s *SessionState) ToggleSplitDiffView() {
s.splitDiffView = !s.splitDiffView
}
diff --git a/pkg/userconfig/userconfig.go b/pkg/userconfig/userconfig.go
index f42575f55..757a049b3 100644
--- a/pkg/userconfig/userconfig.go
+++ b/pkg/userconfig/userconfig.go
@@ -41,6 +41,9 @@ func (a *Alias) HasOptions() bool {
type Settings struct {
// HideToolResults hides tool call results in the TUI by default
HideToolResults bool `yaml:"hide_tool_results,omitempty"`
+ // ExpandThinking expands reasoning/tool blocks in the TUI by default.
+ // Defaults to false when not set.
+ ExpandThinking *bool `yaml:"expand_thinking,omitempty"`
// SplitDiffView enables side-by-side split diff rendering for file edits.
// Defaults to true when not set.
SplitDiffView *bool `yaml:"split_diff_view,omitempty"`
@@ -99,6 +102,14 @@ func (s *Settings) GetSoundThreshold() int {
return s.SoundThreshold
}
+// GetExpandThinking returns whether reasoning/tool blocks are expanded by default.
+func (s *Settings) GetExpandThinking() bool {
+ if s == nil || s.ExpandThinking == nil {
+ return false
+ }
+ return *s.ExpandThinking
+}
+
// GetSplitDiffView returns whether split diff view is enabled, defaulting to true.
func (s *Settings) GetSplitDiffView() bool {
if s == nil || s.SplitDiffView == nil {
diff --git a/pkg/userconfig/userconfig_test.go b/pkg/userconfig/userconfig_test.go
index be54205ac..ac3875f25 100644
--- a/pkg/userconfig/userconfig_test.go
+++ b/pkg/userconfig/userconfig_test.go
@@ -523,6 +523,29 @@ func TestConfig_Settings_Empty(t *testing.T) {
settings := config.GetSettings()
assert.NotNil(t, settings)
assert.False(t, settings.HideToolResults)
+ assert.False(t, settings.GetExpandThinking())
+}
+
+func TestConfig_Settings_ExpandThinking(t *testing.T) {
+ t.Parallel()
+
+ tmpDir := t.TempDir()
+ configFile := filepath.Join(tmpDir, "config.yaml")
+ expandThinking := false
+
+ config := &Config{
+ Settings: &Settings{
+ ExpandThinking: &expandThinking,
+ },
+ }
+
+ require.NoError(t, config.saveTo(configFile))
+
+ loaded, err := loadFrom(configFile, "")
+ require.NoError(t, err)
+ require.NotNil(t, loaded.Settings)
+ require.NotNil(t, loaded.Settings.ExpandThinking)
+ assert.False(t, loaded.Settings.GetExpandThinking())
}
func TestConfig_Settings_GetSettingsNil(t *testing.T) {
@@ -534,6 +557,7 @@ func TestConfig_Settings_GetSettingsNil(t *testing.T) {
settings := config.GetSettings()
assert.NotNil(t, settings)
assert.False(t, settings.HideToolResults)
+ assert.False(t, settings.GetExpandThinking())
}
func TestConfig_AliasWithHideToolResults(t *testing.T) {
@@ -791,6 +815,7 @@ func TestGet_Empty(t *testing.T) {
settings := Get()
require.NotNil(t, settings)
assert.False(t, settings.HideToolResults)
+ assert.False(t, settings.GetExpandThinking())
}
func TestGet_WithHideToolResults(t *testing.T) {