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