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	if opts.IsPending() {
 43		return pendingTool(sty, "To-Do", opts.Anim)
 44	}
 45
 46	var params tools.TodosParams
 47	var meta tools.TodosResponseMetadata
 48	var headerText string
 49	var body string
 50
 51	// Parse params for pending state (before result is available).
 52	if err := json.Unmarshal([]byte(opts.ToolCall.Input), &params); err == nil {
 53		completedCount := 0
 54		inProgressTask := ""
 55		for _, todo := range params.Todos {
 56			if todo.Status == "completed" {
 57				completedCount++
 58			}
 59			if todo.Status == "in_progress" {
 60				if todo.ActiveForm != "" {
 61					inProgressTask = todo.ActiveForm
 62				} else {
 63					inProgressTask = todo.Content
 64				}
 65			}
 66		}
 67
 68		// Default display from params (used when pending or no metadata).
 69		ratio := sty.Tool.TodoRatio.Render(fmt.Sprintf("%d/%d", completedCount, len(params.Todos)))
 70		headerText = ratio
 71		if inProgressTask != "" {
 72			headerText = fmt.Sprintf("%s · %s", ratio, inProgressTask)
 73		}
 74
 75		// If we have metadata, use it for richer display.
 76		if opts.HasResult() && opts.Result.Metadata != "" {
 77			if err := json.Unmarshal([]byte(opts.Result.Metadata), &meta); err == nil {
 78				if meta.IsNew {
 79					if meta.JustStarted != "" {
 80						headerText = fmt.Sprintf("created %d todos, starting first", meta.Total)
 81					} else {
 82						headerText = fmt.Sprintf("created %d todos", meta.Total)
 83					}
 84					body = FormatTodosList(sty, meta.Todos, styles.ArrowRightIcon, width)
 85				} else {
 86					// Build header based on what changed.
 87					hasCompleted := len(meta.JustCompleted) > 0
 88					hasStarted := meta.JustStarted != ""
 89					allCompleted := meta.Completed == meta.Total
 90
 91					ratio := sty.Tool.TodoRatio.Render(fmt.Sprintf("%d/%d", meta.Completed, meta.Total))
 92					if hasCompleted && hasStarted {
 93						text := sty.Subtle.Render(fmt.Sprintf(" · completed %d, starting next", len(meta.JustCompleted)))
 94						headerText = fmt.Sprintf("%s%s", ratio, text)
 95					} else if hasCompleted {
 96						text := sty.Subtle.Render(fmt.Sprintf(" · completed %d", len(meta.JustCompleted)))
 97						if allCompleted {
 98							text = sty.Subtle.Render(" · completed all")
 99						}
100						headerText = fmt.Sprintf("%s%s", ratio, text)
101					} else if hasStarted {
102						headerText = fmt.Sprintf("%s%s", ratio, sty.Subtle.Render(" · starting task"))
103					} else {
104						headerText = ratio
105					}
106
107					// Build body with details.
108					if allCompleted {
109						// Show all todos when all are completed, like when created.
110						body = FormatTodosList(sty, meta.Todos, styles.ArrowRightIcon, width)
111					} else if meta.JustStarted != "" {
112						body = sty.Tool.TodoInProgressIcon.Render(styles.ArrowRightIcon+" ") +
113							sty.Base.Render(meta.JustStarted)
114					}
115				}
116			}
117		}
118	}
119
120	toolParams := []string{headerText}
121	header := toolHeader(sty, opts.Status, "To-Do", width, opts.Compact, toolParams...)
122	if opts.Compact {
123		return header
124	}
125
126	if earlyState, ok := toolEarlyStateContent(sty, opts, width); ok {
127		return joinToolParts(header, earlyState)
128	}
129
130	if body == "" {
131		return header
132	}
133
134	return joinToolParts(header, sty.Tool.Body.Render(body))
135}
136
137// FormatTodosList formats a list of todos for display.
138func FormatTodosList(sty *styles.Styles, todos []session.Todo, inProgressIcon string, width int) string {
139	if len(todos) == 0 {
140		return ""
141	}
142
143	sorted := make([]session.Todo, len(todos))
144	copy(sorted, todos)
145	sortTodos(sorted)
146
147	var lines []string
148	for _, todo := range sorted {
149		var prefix string
150		textStyle := sty.Base
151
152		switch todo.Status {
153		case session.TodoStatusCompleted:
154			prefix = sty.Tool.TodoCompletedIcon.Render(styles.TodoCompletedIcon) + " "
155		case session.TodoStatusInProgress:
156			prefix = sty.Tool.TodoInProgressIcon.Render(inProgressIcon + " ")
157		default:
158			prefix = sty.Tool.TodoPendingIcon.Render(styles.TodoPendingIcon) + " "
159		}
160
161		text := todo.Content
162		if todo.Status == session.TodoStatusInProgress && todo.ActiveForm != "" {
163			text = todo.ActiveForm
164		}
165		line := prefix + textStyle.Render(text)
166		line = ansi.Truncate(line, width, "…")
167
168		lines = append(lines, line)
169	}
170
171	return strings.Join(lines, "\n")
172}
173
174// sortTodos sorts todos by status: completed, in_progress, pending.
175func sortTodos(todos []session.Todo) {
176	slices.SortStableFunc(todos, func(a, b session.Todo) int {
177		return statusOrder(a.Status) - statusOrder(b.Status)
178	})
179}
180
181// statusOrder returns the sort order for a todo status.
182func statusOrder(s session.TodoStatus) int {
183	switch s {
184	case session.TodoStatusCompleted:
185		return 0
186	case session.TodoStatusInProgress:
187		return 1
188	default:
189		return 2
190	}
191}