ai-agent/internal/tui/toolcard.go
admin 8dc496b626
Some checks failed
CI / test (push) Has been cancelled
Release / release (push) Failing after 4m36s
first commit
2026-03-08 15:40:34 +07:00

347 lines
10 KiB
Go

package tui
import (
"fmt"
"strings"
"time"
"charm.land/bubbles/v2/spinner"
"charm.land/lipgloss/v2"
)
// ToolCardKind represents the type of tool operation.
type ToolCardKind int
const (
ToolCardFile ToolCardKind = iota
ToolCardBash
ToolCardSearch
ToolCardGit
ToolCardGeneric
)
// ToolCardState represents the execution state.
type ToolCardState int
const (
ToolCardRunning ToolCardState = iota
ToolCardSuccess
ToolCardError
)
// ToolCard is a fancy tool execution display component.
type ToolCard struct {
Name string
Kind ToolCardKind
State ToolCardState
Args string
Result string
StartTime time.Time
Duration time.Duration
Expanded bool
Spinner spinner.Model
ElapsedTimer *time.Timer
Elapsed time.Duration
IsDark bool
Styles ToolCardStyles
}
// ToolCardStyles holds styles for the tool card.
type ToolCardStyles struct {
BorderRunning lipgloss.Style
BorderSuccess lipgloss.Style
BorderError lipgloss.Style
TitleRunning lipgloss.Style
TitleSuccess lipgloss.Style
TitleError lipgloss.Style
Args lipgloss.Style
Result lipgloss.Style
Error lipgloss.Style
Dimmed lipgloss.Style
Elapsed lipgloss.Style
}
// NewToolCardStyles creates styles based on theme.
func NewToolCardStyles(isDark bool) ToolCardStyles {
if isDark {
return ToolCardStyles{
BorderRunning: lipgloss.NewStyle().Foreground(lipgloss.Color("#81a1c1")),
BorderSuccess: lipgloss.NewStyle().Foreground(lipgloss.Color("#a3be8c")),
BorderError: lipgloss.NewStyle().Foreground(lipgloss.Color("#bf616a")),
TitleRunning: lipgloss.NewStyle().Foreground(lipgloss.Color("#88c0d0")).Bold(true),
TitleSuccess: lipgloss.NewStyle().Foreground(lipgloss.Color("#a3be8c")).Bold(true),
TitleError: lipgloss.NewStyle().Foreground(lipgloss.Color("#bf616a")).Bold(true),
Args: lipgloss.NewStyle().Foreground(lipgloss.Color("#d8dee9")),
Result: lipgloss.NewStyle().Foreground(lipgloss.Color("#d8dee9")),
Error: lipgloss.NewStyle().Foreground(lipgloss.Color("#bf616a")),
Dimmed: lipgloss.NewStyle().Foreground(lipgloss.Color("#4c566a")),
Elapsed: lipgloss.NewStyle().Foreground(lipgloss.Color("#81a1c1")),
}
}
return ToolCardStyles{
BorderRunning: lipgloss.NewStyle().Foreground(lipgloss.Color("#5e81ac")),
BorderSuccess: lipgloss.NewStyle().Foreground(lipgloss.Color("#4f8f38")),
BorderError: lipgloss.NewStyle().Foreground(lipgloss.Color("#c94f4f")),
TitleRunning: lipgloss.NewStyle().Foreground(lipgloss.Color("#4f8f8f")).Bold(true),
TitleSuccess: lipgloss.NewStyle().Foreground(lipgloss.Color("#4f8f38")).Bold(true),
TitleError: lipgloss.NewStyle().Foreground(lipgloss.Color("#c94f4f")).Bold(true),
Args: lipgloss.NewStyle().Foreground(lipgloss.Color("#4c566a")),
Result: lipgloss.NewStyle().Foreground(lipgloss.Color("#4c566a")),
Error: lipgloss.NewStyle().Foreground(lipgloss.Color("#c94f4f")),
Dimmed: lipgloss.NewStyle().Foreground(lipgloss.Color("#9ca0a8")),
Elapsed: lipgloss.NewStyle().Foreground(lipgloss.Color("#5e81ac")),
}
}
// NewToolCard creates a new tool card.
func NewToolCard(name string, kind ToolCardKind, isDark bool) ToolCard {
s := spinner.New(
spinner.WithSpinner(spinner.MiniDot),
spinner.WithStyle(lipgloss.NewStyle().Foreground(lipgloss.Color("#88c0d0"))),
)
return ToolCard{
Name: name,
Kind: kind,
State: ToolCardRunning,
Spinner: s,
IsDark: isDark,
Styles: NewToolCardStyles(isDark),
}
}
// SetDark updates the theme.
func (c *ToolCard) SetDark(isDark bool) {
c.IsDark = isDark
c.Styles = NewToolCardStyles(isDark)
}
// Tick advances the spinner animation.
func (c *ToolCard) Tick() {
c.Spinner.Tick()
}
// UpdateElapsed updates the elapsed time counter.
func (c *ToolCard) UpdateElapsed() {
if c.State == ToolCardRunning {
c.Elapsed = time.Since(c.StartTime)
}
}
// getIcon returns the appropriate icon for the tool kind and state.
func (c *ToolCard) getIcon() string {
switch c.Kind {
case ToolCardFile:
if c.State == ToolCardRunning {
return "📄"
}
if c.State == ToolCardSuccess {
return "✓"
}
return "✗"
case ToolCardBash:
if c.State == ToolCardRunning {
return "💻"
}
if c.State == ToolCardSuccess {
return "✓"
}
return "✗"
case ToolCardSearch:
if c.State == ToolCardRunning {
return "🔍"
}
if c.State == ToolCardSuccess {
return "✓"
}
return "✗"
case ToolCardGit:
if c.State == ToolCardRunning {
return "🌿"
}
if c.State == ToolCardSuccess {
return "✓"
}
return "✗"
default:
if c.State == ToolCardRunning {
return "◌"
}
if c.State == ToolCardSuccess {
return "✓"
}
return "✗"
}
}
// getStatusText returns the status text based on state.
func (c *ToolCard) getStatusText() string {
switch c.State {
case ToolCardRunning:
return "running..."
case ToolCardSuccess:
return fmt.Sprintf("(%s)", formatDuration(c.Duration))
case ToolCardError:
return fmt.Sprintf("error (%s)", formatDuration(c.Duration))
default:
return ""
}
}
// getBorderStyle returns the appropriate border style.
func (c *ToolCard) getBorderStyle() lipgloss.Style {
switch c.State {
case ToolCardRunning:
return c.Styles.BorderRunning
case ToolCardSuccess:
return c.Styles.BorderSuccess
case ToolCardError:
return c.Styles.BorderError
default:
return c.Styles.BorderRunning
}
}
// getTitleStyle returns the appropriate title style.
func (c *ToolCard) getTitleStyle() lipgloss.Style {
switch c.State {
case ToolCardRunning:
return c.Styles.TitleRunning
case ToolCardSuccess:
return c.Styles.TitleSuccess
case ToolCardError:
return c.Styles.TitleError
default:
return c.Styles.TitleRunning
}
}
// View renders the tool card.
func (c *ToolCard) View(width int) string {
// Update elapsed time for running tools
c.UpdateElapsed()
// Build title line
icon := c.getIcon()
statusText := c.getStatusText()
var titleParts []string
titleParts = append(titleParts, icon)
titleParts = append(titleParts, c.Name)
if c.State == ToolCardRunning {
titleParts = append(titleParts, c.Spinner.View())
titleParts = append(titleParts, statusText)
// Show elapsed time for running tools
elapsedStr := fmt.Sprintf("%.1fs", c.Elapsed.Seconds())
titleParts = append(titleParts, c.Styles.Elapsed.Render(elapsedStr))
} else {
titleParts = append(titleParts, statusText)
}
title := strings.Join(titleParts, " ")
titleStyle := c.getTitleStyle()
// Create bordered box
content := titleStyle.Render(title)
if c.Expanded && c.State != ToolCardRunning {
// Show args and result when expanded
var details strings.Builder
if c.Args != "" {
args := truncate(c.Args, 80)
details.WriteString(c.Styles.Args.Render(" args: " + args))
details.WriteString("\n")
}
if c.Result != "" {
if c.State == ToolCardError {
details.WriteString(c.Styles.Error.Render(" " + truncate(c.Result, 200)))
} else {
details.WriteString(c.Styles.Result.Render(" " + truncate(c.Result, 200)))
}
details.WriteString("\n")
}
content = lipgloss.JoinVertical(lipgloss.Left, content, details.String())
}
// Apply border
borderStyle := c.getBorderStyle()
box := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(borderStyle.GetForeground()).
Padding(0, 1)
return box.Render(content)
}
// ToolCardManager manages multiple tool cards with synchronized animations.
type ToolCardManager struct {
Cards []ToolCard
IsDark bool
}
// NewToolCardManager creates a new manager.
func NewToolCardManager(isDark bool) ToolCardManager {
return ToolCardManager{
Cards: []ToolCard{},
IsDark: isDark,
}
}
// AddCard adds a new tool card.
func (m *ToolCardManager) AddCard(name string, kind ToolCardKind, startTime time.Time) {
card := NewToolCard(name, kind, m.IsDark)
card.StartTime = startTime
m.Cards = append(m.Cards, card)
}
// UpdateCard updates an existing card by name.
func (m *ToolCardManager) UpdateCard(name string, state ToolCardState, result string, duration time.Duration) {
for i := range m.Cards {
if m.Cards[i].Name == name && m.Cards[i].State == ToolCardRunning {
m.Cards[i].State = state
m.Cards[i].Result = result
m.Cards[i].Duration = duration
break
}
}
}
// SetExpanded sets a card's expanded state.
func (m *ToolCardManager) SetExpanded(name string, expanded bool) {
for i := range m.Cards {
if m.Cards[i].Name == name {
m.Cards[i].Expanded = expanded
break
}
}
}
// Tick advances all running card spinners.
func (m *ToolCardManager) Tick() {
for i := range m.Cards {
if m.Cards[i].State == ToolCardRunning {
m.Cards[i].Tick()
}
}
}
// SetDark updates theme for all cards.
func (m *ToolCardManager) SetDark(isDark bool) {
m.IsDark = isDark
for i := range m.Cards {
m.Cards[i].SetDark(isDark)
}
}
// View renders all cards.
func (m *ToolCardManager) View(width int) string {
var lines []string
for i := range m.Cards {
lines = append(lines, m.Cards[i].View(width))
}
return strings.Join(lines, "\n")
}