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) {