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), ¶ms); 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}