1package tools
2
3import (
4 "context"
5 _ "embed"
6 "fmt"
7 "maps"
8
9 "charm.land/fantasy"
10 "github.com/charmbracelet/crush/internal/config"
11 "github.com/charmbracelet/crush/internal/session"
12)
13
14//go:embed todos.md
15var todosDescription []byte
16
17const TodosToolName = "todos"
18
19type TodosParams struct {
20 Todos []TodoItem `json:"todos" description:"The updated todo list"`
21}
22
23type TodoItem struct {
24 Content string `json:"content" description:"What needs to be done (imperative form)"`
25 Status string `json:"status" description:"Task status: pending, in_progress, or completed"`
26 ActiveForm string `json:"active_form" description:"Present continuous form (e.g., 'Running tests')"`
27}
28
29type TodosResponseMetadata struct {
30 IsNew bool `json:"is_new"`
31 Todos []session.Todo `json:"todos"`
32 JustCompleted []string `json:"just_completed,omitempty"`
33 JustStarted string `json:"just_started,omitempty"`
34 Completed int `json:"completed"`
35 Total int `json:"total"`
36}
37
38func NewTodosTool(sessions session.Service, cfg *config.ConfigStore) fantasy.AgentTool {
39 return fantasy.NewAgentTool(
40 TodosToolName,
41 FirstLineDescription(todosDescription),
42 func(ctx context.Context, params TodosParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
43 sessionID := GetSessionFromContext(ctx)
44 if sessionID == "" {
45 return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for managing todos")
46 }
47
48 currentSession, err := sessions.Get(ctx, sessionID)
49 if err != nil {
50 return fantasy.ToolResponse{}, fmt.Errorf("failed to get session: %w", err)
51 }
52
53 isNew := len(currentSession.Todos) == 0
54 oldStatusByContent := make(map[string]session.TodoStatus)
55 for _, todo := range currentSession.Todos {
56 oldStatusByContent[todo.Content] = todo.Status
57 }
58
59 for _, item := range params.Todos {
60 switch item.Status {
61 case "pending", "in_progress", "completed":
62 default:
63 return fantasy.ToolResponse{}, fmt.Errorf("invalid status %q for todo %q", item.Status, item.Content)
64 }
65 }
66
67 todos := make([]session.Todo, len(params.Todos))
68 var justCompleted []string
69 var justStarted string
70 completedCount := 0
71
72 for i, item := range params.Todos {
73 todos[i] = session.Todo{
74 Content: item.Content,
75 Status: session.TodoStatus(item.Status),
76 ActiveForm: item.ActiveForm,
77 }
78
79 newStatus := session.TodoStatus(item.Status)
80 oldStatus, existed := oldStatusByContent[item.Content]
81
82 if newStatus == session.TodoStatusCompleted {
83 completedCount++
84 if existed && oldStatus != session.TodoStatusCompleted {
85 justCompleted = append(justCompleted, item.Content)
86 }
87 }
88
89 if newStatus == session.TodoStatusInProgress {
90 if !existed || oldStatus != session.TodoStatusInProgress {
91 if item.ActiveForm != "" {
92 justStarted = item.ActiveForm
93 } else {
94 justStarted = item.Content
95 }
96 }
97 }
98 }
99
100 currentSession.Todos = todos
101 models := maps.Clone(cfg.Config().Models)
102 for name, model := range models {
103 model.ProviderOptions = maps.Clone(model.ProviderOptions)
104 models[name] = model
105 }
106 _, err = sessions.SaveWithModels(ctx, currentSession, models)
107 if err != nil {
108 return fantasy.ToolResponse{}, fmt.Errorf("failed to save todos: %w", err)
109 }
110
111 response := "Todo list updated successfully.\n\n"
112
113 pendingCount := 0
114 inProgressCount := 0
115
116 for _, todo := range todos {
117 switch todo.Status {
118 case session.TodoStatusPending:
119 pendingCount++
120 case session.TodoStatusInProgress:
121 inProgressCount++
122 }
123 }
124
125 response += fmt.Sprintf("Status: %d pending, %d in progress, %d completed\n",
126 pendingCount, inProgressCount, completedCount)
127
128 response += "Todos have been modified successfully. Ensure that you continue to use the todo list to track your progress. Please proceed with the current tasks if applicable."
129
130 metadata := TodosResponseMetadata{
131 IsNew: isNew,
132 Todos: todos,
133 JustCompleted: justCompleted,
134 JustStarted: justStarted,
135 Completed: completedCount,
136 Total: len(todos),
137 }
138
139 return fantasy.WithResponseMetadata(fantasy.NewTextResponse(response), metadata), nil
140 })
141}