196 lines
6.1 KiB
Go
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
|
|
}
|