Compare commits

..

7 Commits

Author SHA1 Message Date
Ed Zynda fefbf19b42 fix(acp): default mcpServers to empty array for clients that omit it 2026-03-15 11:57:30 +03:00
Ed Zynda 93905d4d77 fix(acp): remove startup message from stdio output 2026-03-15 11:38:31 +03:00
Ed Zynda 7268ccdf4d perf(ui): throttle stream rendering with chunk coalescing and render cache
Streaming chunks now accumulate in a pending buffer and flush on a 16ms
tick (~60fps) instead of triggering a full markdown re-render on every
chunk. Between flushes, View() returns a cached string — no markdown
parsing, no lipgloss styling, no terminal escape sequence churn. This is
especially impactful for inline rendering (no alt screen) where each
frame requires cursor repositioning across the full view height.
2026-03-15 11:36:04 +03:00
Ed Zynda 9f59fa42dc fix: resolve golangci-lint issues
- Use strings.Cut instead of strings.Index (modernize)
- Remove unused session registry methods (load, remove)
2026-03-14 17:30:36 +03:00
Ed Zynda 8af7ca8455 refactor(ui): simplify tool names in spinner display
Show 'Subagent' instead of 'spawn_subagent' and remove 'Executing' prefix
for cleaner parallel tool status display.
2026-03-14 17:25:40 +03:00
Ed Zynda 424847f0db feat: enable parallel tool execution with multi-tool status display
- Mark read-only core tools as parallel-safe (read, grep, find, ls)
- Mark spawn_subagent as parallel-safe for concurrent task delegation
- Update UI to track multiple active tools during parallel execution
- Display 'Running: tool1, tool2, ...' in spinner for concurrent tools
- Add test for parallel tool execution scenarios

Fantasy already supports parallel execution via ToolInfo.Parallel field.
Tools marked parallel run concurrently (up to 5 at a time).
2026-03-14 17:24:20 +03:00
Ed Zynda 4c126ca41b feat(ui): show clean summary for subagent results instead of raw output
- Add custom renderer for spawn_subagent tool showing status + 3-line preview
- Pass toolArgs through ToolExecutionEvent to show task in spinner
- Display 'Subagent: <task>' during execution instead of generic message
- Compact mode shows concise one-line status summary
2026-03-14 17:04:50 +03:00
15 changed files with 437 additions and 92 deletions
+96 -4
View File
@@ -1,7 +1,11 @@
package cmd
import (
"bufio"
"bytes"
"encoding/json"
"fmt"
"io"
"log/slog"
"os"
"os/signal"
@@ -37,8 +41,10 @@ func runACP(cmd *cobra.Command, _ []string) error {
defer agent.Close()
// Create the stdio connection. The SDK reads JSON-RPC from stdin and
// writes responses to stdout.
conn := acp.NewAgentSideConnection(agent, os.Stdout, os.Stdin)
// writes responses to stdout. We wrap stdin with a normalizer that
// fills in optional fields the SDK's generated validation requires
// (e.g. mcpServers) so clients that omit them still work.
conn := acp.NewAgentSideConnection(agent, os.Stdout, newACPNormalizer(os.Stdin))
// Wire the connection back to the agent so it can send session updates.
agent.SetAgentConnection(conn)
@@ -50,8 +56,6 @@ func runACP(cmd *cobra.Command, _ []string) error {
})))
}
fmt.Fprintln(os.Stderr, "kit: ACP server ready on stdio")
// Wait for either the client to disconnect or a signal.
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
@@ -65,3 +69,91 @@ func runACP(cmd *cobra.Command, _ []string) error {
return nil
}
// acpNormalizer wraps an io.Reader carrying newline-delimited JSON-RPC and
// patches incoming messages so that fields the SDK validates as required —
// but that some clients (e.g. Zed) omit — are defaulted. This avoids
// InvalidParams errors without forking the SDK.
type acpNormalizer struct {
scanner *bufio.Scanner
buf bytes.Buffer // leftover bytes from the last normalized line
}
func newACPNormalizer(r io.Reader) *acpNormalizer {
const maxMsg = 10 * 1024 * 1024 // 10 MB, matches SDK buffer
s := bufio.NewScanner(r)
s.Buffer(make([]byte, 0, 1024*1024), maxMsg)
return &acpNormalizer{scanner: s}
}
// Read satisfies io.Reader. It feeds one normalized JSON line (plus newline)
// per underlying scan, buffering across short caller reads.
func (n *acpNormalizer) Read(p []byte) (int, error) {
// Drain any leftover bytes from the previous line first.
if n.buf.Len() > 0 {
return n.buf.Read(p)
}
if !n.scanner.Scan() {
if err := n.scanner.Err(); err != nil {
return 0, err
}
return 0, io.EOF
}
line := n.scanner.Bytes()
normalized := normalizeACPLine(line)
n.buf.Write(normalized)
n.buf.WriteByte('\n')
return n.buf.Read(p)
}
// normalizeACPLine ensures session/new and session/load params contain an
// mcpServers array. Returns the original line unchanged for all other methods.
func normalizeACPLine(line []byte) []byte {
// Quick check: if it already contains mcpServers, nothing to do.
if bytes.Contains(line, []byte(`"mcpServers"`)) {
return line
}
// Only bother parsing if the method could be session/new or session/load.
if !bytes.Contains(line, []byte(`"session/new"`)) &&
!bytes.Contains(line, []byte(`"session/load"`)) {
return line
}
var msg struct {
JSONRPC string `json:"jsonrpc"`
ID json.RawMessage `json:"id,omitempty"`
Method string `json:"method"`
Params json.RawMessage `json:"params,omitempty"`
}
if err := json.Unmarshal(line, &msg); err != nil {
return line
}
if msg.Method != "session/new" && msg.Method != "session/load" {
return line
}
// Patch params to include mcpServers: [].
var params map[string]json.RawMessage
if err := json.Unmarshal(msg.Params, &params); err != nil {
return line
}
if _, ok := params["mcpServers"]; ok {
return line
}
params["mcpServers"] = json.RawMessage(`[]`)
patched, err := json.Marshal(params)
if err != nil {
return line
}
msg.Params = patched
out, err := json.Marshal(msg)
if err != nil {
return line
}
return out
}
-56
View File
@@ -61,48 +61,6 @@ func (r *sessionRegistry) create(ctx context.Context, cwd string) (*acpSession,
return sess, nil
}
// load opens an existing Kit session by scanning for a matching session ID
// in the given working directory.
func (r *sessionRegistry) load(ctx context.Context, acpSessionID string, cwd string) (*acpSession, error) {
// Find the session file by scanning the session directory.
sessions, err := kit.ListSessions(cwd)
if err != nil {
return nil, fmt.Errorf("list sessions: %w", err)
}
var sessionPath string
for _, s := range sessions {
if s.ID == acpSessionID {
sessionPath = s.Path
break
}
}
if sessionPath == "" {
return nil, fmt.Errorf("session not found: %s", acpSessionID)
}
kitInstance, err := kit.New(ctx, &kit.Options{
SessionPath: sessionPath,
Quiet: true,
Streaming: true,
})
if err != nil {
return nil, fmt.Errorf("open kit session: %w", err)
}
sess := &acpSession{
kit: kitInstance,
cwd: cwd,
sessionID: acpSessionID,
}
r.mu.Lock()
r.sessions[acpSessionID] = sess
r.mu.Unlock()
return sess, nil
}
// get retrieves a session by ACP session ID.
func (r *sessionRegistry) get(sessionID string) (*acpSession, bool) {
r.mu.RLock()
@@ -111,20 +69,6 @@ func (r *sessionRegistry) get(sessionID string) (*acpSession, bool) {
return s, ok
}
// remove closes and removes a session from the registry.
func (r *sessionRegistry) remove(sessionID string) {
r.mu.Lock()
sess, ok := r.sessions[sessionID]
if ok {
delete(r.sessions, sessionID)
}
r.mu.Unlock()
if ok && sess.kit != nil {
_ = sess.kit.Close()
}
}
// closeAll closes all sessions.
func (r *sessionRegistry) closeAll() {
r.mu.Lock()
+3 -3
View File
@@ -44,7 +44,7 @@ type AgentConfig struct {
type ToolCallHandler func(toolName, toolArgs string)
// ToolExecutionHandler is a function type for handling tool execution start/end events.
type ToolExecutionHandler func(toolName string, isStarting bool)
type ToolExecutionHandler func(toolName, toolArgs string, isStarting bool)
// ToolResultHandler is a function type for handling tool results.
type ToolResultHandler func(toolName, toolArgs, result string, isError bool)
@@ -288,7 +288,7 @@ func (a *Agent) GenerateWithLoopAndStreaming(ctx context.Context, messages []fan
// Notify tool execution starting
if onToolExecution != nil {
onToolExecution(tc.ToolName, true)
onToolExecution(tc.ToolName, tc.Input, true)
}
return nil
@@ -301,7 +301,7 @@ func (a *Agent) GenerateWithLoopAndStreaming(ctx context.Context, messages []fan
}
// Notify tool execution finished
if onToolExecution != nil {
onToolExecution(tr.ToolName, false)
onToolExecution(tr.ToolName, currentToolArgs, false)
}
if onToolResult != nil {
+1 -1
View File
@@ -534,7 +534,7 @@ func (a *App) subscribeSDKEvents(sendFn func(tea.Msg)) func() {
case kit.ToolCallEvent:
sendFn(ToolCallStartedEvent{ToolName: ev.ToolName, ToolArgs: ev.ToolArgs})
case kit.ToolExecutionStartEvent:
sendFn(ToolExecutionEvent{ToolName: ev.ToolName, IsStarting: true})
sendFn(ToolExecutionEvent{ToolName: ev.ToolName, ToolArgs: ev.ToolArgs, IsStarting: true})
case kit.ToolExecutionEndEvent:
sendFn(ToolExecutionEvent{ToolName: ev.ToolName, IsStarting: false})
case kit.ToolResultEvent:
+2
View File
@@ -30,6 +30,8 @@ type ToolCallStartedEvent struct {
type ToolExecutionEvent struct {
// ToolName is the name of the tool being executed.
ToolName string
// ToolArgs is the JSON-encoded arguments for the tool call (only set when IsStarting is true).
ToolArgs string
// IsStarting is true when execution is beginning, false when it is complete.
IsStarting bool
}
+1
View File
@@ -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)
+1
View File
@@ -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)
+1
View File
@@ -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)
+1
View File
@@ -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)
+1
View File
@@ -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)
+50 -7
View File
@@ -348,6 +348,9 @@ func TestStreamComponent_SpinnerKeepsRunningDuringStreaming(t *testing.T) {
// Receive first chunk — spinner should keep running.
c = sendStreamMsg(c, app.StreamChunkEvent{Content: "hello"})
// Flush pending chunks (simulates the 16ms tick firing).
c = sendStreamMsg(c, streamFlushTickMsg{})
if !c.spinning {
t.Fatal("expected spinning=true after first chunk")
}
@@ -372,6 +375,9 @@ func TestStreamComponent_ChunkAccumulation(t *testing.T) {
c = sendStreamMsg(c, app.StreamChunkEvent{Content: chunk})
}
// Flush pending chunks (simulates the 16ms tick firing).
c = sendStreamMsg(c, streamFlushTickMsg{})
got := c.streamContent.String()
want := "Hello, world!"
if got != want {
@@ -397,8 +403,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 +416,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 +430,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 +523,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)
}
}
+153 -19
View File
@@ -69,6 +69,25 @@ func streamSpinnerTickCmd() tea.Cmd {
})
}
// streamFlushTickMsg fires when it's time to commit pending chunks to the
// main content builders and trigger a re-render. This coalesces rapid
// streaming chunks into fewer expensive markdown re-renders.
type streamFlushTickMsg struct{}
// streamFlushInterval is the coalescing window for stream chunks. Chunks
// arriving within this window are batched into a single render pass.
// 16ms ≈ 60 fps — fast enough to appear smooth, slow enough to coalesce
// bursts from the LLM provider.
const streamFlushInterval = 16 * time.Millisecond
// streamFlushTickCmd returns a tea.Cmd that fires streamFlushTickMsg after
// the coalescing interval.
func streamFlushTickCmd() tea.Cmd {
return tea.Tick(streamFlushInterval, func(_ time.Time) tea.Msg {
return streamFlushTickMsg{}
})
}
// streamPhase tracks what the StreamComponent is currently displaying.
type streamPhase int
@@ -114,16 +133,38 @@ 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 holds committed streaming text (flushed from pending).
streamContent strings.Builder
// reasoningContent accumulates reasoning/thinking text chunks.
// reasoningContent holds committed reasoning text (flushed from pending).
reasoningContent strings.Builder
// pendingStream accumulates streaming text chunks between flush ticks.
// Chunks are written here immediately on arrival, then moved to
// streamContent when the flush tick fires.
pendingStream strings.Builder
// pendingReasoning accumulates reasoning chunks between flush ticks.
pendingReasoning strings.Builder
// flushPending is true while a flush tick is in-flight. Prevents
// scheduling duplicate ticks when multiple chunks arrive within
// the same coalescing window.
flushPending bool
// renderCache holds the last rendered output string. Reused by View()
// between flush ticks to avoid redundant markdown re-parsing.
renderCache string
// renderDirty is true when committed content has changed since the
// last render. Set on flush tick; cleared after render() rebuilds
// the cache.
renderDirty bool
// thinkingVisible controls whether reasoning blocks are shown or collapsed.
thinkingVisible bool
@@ -172,7 +213,12 @@ func (s *StreamComponent) SetHeight(h int) {
if h < 0 {
h = 0
}
s.height = h
if s.height != h {
s.height = h
// Invalidate cache — height clamp affects output.
s.renderCache = ""
s.renderDirty = true
}
}
// Reset clears all accumulated state so the component is ready for the next
@@ -181,16 +227,27 @@ 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.pendingStream.Reset()
s.pendingReasoning.Reset()
s.flushPending = false
s.renderCache = ""
s.renderDirty = false
s.timestamp = time.Time{}
}
// GetRenderedContent returns the rendered assistant message from the accumulated
// streaming text. Returns empty string if no text has been accumulated. Used by
// the parent AppModel to flush content via tea.Println() before resetting.
//
// This commits any pending chunks first so the output includes all received
// content, not just what has been flushed by the tick.
func (s *StreamComponent) GetRenderedContent() string {
// Commit any pending chunks so the final output is complete.
s.commitPending()
var sections []string
// Include rendered reasoning block if present.
@@ -209,6 +266,21 @@ func (s *StreamComponent) GetRenderedContent() string {
return strings.Join(sections, "\n")
}
// commitPending moves any pending chunks to the committed content builders.
// Called before reading content for scrollback output or on flush tick.
func (s *StreamComponent) commitPending() {
if s.pendingStream.Len() > 0 {
s.streamContent.WriteString(s.pendingStream.String())
s.pendingStream.Reset()
s.renderDirty = true
}
if s.pendingReasoning.Len() > 0 {
s.reasoningContent.WriteString(s.pendingReasoning.String())
s.pendingReasoning.Reset()
s.renderDirty = true
}
}
// --------------------------------------------------------------------------
// tea.Model interface
// --------------------------------------------------------------------------
@@ -227,6 +299,9 @@ func (s *StreamComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
s.width = msg.Width
s.messageRenderer.SetWidth(s.width)
s.compactRenderer.SetWidth(s.width)
// Invalidate render cache — width change affects wrapping/styling.
s.renderCache = ""
s.renderDirty = true
case streamSpinnerTickMsg:
if s.spinning {
@@ -250,24 +325,37 @@ func (s *StreamComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
s.spinning = false
}
case streamFlushTickMsg:
s.flushPending = false
s.commitPending()
case app.ReasoningChunkEvent:
s.phase = streamPhaseActive
if s.timestamp.IsZero() {
s.timestamp = time.Now()
}
s.reasoningContent.WriteString(msg.Delta)
s.pendingReasoning.WriteString(msg.Delta)
if !s.flushPending {
s.flushPending = true
return s, streamFlushTickCmd()
}
case app.StreamChunkEvent:
s.phase = streamPhaseActive
if s.timestamp.IsZero() {
s.timestamp = time.Now()
}
s.streamContent.WriteString(msg.Content)
s.pendingStream.WriteString(msg.Content)
if !s.flushPending {
s.flushPending = true
return s, streamFlushTickCmd()
}
case app.ToolExecutionEvent:
if msg.IsStarting {
// Show the tool name on the spinner while the tool executes.
s.spinnerMsg = "Executing " + msg.ToolName + "…"
// 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
@@ -275,8 +363,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)
}
}
@@ -292,12 +381,20 @@ func (s *StreamComponent) View() tea.View {
// Internal rendering
// --------------------------------------------------------------------------
// render builds the full content string for the stream region.
// render builds the full content string for the stream region. Uses a render
// cache to avoid redundant markdown re-parsing between flush ticks. The cache
// is invalidated when committed content changes (flush tick), terminal width
// changes, or height/thinking visibility changes.
func (s *StreamComponent) render() string {
if s.phase == streamPhaseIdle {
return ""
}
// Return cached render if committed content hasn't changed.
if !s.renderDirty {
return s.renderCache
}
var sections []string
// Render reasoning/thinking block above the main text if present.
@@ -313,6 +410,8 @@ func (s *StreamComponent) render() string {
}
if len(sections) == 0 {
s.renderCache = ""
s.renderDirty = false
return ""
}
@@ -328,6 +427,8 @@ func (s *StreamComponent) render() string {
}
}
s.renderCache = content
s.renderDirty = false
return content
}
@@ -358,12 +459,18 @@ func (s *StreamComponent) renderReasoningBlock(reasoning string) string {
// SetThinkingVisible sets whether reasoning blocks are shown or collapsed.
func (s *StreamComponent) SetThinkingVisible(visible bool) {
s.thinkingVisible = visible
if s.thinkingVisible != visible {
s.thinkingVisible = visible
// Invalidate cache — thinking visibility affects rendered output.
s.renderCache = ""
s.renderDirty = true
}
}
// HasReasoning returns true if any reasoning content has been accumulated.
// HasReasoning returns true if any reasoning content has been accumulated
// (committed or pending).
func (s *StreamComponent) HasReasoning() bool {
return s.reasoningContent.Len() > 0
return s.reasoningContent.Len() > 0 || s.pendingReasoning.Len() > 0
}
// SpinnerView returns the rendered spinner line for the parent to embed in the
@@ -373,14 +480,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
@@ -398,3 +513,22 @@ func (s *StreamComponent) renderStreamingText(text string) string {
msg := s.messageRenderer.RenderAssistantMessage(text, ts, s.modelName)
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 shows simply as "Subagent" with optional task preview.
func formatToolExecutionMessage(toolName, toolArgs string) string {
if toolName == "spawn_subagent" {
return "Subagent"
}
return toolName
}
+124
View File
@@ -49,6 +49,10 @@ func renderToolBody(toolName, toolArgs, toolResult string, width int) string {
if body := renderBashBody(toolResult, width); body != "" {
return body
}
case toolName == "spawn_subagent":
if body := renderSubagentBody(toolResult, width); body != "" {
return body
}
}
return "" // fall back to default
}
@@ -716,6 +720,8 @@ func renderToolBodyCompact(toolName, toolArgs, toolResult string, width int) str
case toolName == "bash" || toolName == "run_shell_cmd" ||
strings.Contains(toolName, "shell") || strings.Contains(toolName, "command"):
return renderBashCompact(toolResult, width)
case toolName == "spawn_subagent":
return renderSubagentCompact(toolResult)
}
return ""
}
@@ -870,3 +876,121 @@ func renderBashCompact(toolResult string, width int) string {
return lipgloss.NewStyle().Foreground(theme.Muted).Render(summary)
}
// ---------------------------------------------------------------------------
// Subagent tool renderers — show only summary, not full output
// ---------------------------------------------------------------------------
// renderSubagentBody renders a clean summary of subagent results.
// Extracts timing/token info and shows only a brief summary instead of raw output.
func renderSubagentBody(toolResult string, width int) string {
theme := getTheme()
result := strings.TrimSpace(toolResult)
if result == "" {
return ""
}
// Parse the subagent result format:
// "Subagent completed successfully in Xs. (tokens: N in / M out)\n\nResult:\n..."
// or "Subagent failed (exit code X) after Ys.\n\nError: ...\n\nPartial output:\n..."
lines := strings.Split(result, "\n")
if len(lines) == 0 {
return ""
}
// First line is always the status summary
statusLine := lines[0]
// Build a clean summary
var summary strings.Builder
summary.WriteString(lipgloss.NewStyle().Foreground(theme.Muted).Render(statusLine))
// For successful results, extract a brief preview of the actual result
if strings.Contains(statusLine, "successfully") {
// Find where "Result:" starts and extract a preview
if _, resultContent, found := strings.Cut(result, "Result:\n"); found {
resultContent = strings.TrimSpace(resultContent)
if resultContent != "" {
// Show first 3 meaningful lines as preview
preview := extractSubagentPreview(resultContent, 3, width-4)
if preview != "" {
summary.WriteString("\n\n")
summary.WriteString(lipgloss.NewStyle().
Foreground(theme.Muted).
Italic(true).
Render(preview))
}
}
}
}
return summary.String()
}
// extractSubagentPreview extracts the first N non-empty lines from content,
// truncating each line to maxWidth.
func extractSubagentPreview(content string, maxLines, maxWidth int) string {
lines := strings.Split(content, "\n")
var preview []string
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if trimmed == "" {
continue
}
// Truncate long lines
if len(trimmed) > maxWidth {
trimmed = trimmed[:maxWidth-3] + "..."
}
preview = append(preview, trimmed)
if len(preview) >= maxLines {
break
}
}
if len(preview) == 0 {
return ""
}
result := strings.Join(preview, "\n")
// Count remaining lines for "more" indicator
totalLines := 0
for _, line := range lines {
if strings.TrimSpace(line) != "" {
totalLines++
}
}
if totalLines > maxLines {
result += fmt.Sprintf("\n...(%d more lines)", totalLines-maxLines)
}
return result
}
// renderSubagentCompact returns a brief one-line summary for subagent results.
func renderSubagentCompact(toolResult string) string {
result := strings.TrimSpace(toolResult)
if result == "" {
return ""
}
theme := getTheme()
// Extract just the first line which contains the status
lines := strings.Split(result, "\n")
if len(lines) == 0 {
return ""
}
statusLine := lines[0]
// Make it more compact by removing redundant words
statusLine = strings.Replace(statusLine, "Subagent completed successfully in ", "Completed in ", 1)
statusLine = strings.Replace(statusLine, "Subagent failed", "Failed", 1)
return lipgloss.NewStyle().Foreground(theme.Muted).Italic(true).Render(statusLine)
}
+1
View File
@@ -111,6 +111,7 @@ func (e ToolCallEvent) EventType() EventType { return EventToolCall }
// ToolExecutionStartEvent fires when a tool begins executing.
type ToolExecutionStartEvent struct {
ToolName string
ToolArgs string
}
// EventType implements Event.
+2 -2
View File
@@ -1177,9 +1177,9 @@ func (m *Kit) generate(ctx context.Context, messages []fantasy.Message) (*agent.
func(toolName, toolArgs string) {
m.events.emit(ToolCallEvent{ToolName: toolName, ToolArgs: toolArgs})
},
func(toolName string, isStarting bool) {
func(toolName, toolArgs string, isStarting bool) {
if isStarting {
m.events.emit(ToolExecutionStartEvent{ToolName: toolName})
m.events.emit(ToolExecutionStartEvent{ToolName: toolName, ToolArgs: toolArgs})
} else {
m.events.emit(ToolExecutionEndEvent{ToolName: toolName})
}