1package chat
2
3import (
4 "encoding/json"
5 "fmt"
6 "path/filepath"
7 "strings"
8 "time"
9
10 tea "charm.land/bubbletea/v2"
11 "charm.land/lipgloss/v2"
12 "charm.land/lipgloss/v2/tree"
13 "github.com/charmbracelet/crush/internal/agent"
14 "github.com/charmbracelet/crush/internal/agent/tools"
15 "github.com/charmbracelet/crush/internal/diff"
16 "github.com/charmbracelet/crush/internal/fsext"
17 "github.com/charmbracelet/crush/internal/hooks"
18 "github.com/charmbracelet/crush/internal/message"
19 "github.com/charmbracelet/crush/internal/stringext"
20 "github.com/charmbracelet/crush/internal/ui/anim"
21 "github.com/charmbracelet/crush/internal/ui/common"
22 "github.com/charmbracelet/crush/internal/ui/styles"
23 "github.com/charmbracelet/x/ansi"
24)
25
26// responseContextHeight limits the number of lines displayed in tool output.
27const responseContextHeight = 10
28
29// toolBodyLeftPaddingTotal represents the padding that should be applied to each tool body
30const toolBodyLeftPaddingTotal = 2
31
32// ToolStatus represents the current state of a tool call.
33type ToolStatus int
34
35const (
36 ToolStatusAwaitingPermission ToolStatus = iota
37 ToolStatusRunning
38 ToolStatusSuccess
39 ToolStatusError
40 ToolStatusCanceled
41)
42
43// ToolMessageItem represents a tool call message in the chat UI.
44type ToolMessageItem interface {
45 MessageItem
46
47 ToolCall() message.ToolCall
48 SetToolCall(tc message.ToolCall)
49 SetResult(res *message.ToolResult)
50 MessageID() string
51 SetMessageID(id string)
52 SetStatus(status ToolStatus)
53 Status() ToolStatus
54}
55
56// Compactable is an interface for tool items that can render in a compacted mode.
57// When compact mode is enabled, tools render as a compact single-line header.
58type Compactable interface {
59 SetCompact(compact bool)
60}
61
62// SpinningState contains the state passed to SpinningFunc for custom spinning logic.
63type SpinningState struct {
64 ToolCall message.ToolCall
65 Result *message.ToolResult
66 Status ToolStatus
67}
68
69// IsCanceled returns true if the tool status is canceled.
70func (s *SpinningState) IsCanceled() bool {
71 return s.Status == ToolStatusCanceled
72}
73
74// HasResult returns true if the result is not nil.
75func (s *SpinningState) HasResult() bool {
76 return s.Result != nil
77}
78
79// SpinningFunc is a function type for custom spinning logic.
80// Returns true if the tool should show the spinning animation.
81type SpinningFunc func(state SpinningState) bool
82
83// DefaultToolRenderContext implements the default [ToolRenderer] interface.
84type DefaultToolRenderContext struct{}
85
86// RenderTool implements the [ToolRenderer] interface.
87func (d *DefaultToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
88 return "TODO: Implement Tool Renderer For: " + opts.ToolCall.Name
89}
90
91// ToolRenderOpts contains the data needed to render a tool call.
92type ToolRenderOpts struct {
93 ToolCall message.ToolCall
94 Result *message.ToolResult
95 Anim *anim.Anim
96 ExpandedContent bool
97 Compact bool
98 IsSpinning bool
99 Status ToolStatus
100}
101
102// IsPending returns true if the tool call is still pending (not finished and
103// not canceled).
104func (o *ToolRenderOpts) IsPending() bool {
105 return !o.ToolCall.Finished && !o.IsCanceled()
106}
107
108// IsCanceled returns true if the tool status is canceled.
109func (o *ToolRenderOpts) IsCanceled() bool {
110 return o.Status == ToolStatusCanceled
111}
112
113// HasResult returns true if the result is not nil.
114func (o *ToolRenderOpts) HasResult() bool {
115 return o.Result != nil
116}
117
118// HasEmptyResult returns true if the result is nil or has empty content.
119func (o *ToolRenderOpts) HasEmptyResult() bool {
120 return o.Result == nil || o.Result.Content == ""
121}
122
123// ToolRenderer represents an interface for rendering tool calls.
124type ToolRenderer interface {
125 RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string
126}
127
128// ToolRendererFunc is a function type that implements the [ToolRenderer] interface.
129type ToolRendererFunc func(sty *styles.Styles, width int, opts *ToolRenderOpts) string
130
131// RenderTool implements the ToolRenderer interface.
132func (f ToolRendererFunc) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
133 return f(sty, width, opts)
134}
135
136// baseToolMessageItem represents a tool call message that can be displayed in the UI.
137type baseToolMessageItem struct {
138 *highlightableMessageItem
139 *cachedMessageItem
140 *focusableMessageItem
141
142 toolRenderer ToolRenderer
143 toolCall message.ToolCall
144 result *message.ToolResult
145 messageID string
146 status ToolStatus
147 // we use this so we can efficiently cache
148 // tools that have a capped width (e.x bash.. and others)
149 hasCappedWidth bool
150 // isCompact indicates this tool should render in compact mode.
151 isCompact bool
152 // spinningFunc allows tools to override the default spinning logic.
153 // If nil, uses the default: !toolCall.Finished && !canceled.
154 spinningFunc SpinningFunc
155
156 sty *styles.Styles
157 anim *anim.Anim
158 expandedContent bool
159}
160
161var _ Expandable = (*baseToolMessageItem)(nil)
162
163// newBaseToolMessageItem is the internal constructor for base tool message items.
164func newBaseToolMessageItem(
165 sty *styles.Styles,
166 toolCall message.ToolCall,
167 result *message.ToolResult,
168 toolRenderer ToolRenderer,
169 canceled bool,
170) *baseToolMessageItem {
171 // we only do full width for diffs (as far as I know)
172 hasCappedWidth := toolCall.Name != tools.EditToolName && toolCall.Name != tools.MultiEditToolName
173
174 status := ToolStatusRunning
175 if canceled {
176 status = ToolStatusCanceled
177 }
178
179 t := &baseToolMessageItem{
180 highlightableMessageItem: defaultHighlighter(sty),
181 cachedMessageItem: &cachedMessageItem{},
182 focusableMessageItem: &focusableMessageItem{},
183 sty: sty,
184 toolRenderer: toolRenderer,
185 toolCall: toolCall,
186 result: result,
187 status: status,
188 hasCappedWidth: hasCappedWidth,
189 }
190 t.anim = anim.New(anim.Settings{
191 ID: toolCall.ID,
192 Size: 15,
193 GradColorA: sty.WorkingGradFromColor,
194 GradColorB: sty.WorkingGradToColor,
195 LabelColor: sty.WorkingLabelColor,
196 CycleColors: true,
197 })
198
199 return t
200}
201
202// NewToolMessageItem creates a new [ToolMessageItem] based on the tool call name.
203//
204// It returns a specific tool message item type if implemented, otherwise it
205// returns a generic tool message item. The messageID is the ID of the assistant
206// message containing this tool call.
207func NewToolMessageItem(
208 sty *styles.Styles,
209 messageID string,
210 toolCall message.ToolCall,
211 result *message.ToolResult,
212 canceled bool,
213) ToolMessageItem {
214 var item ToolMessageItem
215 switch toolCall.Name {
216 case tools.BashToolName:
217 item = NewBashToolMessageItem(sty, toolCall, result, canceled)
218 case tools.JobOutputToolName:
219 item = NewJobOutputToolMessageItem(sty, toolCall, result, canceled)
220 case tools.JobKillToolName:
221 item = NewJobKillToolMessageItem(sty, toolCall, result, canceled)
222 case tools.ViewToolName:
223 item = NewViewToolMessageItem(sty, toolCall, result, canceled)
224 case tools.TouchToolName:
225 item = NewTouchToolMessageItem(sty, toolCall, result, canceled)
226 case tools.WriteToolName:
227 item = NewWriteToolMessageItem(sty, toolCall, result, canceled)
228 case tools.EditToolName:
229 item = NewEditToolMessageItem(sty, toolCall, result, canceled)
230 case tools.MultiEditToolName:
231 item = NewMultiEditToolMessageItem(sty, toolCall, result, canceled)
232 case tools.GlobToolName:
233 item = NewGlobToolMessageItem(sty, toolCall, result, canceled)
234 case tools.GrepToolName:
235 item = NewGrepToolMessageItem(sty, toolCall, result, canceled)
236 case tools.LSToolName:
237 item = NewLSToolMessageItem(sty, toolCall, result, canceled)
238 case tools.DownloadToolName:
239 item = NewDownloadToolMessageItem(sty, toolCall, result, canceled)
240 case tools.FetchToolName:
241 item = NewFetchToolMessageItem(sty, toolCall, result, canceled)
242 case tools.SourcegraphToolName:
243 item = NewSourcegraphToolMessageItem(sty, toolCall, result, canceled)
244 case tools.DiagnosticsToolName:
245 item = NewDiagnosticsToolMessageItem(sty, toolCall, result, canceled)
246 case agent.AgentToolName:
247 item = NewAgentToolMessageItem(sty, toolCall, result, canceled)
248 case tools.AgenticFetchToolName:
249 item = NewAgenticFetchToolMessageItem(sty, toolCall, result, canceled)
250 case tools.WebFetchToolName:
251 item = NewWebFetchToolMessageItem(sty, toolCall, result, canceled)
252 case tools.WebSearchToolName:
253 item = NewWebSearchToolMessageItem(sty, toolCall, result, canceled)
254 case tools.TodosToolName:
255 item = NewTodosToolMessageItem(sty, toolCall, result, canceled)
256 case tools.ReferencesToolName:
257 item = NewReferencesToolMessageItem(sty, toolCall, result, canceled)
258 case tools.LSPRestartToolName:
259 item = NewLSPRestartToolMessageItem(sty, toolCall, result, canceled)
260 default:
261 if IsDockerMCPTool(toolCall.Name) {
262 item = NewDockerMCPToolMessageItem(sty, toolCall, result, canceled)
263 } else if strings.HasPrefix(toolCall.Name, "mcp_") {
264 item = NewMCPToolMessageItem(sty, toolCall, result, canceled)
265 } else {
266 item = NewGenericToolMessageItem(sty, toolCall, result, canceled)
267 }
268 }
269 item.SetMessageID(messageID)
270 return item
271}
272
273// SetCompact implements the Compactable interface.
274func (t *baseToolMessageItem) SetCompact(compact bool) {
275 t.isCompact = compact
276 t.clearCache()
277}
278
279// ID returns the unique identifier for this tool message item.
280func (t *baseToolMessageItem) ID() string {
281 return t.toolCall.ID
282}
283
284// StartAnimation starts the assistant message animation if it should be spinning.
285func (t *baseToolMessageItem) StartAnimation() tea.Cmd {
286 if !t.isSpinning() {
287 return nil
288 }
289 return t.anim.Start()
290}
291
292// Animate progresses the assistant message animation if it should be spinning.
293func (t *baseToolMessageItem) Animate(msg anim.StepMsg) tea.Cmd {
294 if !t.isSpinning() {
295 return nil
296 }
297 return t.anim.Animate(msg)
298}
299
300// RawRender implements [MessageItem].
301func (t *baseToolMessageItem) RawRender(width int) string {
302 toolItemWidth := width - MessageLeftPaddingTotal
303 if t.hasCappedWidth {
304 toolItemWidth = cappedMessageWidth(width)
305 }
306
307 content, height, ok := t.getCachedRender(toolItemWidth)
308 // if we are spinning or there is no cache rerender
309 if !ok || t.isSpinning() {
310 content = t.toolRenderer.RenderTool(t.sty, toolItemWidth, &ToolRenderOpts{
311 ToolCall: t.toolCall,
312 Result: t.result,
313 Anim: t.anim,
314 ExpandedContent: t.expandedContent,
315 Compact: t.isCompact,
316 IsSpinning: t.isSpinning(),
317 Status: t.computeStatus(),
318 })
319
320 // Prepend hook indicator if hooks ran for this tool call.
321 if t.result != nil {
322 if hookLine := toolOutputHookIndicator(t.sty, t.result.Metadata, toolItemWidth); hookLine != "" {
323 content = hookLine + "\n\n" + content
324 }
325 }
326
327 height = lipgloss.Height(content)
328 // cache the rendered content
329 t.setCachedRender(content, toolItemWidth, height)
330 }
331
332 return t.renderHighlighted(content, toolItemWidth, height)
333}
334
335// Render renders the tool message item at the given width.
336func (t *baseToolMessageItem) Render(width int) string {
337 var prefix string
338 if t.isCompact {
339 prefix = t.sty.Messages.ToolCallCompact.Render()
340 } else if t.focused {
341 prefix = t.sty.Messages.ToolCallFocused.Render()
342 } else {
343 prefix = t.sty.Messages.ToolCallBlurred.Render()
344 }
345 lines := strings.Split(t.RawRender(width), "\n")
346 for i, ln := range lines {
347 lines[i] = prefix + ln
348 }
349 return strings.Join(lines, "\n")
350}
351
352// ToolCall returns the tool call associated with this message item.
353func (t *baseToolMessageItem) ToolCall() message.ToolCall {
354 return t.toolCall
355}
356
357// SetToolCall sets the tool call associated with this message item.
358func (t *baseToolMessageItem) SetToolCall(tc message.ToolCall) {
359 t.toolCall = tc
360 t.clearCache()
361}
362
363// SetResult sets the tool result associated with this message item.
364func (t *baseToolMessageItem) SetResult(res *message.ToolResult) {
365 t.result = res
366 t.clearCache()
367}
368
369// MessageID returns the ID of the message containing this tool call.
370func (t *baseToolMessageItem) MessageID() string {
371 return t.messageID
372}
373
374// SetMessageID sets the ID of the message containing this tool call.
375func (t *baseToolMessageItem) SetMessageID(id string) {
376 t.messageID = id
377}
378
379// SetStatus sets the tool status.
380func (t *baseToolMessageItem) SetStatus(status ToolStatus) {
381 t.status = status
382 t.clearCache()
383}
384
385// Status returns the current tool status.
386func (t *baseToolMessageItem) Status() ToolStatus {
387 return t.status
388}
389
390// computeStatus computes the effective status considering the result.
391func (t *baseToolMessageItem) computeStatus() ToolStatus {
392 if t.result != nil {
393 if t.result.IsError {
394 return ToolStatusError
395 }
396 return ToolStatusSuccess
397 }
398 return t.status
399}
400
401// isSpinning returns true if the tool should show animation.
402func (t *baseToolMessageItem) isSpinning() bool {
403 if t.spinningFunc != nil {
404 return t.spinningFunc(SpinningState{
405 ToolCall: t.toolCall,
406 Result: t.result,
407 Status: t.status,
408 })
409 }
410 return !t.toolCall.Finished && t.status != ToolStatusCanceled
411}
412
413// SetSpinningFunc sets a custom function to determine if the tool should spin.
414func (t *baseToolMessageItem) SetSpinningFunc(fn SpinningFunc) {
415 t.spinningFunc = fn
416}
417
418// ToggleExpanded toggles the expanded state of the thinking box.
419func (t *baseToolMessageItem) ToggleExpanded() bool {
420 t.expandedContent = !t.expandedContent
421 t.clearCache()
422 return t.expandedContent
423}
424
425// HandleMouseClick implements MouseClickable.
426func (t *baseToolMessageItem) HandleMouseClick(btn ansi.MouseButton, x, y int) bool {
427 return btn == ansi.MouseLeft
428}
429
430// HandleKeyEvent implements KeyEventHandler.
431func (t *baseToolMessageItem) HandleKeyEvent(key tea.KeyMsg) (bool, tea.Cmd) {
432 if k := key.String(); k == "c" || k == "y" {
433 text := t.formatToolForCopy()
434 return true, common.CopyToClipboard(text, "Tool content copied to clipboard")
435 }
436 return false, nil
437}
438
439// pendingTool renders a tool that is still in progress with an animation.
440func pendingTool(sty *styles.Styles, name string, anim *anim.Anim, nested bool) string {
441 icon := sty.Tool.IconPending.Render()
442 nameStyle := sty.Tool.NameNormal
443 if nested {
444 nameStyle = sty.Tool.NameNested
445 }
446 toolName := nameStyle.Render(name)
447
448 var animView string
449 if anim != nil {
450 animView = anim.Render()
451 }
452
453 return fmt.Sprintf("%s %s %s", icon, toolName, animView)
454}
455
456// toolEarlyStateContent handles error/cancelled/pending states before content rendering.
457// Returns the rendered output and true if early state was handled.
458func toolEarlyStateContent(sty *styles.Styles, opts *ToolRenderOpts, width int) (string, bool) {
459 var msg string
460 switch opts.Status {
461 case ToolStatusError:
462 msg = toolErrorContent(sty, opts.Result, width)
463 case ToolStatusCanceled:
464 msg = sty.Tool.StateCancelled.Render("Canceled.")
465 case ToolStatusAwaitingPermission:
466 msg = sty.Tool.StateWaiting.Render("Requesting permission...")
467 case ToolStatusRunning:
468 msg = sty.Tool.StateWaiting.Render("Waiting for tool response...")
469 default:
470 return "", false
471 }
472 return msg, true
473}
474
475// toolErrorContent formats an error message with ERROR tag.
476func toolErrorContent(sty *styles.Styles, result *message.ToolResult, width int) string {
477 if result == nil {
478 return ""
479 }
480 errContent := strings.ReplaceAll(result.Content, "\n", " ")
481 errTag := sty.Tool.ErrorTag.Render("ERROR")
482 tagWidth := lipgloss.Width(errTag)
483 errContent = ansi.Truncate(errContent, width-tagWidth-3, "…")
484 return fmt.Sprintf("%s %s", errTag, sty.Tool.ErrorMessage.Render(errContent))
485}
486
487// toolIcon returns the status icon for a tool call.
488// toolIcon returns the status icon for a tool call based on its status.
489func toolIcon(sty *styles.Styles, status ToolStatus) string {
490 switch status {
491 case ToolStatusSuccess:
492 return sty.Tool.IconSuccess.String()
493 case ToolStatusError:
494 return sty.Tool.IconError.String()
495 case ToolStatusCanceled:
496 return sty.Tool.IconCancelled.String()
497 default:
498 return sty.Tool.IconPending.String()
499 }
500}
501
502// toolParamList formats parameters as "main (key=value, ...)" with truncation.
503// toolParamList formats tool parameters as "main (key=value, ...)" with truncation.
504func toolParamList(sty *styles.Styles, params []string, width int) string {
505 // minSpaceForMainParam is the min space required for the main param
506 // if this is less that the value set we will only show the main param nothing else
507 const minSpaceForMainParam = 30
508 if len(params) == 0 {
509 return ""
510 }
511
512 mainParam := params[0]
513
514 // Build key=value pairs from remaining params (consecutive key, value pairs).
515 var kvPairs []string
516 for i := 1; i+1 < len(params); i += 2 {
517 if params[i+1] != "" {
518 kvPairs = append(kvPairs, fmt.Sprintf("%s=%s", params[i], params[i+1]))
519 }
520 }
521
522 // Try to include key=value pairs if there's enough space.
523 output := mainParam
524 if len(kvPairs) > 0 {
525 partsStr := strings.Join(kvPairs, ", ")
526 if remaining := width - lipgloss.Width(partsStr) - 3; remaining >= minSpaceForMainParam {
527 output = fmt.Sprintf("%s (%s)", mainParam, partsStr)
528 }
529 }
530
531 if width >= 0 {
532 output = ansi.Truncate(output, width, "…")
533 }
534 return sty.Tool.ParamMain.Render(output)
535}
536
537// toolHeader builds the tool header line: "● ToolName params..."
538func toolHeader(sty *styles.Styles, status ToolStatus, name string, width int, nested bool, params ...string) string {
539 icon := toolIcon(sty, status)
540 nameStyle := sty.Tool.NameNormal
541 if nested {
542 nameStyle = sty.Tool.NameNested
543 }
544 toolName := nameStyle.Render(name)
545 prefix := fmt.Sprintf("%s %s ", icon, toolName)
546 prefixWidth := lipgloss.Width(prefix)
547 remainingWidth := width - prefixWidth
548 paramsStr := toolParamList(sty, params, remainingWidth)
549 return prefix + paramsStr
550}
551
552// toolOutputPlainContent renders plain text with optional expansion support.
553func toolOutputPlainContent(sty *styles.Styles, content string, width int, expanded bool) string {
554 content = stringext.NormalizeSpace(content)
555 lines := strings.Split(content, "\n")
556
557 maxLines := responseContextHeight
558 if expanded {
559 maxLines = len(lines) // Show all
560 }
561
562 var out []string
563 for i, ln := range lines {
564 if i >= maxLines {
565 break
566 }
567 ln = " " + ln
568 if lipgloss.Width(ln) > width {
569 ln = ansi.Truncate(ln, width, "…")
570 }
571 out = append(out, sty.Tool.ContentLine.Width(width).Render(ln))
572 }
573
574 wasTruncated := len(lines) > responseContextHeight
575
576 if !expanded && wasTruncated {
577 out = append(out, sty.Tool.ContentTruncation.
578 Width(width).
579 Render(fmt.Sprintf(assistantMessageTruncateFormat, len(lines)-responseContextHeight)))
580 }
581
582 return strings.Join(out, "\n")
583}
584
585// toolOutputCodeContent renders code with syntax highlighting and line numbers.
586func toolOutputCodeContent(sty *styles.Styles, path, content string, offset, width int, expanded bool) string {
587 content = stringext.NormalizeSpace(content)
588
589 lines := strings.Split(content, "\n")
590 maxLines := responseContextHeight
591 if expanded {
592 maxLines = len(lines)
593 }
594
595 // Truncate if needed.
596 displayLines := lines
597 if len(lines) > maxLines {
598 displayLines = lines[:maxLines]
599 }
600
601 bg := sty.Tool.ContentCodeBg
602 highlighted, _ := common.SyntaxHighlight(sty, strings.Join(displayLines, "\n"), path, bg)
603 highlightedLines := strings.Split(highlighted, "\n")
604
605 // Calculate line number width.
606 maxLineNumber := len(displayLines) + offset
607 maxDigits := getDigits(maxLineNumber)
608 numFmt := fmt.Sprintf("%%%dd", maxDigits)
609
610 bodyWidth := width - toolBodyLeftPaddingTotal
611 codeWidth := bodyWidth - maxDigits
612
613 var out []string
614 for i, ln := range highlightedLines {
615 lineNum := sty.Tool.ContentLineNumber.Render(fmt.Sprintf(numFmt, i+1+offset))
616
617 // Truncate accounting for padding that will be added.
618 ln = ansi.Truncate(ln, codeWidth-sty.Tool.ContentCodeLine.GetHorizontalPadding(), "…")
619
620 codeLine := sty.Tool.ContentCodeLine.
621 Width(codeWidth).
622 Render(ln)
623
624 out = append(out, lipgloss.JoinHorizontal(lipgloss.Left, lineNum, codeLine))
625 }
626
627 // Add truncation message if needed.
628 if len(lines) > maxLines && !expanded {
629 out = append(out, sty.Tool.ContentCodeTruncation.
630 Width(width).
631 Render(fmt.Sprintf(assistantMessageTruncateFormat, len(lines)-maxLines)),
632 )
633 }
634
635 return sty.Tool.Body.Render(strings.Join(out, "\n"))
636}
637
638// toolOutputImageContent renders image data with size info.
639func toolOutputImageContent(sty *styles.Styles, data, mediaType string) string {
640 dataSize := len(data) * 3 / 4
641 sizeStr := formatSize(dataSize)
642
643 return sty.Tool.Body.Render(fmt.Sprintf(
644 "%s %s %s %s",
645 sty.Tool.ResourceLoadedText.Render("Loaded Image"),
646 sty.Tool.ResourceLoadedIndicator.Render(styles.ArrowRightIcon),
647 sty.Tool.MediaType.Render(mediaType),
648 sty.Tool.ResourceSize.Render(sizeStr),
649 ))
650}
651
652// toolOutputSkillContent renders a skill loaded indicator.
653func toolOutputSkillContent(sty *styles.Styles, name, description string) string {
654 return sty.Tool.Body.Render(fmt.Sprintf(
655 "%s %s %s %s",
656 sty.Tool.ResourceLoadedText.Render("Loaded Skill"),
657 sty.Tool.ResourceLoadedIndicator.Render(styles.ArrowRightIcon),
658 sty.Tool.ResourceName.Render(name),
659 sty.Tool.ResourceSize.Render(description),
660 ))
661}
662
663// toolOutputHookIndicator renders hook indicator lines from tool metadata.
664// Returns empty string if no hook metadata is present. Hook names are
665// sanitized (newlines replaced with ¶) and truncated to fit the available
666// horizontal space.
667func toolOutputHookIndicator(sty *styles.Styles, metadata string, width int) string {
668 if metadata == "" {
669 return ""
670 }
671 var meta struct {
672 Hook *hooks.HookMetadata `json:"hook"`
673 }
674 if err := json.Unmarshal([]byte(metadata), &meta); err != nil || meta.Hook == nil {
675 return ""
676 }
677 h := meta.Hook
678 if len(h.Hooks) == 0 {
679 return ""
680 }
681
682 // Sanitize names (replace newlines with ¶) and compute max widths
683 // for the name, matcher, and detail columns so they align. The name
684 // column is capped at maxHookNameWidth characters.
685 const maxHookNameWidth = 30
686 sanitizedNames := make([]string, len(h.Hooks))
687 details := make([]string, len(h.Hooks))
688 maxNameWidth := 0
689 maxMatcherWidth := 0
690 maxDetailWidth := 0
691 for i, hi := range h.Hooks {
692 sanitizedNames[i] = strings.ReplaceAll(hi.Name, "\n", "¶")
693 w := lipgloss.Width(sty.Tool.HookName.Render(sanitizedNames[i]))
694 if w > maxNameWidth {
695 maxNameWidth = w
696 }
697 if hi.Matcher != "" {
698 mw := lipgloss.Width(sty.Tool.HookMatcher.Render(hi.Matcher))
699 if mw > maxMatcherWidth {
700 maxMatcherWidth = mw
701 }
702 }
703 details[i] = hookDetail(sty, hi)
704 if dw := lipgloss.Width(details[i]); dw > maxDetailWidth {
705 maxDetailWidth = dw
706 }
707 }
708
709 if maxNameWidth > maxHookNameWidth {
710 maxNameWidth = maxHookNameWidth
711 }
712
713 // Cap the name column so the widest line still fits in width. The
714 // per-line layout is:
715 // "Hook " + name(padded) + [" " + matcher(padded)] + " → " + detail
716 if width > 0 {
717 fixed := lipgloss.Width(sty.Tool.HookLabel.Render("Hook")) + 1
718 if maxMatcherWidth > 0 {
719 fixed += 1 + maxMatcherWidth
720 }
721 fixed += 1 + lipgloss.Width(sty.Tool.HookArrow.Render(styles.ArrowRightIcon)) + 1
722 fixed += maxDetailWidth
723 if budget := width - fixed; budget < maxNameWidth {
724 maxNameWidth = max(1, budget)
725 }
726 }
727
728 var lines []string
729 for i, hi := range h.Hooks {
730 name := truncateHookName(sanitizedNames[i], maxNameWidth)
731 lines = append(lines, renderHookLine(sty, hi, name, details[i], maxNameWidth, maxMatcherWidth))
732 }
733 return strings.Join(lines, "\n")
734}
735
736// truncateHookName truncates a hook name to fit within maxWidth cells,
737// using left-truncation for absolute paths (e.g. `…/format.sh`) and
738// right-truncation for everything else. Left-truncation is only applied
739// when the name looks unambiguously like a path: absolute, single-line,
740// and contains no spaces.
741func truncateHookName(name string, maxWidth int) string {
742 if ansi.StringWidth(name) <= maxWidth {
743 return name
744 }
745 if isLikelyPath(name) {
746 // ansi.TruncateLeft removes n graphemes from the start; pick n
747 // so the result plus the "…" prefix fits in maxWidth.
748 n := ansi.StringWidth(name) - maxWidth + 1
749 return ansi.TruncateLeft(name, n, "…")
750 }
751 return ansi.Truncate(name, maxWidth, "…")
752}
753
754// isLikelyPath reports whether s looks unambiguously like a filesystem
755// path, suitable for left-truncation. We accept absolute paths and
756// relative paths that contain a separator and no shell-ish characters.
757func isLikelyPath(s string) bool {
758 if s == "" || strings.ContainsAny(s, " \t\n¶'\"|&;<>$`*?(){}[]\\") {
759 return false
760 }
761 if filepath.IsAbs(s) {
762 return true
763 }
764 return strings.Contains(s, "/")
765}
766
767// renderHookLine renders a single hook indicator line with aligned columns.
768func renderHookLine(sty *styles.Styles, hi hooks.HookInfo, rawName, detail string, maxNameWidth, maxMatcherWidth int) string {
769 name := sty.Tool.HookName.Render(rawName)
770 namePad := strings.Repeat(" ", max(0, maxNameWidth-lipgloss.Width(name)))
771
772 var matcherPart string
773 if maxMatcherWidth > 0 {
774 if hi.Matcher != "" {
775 matcher := sty.Tool.HookMatcher.Render(hi.Matcher)
776 matcherPad := strings.Repeat(" ", maxMatcherWidth-lipgloss.Width(matcher))
777 matcherPart = " " + matcher + matcherPad
778 } else {
779 matcherPart = " " + strings.Repeat(" ", maxMatcherWidth)
780 }
781 }
782
783 labelStyle := sty.Tool.HookLabel
784 arrowStyle := sty.Tool.HookArrow
785 if hi.Decision == "deny" {
786 labelStyle = sty.Tool.HookDeniedLabel
787 arrowStyle = sty.Tool.HookDeniedLabel
788 }
789
790 return fmt.Sprintf("%s %s%s%s %s %s",
791 labelStyle.Render("Hook"),
792 name,
793 namePad,
794 matcherPart,
795 arrowStyle.Render(styles.ArrowRightIcon),
796 detail,
797 )
798}
799
800// hookDetail returns the styled detail text for a single hook result.
801func hookDetail(sty *styles.Styles, hi hooks.HookInfo) string {
802 const (
803 okMessage = "OK"
804 denialMessage = "Denied"
805 rewroteMessage = "Rewrote Output"
806 )
807 switch hi.Decision {
808 case "deny":
809 if hi.Reason != "" {
810 return sty.Tool.HookDenied.Render(denialMessage) + " " + sty.Tool.HookDeniedReason.Render(hi.Reason)
811 }
812 return sty.Tool.HookDenied.Render(denialMessage)
813 case "allow":
814 result := sty.Tool.HookOK.Render(okMessage)
815 if hi.InputRewrite {
816 result += " " + sty.Tool.HookRewrote.Render(rewroteMessage)
817 }
818 return result
819 default:
820 result := sty.Tool.HookOK.Render(okMessage)
821 if hi.InputRewrite {
822 result += " " + sty.Tool.HookRewrote.Render(rewroteMessage)
823 }
824 return result
825 }
826}
827
828// getDigits returns the number of digits in a number.
829func getDigits(n int) int {
830 if n == 0 {
831 return 1
832 }
833 if n < 0 {
834 n = -n
835 }
836 digits := 0
837 for n > 0 {
838 n /= 10
839 digits++
840 }
841 return digits
842}
843
844// formatSize formats byte size into human readable format.
845func formatSize(bytes int) string {
846 const (
847 kb = 1024
848 mb = kb * 1024
849 )
850 switch {
851 case bytes >= mb:
852 return fmt.Sprintf("%.1f MB", float64(bytes)/float64(mb))
853 case bytes >= kb:
854 return fmt.Sprintf("%.1f KB", float64(bytes)/float64(kb))
855 default:
856 return fmt.Sprintf("%d B", bytes)
857 }
858}
859
860// toolOutputDiffContent renders a diff between old and new content.
861func toolOutputDiffContent(sty *styles.Styles, file, oldContent, newContent string, width int, expanded bool) string {
862 bodyWidth := width - toolBodyLeftPaddingTotal
863
864 formatter := common.DiffFormatter(sty).
865 Before(file, oldContent).
866 After(file, newContent).
867 Width(bodyWidth)
868
869 // Use split view for wide terminals.
870 if width > maxTextWidth {
871 formatter = formatter.Split()
872 }
873
874 formatted := formatter.String()
875 lines := strings.Split(formatted, "\n")
876
877 // Truncate if needed.
878 maxLines := responseContextHeight
879 if expanded {
880 maxLines = len(lines)
881 }
882
883 if len(lines) > maxLines && !expanded {
884 truncMsg := sty.Tool.DiffTruncation.
885 Width(bodyWidth).
886 Render(fmt.Sprintf(assistantMessageTruncateFormat, len(lines)-maxLines))
887 formatted = strings.Join(lines[:maxLines], "\n") + "\n" + truncMsg
888 }
889
890 return sty.Tool.Body.Render(formatted)
891}
892
893// formatTimeout converts timeout seconds to a duration string (e.g., "30s").
894// Returns empty string if timeout is 0.
895func formatTimeout(timeout int) string {
896 if timeout == 0 {
897 return ""
898 }
899 return fmt.Sprintf("%ds", timeout)
900}
901
902// formatNonZero returns string representation of non-zero integers, empty string for zero.
903func formatNonZero(value int) string {
904 if value == 0 {
905 return ""
906 }
907 return fmt.Sprintf("%d", value)
908}
909
910// toolOutputMultiEditDiffContent renders a diff with optional failed edits note.
911func toolOutputMultiEditDiffContent(sty *styles.Styles, file string, meta tools.MultiEditResponseMetadata, totalEdits, width int, expanded bool) string {
912 bodyWidth := width - toolBodyLeftPaddingTotal
913
914 formatter := common.DiffFormatter(sty).
915 Before(file, meta.OldContent).
916 After(file, meta.NewContent).
917 Width(bodyWidth)
918
919 // Use split view for wide terminals.
920 if width > maxTextWidth {
921 formatter = formatter.Split()
922 }
923
924 formatted := formatter.String()
925 lines := strings.Split(formatted, "\n")
926
927 // Truncate if needed.
928 maxLines := responseContextHeight
929 if expanded {
930 maxLines = len(lines)
931 }
932
933 if len(lines) > maxLines && !expanded {
934 truncMsg := sty.Tool.DiffTruncation.
935 Width(bodyWidth).
936 Render(fmt.Sprintf(assistantMessageTruncateFormat, len(lines)-maxLines))
937 formatted = truncMsg + "\n" + strings.Join(lines[:maxLines], "\n")
938 }
939
940 // Add failed edits note if any exist.
941 if len(meta.EditsFailed) > 0 {
942 noteTag := sty.Tool.NoteTag.Render("Note")
943 noteMsg := fmt.Sprintf("%d of %d edits succeeded", meta.EditsApplied, totalEdits)
944 note := fmt.Sprintf("%s %s", noteTag, sty.Tool.NoteMessage.Render(noteMsg))
945 formatted = formatted + "\n\n" + note
946 }
947
948 return sty.Tool.Body.Render(formatted)
949}
950
951// roundedEnumerator creates a tree enumerator with rounded corners.
952func roundedEnumerator(lPadding, width int) tree.Enumerator {
953 if width == 0 {
954 width = 2
955 }
956 if lPadding == 0 {
957 lPadding = 1
958 }
959 return func(children tree.Children, index int) string {
960 line := strings.Repeat("─", width)
961 padding := strings.Repeat(" ", lPadding)
962 if children.Length()-1 == index {
963 return padding + "╰" + line
964 }
965 return padding + "├" + line
966 }
967}
968
969// toolOutputMarkdownContent renders markdown content with optional truncation.
970func toolOutputMarkdownContent(sty *styles.Styles, content string, width int, expanded bool) string {
971 content = stringext.NormalizeSpace(content)
972
973 // Cap width for readability.
974 if width > maxTextWidth {
975 width = maxTextWidth
976 }
977
978 renderer := common.QuietMarkdownRenderer(sty, width)
979 rendered, err := renderer.Render(content)
980 if err != nil {
981 return toolOutputPlainContent(sty, content, width, expanded)
982 }
983
984 lines := strings.Split(rendered, "\n")
985 maxLines := responseContextHeight
986 if expanded {
987 maxLines = len(lines)
988 }
989
990 var out []string
991 for i, ln := range lines {
992 if i >= maxLines {
993 break
994 }
995 out = append(out, ln)
996 }
997
998 if len(lines) > maxLines && !expanded {
999 out = append(out, sty.Tool.ContentTruncation.
1000 Width(width).
1001 Render(fmt.Sprintf(assistantMessageTruncateFormat, len(lines)-maxLines)),
1002 )
1003 }
1004
1005 return sty.Tool.Body.Render(strings.Join(out, "\n"))
1006}
1007
1008// formatToolForCopy formats the tool call for clipboard copying.
1009func (t *baseToolMessageItem) formatToolForCopy() string {
1010 var parts []string
1011
1012 toolName := prettifyToolName(t.toolCall.Name)
1013 parts = append(parts, fmt.Sprintf("## %s Tool Call", toolName))
1014
1015 if t.toolCall.Input != "" {
1016 params := t.formatParametersForCopy()
1017 if params != "" {
1018 parts = append(parts, "### Parameters:")
1019 parts = append(parts, params)
1020 }
1021 }
1022
1023 if t.result != nil && t.result.ToolCallID != "" {
1024 if t.result.IsError {
1025 parts = append(parts, "### Error:")
1026 parts = append(parts, t.result.Content)
1027 } else {
1028 parts = append(parts, "### Result:")
1029 content := t.formatResultForCopy()
1030 if content != "" {
1031 parts = append(parts, content)
1032 }
1033 }
1034 } else if t.status == ToolStatusCanceled {
1035 parts = append(parts, "### Status:")
1036 parts = append(parts, "Cancelled")
1037 } else {
1038 parts = append(parts, "### Status:")
1039 parts = append(parts, "Pending...")
1040 }
1041
1042 return strings.Join(parts, "\n\n")
1043}
1044
1045// formatParametersForCopy formats tool parameters for clipboard copying.
1046func (t *baseToolMessageItem) formatParametersForCopy() string {
1047 switch t.toolCall.Name {
1048 case tools.BashToolName:
1049 var params tools.BashParams
1050 if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) == nil {
1051 cmd := strings.ReplaceAll(params.Command, "\n", " ")
1052 cmd = strings.ReplaceAll(cmd, "\t", " ")
1053 return fmt.Sprintf("**Command:** %s", cmd)
1054 }
1055 case tools.ViewToolName:
1056 var params tools.ViewParams
1057 if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) == nil {
1058 var parts []string
1059 parts = append(parts, fmt.Sprintf("**File:** %s", fsext.PrettyPath(params.FilePath)))
1060 if params.Limit > 0 {
1061 parts = append(parts, fmt.Sprintf("**Limit:** %d", params.Limit))
1062 }
1063 if params.Offset > 0 {
1064 parts = append(parts, fmt.Sprintf("**Offset:** %d", params.Offset))
1065 }
1066 return strings.Join(parts, "\n")
1067 }
1068 case tools.EditToolName:
1069 var params tools.EditParams
1070 if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) == nil {
1071 return fmt.Sprintf("**File:** %s", fsext.PrettyPath(params.FilePath))
1072 }
1073 case tools.MultiEditToolName:
1074 var params tools.MultiEditParams
1075 if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) == nil {
1076 var parts []string
1077 parts = append(parts, fmt.Sprintf("**File:** %s", fsext.PrettyPath(params.FilePath)))
1078 parts = append(parts, fmt.Sprintf("**Edits:** %d", len(params.Edits)))
1079 return strings.Join(parts, "\n")
1080 }
1081 case tools.TouchToolName:
1082 var params tools.TouchParams
1083 if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) == nil {
1084 return fmt.Sprintf("**File:** %s", fsext.PrettyPath(params.FilePath))
1085 }
1086 case tools.WriteToolName:
1087 var params tools.WriteParams
1088 if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) == nil {
1089 return fmt.Sprintf("**File:** %s", fsext.PrettyPath(params.FilePath))
1090 }
1091 case tools.FetchToolName:
1092 var params tools.FetchParams
1093 if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) == nil {
1094 var parts []string
1095 parts = append(parts, fmt.Sprintf("**URL:** %s", params.URL))
1096 if params.Format != "" {
1097 parts = append(parts, fmt.Sprintf("**Format:** %s", params.Format))
1098 }
1099 if params.Timeout > 0 {
1100 parts = append(parts, fmt.Sprintf("**Timeout:** %ds", params.Timeout))
1101 }
1102 return strings.Join(parts, "\n")
1103 }
1104 case tools.AgenticFetchToolName:
1105 var params tools.AgenticFetchParams
1106 if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) == nil {
1107 var parts []string
1108 if params.URL != "" {
1109 parts = append(parts, fmt.Sprintf("**URL:** %s", params.URL))
1110 }
1111 if params.Prompt != "" {
1112 parts = append(parts, fmt.Sprintf("**Prompt:** %s", params.Prompt))
1113 }
1114 return strings.Join(parts, "\n")
1115 }
1116 case tools.WebFetchToolName:
1117 var params tools.WebFetchParams
1118 if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) == nil {
1119 return fmt.Sprintf("**URL:** %s", params.URL)
1120 }
1121 case tools.GrepToolName:
1122 var params tools.GrepParams
1123 if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) == nil {
1124 var parts []string
1125 parts = append(parts, fmt.Sprintf("**Pattern:** %s", params.Pattern))
1126 if params.Path != "" {
1127 parts = append(parts, fmt.Sprintf("**Path:** %s", params.Path))
1128 }
1129 if params.Include != "" {
1130 parts = append(parts, fmt.Sprintf("**Include:** %s", params.Include))
1131 }
1132 if params.LiteralText {
1133 parts = append(parts, "**Literal:** true")
1134 }
1135 return strings.Join(parts, "\n")
1136 }
1137 case tools.GlobToolName:
1138 var params tools.GlobParams
1139 if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) == nil {
1140 var parts []string
1141 parts = append(parts, fmt.Sprintf("**Pattern:** %s", params.Pattern))
1142 if params.Path != "" {
1143 parts = append(parts, fmt.Sprintf("**Path:** %s", params.Path))
1144 }
1145 return strings.Join(parts, "\n")
1146 }
1147 case tools.LSToolName:
1148 var params tools.LSParams
1149 if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) == nil {
1150 path := params.Path
1151 if path == "" {
1152 path = "."
1153 }
1154 return fmt.Sprintf("**Path:** %s", fsext.PrettyPath(path))
1155 }
1156 case tools.DownloadToolName:
1157 var params tools.DownloadParams
1158 if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) == nil {
1159 var parts []string
1160 parts = append(parts, fmt.Sprintf("**URL:** %s", params.URL))
1161 parts = append(parts, fmt.Sprintf("**File Path:** %s", fsext.PrettyPath(params.FilePath)))
1162 if params.Timeout > 0 {
1163 parts = append(parts, fmt.Sprintf("**Timeout:** %s", (time.Duration(params.Timeout)*time.Second).String()))
1164 }
1165 return strings.Join(parts, "\n")
1166 }
1167 case tools.SourcegraphToolName:
1168 var params tools.SourcegraphParams
1169 if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) == nil {
1170 var parts []string
1171 parts = append(parts, fmt.Sprintf("**Query:** %s", params.Query))
1172 if params.Count > 0 {
1173 parts = append(parts, fmt.Sprintf("**Count:** %d", params.Count))
1174 }
1175 if params.ContextWindow > 0 {
1176 parts = append(parts, fmt.Sprintf("**Context:** %d", params.ContextWindow))
1177 }
1178 return strings.Join(parts, "\n")
1179 }
1180 case tools.DiagnosticsToolName:
1181 return "**Project:** diagnostics"
1182 case agent.AgentToolName:
1183 var params agent.AgentParams
1184 if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) == nil {
1185 return fmt.Sprintf("**Task:**\n%s", params.Prompt)
1186 }
1187 }
1188
1189 var params map[string]any
1190 if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) == nil {
1191 var parts []string
1192 for key, value := range params {
1193 displayKey := strings.ReplaceAll(key, "_", " ")
1194 if len(displayKey) > 0 {
1195 displayKey = strings.ToUpper(displayKey[:1]) + displayKey[1:]
1196 }
1197 parts = append(parts, fmt.Sprintf("**%s:** %v", displayKey, value))
1198 }
1199 return strings.Join(parts, "\n")
1200 }
1201
1202 return ""
1203}
1204
1205// formatResultForCopy formats tool results for clipboard copying.
1206func (t *baseToolMessageItem) formatResultForCopy() string {
1207 if t.result == nil {
1208 return ""
1209 }
1210
1211 if t.result.Data != "" {
1212 if strings.HasPrefix(t.result.MIMEType, "image/") {
1213 return fmt.Sprintf("[Image: %s]", t.result.MIMEType)
1214 }
1215 return fmt.Sprintf("[Media: %s]", t.result.MIMEType)
1216 }
1217
1218 switch t.toolCall.Name {
1219 case tools.BashToolName:
1220 return t.formatBashResultForCopy()
1221 case tools.ViewToolName:
1222 return t.formatViewResultForCopy()
1223 case tools.EditToolName:
1224 return t.formatEditResultForCopy()
1225 case tools.MultiEditToolName:
1226 return t.formatMultiEditResultForCopy()
1227 case tools.TouchToolName:
1228 return t.formatTouchResultForCopy()
1229 case tools.WriteToolName:
1230 return t.formatWriteResultForCopy()
1231 case tools.FetchToolName:
1232 return t.formatFetchResultForCopy()
1233 case tools.AgenticFetchToolName:
1234 return t.formatAgenticFetchResultForCopy()
1235 case tools.WebFetchToolName:
1236 return t.formatWebFetchResultForCopy()
1237 case agent.AgentToolName:
1238 return t.formatAgentResultForCopy()
1239 case tools.DownloadToolName, tools.GrepToolName, tools.GlobToolName, tools.LSToolName, tools.SourcegraphToolName, tools.DiagnosticsToolName, tools.TodosToolName:
1240 return fmt.Sprintf("```\n%s\n```", t.result.Content)
1241 default:
1242 return t.result.Content
1243 }
1244}
1245
1246// formatBashResultForCopy formats bash tool results for clipboard.
1247func (t *baseToolMessageItem) formatBashResultForCopy() string {
1248 if t.result == nil {
1249 return ""
1250 }
1251
1252 var meta tools.BashResponseMetadata
1253 if t.result.Metadata != "" {
1254 json.Unmarshal([]byte(t.result.Metadata), &meta)
1255 }
1256
1257 output := meta.Output
1258 if output == "" && t.result.Content != tools.BashNoOutput {
1259 output = t.result.Content
1260 }
1261
1262 if output == "" {
1263 return ""
1264 }
1265
1266 return fmt.Sprintf("```bash\n%s\n```", output)
1267}
1268
1269// formatViewResultForCopy formats view tool results for clipboard.
1270func (t *baseToolMessageItem) formatViewResultForCopy() string {
1271 if t.result == nil {
1272 return ""
1273 }
1274
1275 var meta tools.ViewResponseMetadata
1276 if t.result.Metadata != "" {
1277 json.Unmarshal([]byte(t.result.Metadata), &meta)
1278 }
1279
1280 if meta.Content == "" {
1281 return t.result.Content
1282 }
1283
1284 lang := ""
1285 if meta.FilePath != "" {
1286 ext := strings.ToLower(filepath.Ext(meta.FilePath))
1287 switch ext {
1288 case ".go":
1289 lang = "go"
1290 case ".js", ".mjs":
1291 lang = "javascript"
1292 case ".ts":
1293 lang = "typescript"
1294 case ".py":
1295 lang = "python"
1296 case ".rs":
1297 lang = "rust"
1298 case ".java":
1299 lang = "java"
1300 case ".c":
1301 lang = "c"
1302 case ".cpp", ".cc", ".cxx":
1303 lang = "cpp"
1304 case ".sh", ".bash":
1305 lang = "bash"
1306 case ".json":
1307 lang = "json"
1308 case ".yaml", ".yml":
1309 lang = "yaml"
1310 case ".xml":
1311 lang = "xml"
1312 case ".html":
1313 lang = "html"
1314 case ".css":
1315 lang = "css"
1316 case ".md":
1317 lang = "markdown"
1318 }
1319 }
1320
1321 var result strings.Builder
1322 if lang != "" {
1323 fmt.Fprintf(&result, "```%s\n", lang)
1324 } else {
1325 result.WriteString("```\n")
1326 }
1327 result.WriteString(meta.Content)
1328 result.WriteString("\n```")
1329
1330 return result.String()
1331}
1332
1333// formatEditResultForCopy formats edit tool results for clipboard.
1334func (t *baseToolMessageItem) formatEditResultForCopy() string {
1335 if t.result == nil || t.result.Metadata == "" {
1336 if t.result != nil {
1337 return t.result.Content
1338 }
1339 return ""
1340 }
1341
1342 var meta tools.EditResponseMetadata
1343 if json.Unmarshal([]byte(t.result.Metadata), &meta) != nil {
1344 return t.result.Content
1345 }
1346
1347 var params tools.EditParams
1348 json.Unmarshal([]byte(t.toolCall.Input), ¶ms)
1349
1350 var result strings.Builder
1351
1352 if meta.OldContent != "" || meta.NewContent != "" {
1353 fileName := params.FilePath
1354 if fileName != "" {
1355 fileName = fsext.PrettyPath(fileName)
1356 }
1357 diffContent, additions, removals := diff.GenerateDiff(meta.OldContent, meta.NewContent, fileName)
1358
1359 fmt.Fprintf(&result, "Changes: +%d -%d\n", additions, removals)
1360 result.WriteString("```diff\n")
1361 result.WriteString(diffContent)
1362 result.WriteString("\n```")
1363 }
1364
1365 return result.String()
1366}
1367
1368// formatMultiEditResultForCopy formats multi-edit tool results for clipboard.
1369func (t *baseToolMessageItem) formatMultiEditResultForCopy() string {
1370 if t.result == nil || t.result.Metadata == "" {
1371 if t.result != nil {
1372 return t.result.Content
1373 }
1374 return ""
1375 }
1376
1377 var meta tools.MultiEditResponseMetadata
1378 if json.Unmarshal([]byte(t.result.Metadata), &meta) != nil {
1379 return t.result.Content
1380 }
1381
1382 var params tools.MultiEditParams
1383 json.Unmarshal([]byte(t.toolCall.Input), ¶ms)
1384
1385 var result strings.Builder
1386 if meta.OldContent != "" || meta.NewContent != "" {
1387 fileName := params.FilePath
1388 if fileName != "" {
1389 fileName = fsext.PrettyPath(fileName)
1390 }
1391 diffContent, additions, removals := diff.GenerateDiff(meta.OldContent, meta.NewContent, fileName)
1392
1393 fmt.Fprintf(&result, "Changes: +%d -%d\n", additions, removals)
1394 result.WriteString("```diff\n")
1395 result.WriteString(diffContent)
1396 result.WriteString("\n```")
1397 }
1398
1399 return result.String()
1400}
1401
1402// formatTouchResultForCopy formats touch tool results for clipboard.
1403func (t *baseToolMessageItem) formatTouchResultForCopy() string {
1404 if t.result == nil {
1405 return ""
1406 }
1407
1408 var params tools.TouchParams
1409 if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) != nil {
1410 return t.result.Content
1411 }
1412
1413 return fmt.Sprintf("File: %s\n```\n```", fsext.PrettyPath(params.FilePath))
1414}
1415
1416// formatWriteResultForCopy formats write tool results for clipboard.
1417func (t *baseToolMessageItem) formatWriteResultForCopy() string {
1418 if t.result == nil {
1419 return ""
1420 }
1421
1422 var params tools.WriteParams
1423 if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) != nil {
1424 return t.result.Content
1425 }
1426
1427 lang := ""
1428 if params.FilePath != "" {
1429 ext := strings.ToLower(filepath.Ext(params.FilePath))
1430 switch ext {
1431 case ".go":
1432 lang = "go"
1433 case ".js", ".mjs":
1434 lang = "javascript"
1435 case ".ts":
1436 lang = "typescript"
1437 case ".py":
1438 lang = "python"
1439 case ".rs":
1440 lang = "rust"
1441 case ".java":
1442 lang = "java"
1443 case ".c":
1444 lang = "c"
1445 case ".cpp", ".cc", ".cxx":
1446 lang = "cpp"
1447 case ".sh", ".bash":
1448 lang = "bash"
1449 case ".json":
1450 lang = "json"
1451 case ".yaml", ".yml":
1452 lang = "yaml"
1453 case ".xml":
1454 lang = "xml"
1455 case ".html":
1456 lang = "html"
1457 case ".css":
1458 lang = "css"
1459 case ".md":
1460 lang = "markdown"
1461 }
1462 }
1463
1464 var result strings.Builder
1465 fmt.Fprintf(&result, "File: %s\n", fsext.PrettyPath(params.FilePath))
1466 if lang != "" {
1467 fmt.Fprintf(&result, "```%s\n", lang)
1468 } else {
1469 result.WriteString("```\n")
1470 }
1471 result.WriteString(params.Content)
1472 result.WriteString("\n```")
1473
1474 return result.String()
1475}
1476
1477// formatFetchResultForCopy formats fetch tool results for clipboard.
1478func (t *baseToolMessageItem) formatFetchResultForCopy() string {
1479 if t.result == nil {
1480 return ""
1481 }
1482
1483 var params tools.FetchParams
1484 if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) != nil {
1485 return t.result.Content
1486 }
1487
1488 var result strings.Builder
1489 if params.URL != "" {
1490 fmt.Fprintf(&result, "URL: %s\n", params.URL)
1491 }
1492 if params.Format != "" {
1493 fmt.Fprintf(&result, "Format: %s\n", params.Format)
1494 }
1495 if params.Timeout > 0 {
1496 fmt.Fprintf(&result, "Timeout: %ds\n", params.Timeout)
1497 }
1498 result.WriteString("\n")
1499
1500 result.WriteString(t.result.Content)
1501
1502 return result.String()
1503}
1504
1505// formatAgenticFetchResultForCopy formats agentic fetch tool results for clipboard.
1506func (t *baseToolMessageItem) formatAgenticFetchResultForCopy() string {
1507 if t.result == nil {
1508 return ""
1509 }
1510
1511 var params tools.AgenticFetchParams
1512 if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) != nil {
1513 return t.result.Content
1514 }
1515
1516 var result strings.Builder
1517 if params.URL != "" {
1518 fmt.Fprintf(&result, "URL: %s\n", params.URL)
1519 }
1520 if params.Prompt != "" {
1521 fmt.Fprintf(&result, "Prompt: %s\n\n", params.Prompt)
1522 }
1523
1524 result.WriteString("```markdown\n")
1525 result.WriteString(t.result.Content)
1526 result.WriteString("\n```")
1527
1528 return result.String()
1529}
1530
1531// formatWebFetchResultForCopy formats web fetch tool results for clipboard.
1532func (t *baseToolMessageItem) formatWebFetchResultForCopy() string {
1533 if t.result == nil {
1534 return ""
1535 }
1536
1537 var params tools.WebFetchParams
1538 if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) != nil {
1539 return t.result.Content
1540 }
1541
1542 var result strings.Builder
1543 fmt.Fprintf(&result, "URL: %s\n\n", params.URL)
1544 result.WriteString("```markdown\n")
1545 result.WriteString(t.result.Content)
1546 result.WriteString("\n```")
1547
1548 return result.String()
1549}
1550
1551// formatAgentResultForCopy formats agent tool results for clipboard.
1552func (t *baseToolMessageItem) formatAgentResultForCopy() string {
1553 if t.result == nil {
1554 return ""
1555 }
1556
1557 var result strings.Builder
1558
1559 if t.result.Content != "" {
1560 fmt.Fprintf(&result, "```markdown\n%s\n```", t.result.Content)
1561 }
1562
1563 return result.String()
1564}
1565
1566// prettifyToolName returns a human-readable name for tool names.
1567func prettifyToolName(name string) string {
1568 switch name {
1569 case agent.AgentToolName:
1570 return "Agent"
1571 case tools.BashToolName:
1572 return "Bash"
1573 case tools.JobOutputToolName:
1574 return "Job: Output"
1575 case tools.JobKillToolName:
1576 return "Job: Kill"
1577 case tools.DownloadToolName:
1578 return "Download"
1579 case tools.EditToolName:
1580 return "Edit"
1581 case tools.MultiEditToolName:
1582 return "Multi-Edit"
1583 case tools.FetchToolName:
1584 return "Fetch"
1585 case tools.AgenticFetchToolName:
1586 return "Agentic Fetch"
1587 case tools.WebFetchToolName:
1588 return "Fetch"
1589 case tools.WebSearchToolName:
1590 return "Search"
1591 case tools.GlobToolName:
1592 return "Glob"
1593 case tools.GrepToolName:
1594 return "Grep"
1595 case tools.LSToolName:
1596 return "List"
1597 case tools.SourcegraphToolName:
1598 return "Sourcegraph"
1599 case tools.TodosToolName:
1600 return "To-Do"
1601 case tools.TouchToolName:
1602 return "Touch"
1603 case tools.ViewToolName:
1604 return "View"
1605 case tools.WriteToolName:
1606 return "Write"
1607 default:
1608 return humanizedToolName(name)
1609 }
1610}