返回 DeepSeek-TUI-2026
todo.rs
根目录 / crates / tui / src / tools / todo.rs
1 //! Todo list tool and supporting data structures.
2
3 use std::sync::Arc;
4 use tokio::sync::Mutex;
5
6 use async_trait::async_trait;
7 use serde::{Deserialize, Serialize};
8 use serde_json::json;
9
10 use crate::tools::spec::{
11 ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec,
12 };
13
14 // === Types ===
15
16 /// Status for a todo item.
17 #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
18 #[serde(rename_all = "snake_case")]
19 pub enum TodoStatus {
20 Pending,
21 InProgress,
22 Completed,
23 }
24
25 impl TodoStatus {
26 #[allow(dead_code)]
27 pub fn as_str(self) -> &'static str {
28 match self {
29 TodoStatus::Pending => "pending",
30 TodoStatus::InProgress => "in_progress",
31 TodoStatus::Completed => "completed",
32 }
33 }
34
35 /// Parse a string into a todo status.
36 #[must_use]
37 pub fn from_str(value: &str) -> Option<Self> {
38 match value.trim().to_lowercase().as_str() {
39 "pending" => Some(TodoStatus::Pending),
40 "in_progress" | "inprogress" => Some(TodoStatus::InProgress),
41 "completed" | "done" => Some(TodoStatus::Completed),
42 _ => None,
43 }
44 }
45 }
46
47 /// A single todo item.
48 #[derive(Debug, Clone, Serialize, Deserialize)]
49 pub struct TodoItem {
50 pub id: u32,
51 pub content: String,
52 pub status: TodoStatus,
53 }
54
55 /// Snapshot of a todo list for display or serialization.
56 #[derive(Debug, Clone, Serialize)]
57 pub struct TodoListSnapshot {
58 pub items: Vec<TodoItem>,
59 pub completion_pct: u8,
60 pub in_progress_id: Option<u32>,
61 }
62
63 /// Mutable list of todo items with helper operations.
64 #[derive(Debug, Clone, Default)]
65 pub struct TodoList {
66 items: Vec<TodoItem>,
67 next_id: u32,
68 }
69
70 impl TodoList {
71 /// Create an empty todo list.
72 #[must_use]
73 pub fn new() -> Self {
74 Self {
75 items: Vec::new(),
76 next_id: 1,
77 }
78 }
79
80 /// Return a snapshot of the list with computed metrics.
81 #[must_use]
82 pub fn snapshot(&self) -> TodoListSnapshot {
83 TodoListSnapshot {
84 items: self.items.clone(),
85 completion_pct: self.completion_percentage(),
86 in_progress_id: self.in_progress_id(),
87 }
88 }
89
90 /// Add a new todo item.
91 pub fn add(&mut self, content: String, status: TodoStatus) -> TodoItem {
92 let status = match status {
93 TodoStatus::InProgress => {
94 self.set_single_in_progress(None);
95 TodoStatus::InProgress
96 }
97 other => other,
98 };
99
100 let item = TodoItem {
101 id: self.next_id,
102 content,
103 status,
104 };
105 self.next_id += 1;
106 self.items.push(item.clone());
107 item
108 }
109
110 /// Update an item's status by id.
111 pub fn update_status(&mut self, id: u32, status: TodoStatus) -> Option<TodoItem> {
112 let mut updated: Option<TodoItem> = None;
113 if status == TodoStatus::InProgress {
114 self.set_single_in_progress(Some(id));
115 }
116 for item in &mut self.items {
117 if item.id == id {
118 item.status = status;
119 updated = Some(item.clone());
120 break;
121 }
122 }
123 updated
124 }
125
126 /// Compute completion percentage for the list.
127 #[must_use]
128 pub fn completion_percentage(&self) -> u8 {
129 if self.items.is_empty() {
130 return 0;
131 }
132 let total = self.items.len();
133 let completed = self
134 .items
135 .iter()
136 .filter(|item| item.status == TodoStatus::Completed)
137 .count();
138 let percent = completed.saturating_mul(100);
139 let percent = (percent + total / 2) / total;
140 u8::try_from(percent).unwrap_or(u8::MAX)
141 }
142
143 /// Return the id of the in-progress item, if any.
144 #[must_use]
145 pub fn in_progress_id(&self) -> Option<u32> {
146 self.items
147 .iter()
148 .find(|item| item.status == TodoStatus::InProgress)
149 .map(|item| item.id)
150 }
151
152 /// Clear all todo items.
153 pub fn clear(&mut self) {
154 self.items.clear();
155 self.next_id = 1;
156 }
157
158 fn set_single_in_progress(&mut self, allow_id: Option<u32>) {
159 for item in &mut self.items {
160 if Some(item.id) != allow_id && item.status == TodoStatus::InProgress {
161 item.status = TodoStatus::Pending;
162 }
163 }
164 }
165 }
166
167 // === TodoWriteTool - ToolSpec implementation ===
168
169 /// Shared reference to a `TodoList` for use across tools
170 pub type SharedTodoList = Arc<Mutex<TodoList>>;
171
172 /// Create a new shared `TodoList`
173 pub fn new_shared_todo_list() -> SharedTodoList {
174 Arc::new(Mutex::new(TodoList::new()))
175 }
176
177 /// Tool for writing and updating the todo list
178 pub struct TodoWriteTool {
179 todo_list: SharedTodoList,
180 tool_name: &'static str,
181 }
182
183 impl TodoWriteTool {
184 pub fn new(todo_list: SharedTodoList) -> Self {
185 Self {
186 todo_list,
187 tool_name: "todo_write",
188 }
189 }
190
191 pub fn checklist(todo_list: SharedTodoList) -> Self {
192 Self {
193 todo_list,
194 tool_name: "checklist_write",
195 }
196 }
197 }
198
199 /// Tool for adding a single todo item (legacy compatibility).
200 pub struct TodoAddTool {
201 todo_list: SharedTodoList,
202 tool_name: &'static str,
203 }
204
205 impl TodoAddTool {
206 pub fn new(todo_list: SharedTodoList) -> Self {
207 Self {
208 todo_list,
209 tool_name: "todo_add",
210 }
211 }
212
213 pub fn checklist(todo_list: SharedTodoList) -> Self {
214 Self {
215 todo_list,
216 tool_name: "checklist_add",
217 }
218 }
219 }
220
221 #[async_trait]
222 impl ToolSpec for TodoAddTool {
223 fn name(&self) -> &'static str {
224 self.tool_name
225 }
226
227 fn description(&self) -> &'static str {
228 if self.tool_name == "todo_add" {
229 "Compatibility alias for checklist_add. Adds one checklist item on the active thread/task."
230 } else {
231 "Add one checklist item on the active thread/task. Durable tasks persist this checklist as subordinate work progress."
232 }
233 }
234
235 fn input_schema(&self) -> serde_json::Value {
236 json!({
237 "type": "object",
238 "properties": {
239 "content": {
240 "type": "string",
241 "description": "The task description"
242 },
243 "status": {
244 "type": "string",
245 "enum": ["pending", "in_progress", "completed"],
246 "description": "Task status (default: pending)"
247 }
248 },
249 "required": ["content"]
250 })
251 }
252
253 fn capabilities(&self) -> Vec<ToolCapability> {
254 vec![ToolCapability::WritesFiles]
255 }
256
257 fn approval_requirement(&self) -> ApprovalRequirement {
258 ApprovalRequirement::Auto
259 }
260
261 async fn execute(
262 &self,
263 input: serde_json::Value,
264 _context: &ToolContext,
265 ) -> Result<ToolResult, ToolError> {
266 let content = input
267 .get("content")
268 .and_then(|v| v.as_str())
269 .ok_or_else(|| ToolError::invalid_input("Missing 'content'"))?;
270 let status = input
271 .get("status")
272 .and_then(|v| v.as_str())
273 .and_then(TodoStatus::from_str)
274 .unwrap_or(TodoStatus::Pending);
275
276 let mut list = self.todo_list.lock().await;
277 let item = list.add(content.to_string(), status);
278 let snapshot = list.snapshot();
279
280 let result = serde_json::to_string_pretty(&snapshot).unwrap_or_else(|_| "{}".to_string());
281 Ok(ToolResult::success(format!(
282 "Added todo #{} ({})\n{}",
283 item.id,
284 item.status.as_str(),
285 result
286 ))
287 .with_metadata(checklist_metadata(&snapshot, self.tool_name)))
288 }
289 }
290
291 /// Tool for updating a todo item's status (legacy compatibility).
292 pub struct TodoUpdateTool {
293 todo_list: SharedTodoList,
294 tool_name: &'static str,
295 }
296
297 impl TodoUpdateTool {
298 pub fn new(todo_list: SharedTodoList) -> Self {
299 Self {
300 todo_list,
301 tool_name: "todo_update",
302 }
303 }
304
305 pub fn checklist(todo_list: SharedTodoList) -> Self {
306 Self {
307 todo_list,
308 tool_name: "checklist_update",
309 }
310 }
311 }
312
313 #[async_trait]
314 impl ToolSpec for TodoUpdateTool {
315 fn name(&self) -> &'static str {
316 self.tool_name
317 }
318
319 fn description(&self) -> &'static str {
320 if self.tool_name == "todo_update" {
321 "Compatibility alias for checklist_update. Updates one checklist item by id on the active thread/task."
322 } else {
323 "Update one checklist item's status by id on the active thread/task."
324 }
325 }
326
327 fn input_schema(&self) -> serde_json::Value {
328 json!({
329 "type": "object",
330 "properties": {
331 "id": {
332 "type": "integer",
333 "description": "Todo item id"
334 },
335 "status": {
336 "type": "string",
337 "enum": ["pending", "in_progress", "completed"],
338 "description": "New status"
339 }
340 },
341 "required": ["id", "status"]
342 })
343 }
344
345 fn capabilities(&self) -> Vec<ToolCapability> {
346 vec![ToolCapability::WritesFiles]
347 }
348
349 fn approval_requirement(&self) -> ApprovalRequirement {
350 ApprovalRequirement::Auto
351 }
352
353 async fn execute(
354 &self,
355 input: serde_json::Value,
356 _context: &ToolContext,
357 ) -> Result<ToolResult, ToolError> {
358 let id = input
359 .get("id")
360 .and_then(|v| v.as_u64())
361 .and_then(|v| u32::try_from(v).ok())
362 .ok_or_else(|| ToolError::invalid_input("Missing or invalid 'id'"))?;
363 let status = input
364 .get("status")
365 .and_then(|v| v.as_str())
366 .and_then(TodoStatus::from_str)
367 .ok_or_else(|| ToolError::invalid_input("Missing or invalid 'status'"))?;
368
369 let mut list = self.todo_list.lock().await;
370 let updated = list.update_status(id, status);
371 let snapshot = list.snapshot();
372 let result = serde_json::to_string_pretty(&snapshot).unwrap_or_else(|_| "{}".to_string());
373
374 match updated {
375 Some(item) => Ok(ToolResult::success(format!(
376 "Updated todo #{} to {}\n{}",
377 item.id,
378 item.status.as_str(),
379 result
380 ))
381 .with_metadata(checklist_metadata(&snapshot, self.tool_name))),
382 None => Ok(ToolResult::error(format!("Todo id {id} not found"))),
383 }
384 }
385 }
386
387 /// Tool for listing current todos (legacy compatibility).
388 pub struct TodoListTool {
389 todo_list: SharedTodoList,
390 tool_name: &'static str,
391 }
392
393 impl TodoListTool {
394 pub fn new(todo_list: SharedTodoList) -> Self {
395 Self {
396 todo_list,
397 tool_name: "todo_list",
398 }
399 }
400
401 pub fn checklist(todo_list: SharedTodoList) -> Self {
402 Self {
403 todo_list,
404 tool_name: "checklist_list",
405 }
406 }
407 }
408
409 #[async_trait]
410 impl ToolSpec for TodoListTool {
411 fn name(&self) -> &'static str {
412 self.tool_name
413 }
414
415 fn description(&self) -> &'static str {
416 if self.tool_name == "todo_list" {
417 "Compatibility alias for checklist_list. Lists current checklist progress."
418 } else {
419 "List current checklist progress for the active thread/task."
420 }
421 }
422
423 fn input_schema(&self) -> serde_json::Value {
424 json!({
425 "type": "object",
426 "properties": {}
427 })
428 }
429
430 fn capabilities(&self) -> Vec<ToolCapability> {
431 vec![ToolCapability::ReadOnly]
432 }
433
434 fn approval_requirement(&self) -> ApprovalRequirement {
435 ApprovalRequirement::Auto
436 }
437
438 async fn execute(
439 &self,
440 _input: serde_json::Value,
441 _context: &ToolContext,
442 ) -> Result<ToolResult, ToolError> {
443 let list = self.todo_list.lock().await;
444 let snapshot = list.snapshot();
445 let result = serde_json::to_string_pretty(&snapshot).unwrap_or_else(|_| "{}".to_string());
446 Ok(ToolResult::success(format!(
447 "Todo list ({} items, {}% complete)\n{}",
448 snapshot.items.len(),
449 snapshot.completion_pct,
450 result
451 )))
452 }
453 }
454
455 #[async_trait]
456 impl ToolSpec for TodoWriteTool {
457 fn name(&self) -> &'static str {
458 self.tool_name
459 }
460
461 fn description(&self) -> &'static str {
462 if self.tool_name == "todo_write" {
463 "Compatibility alias for checklist_write. Replace the active thread/task checklist; durable tasks are the real executable work object."
464 } else {
465 "Replace the active thread/task checklist. Use this for granular progress under the current durable task or runtime thread; durable tasks remain the real executable work object."
466 }
467 }
468
469 fn input_schema(&self) -> serde_json::Value {
470 json!({
471 "type": "object",
472 "properties": {
473 "todos": {
474 "type": "array",
475 "description": "The complete list of todo items. This replaces the existing list.",
476 "items": {
477 "type": "object",
478 "properties": {
479 "content": {
480 "type": "string",
481 "description": "The task description"
482 },
483 "status": {
484 "type": "string",
485 "enum": ["pending", "in_progress", "completed"],
486 "description": "Task status"
487 }
488 },
489 "required": ["content", "status"]
490 }
491 }
492 },
493 "required": ["todos"]
494 })
495 }
496
497 fn capabilities(&self) -> Vec<ToolCapability> {
498 vec![ToolCapability::WritesFiles]
499 }
500
501 fn approval_requirement(&self) -> ApprovalRequirement {
502 ApprovalRequirement::Auto
503 }
504
505 async fn execute(
506 &self,
507 input: serde_json::Value,
508 _context: &ToolContext,
509 ) -> Result<ToolResult, ToolError> {
510 let todos = input
511 .get("todos")
512 .and_then(|v| v.as_array())
513 .ok_or_else(|| ToolError::invalid_input("Missing or invalid 'todos' array"))?;
514
515 let mut list = self.todo_list.lock().await;
516
517 // Clear and rebuild the list
518 list.clear();
519
520 for item in todos {
521 let content = item
522 .get("content")
523 .and_then(|v| v.as_str())
524 .ok_or_else(|| ToolError::invalid_input("Todo item missing 'content'"))?;
525
526 let status_str = item
527 .get("status")
528 .and_then(|v| v.as_str())
529 .unwrap_or("pending");
530
531 let status = TodoStatus::from_str(status_str).unwrap_or(TodoStatus::Pending);
532
533 list.add(content.to_string(), status);
534 }
535
536 let snapshot = list.snapshot();
537 let result = serde_json::to_string_pretty(&snapshot).unwrap_or_else(|_| "{}".to_string());
538
539 Ok(ToolResult::success(format!(
540 "Todo list updated ({} items, {}% complete)\n{}",
541 snapshot.items.len(),
542 snapshot.completion_pct,
543 result
544 ))
545 .with_metadata(checklist_metadata(&snapshot, self.tool_name)))
546 }
547 }
548
549 fn checklist_metadata(snapshot: &TodoListSnapshot, tool_name: &str) -> serde_json::Value {
550 let items = snapshot
551 .items
552 .iter()
553 .map(|item| {
554 json!({
555 "id": item.id,
556 "content": item.content,
557 "status": item.status.as_str(),
558 })
559 })
560 .collect::<Vec<_>>();
561 json!({
562 "canonical_tool": "checklist_write",
563 "compat_alias": tool_name.starts_with("todo_"),
564 "task_updates": {
565 "checklist": {
566 "items": items,
567 "completion_pct": snapshot.completion_pct,
568 "in_progress_id": snapshot.in_progress_id,
569 "updated_at": null
570 }
571 }
572 })
573 }
574
575 #[cfg(test)]
576 mod tests {
577 use super::*;
578
579 #[tokio::test]
580 async fn checklist_write_returns_task_update_metadata() {
581 let tool = TodoWriteTool::checklist(new_shared_todo_list());
582 let context = ToolContext::new(std::env::temp_dir());
583 let result = tool
584 .execute(
585 json!({
586 "todos": [
587 { "content": "wire durable task tools", "status": "in_progress" },
588 { "content": "run gates", "status": "pending" }
589 ]
590 }),
591 &context,
592 )
593 .await
594 .expect("checklist write succeeds");
595
596 let metadata = result.metadata.expect("metadata");
597 assert_eq!(metadata["canonical_tool"], "checklist_write");
598 assert_eq!(metadata["compat_alias"], false);
599 assert_eq!(
600 metadata["task_updates"]["checklist"]["in_progress_id"],
601 json!(1)
602 );
603 assert_eq!(
604 metadata["task_updates"]["checklist"]["items"][0]["content"],
605 "wire durable task tools"
606 );
607 }
608
609 #[tokio::test]
610 async fn todo_write_remains_compat_alias() {
611 let tool = TodoWriteTool::new(new_shared_todo_list());
612 let context = ToolContext::new(std::env::temp_dir());
613 let result = tool
614 .execute(
615 json!({
616 "todos": [
617 { "content": "legacy caller", "status": "completed" }
618 ]
619 }),
620 &context,
621 )
622 .await
623 .expect("todo write succeeds");
624
625 let metadata = result.metadata.expect("metadata");
626 assert_eq!(tool.name(), "todo_write");
627 assert_eq!(metadata["canonical_tool"], "checklist_write");
628 assert_eq!(metadata["compat_alias"], true);
629 }
630 }
631
631 lines RUST