Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions docs/features/tui/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,20 @@ Type `/` during a session to see available commands, or press <kbd>Ctrl</kbd>+<k
| `/speak` | Voice input via system speech-to-text (macOS only) |
| `/exit` | Exit the application (aliases: `/quit`, `/q`) |

### Thinking and Tool Details

Reasoning/thinking blocks are collapsed by default. When collapsed, the TUI shows a short preview and compact tool summaries. Expand a block to see the full thinking content and the real tool renderers, including detailed tool output such as file edit diffs.

To start new sessions with thinking/tool blocks expanded by default, set `expand_thinking` in your user config:

```yaml
# ~/.config/cagent/config.yaml
settings:
expand_thinking: true
```

Set it to `false` or omit it to keep the default collapsed behavior.

### Snapshots, `/undo`, and `/snapshots`

Enable shadow-git snapshots globally in `~/.config/cagent/config.yaml`:
Expand Down
15 changes: 13 additions & 2 deletions pkg/tui/components/reasoningblock/reasoningblock.go
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,10 @@ type renderCache struct {
hasExtra bool // whether there's extra content beyond preview
}

type expandedToolView interface {
ExpandedView() string
}

// Model represents a collapsible reasoning + tool calls block.
type Model struct {
id string
Expand All @@ -119,7 +123,7 @@ func New(id, agentName string, sessionState *service.SessionState) *Model {
return &Model{
id: id,
agentName: agentName,
expanded: false,
expanded: sessionState == nil || sessionState.ExpandThinking(),
width: 80,
sessionState: sessionState,
}
Expand Down Expand Up @@ -522,7 +526,7 @@ func (m *Model) renderExpanded() string {
if i == 0 || (i > 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
Expand All @@ -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
Expand Down
91 changes: 91 additions & 0 deletions pkg/tui/components/reasoningblock/reasoningblock_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package reasoningblock

import (
"os"
"path/filepath"
"strconv"
"testing"
"time"
Expand All @@ -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()

Expand Down Expand Up @@ -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()

Expand Down
5 changes: 5 additions & 0 deletions pkg/tui/components/toolcommon/base.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
28 changes: 23 additions & 5 deletions pkg/tui/service/sessionstate.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
}
Expand Down
11 changes: 11 additions & 0 deletions pkg/userconfig/userconfig.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down Expand Up @@ -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 {
Expand Down
25 changes: 25 additions & 0 deletions pkg/userconfig/userconfig_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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) {
Expand Down Expand Up @@ -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) {
Expand Down
Loading