Handle unknown tool calls in the TUI (#2001)

Kujtim Hoxha created

Change summary

internal/ui/chat/generic.go | 98 +++++++++++++++++++++++++++++++++++++++
internal/ui/chat/tools.go   | 11 ---
internal/ui/model/ui.go     |  5 -
3 files changed, 100 insertions(+), 14 deletions(-)

Detailed changes

internal/ui/chat/generic.go 🔗

@@ -0,0 +1,98 @@
+package chat
+
+import (
+	"encoding/json"
+	"strings"
+
+	"github.com/charmbracelet/crush/internal/message"
+	"github.com/charmbracelet/crush/internal/stringext"
+	"github.com/charmbracelet/crush/internal/ui/styles"
+)
+
+// GenericToolMessageItem is a message item that represents an unknown tool call.
+type GenericToolMessageItem struct {
+	*baseToolMessageItem
+}
+
+var _ ToolMessageItem = (*GenericToolMessageItem)(nil)
+
+// NewGenericToolMessageItem creates a new [GenericToolMessageItem].
+func NewGenericToolMessageItem(
+	sty *styles.Styles,
+	toolCall message.ToolCall,
+	result *message.ToolResult,
+	canceled bool,
+) ToolMessageItem {
+	return newBaseToolMessageItem(sty, toolCall, result, &GenericToolRenderContext{}, canceled)
+}
+
+// GenericToolRenderContext renders unknown/generic tool messages.
+type GenericToolRenderContext struct{}
+
+// RenderTool implements the [ToolRenderer] interface.
+func (g *GenericToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
+	cappedWidth := cappedMessageWidth(width)
+	name := genericPrettyName(opts.ToolCall.Name)
+
+	if opts.IsPending() {
+		return pendingTool(sty, name, opts.Anim)
+	}
+
+	var params map[string]any
+	if err := json.Unmarshal([]byte(opts.ToolCall.Input), &params); err != nil {
+		return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, cappedWidth)
+	}
+
+	var toolParams []string
+	if len(params) > 0 {
+		parsed, _ := json.Marshal(params)
+		toolParams = append(toolParams, string(parsed))
+	}
+
+	header := toolHeader(sty, opts.Status, name, cappedWidth, opts.Compact, toolParams...)
+	if opts.Compact {
+		return header
+	}
+
+	if earlyState, ok := toolEarlyStateContent(sty, opts, cappedWidth); ok {
+		return joinToolParts(header, earlyState)
+	}
+
+	if !opts.HasResult() || opts.Result.Content == "" {
+		return header
+	}
+
+	bodyWidth := cappedWidth - toolBodyLeftPaddingTotal
+
+	// Handle image data.
+	if opts.Result.Data != "" && strings.HasPrefix(opts.Result.MIMEType, "image/") {
+		body := sty.Tool.Body.Render(toolOutputImageContent(sty, opts.Result.Data, opts.Result.MIMEType))
+		return joinToolParts(header, body)
+	}
+
+	// Try to parse result as JSON for pretty display.
+	var result json.RawMessage
+	var body string
+	if err := json.Unmarshal([]byte(opts.Result.Content), &result); err == nil {
+		prettyResult, err := json.MarshalIndent(result, "", "  ")
+		if err == nil {
+			body = sty.Tool.Body.Render(toolOutputCodeContent(sty, "result.json", string(prettyResult), 0, bodyWidth, opts.ExpandedContent))
+		} else {
+			body = sty.Tool.Body.Render(toolOutputPlainContent(sty, opts.Result.Content, bodyWidth, opts.ExpandedContent))
+		}
+	} else if looksLikeMarkdown(opts.Result.Content) {
+		body = sty.Tool.Body.Render(toolOutputCodeContent(sty, "result.md", opts.Result.Content, 0, bodyWidth, opts.ExpandedContent))
+	} else {
+		body = sty.Tool.Body.Render(toolOutputPlainContent(sty, opts.Result.Content, bodyWidth, opts.ExpandedContent))
+	}
+
+	return joinToolParts(header, body)
+}
+
+// genericPrettyName converts a snake_case or kebab-case tool name to a
+// human-readable title case name.
+func genericPrettyName(name string) string {
+	name = strings.ReplaceAll(name, "_", " ")
+	name = strings.ReplaceAll(name, "-", " ")
+	return stringext.Capitalize(name)
+}

internal/ui/chat/tools.go 🔗

@@ -255,14 +255,7 @@ func NewToolMessageItem(
 		if strings.HasPrefix(toolCall.Name, "mcp_") {
 			item = NewMCPToolMessageItem(sty, toolCall, result, canceled)
 		} else {
-			// TODO: Implement other tool items
-			item = newBaseToolMessageItem(
-				sty,
-				toolCall,
-				result,
-				&DefaultToolRenderContext{},
-				canceled,
-			)
+			item = NewGenericToolMessageItem(sty, toolCall, result, canceled)
 		}
 	}
 	item.SetMessageID(messageID)
@@ -1399,6 +1392,6 @@ func prettifyToolName(name string) string {
 	case tools.WriteToolName:
 		return "Write"
 	default:
-		return name
+		return genericPrettyName(name)
 	}
 }

internal/ui/model/ui.go 🔗

@@ -1124,11 +1124,6 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
 		cmds = append(cmds, m.toggleCompactMode())
 		m.dialog.CloseDialog(dialog.CommandsID)
 	case dialog.ActionToggleThinking:
-		if m.isAgentBusy() {
-			cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait..."))
-			break
-		}
-
 		cmds = append(cmds, func() tea.Msg {
 			cfg := m.com.Config()
 			if cfg == nil {