diff --git a/internal/core/find.go b/internal/core/find.go index a8be35e5..f794288f 100644 --- a/internal/core/find.go +++ b/internal/core/find.go @@ -39,6 +39,7 @@ func NewFindTool(opts ...ToolOption) fantasy.AgentTool { }, }, Required: []string{"pattern"}, + Parallel: true, }, handler: func(ctx context.Context, call fantasy.ToolCall) (fantasy.ToolResponse, error) { return executeFind(ctx, call, cfg.WorkDir) diff --git a/internal/core/grep.go b/internal/core/grep.go index bf842198..4754135c 100644 --- a/internal/core/grep.go +++ b/internal/core/grep.go @@ -59,6 +59,7 @@ func NewGrepTool(opts ...ToolOption) fantasy.AgentTool { }, }, Required: []string{"pattern"}, + Parallel: true, }, handler: func(ctx context.Context, call fantasy.ToolCall) (fantasy.ToolResponse, error) { return executeGrep(ctx, call, cfg.WorkDir) diff --git a/internal/core/ls.go b/internal/core/ls.go index 62b0081d..196c62d6 100644 --- a/internal/core/ls.go +++ b/internal/core/ls.go @@ -33,6 +33,7 @@ func NewLsTool(opts ...ToolOption) fantasy.AgentTool { }, }, Required: []string{}, + Parallel: true, }, handler: func(ctx context.Context, call fantasy.ToolCall) (fantasy.ToolResponse, error) { return executeLs(ctx, call, cfg.WorkDir) diff --git a/internal/core/read.go b/internal/core/read.go index b3dae554..f48b27c7 100644 --- a/internal/core/read.go +++ b/internal/core/read.go @@ -38,6 +38,7 @@ func NewReadTool(opts ...ToolOption) fantasy.AgentTool { }, }, Required: []string{"path"}, + Parallel: true, }, handler: func(ctx context.Context, call fantasy.ToolCall) (fantasy.ToolResponse, error) { return executeRead(ctx, call, cfg.WorkDir) diff --git a/internal/core/subagent.go b/internal/core/subagent.go index 2b2fca5f..91f965b1 100644 --- a/internal/core/subagent.go +++ b/internal/core/subagent.go @@ -57,6 +57,7 @@ Example use cases: }, }, Required: []string{"task"}, + Parallel: true, }, handler: func(ctx context.Context, call fantasy.ToolCall) (fantasy.ToolResponse, error) { return executeSubagent(ctx, call) diff --git a/internal/ui/children_test.go b/internal/ui/children_test.go index 11fad842..1473c56e 100644 --- a/internal/ui/children_test.go +++ b/internal/ui/children_test.go @@ -397,8 +397,8 @@ func TestStreamComponent_ToolExecution_IsStarting_ShowsSpinner(t *testing.T) { if !c.spinning { t.Fatal("expected spinning=true during tool execution") } - if !strings.Contains(c.spinnerMsg, "exec_tool") { - t.Fatalf("expected spinnerMsg to contain tool name, got %q", c.spinnerMsg) + if len(c.activeTools) != 1 || !strings.Contains(c.activeTools[0], "exec_tool") { + t.Fatalf("expected activeTools to contain tool name, got %v", c.activeTools) } if cmd == nil { t.Fatal("expected tick cmd from ToolExecutionEvent{IsStarting:true}") @@ -410,7 +410,11 @@ func TestStreamComponent_ToolExecution_NotStarting_KeepsSpinning(t *testing.T) { c := newTestStream() // Start spinning first (simulating execution in progress). c = sendStreamMsg(c, app.SpinnerEvent{Show: true}) - c.spinnerMsg = "Executing some_tool…" + // Simulate a tool starting + c = sendStreamMsg(c, app.ToolExecutionEvent{ + ToolName: "some_tool", + IsStarting: true, + }) c = sendStreamMsg(c, app.ToolExecutionEvent{ ToolName: "some_tool", @@ -420,8 +424,41 @@ func TestStreamComponent_ToolExecution_NotStarting_KeepsSpinning(t *testing.T) { if !c.spinning { t.Fatal("expected spinning=true after tool execution finished (spinner keeps running)") } - if c.spinnerMsg != "" { - t.Fatalf("expected spinnerMsg cleared after tool finished, got %q", c.spinnerMsg) + if len(c.activeTools) != 0 { + t.Fatalf("expected activeTools cleared after tool finished, got %v", c.activeTools) + } +} + +// TestStreamComponent_ParallelToolExecution verifies multiple tools can run concurrently. +func TestStreamComponent_ParallelToolExecution(t *testing.T) { + c := newTestStream() + + // Start three tools in parallel + c = sendStreamMsg(c, app.ToolExecutionEvent{ToolName: "read", IsStarting: true}) + c = sendStreamMsg(c, app.ToolExecutionEvent{ToolName: "grep", IsStarting: true}) + c = sendStreamMsg(c, app.ToolExecutionEvent{ToolName: "find", IsStarting: true}) + + if len(c.activeTools) != 3 { + t.Fatalf("expected 3 active tools, got %d: %v", len(c.activeTools), c.activeTools) + } + + // Check SpinnerView shows all tools + view := c.SpinnerView() + if !strings.Contains(view, "Running:") { + t.Fatalf("expected spinner view to contain 'Running:' for multiple tools, got %q", view) + } + + // Finish one tool + c = sendStreamMsg(c, app.ToolExecutionEvent{ToolName: "grep", IsStarting: false}) + if len(c.activeTools) != 2 { + t.Fatalf("expected 2 active tools after one finished, got %d: %v", len(c.activeTools), c.activeTools) + } + + // Finish remaining tools + c = sendStreamMsg(c, app.ToolExecutionEvent{ToolName: "read", IsStarting: false}) + c = sendStreamMsg(c, app.ToolExecutionEvent{ToolName: "find", IsStarting: false}) + if len(c.activeTools) != 0 { + t.Fatalf("expected 0 active tools after all finished, got %d: %v", len(c.activeTools), c.activeTools) } } @@ -480,8 +517,8 @@ func TestStreamComponent_Reset(t *testing.T) { if !c.timestamp.IsZero() { t.Fatal("expected zero timestamp after Reset()") } - if c.spinnerMsg != "" { - t.Fatalf("expected spinnerMsg empty after Reset(), got %q", c.spinnerMsg) + if len(c.activeTools) != 0 { + t.Fatalf("expected activeTools empty after Reset(), got %v", c.activeTools) } } diff --git a/internal/ui/stream.go b/internal/ui/stream.go index 0731f281..85865316 100644 --- a/internal/ui/stream.go +++ b/internal/ui/stream.go @@ -115,9 +115,9 @@ type StreamComponent struct { // spinnerFrame is the current frame index. spinnerFrame int - // spinnerMsg is the label shown next to the KITT animation (e.g. - // "Executing tool_name…"). Empty string means no label. - spinnerMsg string + // activeTools tracks the names of tools currently executing in parallel. + // When multiple tools run concurrently, all are displayed in the spinner. + activeTools []string // streamContent accumulates all streaming text chunks. streamContent strings.Builder @@ -182,7 +182,7 @@ func (s *StreamComponent) Reset() { s.phase = streamPhaseIdle s.spinning = false s.spinnerFrame = 0 - s.spinnerMsg = "" + s.activeTools = nil s.streamContent.Reset() s.reasoningContent.Reset() s.timestamp = time.Time{} @@ -267,9 +267,9 @@ func (s *StreamComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case app.ToolExecutionEvent: if msg.IsStarting { - // Show the tool name on the spinner while the tool executes. - // For spawn_subagent, show a descriptive message with the task. - s.spinnerMsg = formatToolExecutionMessage(msg.ToolName, msg.ToolArgs) + // Add tool to active list for parallel execution display. + toolDisplay := formatToolExecutionMessage(msg.ToolName, msg.ToolArgs) + s.activeTools = append(s.activeTools, toolDisplay) s.spinnerFrame = 0 if !s.spinning { s.phase = streamPhaseActive @@ -277,8 +277,9 @@ func (s *StreamComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return s, streamSpinnerTickCmd() } } else { - // Tool finished — clear execution label but keep spinning. - s.spinnerMsg = "" + // Tool finished — remove from active list but keep spinning if others remain. + toolDisplay := formatToolExecutionMessage(msg.ToolName, msg.ToolArgs) + s.activeTools = removeFromSlice(s.activeTools, toolDisplay) } } @@ -375,14 +376,22 @@ func (s *StreamComponent) SpinnerView() string { return "" } frame := s.spinnerFrames[s.spinnerFrame%len(s.spinnerFrames)] - if s.spinnerMsg == "" { + if len(s.activeTools) == 0 { return " " + frame } theme := GetTheme() msgStyle := lipgloss.NewStyle(). Foreground(theme.Text). Italic(true) - return " " + frame + " " + msgStyle.Render(s.spinnerMsg) + + // Format active tools list + var toolsMsg string + if len(s.activeTools) == 1 { + toolsMsg = s.activeTools[0] + } else { + toolsMsg = "Running: " + strings.Join(s.activeTools, ", ") + } + return " " + frame + " " + msgStyle.Render(toolsMsg) } // renderStreamingText renders the accumulated streaming text as a live assistant @@ -401,6 +410,16 @@ func (s *StreamComponent) renderStreamingText(text string) string { return msg.Content } +// removeFromSlice removes the first occurrence of a string from a slice. +func removeFromSlice(slice []string, s string) []string { + for i, v := range slice { + if v == s { + return append(slice[:i], slice[i+1:]...) + } + } + return slice +} + // formatToolExecutionMessage creates a descriptive spinner message for tool execution. // For spawn_subagent, it extracts and displays the task being performed. func formatToolExecutionMessage(toolName, toolArgs string) string {