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