feat(noninteractive): print tool calls when --verbose is on

Christian Rocha created

Change summary

internal/agent/agent.go       | 34 +++++++++++++++++++++++++++++++---
internal/agent/coordinator.go |  5 +++--
internal/app/app.go           |  4 ++--
internal/cmd/run.go           |  2 +-
internal/ui/model/ui.go       |  2 +-
5 files changed, 38 insertions(+), 9 deletions(-)

Detailed changes

internal/agent/agent.go 🔗

@@ -65,9 +65,10 @@ type SessionAgentCall struct {
 	SessionID        string
 	Prompt           string
 	ProviderOptions  fantasy.ProviderOptions
-	Attachments      []message.Attachment
-	MaxOutputTokens  int64
-	Temperature      *float64
+	Attachments        []message.Attachment
+	MaxOutputTokens    int64
+	ShowToolCalls      bool
+	Temperature        *float64
 	TopP             *float64
 	TopK             *int64
 	FrequencyPenalty *float64
@@ -357,6 +358,9 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*fantasy
 			// TODO: implement
 		},
 		OnToolCall: func(tc fantasy.ToolCallContent) error {
+			if call.ShowToolCalls {
+				slog.Default().WithGroup("TOOL").Info("call", "name", tc.ToolName, "input", tc.Input)
+			}
 			toolCall := message.ToolCall{
 				ID:               tc.ToolCallID,
 				Name:             tc.ToolName,
@@ -368,6 +372,30 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*fantasy
 			return a.messages.Update(genCtx, *currentAssistant)
 		},
 		OnToolResult: func(result fantasy.ToolResultContent) error {
+			if call.ShowToolCalls {
+				content := ""
+				switch result.Result.GetType() {
+				case fantasy.ToolResultContentTypeText:
+					if r, ok := fantasy.AsToolResultOutputType[fantasy.ToolResultOutputContentText](result.Result); ok {
+						content = r.Text
+					}
+				case fantasy.ToolResultContentTypeError:
+					if r, ok := fantasy.AsToolResultOutputType[fantasy.ToolResultOutputContentError](result.Result); ok {
+						content = "Error: " + r.Error.Error()
+					}
+				case fantasy.ToolResultContentTypeMedia:
+					if r, ok := fantasy.AsToolResultOutputType[fantasy.ToolResultOutputContentMedia](result.Result); ok {
+						content = r.Text
+						if content == "" {
+							content = fmt.Sprintf("[%s content]", r.MediaType)
+						}
+					}
+				}
+				if len(content) > 200 {
+					content = content[:200] + "..."
+				}
+				slog.Default().WithGroup("TOOL").Info("result", "name", result.ToolName, "output", content)
+			}
 			toolResult := a.convertToToolResult(result)
 			_, createMsgErr := a.messages.Create(genCtx, currentAssistant.SessionID, message.CreateMessageParams{
 				Role: message.Tool,

internal/agent/coordinator.go 🔗

@@ -46,7 +46,7 @@ import (
 type Coordinator interface {
 	// INFO: (kujtim) this is not used yet we will use this when we have multiple agents
 	// SetMainAgent(string)
-	Run(ctx context.Context, sessionID, prompt string, attachments ...message.Attachment) (*fantasy.AgentResult, error)
+	Run(ctx context.Context, sessionID, prompt string, verbose bool, attachments ...message.Attachment) (*fantasy.AgentResult, error)
 	Cancel(sessionID string)
 	CancelAll()
 	IsSessionBusy(sessionID string) bool
@@ -116,7 +116,7 @@ func NewCoordinator(
 }
 
 // Run implements Coordinator.
-func (c *coordinator) Run(ctx context.Context, sessionID string, prompt string, attachments ...message.Attachment) (*fantasy.AgentResult, error) {
+func (c *coordinator) Run(ctx context.Context, sessionID string, prompt string, verbose bool, attachments ...message.Attachment) (*fantasy.AgentResult, error) {
 	if err := c.readyWg.Wait(); err != nil {
 		return nil, err
 	}
@@ -163,6 +163,7 @@ func (c *coordinator) Run(ctx context.Context, sessionID string, prompt string,
 			Prompt:           prompt,
 			Attachments:      attachments,
 			MaxOutputTokens:  maxTokens,
+			ShowToolCalls:    verbose,
 			ProviderOptions:  mergedOptions,
 			Temperature:      temp,
 			TopP:             topP,

internal/app/app.go 🔗

@@ -145,7 +145,7 @@ func (app *App) Config() *config.Config {
 
 // RunNonInteractive runs the application in non-interactive mode with the
 // given prompt, printing to stdout.
-func (app *App) RunNonInteractive(ctx context.Context, output io.Writer, prompt, largeModel, smallModel string, hideSpinner bool) error {
+func (app *App) RunNonInteractive(ctx context.Context, output io.Writer, prompt, largeModel, smallModel string, hideSpinner bool, verbose bool) error {
 	slog.Info("Running in non-interactive mode")
 
 	ctx, cancel := context.WithCancel(ctx)
@@ -241,7 +241,7 @@ func (app *App) RunNonInteractive(ctx context.Context, output io.Writer, prompt,
 	done := make(chan response, 1)
 
 	go func(ctx context.Context, sessionID, prompt string) {
-		result, err := app.AgentCoordinator.Run(ctx, sess.ID, prompt)
+		result, err := app.AgentCoordinator.Run(ctx, sess.ID, prompt, verbose)
 		if err != nil {
 			done <- response{
 				err: fmt.Errorf("failed to start agent processing stream: %w", err),

internal/cmd/run.go 🔗

@@ -73,7 +73,7 @@ crush run --verbose "Generate a README for this project"
 		event.SetNonInteractive(true)
 		event.AppInitialized()
 
-		return app.RunNonInteractive(ctx, os.Stdout, prompt, largeModel, smallModel, quiet || verbose)
+		return app.RunNonInteractive(ctx, os.Stdout, prompt, largeModel, smallModel, quiet || verbose, verbose)
 	},
 	PostRun: func(cmd *cobra.Command, args []string) {
 		event.AppExited()

internal/ui/model/ui.go 🔗

@@ -2744,7 +2744,7 @@ func (m *UI) sendMessage(content string, attachments ...message.Attachment) tea.
 	// Capture session ID to avoid race with main goroutine updating m.session.
 	sessionID := m.session.ID
 	cmds = append(cmds, func() tea.Msg {
-		_, err := m.com.App.AgentCoordinator.Run(context.Background(), sessionID, content, attachments...)
+		_, err := m.com.App.AgentCoordinator.Run(context.Background(), sessionID, content, false, attachments...)
 		if err != nil {
 			isCancelErr := errors.Is(err, context.Canceled)
 			isPermissionErr := errors.Is(err, permission.ErrorPermissionDenied)