| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390 |
- package chat
- import (
- "encoding/json"
- "fmt"
- "path/filepath"
- "strings"
- "time"
- tea "charm.land/bubbletea/v2"
- "charm.land/lipgloss/v2"
- "charm.land/lipgloss/v2/tree"
- "github.com/charmbracelet/crush/internal/agent"
- "github.com/charmbracelet/crush/internal/agent/tools"
- "github.com/charmbracelet/crush/internal/diff"
- "github.com/charmbracelet/crush/internal/fsext"
- "github.com/charmbracelet/crush/internal/message"
- "github.com/charmbracelet/crush/internal/stringext"
- "github.com/charmbracelet/crush/internal/ui/anim"
- "github.com/charmbracelet/crush/internal/ui/common"
- "github.com/charmbracelet/crush/internal/ui/styles"
- "github.com/charmbracelet/x/ansi"
- )
- // responseContextHeight limits the number of lines displayed in tool output.
- const responseContextHeight = 10
- // toolBodyLeftPaddingTotal represents the padding that should be applied to each tool body
- const toolBodyLeftPaddingTotal = 2
- // ToolStatus represents the current state of a tool call.
- type ToolStatus int
- const (
- ToolStatusAwaitingPermission ToolStatus = iota
- ToolStatusRunning
- ToolStatusSuccess
- ToolStatusError
- ToolStatusCanceled
- )
- // ToolMessageItem represents a tool call message in the chat UI.
- type ToolMessageItem interface {
- MessageItem
- ToolCall() message.ToolCall
- SetToolCall(tc message.ToolCall)
- SetResult(res *message.ToolResult)
- MessageID() string
- SetMessageID(id string)
- SetStatus(status ToolStatus)
- Status() ToolStatus
- }
- // Compactable is an interface for tool items that can render in a compacted mode.
- // When compact mode is enabled, tools render as a compact single-line header.
- type Compactable interface {
- SetCompact(compact bool)
- }
- // SpinningState contains the state passed to SpinningFunc for custom spinning logic.
- type SpinningState struct {
- ToolCall message.ToolCall
- Result *message.ToolResult
- Status ToolStatus
- }
- // IsCanceled returns true if the tool status is canceled.
- func (s *SpinningState) IsCanceled() bool {
- return s.Status == ToolStatusCanceled
- }
- // HasResult returns true if the result is not nil.
- func (s *SpinningState) HasResult() bool {
- return s.Result != nil
- }
- // SpinningFunc is a function type for custom spinning logic.
- // Returns true if the tool should show the spinning animation.
- type SpinningFunc func(state SpinningState) bool
- // DefaultToolRenderContext implements the default [ToolRenderer] interface.
- type DefaultToolRenderContext struct{}
- // RenderTool implements the [ToolRenderer] interface.
- func (d *DefaultToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
- return "TODO: Implement Tool Renderer For: " + opts.ToolCall.Name
- }
- // ToolRenderOpts contains the data needed to render a tool call.
- type ToolRenderOpts struct {
- ToolCall message.ToolCall
- Result *message.ToolResult
- Anim *anim.Anim
- ExpandedContent bool
- Compact bool
- IsSpinning bool
- Status ToolStatus
- }
- // IsPending returns true if the tool call is still pending (not finished and
- // not canceled).
- func (o *ToolRenderOpts) IsPending() bool {
- return !o.ToolCall.Finished && !o.IsCanceled()
- }
- // IsCanceled returns true if the tool status is canceled.
- func (o *ToolRenderOpts) IsCanceled() bool {
- return o.Status == ToolStatusCanceled
- }
- // HasResult returns true if the result is not nil.
- func (o *ToolRenderOpts) HasResult() bool {
- return o.Result != nil
- }
- // HasEmptyResult returns true if the result is nil or has empty content.
- func (o *ToolRenderOpts) HasEmptyResult() bool {
- return o.Result == nil || o.Result.Content == ""
- }
- // ToolRenderer represents an interface for rendering tool calls.
- type ToolRenderer interface {
- RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string
- }
- // ToolRendererFunc is a function type that implements the [ToolRenderer] interface.
- type ToolRendererFunc func(sty *styles.Styles, width int, opts *ToolRenderOpts) string
- // RenderTool implements the ToolRenderer interface.
- func (f ToolRendererFunc) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
- return f(sty, width, opts)
- }
- // baseToolMessageItem represents a tool call message that can be displayed in the UI.
- type baseToolMessageItem struct {
- *highlightableMessageItem
- *cachedMessageItem
- *focusableMessageItem
- toolRenderer ToolRenderer
- toolCall message.ToolCall
- result *message.ToolResult
- messageID string
- status ToolStatus
- // we use this so we can efficiently cache
- // tools that have a capped width (e.x bash.. and others)
- hasCappedWidth bool
- // isCompact indicates this tool should render in compact mode.
- isCompact bool
- // spinningFunc allows tools to override the default spinning logic.
- // If nil, uses the default: !toolCall.Finished && !canceled.
- spinningFunc SpinningFunc
- sty *styles.Styles
- anim *anim.Anim
- expandedContent bool
- }
- var _ Expandable = (*baseToolMessageItem)(nil)
- // newBaseToolMessageItem is the internal constructor for base tool message items.
- func newBaseToolMessageItem(
- sty *styles.Styles,
- toolCall message.ToolCall,
- result *message.ToolResult,
- toolRenderer ToolRenderer,
- canceled bool,
- ) *baseToolMessageItem {
- // we only do full width for diffs (as far as I know)
- hasCappedWidth := toolCall.Name != tools.EditToolName && toolCall.Name != tools.MultiEditToolName
- status := ToolStatusRunning
- if canceled {
- status = ToolStatusCanceled
- }
- t := &baseToolMessageItem{
- highlightableMessageItem: defaultHighlighter(sty),
- cachedMessageItem: &cachedMessageItem{},
- focusableMessageItem: &focusableMessageItem{},
- sty: sty,
- toolRenderer: toolRenderer,
- toolCall: toolCall,
- result: result,
- status: status,
- hasCappedWidth: hasCappedWidth,
- }
- t.anim = anim.New(anim.Settings{
- ID: toolCall.ID,
- Size: 15,
- GradColorA: sty.Primary,
- GradColorB: sty.Secondary,
- LabelColor: sty.FgBase,
- CycleColors: true,
- })
- return t
- }
- // NewToolMessageItem creates a new [ToolMessageItem] based on the tool call name.
- //
- // It returns a specific tool message item type if implemented, otherwise it
- // returns a generic tool message item. The messageID is the ID of the assistant
- // message containing this tool call.
- func NewToolMessageItem(
- sty *styles.Styles,
- messageID string,
- toolCall message.ToolCall,
- result *message.ToolResult,
- canceled bool,
- ) ToolMessageItem {
- var item ToolMessageItem
- switch toolCall.Name {
- case tools.BashToolName:
- item = NewBashToolMessageItem(sty, toolCall, result, canceled)
- case tools.JobOutputToolName:
- item = NewJobOutputToolMessageItem(sty, toolCall, result, canceled)
- case tools.JobKillToolName:
- item = NewJobKillToolMessageItem(sty, toolCall, result, canceled)
- case tools.ViewToolName:
- item = NewViewToolMessageItem(sty, toolCall, result, canceled)
- case tools.WriteToolName:
- item = NewWriteToolMessageItem(sty, toolCall, result, canceled)
- case tools.EditToolName:
- item = NewEditToolMessageItem(sty, toolCall, result, canceled)
- case tools.MultiEditToolName:
- item = NewMultiEditToolMessageItem(sty, toolCall, result, canceled)
- case tools.GlobToolName:
- item = NewGlobToolMessageItem(sty, toolCall, result, canceled)
- case tools.GrepToolName:
- item = NewGrepToolMessageItem(sty, toolCall, result, canceled)
- case tools.LSToolName:
- item = NewLSToolMessageItem(sty, toolCall, result, canceled)
- case tools.DownloadToolName:
- item = NewDownloadToolMessageItem(sty, toolCall, result, canceled)
- case tools.FetchToolName:
- item = NewFetchToolMessageItem(sty, toolCall, result, canceled)
- case tools.SourcegraphToolName:
- item = NewSourcegraphToolMessageItem(sty, toolCall, result, canceled)
- case tools.DiagnosticsToolName:
- item = NewDiagnosticsToolMessageItem(sty, toolCall, result, canceled)
- case agent.AgentToolName:
- item = NewAgentToolMessageItem(sty, toolCall, result, canceled)
- case tools.AgenticFetchToolName:
- item = NewAgenticFetchToolMessageItem(sty, toolCall, result, canceled)
- case tools.WebFetchToolName:
- item = NewWebFetchToolMessageItem(sty, toolCall, result, canceled)
- case tools.WebSearchToolName:
- item = NewWebSearchToolMessageItem(sty, toolCall, result, canceled)
- case tools.TodosToolName:
- item = NewTodosToolMessageItem(sty, toolCall, result, canceled)
- case tools.ReferencesToolName:
- item = NewReferencesToolMessageItem(sty, toolCall, result, canceled)
- case tools.LSPRestartToolName:
- item = NewLSPRestartToolMessageItem(sty, toolCall, result, canceled)
- default:
- if strings.HasPrefix(toolCall.Name, "mcp_") {
- item = NewMCPToolMessageItem(sty, toolCall, result, canceled)
- } else {
- item = NewGenericToolMessageItem(sty, toolCall, result, canceled)
- }
- }
- item.SetMessageID(messageID)
- return item
- }
- // SetCompact implements the Compactable interface.
- func (t *baseToolMessageItem) SetCompact(compact bool) {
- t.isCompact = compact
- t.clearCache()
- }
- // ID returns the unique identifier for this tool message item.
- func (t *baseToolMessageItem) ID() string {
- return t.toolCall.ID
- }
- // StartAnimation starts the assistant message animation if it should be spinning.
- func (t *baseToolMessageItem) StartAnimation() tea.Cmd {
- if !t.isSpinning() {
- return nil
- }
- return t.anim.Start()
- }
- // Animate progresses the assistant message animation if it should be spinning.
- func (t *baseToolMessageItem) Animate(msg anim.StepMsg) tea.Cmd {
- if !t.isSpinning() {
- return nil
- }
- return t.anim.Animate(msg)
- }
- // RawRender implements [MessageItem].
- func (t *baseToolMessageItem) RawRender(width int) string {
- toolItemWidth := width - MessageLeftPaddingTotal
- if t.hasCappedWidth {
- toolItemWidth = cappedMessageWidth(width)
- }
- content, height, ok := t.getCachedRender(toolItemWidth)
- // if we are spinning or there is no cache rerender
- if !ok || t.isSpinning() {
- content = t.toolRenderer.RenderTool(t.sty, toolItemWidth, &ToolRenderOpts{
- ToolCall: t.toolCall,
- Result: t.result,
- Anim: t.anim,
- ExpandedContent: t.expandedContent,
- Compact: t.isCompact,
- IsSpinning: t.isSpinning(),
- Status: t.computeStatus(),
- })
- height = lipgloss.Height(content)
- // cache the rendered content
- t.setCachedRender(content, toolItemWidth, height)
- }
- return t.renderHighlighted(content, toolItemWidth, height)
- }
- // Render renders the tool message item at the given width.
- func (t *baseToolMessageItem) Render(width int) string {
- style := t.sty.Chat.Message.ToolCallBlurred
- if t.focused {
- style = t.sty.Chat.Message.ToolCallFocused
- }
- if t.isCompact {
- style = t.sty.Chat.Message.ToolCallCompact
- }
- return style.Render(t.RawRender(width))
- }
- // ToolCall returns the tool call associated with this message item.
- func (t *baseToolMessageItem) ToolCall() message.ToolCall {
- return t.toolCall
- }
- // SetToolCall sets the tool call associated with this message item.
- func (t *baseToolMessageItem) SetToolCall(tc message.ToolCall) {
- t.toolCall = tc
- t.clearCache()
- }
- // SetResult sets the tool result associated with this message item.
- func (t *baseToolMessageItem) SetResult(res *message.ToolResult) {
- t.result = res
- t.clearCache()
- }
- // MessageID returns the ID of the message containing this tool call.
- func (t *baseToolMessageItem) MessageID() string {
- return t.messageID
- }
- // SetMessageID sets the ID of the message containing this tool call.
- func (t *baseToolMessageItem) SetMessageID(id string) {
- t.messageID = id
- }
- // SetStatus sets the tool status.
- func (t *baseToolMessageItem) SetStatus(status ToolStatus) {
- t.status = status
- t.clearCache()
- }
- // Status returns the current tool status.
- func (t *baseToolMessageItem) Status() ToolStatus {
- return t.status
- }
- // computeStatus computes the effective status considering the result.
- func (t *baseToolMessageItem) computeStatus() ToolStatus {
- if t.result != nil {
- if t.result.IsError {
- return ToolStatusError
- }
- return ToolStatusSuccess
- }
- return t.status
- }
- // isSpinning returns true if the tool should show animation.
- func (t *baseToolMessageItem) isSpinning() bool {
- if t.spinningFunc != nil {
- return t.spinningFunc(SpinningState{
- ToolCall: t.toolCall,
- Result: t.result,
- Status: t.status,
- })
- }
- return !t.toolCall.Finished && t.status != ToolStatusCanceled
- }
- // SetSpinningFunc sets a custom function to determine if the tool should spin.
- func (t *baseToolMessageItem) SetSpinningFunc(fn SpinningFunc) {
- t.spinningFunc = fn
- }
- // ToggleExpanded toggles the expanded state of the thinking box.
- func (t *baseToolMessageItem) ToggleExpanded() bool {
- t.expandedContent = !t.expandedContent
- t.clearCache()
- return t.expandedContent
- }
- // HandleMouseClick implements MouseClickable.
- func (t *baseToolMessageItem) HandleMouseClick(btn ansi.MouseButton, x, y int) bool {
- return btn == ansi.MouseLeft
- }
- // HandleKeyEvent implements KeyEventHandler.
- func (t *baseToolMessageItem) HandleKeyEvent(key tea.KeyMsg) (bool, tea.Cmd) {
- if k := key.String(); k == "c" || k == "y" {
- text := t.formatToolForCopy()
- return true, common.CopyToClipboard(text, "Tool content copied to clipboard")
- }
- return false, nil
- }
- // pendingTool renders a tool that is still in progress with an animation.
- func pendingTool(sty *styles.Styles, name string, anim *anim.Anim) string {
- icon := sty.Tool.IconPending.Render()
- toolName := sty.Tool.NameNormal.Render(name)
- var animView string
- if anim != nil {
- animView = anim.Render()
- }
- return fmt.Sprintf("%s %s %s", icon, toolName, animView)
- }
- // toolEarlyStateContent handles error/cancelled/pending states before content rendering.
- // Returns the rendered output and true if early state was handled.
- func toolEarlyStateContent(sty *styles.Styles, opts *ToolRenderOpts, width int) (string, bool) {
- var msg string
- switch opts.Status {
- case ToolStatusError:
- msg = toolErrorContent(sty, opts.Result, width)
- case ToolStatusCanceled:
- msg = sty.Tool.StateCancelled.Render("Canceled.")
- case ToolStatusAwaitingPermission:
- msg = sty.Tool.StateWaiting.Render("Requesting permission...")
- case ToolStatusRunning:
- msg = sty.Tool.StateWaiting.Render("Waiting for tool response...")
- default:
- return "", false
- }
- return msg, true
- }
- // toolErrorContent formats an error message with ERROR tag.
- func toolErrorContent(sty *styles.Styles, result *message.ToolResult, width int) string {
- if result == nil {
- return ""
- }
- errContent := strings.ReplaceAll(result.Content, "\n", " ")
- errTag := sty.Tool.ErrorTag.Render("ERROR")
- tagWidth := lipgloss.Width(errTag)
- errContent = ansi.Truncate(errContent, width-tagWidth-3, "…")
- return fmt.Sprintf("%s %s", errTag, sty.Tool.ErrorMessage.Render(errContent))
- }
- // toolIcon returns the status icon for a tool call.
- // toolIcon returns the status icon for a tool call based on its status.
- func toolIcon(sty *styles.Styles, status ToolStatus) string {
- switch status {
- case ToolStatusSuccess:
- return sty.Tool.IconSuccess.String()
- case ToolStatusError:
- return sty.Tool.IconError.String()
- case ToolStatusCanceled:
- return sty.Tool.IconCancelled.String()
- default:
- return sty.Tool.IconPending.String()
- }
- }
- // toolParamList formats parameters as "main (key=value, ...)" with truncation.
- // toolParamList formats tool parameters as "main (key=value, ...)" with truncation.
- func toolParamList(sty *styles.Styles, params []string, width int) string {
- // minSpaceForMainParam is the min space required for the main param
- // if this is less that the value set we will only show the main param nothing else
- const minSpaceForMainParam = 30
- if len(params) == 0 {
- return ""
- }
- mainParam := params[0]
- // Build key=value pairs from remaining params (consecutive key, value pairs).
- var kvPairs []string
- for i := 1; i+1 < len(params); i += 2 {
- if params[i+1] != "" {
- kvPairs = append(kvPairs, fmt.Sprintf("%s=%s", params[i], params[i+1]))
- }
- }
- // Try to include key=value pairs if there's enough space.
- output := mainParam
- if len(kvPairs) > 0 {
- partsStr := strings.Join(kvPairs, ", ")
- if remaining := width - lipgloss.Width(partsStr) - 3; remaining >= minSpaceForMainParam {
- output = fmt.Sprintf("%s (%s)", mainParam, partsStr)
- }
- }
- if width >= 0 {
- output = ansi.Truncate(output, width, "…")
- }
- return sty.Tool.ParamMain.Render(output)
- }
- // toolHeader builds the tool header line: "● ToolName params..."
- func toolHeader(sty *styles.Styles, status ToolStatus, name string, width int, nested bool, params ...string) string {
- icon := toolIcon(sty, status)
- nameStyle := sty.Tool.NameNormal
- if nested {
- nameStyle = sty.Tool.NameNested
- }
- toolName := nameStyle.Render(name)
- prefix := fmt.Sprintf("%s %s ", icon, toolName)
- prefixWidth := lipgloss.Width(prefix)
- remainingWidth := width - prefixWidth
- paramsStr := toolParamList(sty, params, remainingWidth)
- return prefix + paramsStr
- }
- // toolOutputPlainContent renders plain text with optional expansion support.
- func toolOutputPlainContent(sty *styles.Styles, content string, width int, expanded bool) string {
- content = stringext.NormalizeSpace(content)
- lines := strings.Split(content, "\n")
- maxLines := responseContextHeight
- if expanded {
- maxLines = len(lines) // Show all
- }
- var out []string
- for i, ln := range lines {
- if i >= maxLines {
- break
- }
- ln = " " + ln
- if lipgloss.Width(ln) > width {
- ln = ansi.Truncate(ln, width, "…")
- }
- out = append(out, sty.Tool.ContentLine.Width(width).Render(ln))
- }
- wasTruncated := len(lines) > responseContextHeight
- if !expanded && wasTruncated {
- out = append(out, sty.Tool.ContentTruncation.
- Width(width).
- Render(fmt.Sprintf(assistantMessageTruncateFormat, len(lines)-responseContextHeight)))
- }
- return strings.Join(out, "\n")
- }
- // toolOutputCodeContent renders code with syntax highlighting and line numbers.
- func toolOutputCodeContent(sty *styles.Styles, path, content string, offset, width int, expanded bool) string {
- content = stringext.NormalizeSpace(content)
- lines := strings.Split(content, "\n")
- maxLines := responseContextHeight
- if expanded {
- maxLines = len(lines)
- }
- // Truncate if needed.
- displayLines := lines
- if len(lines) > maxLines {
- displayLines = lines[:maxLines]
- }
- bg := sty.Tool.ContentCodeBg
- highlighted, _ := common.SyntaxHighlight(sty, strings.Join(displayLines, "\n"), path, bg)
- highlightedLines := strings.Split(highlighted, "\n")
- // Calculate line number width.
- maxLineNumber := len(displayLines) + offset
- maxDigits := getDigits(maxLineNumber)
- numFmt := fmt.Sprintf("%%%dd", maxDigits)
- bodyWidth := width - toolBodyLeftPaddingTotal
- codeWidth := bodyWidth - maxDigits
- var out []string
- for i, ln := range highlightedLines {
- lineNum := sty.Tool.ContentLineNumber.Render(fmt.Sprintf(numFmt, i+1+offset))
- // Truncate accounting for padding that will be added.
- ln = ansi.Truncate(ln, codeWidth-sty.Tool.ContentCodeLine.GetHorizontalPadding(), "…")
- codeLine := sty.Tool.ContentCodeLine.
- Width(codeWidth).
- Render(ln)
- out = append(out, lipgloss.JoinHorizontal(lipgloss.Left, lineNum, codeLine))
- }
- // Add truncation message if needed.
- if len(lines) > maxLines && !expanded {
- out = append(out, sty.Tool.ContentCodeTruncation.
- Width(width).
- Render(fmt.Sprintf(assistantMessageTruncateFormat, len(lines)-maxLines)),
- )
- }
- return sty.Tool.Body.Render(strings.Join(out, "\n"))
- }
- // toolOutputImageContent renders image data with size info.
- func toolOutputImageContent(sty *styles.Styles, data, mediaType string) string {
- dataSize := len(data) * 3 / 4
- sizeStr := formatSize(dataSize)
- loaded := sty.Base.Foreground(sty.Green).Render("Loaded")
- arrow := sty.Base.Foreground(sty.GreenDark).Render("→")
- typeStyled := sty.Base.Render(mediaType)
- sizeStyled := sty.Subtle.Render(sizeStr)
- return sty.Tool.Body.Render(fmt.Sprintf("%s %s %s %s", loaded, arrow, typeStyled, sizeStyled))
- }
- // getDigits returns the number of digits in a number.
- func getDigits(n int) int {
- if n == 0 {
- return 1
- }
- if n < 0 {
- n = -n
- }
- digits := 0
- for n > 0 {
- n /= 10
- digits++
- }
- return digits
- }
- // formatSize formats byte size into human readable format.
- func formatSize(bytes int) string {
- const (
- kb = 1024
- mb = kb * 1024
- )
- switch {
- case bytes >= mb:
- return fmt.Sprintf("%.1f MB", float64(bytes)/float64(mb))
- case bytes >= kb:
- return fmt.Sprintf("%.1f KB", float64(bytes)/float64(kb))
- default:
- return fmt.Sprintf("%d B", bytes)
- }
- }
- // toolOutputDiffContent renders a diff between old and new content.
- func toolOutputDiffContent(sty *styles.Styles, file, oldContent, newContent string, width int, expanded bool) string {
- bodyWidth := width - toolBodyLeftPaddingTotal
- formatter := common.DiffFormatter(sty).
- Before(file, oldContent).
- After(file, newContent).
- Width(bodyWidth)
- // Use split view for wide terminals.
- if width > maxTextWidth {
- formatter = formatter.Split()
- }
- formatted := formatter.String()
- lines := strings.Split(formatted, "\n")
- // Truncate if needed.
- maxLines := responseContextHeight
- if expanded {
- maxLines = len(lines)
- }
- if len(lines) > maxLines && !expanded {
- truncMsg := sty.Tool.DiffTruncation.
- Width(bodyWidth).
- Render(fmt.Sprintf(assistantMessageTruncateFormat, len(lines)-maxLines))
- formatted = strings.Join(lines[:maxLines], "\n") + "\n" + truncMsg
- }
- 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
- formatter := common.DiffFormatter(sty).
- Before(file, meta.OldContent).
- After(file, meta.NewContent).
- Width(bodyWidth)
- // Use split view for wide terminals.
- if width > maxTextWidth {
- formatter = formatter.Split()
- }
- formatted := formatter.String()
- lines := strings.Split(formatted, "\n")
- // Truncate if needed.
- maxLines := responseContextHeight
- if expanded {
- maxLines = len(lines)
- }
- if len(lines) > maxLines && !expanded {
- truncMsg := sty.Tool.DiffTruncation.
- Width(bodyWidth).
- Render(fmt.Sprintf(assistantMessageTruncateFormat, len(lines)-maxLines))
- formatted = truncMsg + "\n" + strings.Join(lines[:maxLines], "\n")
- }
- // Add failed edits note if any exist.
- if len(meta.EditsFailed) > 0 {
- noteTag := sty.Tool.NoteTag.Render("Note")
- noteMsg := fmt.Sprintf("%d of %d edits succeeded", meta.EditsApplied, totalEdits)
- note := fmt.Sprintf("%s %s", noteTag, sty.Tool.NoteMessage.Render(noteMsg))
- formatted = formatted + "\n\n" + note
- }
- return sty.Tool.Body.Render(formatted)
- }
- // roundedEnumerator creates a tree enumerator with rounded corners.
- func roundedEnumerator(lPadding, width int) tree.Enumerator {
- if width == 0 {
- width = 2
- }
- if lPadding == 0 {
- lPadding = 1
- }
- return func(children tree.Children, index int) string {
- line := strings.Repeat("─", width)
- padding := strings.Repeat(" ", lPadding)
- if children.Length()-1 == index {
- return padding + "╰" + line
- }
- return padding + "├" + line
- }
- }
- // toolOutputMarkdownContent renders markdown content with optional truncation.
- func toolOutputMarkdownContent(sty *styles.Styles, content string, width int, expanded bool) string {
- content = stringext.NormalizeSpace(content)
- // Cap width for readability.
- if width > maxTextWidth {
- width = maxTextWidth
- }
- renderer := common.PlainMarkdownRenderer(sty, width)
- rendered, err := renderer.Render(content)
- if err != nil {
- return toolOutputPlainContent(sty, content, width, expanded)
- }
- lines := strings.Split(rendered, "\n")
- maxLines := responseContextHeight
- if expanded {
- maxLines = len(lines)
- }
- var out []string
- for i, ln := range lines {
- if i >= maxLines {
- break
- }
- out = append(out, ln)
- }
- if len(lines) > maxLines && !expanded {
- out = append(out, sty.Tool.ContentTruncation.
- Width(width).
- Render(fmt.Sprintf(assistantMessageTruncateFormat, len(lines)-maxLines)),
- )
- }
- return sty.Tool.Body.Render(strings.Join(out, "\n"))
- }
- // formatToolForCopy formats the tool call for clipboard copying.
- func (t *baseToolMessageItem) formatToolForCopy() string {
- var parts []string
- toolName := prettifyToolName(t.toolCall.Name)
- parts = append(parts, fmt.Sprintf("## %s Tool Call", toolName))
- if t.toolCall.Input != "" {
- params := t.formatParametersForCopy()
- if params != "" {
- parts = append(parts, "### Parameters:")
- parts = append(parts, params)
- }
- }
- if t.result != nil && t.result.ToolCallID != "" {
- if t.result.IsError {
- parts = append(parts, "### Error:")
- parts = append(parts, t.result.Content)
- } else {
- parts = append(parts, "### Result:")
- content := t.formatResultForCopy()
- if content != "" {
- parts = append(parts, content)
- }
- }
- } else if t.status == ToolStatusCanceled {
- parts = append(parts, "### Status:")
- parts = append(parts, "Cancelled")
- } else {
- parts = append(parts, "### Status:")
- parts = append(parts, "Pending...")
- }
- return strings.Join(parts, "\n\n")
- }
- // formatParametersForCopy formats tool parameters for clipboard copying.
- func (t *baseToolMessageItem) formatParametersForCopy() string {
- switch t.toolCall.Name {
- case tools.BashToolName:
- var params tools.BashParams
- if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) == nil {
- cmd := strings.ReplaceAll(params.Command, "\n", " ")
- cmd = strings.ReplaceAll(cmd, "\t", " ")
- return fmt.Sprintf("**Command:** %s", cmd)
- }
- case tools.ViewToolName:
- var params tools.ViewParams
- if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) == nil {
- var parts []string
- parts = append(parts, fmt.Sprintf("**File:** %s", fsext.PrettyPath(params.FilePath)))
- if params.Limit > 0 {
- parts = append(parts, fmt.Sprintf("**Limit:** %d", params.Limit))
- }
- if params.Offset > 0 {
- parts = append(parts, fmt.Sprintf("**Offset:** %d", params.Offset))
- }
- return strings.Join(parts, "\n")
- }
- case tools.EditToolName:
- var params tools.EditParams
- if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) == nil {
- return fmt.Sprintf("**File:** %s", fsext.PrettyPath(params.FilePath))
- }
- case tools.MultiEditToolName:
- var params tools.MultiEditParams
- if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) == nil {
- var parts []string
- parts = append(parts, fmt.Sprintf("**File:** %s", fsext.PrettyPath(params.FilePath)))
- parts = append(parts, fmt.Sprintf("**Edits:** %d", len(params.Edits)))
- return strings.Join(parts, "\n")
- }
- case tools.WriteToolName:
- var params tools.WriteParams
- if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) == nil {
- return fmt.Sprintf("**File:** %s", fsext.PrettyPath(params.FilePath))
- }
- case tools.FetchToolName:
- var params tools.FetchParams
- if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) == nil {
- var parts []string
- parts = append(parts, fmt.Sprintf("**URL:** %s", params.URL))
- if params.Format != "" {
- parts = append(parts, fmt.Sprintf("**Format:** %s", params.Format))
- }
- if params.Timeout > 0 {
- parts = append(parts, fmt.Sprintf("**Timeout:** %ds", params.Timeout))
- }
- return strings.Join(parts, "\n")
- }
- case tools.AgenticFetchToolName:
- var params tools.AgenticFetchParams
- if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) == nil {
- var parts []string
- if params.URL != "" {
- parts = append(parts, fmt.Sprintf("**URL:** %s", params.URL))
- }
- if params.Prompt != "" {
- parts = append(parts, fmt.Sprintf("**Prompt:** %s", params.Prompt))
- }
- return strings.Join(parts, "\n")
- }
- case tools.WebFetchToolName:
- var params tools.WebFetchParams
- if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) == nil {
- return fmt.Sprintf("**URL:** %s", params.URL)
- }
- case tools.GrepToolName:
- var params tools.GrepParams
- if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) == nil {
- var parts []string
- parts = append(parts, fmt.Sprintf("**Pattern:** %s", params.Pattern))
- if params.Path != "" {
- parts = append(parts, fmt.Sprintf("**Path:** %s", params.Path))
- }
- if params.Include != "" {
- parts = append(parts, fmt.Sprintf("**Include:** %s", params.Include))
- }
- if params.LiteralText {
- parts = append(parts, "**Literal:** true")
- }
- return strings.Join(parts, "\n")
- }
- case tools.GlobToolName:
- var params tools.GlobParams
- if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) == nil {
- var parts []string
- parts = append(parts, fmt.Sprintf("**Pattern:** %s", params.Pattern))
- if params.Path != "" {
- parts = append(parts, fmt.Sprintf("**Path:** %s", params.Path))
- }
- return strings.Join(parts, "\n")
- }
- case tools.LSToolName:
- var params tools.LSParams
- if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) == nil {
- path := params.Path
- if path == "" {
- path = "."
- }
- return fmt.Sprintf("**Path:** %s", fsext.PrettyPath(path))
- }
- case tools.DownloadToolName:
- var params tools.DownloadParams
- if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) == nil {
- var parts []string
- parts = append(parts, fmt.Sprintf("**URL:** %s", params.URL))
- parts = append(parts, fmt.Sprintf("**File Path:** %s", fsext.PrettyPath(params.FilePath)))
- if params.Timeout > 0 {
- parts = append(parts, fmt.Sprintf("**Timeout:** %s", (time.Duration(params.Timeout)*time.Second).String()))
- }
- return strings.Join(parts, "\n")
- }
- case tools.SourcegraphToolName:
- var params tools.SourcegraphParams
- if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) == nil {
- var parts []string
- parts = append(parts, fmt.Sprintf("**Query:** %s", params.Query))
- if params.Count > 0 {
- parts = append(parts, fmt.Sprintf("**Count:** %d", params.Count))
- }
- if params.ContextWindow > 0 {
- parts = append(parts, fmt.Sprintf("**Context:** %d", params.ContextWindow))
- }
- return strings.Join(parts, "\n")
- }
- case tools.DiagnosticsToolName:
- return "**Project:** diagnostics"
- case agent.AgentToolName:
- var params agent.AgentParams
- if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) == nil {
- return fmt.Sprintf("**Task:**\n%s", params.Prompt)
- }
- }
- var params map[string]any
- if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) == nil {
- var parts []string
- for key, value := range params {
- displayKey := strings.ReplaceAll(key, "_", " ")
- if len(displayKey) > 0 {
- displayKey = strings.ToUpper(displayKey[:1]) + displayKey[1:]
- }
- parts = append(parts, fmt.Sprintf("**%s:** %v", displayKey, value))
- }
- return strings.Join(parts, "\n")
- }
- return ""
- }
- // formatResultForCopy formats tool results for clipboard copying.
- func (t *baseToolMessageItem) formatResultForCopy() string {
- if t.result == nil {
- return ""
- }
- if t.result.Data != "" {
- if strings.HasPrefix(t.result.MIMEType, "image/") {
- return fmt.Sprintf("[Image: %s]", t.result.MIMEType)
- }
- return fmt.Sprintf("[Media: %s]", t.result.MIMEType)
- }
- switch t.toolCall.Name {
- case tools.BashToolName:
- return t.formatBashResultForCopy()
- case tools.ViewToolName:
- return t.formatViewResultForCopy()
- case tools.EditToolName:
- return t.formatEditResultForCopy()
- case tools.MultiEditToolName:
- return t.formatMultiEditResultForCopy()
- case tools.WriteToolName:
- return t.formatWriteResultForCopy()
- case tools.FetchToolName:
- return t.formatFetchResultForCopy()
- case tools.AgenticFetchToolName:
- return t.formatAgenticFetchResultForCopy()
- case tools.WebFetchToolName:
- return t.formatWebFetchResultForCopy()
- case agent.AgentToolName:
- return t.formatAgentResultForCopy()
- case tools.DownloadToolName, tools.GrepToolName, tools.GlobToolName, tools.LSToolName, tools.SourcegraphToolName, tools.DiagnosticsToolName, tools.TodosToolName:
- return fmt.Sprintf("```\n%s\n```", t.result.Content)
- default:
- return t.result.Content
- }
- }
- // formatBashResultForCopy formats bash tool results for clipboard.
- func (t *baseToolMessageItem) formatBashResultForCopy() string {
- if t.result == nil {
- return ""
- }
- var meta tools.BashResponseMetadata
- if t.result.Metadata != "" {
- json.Unmarshal([]byte(t.result.Metadata), &meta)
- }
- output := meta.Output
- if output == "" && t.result.Content != tools.BashNoOutput {
- output = t.result.Content
- }
- if output == "" {
- return ""
- }
- return fmt.Sprintf("```bash\n%s\n```", output)
- }
- // formatViewResultForCopy formats view tool results for clipboard.
- func (t *baseToolMessageItem) formatViewResultForCopy() string {
- if t.result == nil {
- return ""
- }
- var meta tools.ViewResponseMetadata
- if t.result.Metadata != "" {
- json.Unmarshal([]byte(t.result.Metadata), &meta)
- }
- if meta.Content == "" {
- return t.result.Content
- }
- lang := ""
- if meta.FilePath != "" {
- ext := strings.ToLower(filepath.Ext(meta.FilePath))
- switch ext {
- case ".go":
- lang = "go"
- case ".js", ".mjs":
- lang = "javascript"
- case ".ts":
- lang = "typescript"
- case ".py":
- lang = "python"
- case ".rs":
- lang = "rust"
- case ".java":
- lang = "java"
- case ".c":
- lang = "c"
- case ".cpp", ".cc", ".cxx":
- lang = "cpp"
- case ".sh", ".bash":
- lang = "bash"
- case ".json":
- lang = "json"
- case ".yaml", ".yml":
- lang = "yaml"
- case ".xml":
- lang = "xml"
- case ".html":
- lang = "html"
- case ".css":
- lang = "css"
- case ".md":
- lang = "markdown"
- }
- }
- var result strings.Builder
- if lang != "" {
- fmt.Fprintf(&result, "```%s\n", lang)
- } else {
- result.WriteString("```\n")
- }
- result.WriteString(meta.Content)
- result.WriteString("\n```")
- return result.String()
- }
- // formatEditResultForCopy formats edit tool results for clipboard.
- func (t *baseToolMessageItem) formatEditResultForCopy() string {
- if t.result == nil || t.result.Metadata == "" {
- if t.result != nil {
- return t.result.Content
- }
- return ""
- }
- var meta tools.EditResponseMetadata
- if json.Unmarshal([]byte(t.result.Metadata), &meta) != nil {
- return t.result.Content
- }
- var params tools.EditParams
- json.Unmarshal([]byte(t.toolCall.Input), ¶ms)
- var result strings.Builder
- if meta.OldContent != "" || meta.NewContent != "" {
- fileName := params.FilePath
- if fileName != "" {
- fileName = fsext.PrettyPath(fileName)
- }
- diffContent, additions, removals := diff.GenerateDiff(meta.OldContent, meta.NewContent, fileName)
- fmt.Fprintf(&result, "Changes: +%d -%d\n", additions, removals)
- result.WriteString("```diff\n")
- result.WriteString(diffContent)
- result.WriteString("\n```")
- }
- return result.String()
- }
- // formatMultiEditResultForCopy formats multi-edit tool results for clipboard.
- func (t *baseToolMessageItem) formatMultiEditResultForCopy() string {
- if t.result == nil || t.result.Metadata == "" {
- if t.result != nil {
- return t.result.Content
- }
- return ""
- }
- var meta tools.MultiEditResponseMetadata
- if json.Unmarshal([]byte(t.result.Metadata), &meta) != nil {
- return t.result.Content
- }
- var params tools.MultiEditParams
- json.Unmarshal([]byte(t.toolCall.Input), ¶ms)
- var result strings.Builder
- if meta.OldContent != "" || meta.NewContent != "" {
- fileName := params.FilePath
- if fileName != "" {
- fileName = fsext.PrettyPath(fileName)
- }
- diffContent, additions, removals := diff.GenerateDiff(meta.OldContent, meta.NewContent, fileName)
- fmt.Fprintf(&result, "Changes: +%d -%d\n", additions, removals)
- result.WriteString("```diff\n")
- result.WriteString(diffContent)
- result.WriteString("\n```")
- }
- return result.String()
- }
- // formatWriteResultForCopy formats write tool results for clipboard.
- func (t *baseToolMessageItem) formatWriteResultForCopy() string {
- if t.result == nil {
- return ""
- }
- var params tools.WriteParams
- if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) != nil {
- return t.result.Content
- }
- lang := ""
- if params.FilePath != "" {
- ext := strings.ToLower(filepath.Ext(params.FilePath))
- switch ext {
- case ".go":
- lang = "go"
- case ".js", ".mjs":
- lang = "javascript"
- case ".ts":
- lang = "typescript"
- case ".py":
- lang = "python"
- case ".rs":
- lang = "rust"
- case ".java":
- lang = "java"
- case ".c":
- lang = "c"
- case ".cpp", ".cc", ".cxx":
- lang = "cpp"
- case ".sh", ".bash":
- lang = "bash"
- case ".json":
- lang = "json"
- case ".yaml", ".yml":
- lang = "yaml"
- case ".xml":
- lang = "xml"
- case ".html":
- lang = "html"
- case ".css":
- lang = "css"
- case ".md":
- lang = "markdown"
- }
- }
- var result strings.Builder
- fmt.Fprintf(&result, "File: %s\n", fsext.PrettyPath(params.FilePath))
- if lang != "" {
- fmt.Fprintf(&result, "```%s\n", lang)
- } else {
- result.WriteString("```\n")
- }
- result.WriteString(params.Content)
- result.WriteString("\n```")
- return result.String()
- }
- // formatFetchResultForCopy formats fetch tool results for clipboard.
- func (t *baseToolMessageItem) formatFetchResultForCopy() string {
- if t.result == nil {
- return ""
- }
- var params tools.FetchParams
- if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) != nil {
- return t.result.Content
- }
- var result strings.Builder
- if params.URL != "" {
- fmt.Fprintf(&result, "URL: %s\n", params.URL)
- }
- if params.Format != "" {
- fmt.Fprintf(&result, "Format: %s\n", params.Format)
- }
- if params.Timeout > 0 {
- fmt.Fprintf(&result, "Timeout: %ds\n", params.Timeout)
- }
- result.WriteString("\n")
- result.WriteString(t.result.Content)
- return result.String()
- }
- // formatAgenticFetchResultForCopy formats agentic fetch tool results for clipboard.
- func (t *baseToolMessageItem) formatAgenticFetchResultForCopy() string {
- if t.result == nil {
- return ""
- }
- var params tools.AgenticFetchParams
- if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) != nil {
- return t.result.Content
- }
- var result strings.Builder
- if params.URL != "" {
- fmt.Fprintf(&result, "URL: %s\n", params.URL)
- }
- if params.Prompt != "" {
- fmt.Fprintf(&result, "Prompt: %s\n\n", params.Prompt)
- }
- result.WriteString("```markdown\n")
- result.WriteString(t.result.Content)
- result.WriteString("\n```")
- return result.String()
- }
- // formatWebFetchResultForCopy formats web fetch tool results for clipboard.
- func (t *baseToolMessageItem) formatWebFetchResultForCopy() string {
- if t.result == nil {
- return ""
- }
- var params tools.WebFetchParams
- if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) != nil {
- return t.result.Content
- }
- var result strings.Builder
- result.WriteString(fmt.Sprintf("URL: %s\n\n", params.URL))
- result.WriteString("```markdown\n")
- result.WriteString(t.result.Content)
- result.WriteString("\n```")
- return result.String()
- }
- // formatAgentResultForCopy formats agent tool results for clipboard.
- func (t *baseToolMessageItem) formatAgentResultForCopy() string {
- if t.result == nil {
- return ""
- }
- var result strings.Builder
- if t.result.Content != "" {
- result.WriteString(fmt.Sprintf("```markdown\n%s\n```", t.result.Content))
- }
- return result.String()
- }
- // prettifyToolName returns a human-readable name for tool names.
- func prettifyToolName(name string) string {
- switch name {
- case agent.AgentToolName:
- return "Agent"
- case tools.BashToolName:
- return "Bash"
- case tools.JobOutputToolName:
- return "Job: Output"
- case tools.JobKillToolName:
- return "Job: Kill"
- case tools.DownloadToolName:
- return "Download"
- case tools.EditToolName:
- return "Edit"
- case tools.MultiEditToolName:
- return "Multi-Edit"
- case tools.FetchToolName:
- return "Fetch"
- case tools.AgenticFetchToolName:
- return "Agentic Fetch"
- case tools.WebFetchToolName:
- return "Fetch"
- case tools.WebSearchToolName:
- return "Search"
- case tools.GlobToolName:
- return "Glob"
- case tools.GrepToolName:
- return "Grep"
- case tools.LSToolName:
- return "List"
- case tools.SourcegraphToolName:
- return "Sourcegraph"
- case tools.TodosToolName:
- return "To-Do"
- case tools.ViewToolName:
- return "View"
- case tools.WriteToolName:
- return "Write"
- default:
- return genericPrettyName(name)
- }
- }
|