Files
2026-08-14 23:41:57 +08:00

196 lines
6.1 KiB
Go

package tui
import (
"fmt"
"sort"
"strings"
"charm.land/lipgloss/v2"
"github.com/smallnest/pigo/internal/cli/ui"
)
// This file implements the rich tool-call card component (US-006, SPEC 3.2,
// FR-6/7/8). A toolCard is a bordered inline block in the transcript that shows
// a single tool invocation: a header with the tool name and a status icon
// (running / success / warn), the decoded call arguments, and the tool's
// response rendered as an indented tree. Cards are created on toolStartMsg,
// completed on toolEndMsg, and toggled between a capped and a full response view
// with Ctrl+O (see model.go). All width math goes through ui.Width /
// WrapToWidth / TruncateToWidth so CJK and emoji (two columns) never split.
// cardState is the lifecycle of a tool card: running while the tool executes,
// then success or warn once it finishes (warn covers a reported tool error).
type cardState int
const (
cardRunning cardState = iota
cardSuccess
cardWarn
)
// respNode is one line of a tool's response, with depth giving the tree indent
// level (each level is rendered as two leading spaces).
type respNode struct {
text string
depth int
}
// toolCard is a single tool invocation rendered as a bordered card. input holds
// the decoded call arguments (nil when the args were not a JSON object);
// response is the parsed result tree, populated on completion. expanded flips
// the response between a capped preview and the full tree.
type toolCard struct {
id string
name string
input map[string]any
response []respNode
state cardState
expanded bool
}
// collapsedResponseLines is how many response lines a card shows before it is
// expanded; past this the preview is truncated and a Ctrl+O hint is appended.
const collapsedResponseLines = 5
// statusIcon returns the header status glyph for the card's state. Running is a
// spinner-like ellipsis, success a check, warn a bang.
func (c toolCard) statusIcon() string {
switch c.state {
case cardSuccess:
return "✓"
case cardWarn:
return "!"
default:
return "…"
}
}
// styledIcon renders the status glyph with the state's theme color: gray while
// running, green on success, yellow/red on warn.
func (c toolCard) styledIcon(theme Theme) string {
icon := c.statusIcon()
switch c.state {
case cardSuccess:
return theme.Success.Render(icon)
case cardWarn:
return theme.Warn.Render(icon)
default:
return theme.System.Render(icon)
}
}
// render draws the card at the given content width: a rounded border wrapping a
// header (status icon + tool name), an "Input arguments" section listing the input map,
// and a "Response" section with the tree lines. When not expanded the response
// is capped to collapsedResponseLines with a "(Ctrl+O for more)" hint; when
// expanded every line is shown.
func (c toolCard) render(theme Theme, width int) string {
if width < 4 {
width = 4
}
// The rounded border consumes one column on each side; wrap everything to the
// inner width so nothing overflows the frame.
inner := width - 2
var lines []string
icon := c.styledIcon(theme)
nameBudget := inner - ui.Width(icon) - 1
if nameBudget < 1 {
nameBudget = 1
}
header := c.name
if arg := c.primaryArg(); arg != "" {
header = c.name + "(" + arg + ")"
}
header = TruncateToWidth(header, nameBudget)
lines = append(lines, icon+" "+theme.ToolHeader.Render(header))
if len(c.input) > 0 {
lines = append(lines, theme.ToolBody.Render("Input arguments"))
for _, k := range sortedKeys(c.input) {
kv := " " + k + ": " + fmt.Sprintf("%v", c.input[k])
lines = append(lines, theme.ToolBody.Render(WrapToWidth(kv, inner)))
}
}
if len(c.response) > 0 {
lines = append(lines, theme.ToolBody.Render("Response"))
resp := c.response
truncated := false
if !c.expanded && len(resp) > collapsedResponseLines {
resp = resp[:collapsedResponseLines]
truncated = true
}
for _, n := range resp {
indent := strings.Repeat(" ", n.depth)
lines = append(lines, theme.ToolBody.Render(WrapToWidth(indent+n.text, inner)))
}
if truncated {
lines = append(lines, theme.System.Render("(Ctrl+O for more)"))
}
}
border := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color(colorGray)).
Width(inner)
return border.Render(strings.Join(lines, "\n"))
}
// primaryArg returns the most salient call argument to inline in the card header
// so the user can see what the tool is operating on at a glance (FR-6), e.g.
// Bash(cd /x && git add -A). It picks the command for bash and the file path for
// the file tools, otherwise the first argument in sorted-key order. Returns ""
// when the call carried no arguments.
func (c toolCard) primaryArg() string {
if len(c.input) == 0 {
return ""
}
var keyPrefs []string
switch strings.ToLower(c.name) {
case "bash":
keyPrefs = []string{"command"}
case "read", "write", "edit", "multiedit":
// The file tools emit "path"; accept "file_path" as a fallback for
// callers that use the Claude-style key.
keyPrefs = []string{"path", "file_path"}
}
for _, key := range keyPrefs {
if v, ok := c.input[key]; ok {
return fmt.Sprintf("%v", v)
}
}
keys := sortedKeys(c.input)
return fmt.Sprintf("%v", c.input[keys[0]])
}
// sortedKeys returns the map keys in a stable (sorted) order so the input
// section renders deterministically instead of in Go's random map order.
func sortedKeys(m map[string]any) []string {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)
return keys
}
// parseToolResult splits a tool's textual result into response tree nodes,
// inferring depth from leading whitespace (every two leading spaces is one
// level). Trailing empty lines are trimmed so the card does not render blank
// tail rows.
func parseToolResult(result string) []respNode {
lines := strings.Split(result, "\n")
for len(lines) > 0 && strings.TrimSpace(lines[len(lines)-1]) == "" {
lines = lines[:len(lines)-1]
}
nodes := make([]respNode, 0, len(lines))
for _, ln := range lines {
leading := len(ln) - len(strings.TrimLeft(ln, " "))
nodes = append(nodes, respNode{text: ln[leading:], depth: leading / 2})
}
return nodes
}