diff --git a/internal/ui/chat/bash.go b/internal/ui/chat/bash.go index 000d83b8b1f7d1b06b113f3b92e1c6fc0ec4e32e..85b3c0db81756a47ec5a8009b24d975dcf4d3358 100644 --- a/internal/ui/chat/bash.go +++ b/internal/ui/chat/bash.go @@ -69,8 +69,8 @@ func (b *BashToolRenderContext) RenderTool(sty *styles.Styles, width int, opts * toolParams = append(toolParams, "background", "true") } - header := toolHeader(sty, opts.Status(), "Bash", cappedWidth, opts.Nested, toolParams...) - if opts.Nested { + header := toolHeader(sty, opts.Status(), "Bash", cappedWidth, opts.Simple, toolParams...) + if opts.Simple { return header } @@ -201,7 +201,7 @@ func (j *JobKillToolRenderContext) RenderTool(sty *styles.Styles, width int, opt // header → nested check → early state → body. func renderJobTool(sty *styles.Styles, opts *ToolRenderOpts, width int, action, shellID, description, content string) string { header := jobHeader(sty, opts.Status(), action, shellID, description, width) - if opts.Nested { + if opts.Simple { return header } diff --git a/internal/ui/chat/diagnostics.go b/internal/ui/chat/diagnostics.go new file mode 100644 index 0000000000000000000000000000000000000000..6d59141c651a4235cee3a65e97f456cd95c7dc77 --- /dev/null +++ b/internal/ui/chat/diagnostics.go @@ -0,0 +1,68 @@ +package chat + +import ( + "encoding/json" + + "github.com/charmbracelet/crush/internal/agent/tools" + "github.com/charmbracelet/crush/internal/fsext" + "github.com/charmbracelet/crush/internal/message" + "github.com/charmbracelet/crush/internal/ui/styles" +) + +// ----------------------------------------------------------------------------- +// Diagnostics Tool +// ----------------------------------------------------------------------------- + +// DiagnosticsToolMessageItem is a message item that represents a diagnostics tool call. +type DiagnosticsToolMessageItem struct { + *baseToolMessageItem +} + +var _ ToolMessageItem = (*DiagnosticsToolMessageItem)(nil) + +// NewDiagnosticsToolMessageItem creates a new [DiagnosticsToolMessageItem]. +func NewDiagnosticsToolMessageItem( + sty *styles.Styles, + toolCall message.ToolCall, + result *message.ToolResult, + canceled bool, +) ToolMessageItem { + return newBaseToolMessageItem(sty, toolCall, result, &DiagnosticsToolRenderContext{}, canceled) +} + +// DiagnosticsToolRenderContext renders diagnostics tool messages. +type DiagnosticsToolRenderContext struct{} + +// RenderTool implements the [ToolRenderer] interface. +func (d *DiagnosticsToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string { + cappedWidth := cappedMessageWidth(width) + if !opts.ToolCall.Finished && !opts.Canceled { + return pendingTool(sty, "Diagnostics", opts.Anim) + } + + var params tools.DiagnosticsParams + _ = json.Unmarshal([]byte(opts.ToolCall.Input), ¶ms) + + // Show "project" if no file path, otherwise show the file path. + mainParam := "project" + if params.FilePath != "" { + mainParam = fsext.PrettyPath(params.FilePath) + } + + header := toolHeader(sty, opts.Status(), "Diagnostics", cappedWidth, opts.Simple, mainParam) + if opts.Simple { + return header + } + + if earlyState, ok := toolEarlyStateContent(sty, opts, cappedWidth); ok { + return joinToolParts(header, earlyState) + } + + if opts.Result == nil || opts.Result.Content == "" { + return header + } + + bodyWidth := cappedWidth - toolBodyLeftPaddingTotal + body := sty.Tool.Body.Render(toolOutputPlainContent(sty, opts.Result.Content, bodyWidth, opts.Expanded)) + return joinToolParts(header, body) +} diff --git a/internal/ui/chat/fetch.go b/internal/ui/chat/fetch.go new file mode 100644 index 0000000000000000000000000000000000000000..53f5c066060266eb9f2f88844eb1640daf05e245 --- /dev/null +++ b/internal/ui/chat/fetch.go @@ -0,0 +1,84 @@ +package chat + +import ( + "encoding/json" + + "github.com/charmbracelet/crush/internal/agent/tools" + "github.com/charmbracelet/crush/internal/message" + "github.com/charmbracelet/crush/internal/ui/styles" +) + +// ----------------------------------------------------------------------------- +// Fetch Tool +// ----------------------------------------------------------------------------- + +// FetchToolMessageItem is a message item that represents a fetch tool call. +type FetchToolMessageItem struct { + *baseToolMessageItem +} + +var _ ToolMessageItem = (*FetchToolMessageItem)(nil) + +// NewFetchToolMessageItem creates a new [FetchToolMessageItem]. +func NewFetchToolMessageItem( + sty *styles.Styles, + toolCall message.ToolCall, + result *message.ToolResult, + canceled bool, +) ToolMessageItem { + return newBaseToolMessageItem(sty, toolCall, result, &FetchToolRenderContext{}, canceled) +} + +// FetchToolRenderContext renders fetch tool messages. +type FetchToolRenderContext struct{} + +// RenderTool implements the [ToolRenderer] interface. +func (f *FetchToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string { + cappedWidth := cappedMessageWidth(width) + if !opts.ToolCall.Finished && !opts.Canceled { + return pendingTool(sty, "Fetch", opts.Anim) + } + + var params tools.FetchParams + if err := json.Unmarshal([]byte(opts.ToolCall.Input), ¶ms); err != nil { + return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, cappedWidth) + } + + toolParams := []string{params.URL} + if params.Format != "" { + toolParams = append(toolParams, "format", params.Format) + } + if params.Timeout != 0 { + toolParams = append(toolParams, "timeout", formatTimeout(params.Timeout)) + } + + header := toolHeader(sty, opts.Status(), "Fetch", cappedWidth, opts.Simple, toolParams...) + if opts.Simple { + return header + } + + if earlyState, ok := toolEarlyStateContent(sty, opts, cappedWidth); ok { + return joinToolParts(header, earlyState) + } + + if opts.Result == nil || opts.Result.Content == "" { + return header + } + + // Determine file extension for syntax highlighting based on format. + file := getFileExtensionForFormat(params.Format) + body := toolOutputCodeContent(sty, file, opts.Result.Content, 0, cappedWidth, opts.Expanded) + return joinToolParts(header, body) +} + +// getFileExtensionForFormat returns a filename with appropriate extension for syntax highlighting. +func getFileExtensionForFormat(format string) string { + switch format { + case "text": + return "fetch.txt" + case "html": + return "fetch.html" + default: + return "fetch.md" + } +} diff --git a/internal/ui/chat/file.go b/internal/ui/chat/file.go index 255b0df3bac9455cd81f7c9ae882e4a2ae1d368a..2c0a1e49440ed263735afbd0ab3104034a2f4634 100644 --- a/internal/ui/chat/file.go +++ b/internal/ui/chat/file.go @@ -56,8 +56,8 @@ func (v *ViewToolRenderContext) RenderTool(sty *styles.Styles, width int, opts * toolParams = append(toolParams, "offset", fmt.Sprintf("%d", params.Offset)) } - header := toolHeader(sty, opts.Status(), "View", cappedWidth, opts.Nested, toolParams...) - if opts.Nested { + header := toolHeader(sty, opts.Status(), "View", cappedWidth, opts.Simple, toolParams...) + if opts.Simple { return header } @@ -128,8 +128,8 @@ func (w *WriteToolRenderContext) RenderTool(sty *styles.Styles, width int, opts } file := fsext.PrettyPath(params.FilePath) - header := toolHeader(sty, opts.Status(), "Write", cappedWidth, opts.Nested, file) - if opts.Nested { + header := toolHeader(sty, opts.Status(), "Write", cappedWidth, opts.Simple, file) + if opts.Simple { return header } @@ -183,8 +183,8 @@ func (e *EditToolRenderContext) RenderTool(sty *styles.Styles, width int, opts * } file := fsext.PrettyPath(params.FilePath) - header := toolHeader(sty, opts.Status(), "Edit", width, opts.Nested, file) - if opts.Nested { + header := toolHeader(sty, opts.Status(), "Edit", width, opts.Simple, file) + if opts.Simple { return header } @@ -251,8 +251,8 @@ func (m *MultiEditToolRenderContext) RenderTool(sty *styles.Styles, width int, o toolParams = append(toolParams, "edits", fmt.Sprintf("%d", len(params.Edits))) } - header := toolHeader(sty, opts.Status(), "Multi-Edit", width, opts.Nested, toolParams...) - if opts.Nested { + header := toolHeader(sty, opts.Status(), "Multi-Edit", width, opts.Simple, toolParams...) + if opts.Simple { return header } @@ -276,3 +276,65 @@ func (m *MultiEditToolRenderContext) RenderTool(sty *styles.Styles, width int, o body := toolOutputMultiEditDiffContent(sty, file, meta, len(params.Edits), width, opts.Expanded) return joinToolParts(header, body) } + +// ----------------------------------------------------------------------------- +// Download Tool +// ----------------------------------------------------------------------------- + +// DownloadToolMessageItem is a message item that represents a download tool call. +type DownloadToolMessageItem struct { + *baseToolMessageItem +} + +var _ ToolMessageItem = (*DownloadToolMessageItem)(nil) + +// NewDownloadToolMessageItem creates a new [DownloadToolMessageItem]. +func NewDownloadToolMessageItem( + sty *styles.Styles, + toolCall message.ToolCall, + result *message.ToolResult, + canceled bool, +) ToolMessageItem { + return newBaseToolMessageItem(sty, toolCall, result, &DownloadToolRenderContext{}, canceled) +} + +// DownloadToolRenderContext renders download tool messages. +type DownloadToolRenderContext struct{} + +// RenderTool implements the [ToolRenderer] interface. +func (d *DownloadToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string { + cappedWidth := cappedMessageWidth(width) + if !opts.ToolCall.Finished && !opts.Canceled { + return pendingTool(sty, "Download", opts.Anim) + } + + var params tools.DownloadParams + if err := json.Unmarshal([]byte(opts.ToolCall.Input), ¶ms); err != nil { + return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, cappedWidth) + } + + toolParams := []string{params.URL} + if params.FilePath != "" { + toolParams = append(toolParams, "file_path", fsext.PrettyPath(params.FilePath)) + } + if params.Timeout != 0 { + toolParams = append(toolParams, "timeout", formatTimeout(params.Timeout)) + } + + header := toolHeader(sty, opts.Status(), "Download", cappedWidth, opts.Simple, toolParams...) + if opts.Simple { + return header + } + + if earlyState, ok := toolEarlyStateContent(sty, opts, cappedWidth); ok { + return joinToolParts(header, earlyState) + } + + if opts.Result == nil || opts.Result.Content == "" { + return header + } + + bodyWidth := cappedWidth - toolBodyLeftPaddingTotal + body := sty.Tool.Body.Render(toolOutputPlainContent(sty, opts.Result.Content, bodyWidth, opts.Expanded)) + return joinToolParts(header, body) +} diff --git a/internal/ui/chat/search.go b/internal/ui/chat/search.go index 6fa907d2c1f37dc17b62a925d720a52048f1f342..cd19eeef5712761a5c533686297f7baec65b87de 100644 --- a/internal/ui/chat/search.go +++ b/internal/ui/chat/search.go @@ -50,8 +50,8 @@ func (g *GlobToolRenderContext) RenderTool(sty *styles.Styles, width int, opts * toolParams = append(toolParams, "path", params.Path) } - header := toolHeader(sty, opts.Status(), "Glob", cappedWidth, opts.Nested, toolParams...) - if opts.Nested { + header := toolHeader(sty, opts.Status(), "Glob", cappedWidth, opts.Simple, toolParams...) + if opts.Simple { return header } @@ -115,8 +115,8 @@ func (g *GrepToolRenderContext) RenderTool(sty *styles.Styles, width int, opts * toolParams = append(toolParams, "literal", "true") } - header := toolHeader(sty, opts.Status(), "Grep", cappedWidth, opts.Nested, toolParams...) - if opts.Nested { + header := toolHeader(sty, opts.Status(), "Grep", cappedWidth, opts.Simple, toolParams...) + if opts.Simple { return header } @@ -175,8 +175,70 @@ func (l *LSToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *To } path = fsext.PrettyPath(path) - header := toolHeader(sty, opts.Status(), "List", cappedWidth, opts.Nested, path) - if opts.Nested { + header := toolHeader(sty, opts.Status(), "List", cappedWidth, opts.Simple, path) + if opts.Simple { + return header + } + + if earlyState, ok := toolEarlyStateContent(sty, opts, cappedWidth); ok { + return joinToolParts(header, earlyState) + } + + if opts.Result == nil || opts.Result.Content == "" { + return header + } + + bodyWidth := cappedWidth - toolBodyLeftPaddingTotal + body := sty.Tool.Body.Render(toolOutputPlainContent(sty, opts.Result.Content, bodyWidth, opts.Expanded)) + return joinToolParts(header, body) +} + +// ----------------------------------------------------------------------------- +// Sourcegraph Tool +// ----------------------------------------------------------------------------- + +// SourcegraphToolMessageItem is a message item that represents a sourcegraph tool call. +type SourcegraphToolMessageItem struct { + *baseToolMessageItem +} + +var _ ToolMessageItem = (*SourcegraphToolMessageItem)(nil) + +// NewSourcegraphToolMessageItem creates a new [SourcegraphToolMessageItem]. +func NewSourcegraphToolMessageItem( + sty *styles.Styles, + toolCall message.ToolCall, + result *message.ToolResult, + canceled bool, +) ToolMessageItem { + return newBaseToolMessageItem(sty, toolCall, result, &SourcegraphToolRenderContext{}, canceled) +} + +// SourcegraphToolRenderContext renders sourcegraph tool messages. +type SourcegraphToolRenderContext struct{} + +// RenderTool implements the [ToolRenderer] interface. +func (s *SourcegraphToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string { + cappedWidth := cappedMessageWidth(width) + if !opts.ToolCall.Finished && !opts.Canceled { + return pendingTool(sty, "Sourcegraph", opts.Anim) + } + + var params tools.SourcegraphParams + if err := json.Unmarshal([]byte(opts.ToolCall.Input), ¶ms); err != nil { + return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, cappedWidth) + } + + toolParams := []string{params.Query} + if params.Count != 0 { + toolParams = append(toolParams, "count", formatNonZero(params.Count)) + } + if params.ContextWindow != 0 { + toolParams = append(toolParams, "context", formatNonZero(params.ContextWindow)) + } + + header := toolHeader(sty, opts.Status(), "Sourcegraph", cappedWidth, opts.Simple, toolParams...) + if opts.Simple { return header } diff --git a/internal/ui/chat/tools.go b/internal/ui/chat/tools.go index fb3ab943e3d5798278ded7f377657d4dd4e420e4..d621cad4c8a23f2cb638c2b708509feb4c1b64c7 100644 --- a/internal/ui/chat/tools.go +++ b/internal/ui/chat/tools.go @@ -40,6 +40,12 @@ type ToolMessageItem interface { SetResult(res *message.ToolResult) } +// Simplifiable is an interface for tool items that can render in a simplified mode. +// When simple mode is enabled, tools render as a compact single-line header. +type Simplifiable interface { + SetSimple(simple bool) +} + // DefaultToolRenderContext implements the default [ToolRenderer] interface. type DefaultToolRenderContext struct{} @@ -55,7 +61,7 @@ type ToolRenderOpts struct { Canceled bool Anim *anim.Anim Expanded bool - Nested bool + Simple bool IsSpinning bool PermissionRequested bool PermissionGranted bool @@ -106,6 +112,8 @@ type baseToolMessageItem struct { // we use this so we can efficiently cache // tools that have a capped width (e.x bash.. and others) hasCappedWidth bool + // isSimple indicates this tool should render in simplified/compact mode. + isSimple bool sty *styles.Styles anim *anim.Anim @@ -177,6 +185,14 @@ func NewToolMessageItem( return NewGrepToolMessageItem(sty, toolCall, result, canceled) case tools.LSToolName: return NewLSToolMessageItem(sty, toolCall, result, canceled) + case tools.DownloadToolName: + return NewDownloadToolMessageItem(sty, toolCall, result, canceled) + case tools.FetchToolName: + return NewFetchToolMessageItem(sty, toolCall, result, canceled) + case tools.SourcegraphToolName: + return NewSourcegraphToolMessageItem(sty, toolCall, result, canceled) + case tools.DiagnosticsToolName: + return NewDiagnosticsToolMessageItem(sty, toolCall, result, canceled) default: // TODO: Implement other tool items return newBaseToolMessageItem( @@ -189,6 +205,12 @@ func NewToolMessageItem( } } +// SetSimple implements the Simplifiable interface. +func (t *baseToolMessageItem) SetSimple(simple bool) { + t.isSimple = simple + t.clearCache() +} + // ID returns the unique identifier for this tool message item. func (t *baseToolMessageItem) ID() string { return t.toolCall.ID @@ -230,6 +252,7 @@ func (t *baseToolMessageItem) Render(width int) string { Canceled: t.canceled, Anim: t.anim, Expanded: t.expanded, + Simple: t.isSimple, PermissionRequested: t.permissionRequested, PermissionGranted: t.permissionGranted, IsSpinning: t.isSpinning(), @@ -487,10 +510,10 @@ func toolOutputCodeContent(sty *styles.Styles, path, content string, offset, wid // Add truncation message if needed. if len(lines) > maxLines && !expanded { - truncMsg := sty.Tool.ContentCodeTruncation. + out = append(out, sty.Tool.ContentCodeTruncation. Width(bodyWidth). - Render(fmt.Sprintf(assistantMessageTruncateFormat, len(lines)-maxLines)) - out = append([]string{truncMsg}, out...) + Render(fmt.Sprintf(assistantMessageTruncateFormat, len(lines)-maxLines)), + ) } return sty.Tool.Body.Render(strings.Join(out, "\n")) @@ -574,6 +597,23 @@ func toolOutputDiffContent(sty *styles.Styles, file, oldContent, newContent stri return sty.Tool.Body.Render(formatted) } +// formatTimeout converts timeout seconds to a duration string (e.g., "30s"). +// Returns empty string if timeout is 0. +func formatTimeout(timeout int) string { + if timeout == 0 { + return "" + } + return fmt.Sprintf("%ds", timeout) +} + +// formatNonZero returns string representation of non-zero integers, empty string for zero. +func formatNonZero(value int) string { + if value == 0 { + return "" + } + return fmt.Sprintf("%d", value) +} + // toolOutputMultiEditDiffContent renders a diff with optional failed edits note. func toolOutputMultiEditDiffContent(sty *styles.Styles, file string, meta tools.MultiEditResponseMetadata, totalEdits, width int, expanded bool) string { bodyWidth := width - toolBodyLeftPaddingTotal