todos.go

  1package chat
  2
  3import (
  4	"encoding/json"
  5	"fmt"
  6	"slices"
  7	"strings"
  8
  9	"github.com/charmbracelet/crush/internal/agent/tools"
 10	"github.com/charmbracelet/crush/internal/message"
 11	"github.com/charmbracelet/crush/internal/session"
 12	"github.com/charmbracelet/crush/internal/ui/styles"
 13	"github.com/charmbracelet/x/ansi"
 14)
 15
 16// -----------------------------------------------------------------------------
 17// Todos Tool
 18// -----------------------------------------------------------------------------
 19
 20// TodosToolMessageItem is a message item that represents a todos tool call.
 21type TodosToolMessageItem struct {
 22	*baseToolMessageItem
 23}
 24
 25var _ ToolMessageItem = (*TodosToolMessageItem)(nil)
 26
 27// NewTodosToolMessageItem creates a new [TodosToolMessageItem].
 28func NewTodosToolMessageItem(
 29	sty *styles.Styles,
 30	toolCall message.ToolCall,
 31	result *message.ToolResult,
 32	canceled bool,
 33) ToolMessageItem {
 34	return newBaseToolMessageItem(sty, toolCall, result, &TodosToolRenderContext{}, canceled)
 35}
 36
 37// TodosToolRenderContext renders todos tool messages.
 38type TodosToolRenderContext struct{}
 39
 40// RenderTool implements the [ToolRenderer] interface.
 41func (t *TodosToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
 42	cappedWidth := cappedMessageWidth(width)
 43	if opts.IsPending() {
 44		return pendingTool(sty, "To-Do", opts.Anim)
 45	}
 46
 47	var params tools.TodosParams
 48	var meta tools.TodosResponseMetadata
 49	var headerText string
 50	var body string
 51
 52	// Parse params for pending state (before result is available).
 53	if err := json.Unmarshal([]byte(opts.ToolCall.Input), &params); err == nil {
 54		completedCount := 0
 55		inProgressTask := ""
 56		for _, todo := range params.Todos {
 57			if todo.Status == "completed" {
 58				completedCount++
 59			}
 60			if todo.Status == "in_progress" {
 61				if todo.ActiveForm != "" {
 62					inProgressTask = todo.ActiveForm
 63				} else {
 64					inProgressTask = todo.Content
 65				}
 66			}
 67		}
 68
 69		// Default display from params (used when pending or no metadata).
 70		ratio := sty.Tool.TodoRatio.Render(fmt.Sprintf("%d/%d", completedCount, len(params.Todos)))
 71		headerText = ratio
 72		if inProgressTask != "" {
 73			headerText = fmt.Sprintf("%s · %s", ratio, inProgressTask)
 74		}
 75
 76		// If we have metadata, use it for richer display.
 77		if opts.HasResult() && opts.Result.Metadata != "" {
 78			if err := json.Unmarshal([]byte(opts.Result.Metadata), &meta); err == nil {
 79				if meta.IsNew {
 80					if meta.JustStarted != "" {
 81						headerText = fmt.Sprintf("created %d todos, starting first", meta.Total)
 82					} else {
 83						headerText = fmt.Sprintf("created %d todos", meta.Total)
 84					}
 85					body = FormatTodosList(sty, meta.Todos, styles.ArrowRightIcon, cappedWidth)
 86				} else {
 87					// Build header based on what changed.
 88					hasCompleted := len(meta.JustCompleted) > 0
 89					hasStarted := meta.JustStarted != ""
 90					allCompleted := meta.Completed == meta.Total
 91
 92					ratio := sty.Tool.TodoRatio.Render(fmt.Sprintf("%d/%d", meta.Completed, meta.Total))
 93					if hasCompleted && hasStarted {
 94						text := sty.Subtle.Render(fmt.Sprintf(" · completed %d, starting next", len(meta.JustCompleted)))
 95						headerText = fmt.Sprintf("%s%s", ratio, text)
 96					} else if hasCompleted {
 97						text := sty.Subtle.Render(fmt.Sprintf(" · completed %d", len(meta.JustCompleted)))
 98						if allCompleted {
 99							text = sty.Subtle.Render(" · completed all")
100						}
101						headerText = fmt.Sprintf("%s%s", ratio, text)
102					} else if hasStarted {
103						headerText = fmt.Sprintf("%s%s", ratio, sty.Subtle.Render(" · starting task"))
104					} else {
105						headerText = ratio
106					}
107
108					// Build body with details.
109					if allCompleted {
110						// Show all todos when all are completed, like when created.
111						body = FormatTodosList(sty, meta.Todos, styles.ArrowRightIcon, cappedWidth)
112					} else if meta.JustStarted != "" {
113						body = sty.Tool.TodoInProgressIcon.Render(styles.ArrowRightIcon+" ") +
114							sty.Base.Render(meta.JustStarted)
115					}
116				}
117			}
118		}
119	}
120
121	toolParams := []string{headerText}
122	header := toolHeader(sty, opts.Status, "To-Do", cappedWidth, opts.Compact, toolParams...)
123	if opts.Compact {
124		return header
125	}
126
127	if earlyState, ok := toolEarlyStateContent(sty, opts, cappedWidth); ok {
128		return joinToolParts(header, earlyState)
129	}
130
131	if body == "" {
132		return header
133	}
134
135	return joinToolParts(header, sty.Tool.Body.Render(body))
136}
137
138// FormatTodosList formats a list of todos for display.
139func FormatTodosList(sty *styles.Styles, todos []session.Todo, inProgressIcon string, width int) string {
140	if len(todos) == 0 {
141		return ""
142	}
143
144	sorted := make([]session.Todo, len(todos))
145	copy(sorted, todos)
146	sortTodos(sorted)
147
148	var lines []string
149	for _, todo := range sorted {
150		var prefix string
151		textStyle := sty.Base
152
153		switch todo.Status {
154		case session.TodoStatusCompleted:
155			prefix = sty.Tool.TodoCompletedIcon.Render(styles.TodoCompletedIcon) + " "
156		case session.TodoStatusInProgress:
157			prefix = sty.Tool.TodoInProgressIcon.Render(inProgressIcon + " ")
158		default:
159			prefix = sty.Tool.TodoPendingIcon.Render(styles.TodoPendingIcon) + " "
160		}
161
162		text := todo.Content
163		if todo.Status == session.TodoStatusInProgress && todo.ActiveForm != "" {
164			text = todo.ActiveForm
165		}
166		line := prefix + textStyle.Render(text)
167		line = ansi.Truncate(line, width, "…")
168
169		lines = append(lines, line)
170	}
171
172	return strings.Join(lines, "\n")
173}
174
175// sortTodos sorts todos by status: completed, in_progress, pending.
176func sortTodos(todos []session.Todo) {
177	slices.SortStableFunc(todos, func(a, b session.Todo) int {
178		return statusOrder(a.Status) - statusOrder(b.Status)
179	})
180}
181
182// statusOrder returns the sort order for a todo status.
183func statusOrder(s session.TodoStatus) int {
184	switch s {
185	case session.TodoStatusCompleted:
186		return 0
187	case session.TodoStatusInProgress:
188		return 1
189	default:
190		return 2
191	}
192}