feat: pin todo list (#464)

* wip: backend todo normalisation

* fe implementation

* remove unused dep

* cursor return ActionType::TodoManagement

* use lucide icons rather than emojis in the todo list

* review comments
This commit is contained in:
Gabriel Gordon-Hall
2025-08-15 10:25:06 +01:00
committed by GitHub
parent e9882b23b9
commit ba8650cfca
14 changed files with 305 additions and 196 deletions

View File

@@ -13,7 +13,7 @@ use crate::{
command::CommandBuilder,
executors::{ExecutorError, StandardCodingAgentExecutor},
logs::{
ActionType, EditDiff, NormalizedEntry, NormalizedEntryType,
ActionType, EditDiff, NormalizedEntry, NormalizedEntryType, TodoItem as LogsTodoItem,
stderr_processor::normalize_stderr_logs,
utils::{EntryIndexProvider, patch::ConversationPatch},
},
@@ -499,8 +499,21 @@ impl AmpContentItem {
AmpToolData::List { .. } => ActionType::Other {
description: "List directory".to_string(),
},
AmpToolData::Todo { .. } => ActionType::Other {
description: "Manage TODO list".to_string(),
AmpToolData::Todo { todos } => ActionType::TodoManagement {
todos: todos
.as_ref()
.map(|todos| {
todos
.iter()
.map(|t| LogsTodoItem {
content: t.content.clone(),
status: t.status.clone(),
priority: t.priority.clone(),
})
.collect()
})
.unwrap_or_default(),
operation: "write".to_string(),
},
AmpToolData::Unknown { .. } => ActionType::Other {
description: format!("Tool: {tool_name}"),
@@ -522,32 +535,10 @@ impl AmpContentItem {
ActionType::WebFetch { url } => format!("`{url}`"),
ActionType::PlanPresentation { plan } => format!("Plan Presentation: `{plan}`"),
ActionType::TaskCreate { description } => description.clone(),
ActionType::TodoManagement { .. } => "TODO list updated".to_string(),
ActionType::Other { description: _ } => {
// For other tools, try to extract key information or fall back to tool name
match input {
AmpToolData::Todo { todos, .. } => {
if let Some(todos) = todos {
let mut todo_items = Vec::new();
for todo in todos {
let emoji = match todo.status.as_str() {
"completed" => "",
"in_progress" | "in-progress" => "🔄",
"pending" | "todo" => "",
_ => "📝",
};
let priority = todo.priority.as_deref().unwrap_or("medium");
todo_items
.push(format!("{} {} ({})", emoji, todo.content, priority));
}
if !todo_items.is_empty() {
format!("TODO List:\n{}", todo_items.join("\n"))
} else {
"Managing TODO list".to_string()
}
} else {
"Managing TODO list".to_string()
}
}
AmpToolData::List { path, .. } => {
if let Some(path) = path {
let relative_path = make_path_relative(path, worktree_path);
@@ -584,6 +575,19 @@ impl AmpContentItem {
}
parts.join(" ")
}
AmpToolData::Unknown { data } => {
// Manually check if "name" is prefixed with "todo"
// This is a hack to avoid flickering on the frontend
let name = data
.get("name")
.and_then(|v| v.as_str())
.unwrap_or(tool_name);
if name.starts_with("todo") {
"TODO list updated".to_string()
} else {
tool_name.to_string()
}
}
_ => tool_name.to_string(),
}
}

View File

@@ -14,7 +14,7 @@ use crate::{
command::CommandBuilder,
executors::{ExecutorError, StandardCodingAgentExecutor},
logs::{
ActionType, EditDiff, NormalizedEntry, NormalizedEntryType,
ActionType, EditDiff, NormalizedEntry, NormalizedEntryType, TodoItem,
stderr_processor::normalize_stderr_logs,
utils::{EntryIndexProvider, patch::ConversationPatch},
},
@@ -505,8 +505,16 @@ impl ClaudeLogProcessor {
path: make_path_relative(notebook_path, worktree_path),
diffs: vec![],
},
ClaudeToolData::TodoWrite { .. } => ActionType::Other {
description: "Manage TODO list".to_string(),
ClaudeToolData::TodoWrite { todos } => ActionType::TodoManagement {
todos: todos
.iter()
.map(|t| TodoItem {
content: t.content.clone(),
status: t.status.clone(),
priority: t.priority.clone(),
})
.collect(),
operation: "write".to_string(),
},
ClaudeToolData::Glob { pattern, path: _ } => ActionType::Search {
query: pattern.clone(),
@@ -534,26 +542,8 @@ impl ClaudeLogProcessor {
ActionType::WebFetch { url } => format!("`{url}`"),
ActionType::TaskCreate { description } => description.clone(),
ActionType::PlanPresentation { plan } => plan.clone(),
ActionType::TodoManagement { .. } => "TODO list updated".to_string(),
ActionType::Other { description: _ } => match tool_data {
ClaudeToolData::TodoWrite { todos } => {
let mut todo_items = Vec::new();
for todo in todos {
let status_emoji = match todo.status.as_str() {
"completed" => "",
"in_progress" => "🔄",
"pending" | "todo" => "",
_ => "📝",
};
let priority = todo.priority.as_deref().unwrap_or("medium");
todo_items
.push(format!("{} {} ({})", status_emoji, todo.content, priority));
}
if !todo_items.is_empty() {
format!("TODO List:\n{}", todo_items.join("\n"))
} else {
"Managing TODO list".to_string()
}
}
ClaudeToolData::LS { path } => {
let relative_path = make_path_relative(path, worktree_path);
if relative_path.is_empty() {
@@ -843,45 +833,6 @@ mod tests {
assert_eq!(entries[0].content, "Let me think about this...");
}
#[test]
fn test_todo_tool_content_extraction() {
// Test TodoWrite with actual todo list
let todo_data = ClaudeToolData::TodoWrite {
todos: vec![
ClaudeTodoItem {
id: Some("1".to_string()),
content: "Fix the navigation bug".to_string(),
status: "completed".to_string(),
priority: Some("high".to_string()),
},
ClaudeTodoItem {
id: Some("2".to_string()),
content: "Add user authentication".to_string(),
status: "in_progress".to_string(),
priority: Some("medium".to_string()),
},
ClaudeTodoItem {
id: Some("3".to_string()),
content: "Write documentation".to_string(),
status: "pending".to_string(),
priority: Some("low".to_string()),
},
],
};
let action_type = ClaudeLogProcessor::extract_action_type(&todo_data, "/tmp/test-worktree");
let result = ClaudeLogProcessor::generate_concise_content(
&todo_data,
&action_type,
"/tmp/test-worktree",
);
assert!(result.contains("TODO List:"));
assert!(result.contains("✅ Fix the navigation bug (high)"));
assert!(result.contains("🔄 Add user authentication (medium)"));
assert!(result.contains("⏳ Write documentation (low)"));
}
#[test]
fn test_todo_tool_empty_list() {
// Test TodoWrite with empty todo list
@@ -895,7 +846,7 @@ mod tests {
"/tmp/test-worktree",
);
assert_eq!(result, "Managing TODO list");
assert_eq!(result, "TODO list updated");
}
#[test]

View File

@@ -13,7 +13,7 @@ use crate::{
command::CommandBuilder,
executors::{ExecutorError, StandardCodingAgentExecutor},
logs::{
ActionType, EditDiff, NormalizedEntry, NormalizedEntryType,
ActionType, EditDiff, NormalizedEntry, NormalizedEntryType, TodoItem,
plain_text_processor::PlainTextLogProcessor,
utils::{ConversationPatch, EntryIndexProvider},
},
@@ -579,16 +579,27 @@ impl CursorToolCall {
)
}
CursorToolCall::Todo { args, .. } => {
let content = if let Some(todos) = args.todos.as_ref() {
format!("TODO List:\n{}", summarize_todos_typed(todos))
} else {
"Managing TODO list".to_string()
};
let todos = args
.todos
.as_ref()
.map(|todos| {
todos
.iter()
.map(|t| TodoItem {
content: t.content.clone(),
status: t.status.clone(),
priority: None, // CursorTodoItem doesn't have priority field
})
.collect()
})
.unwrap_or_default();
(
ActionType::Other {
description: "Manage TODO list".to_string(),
ActionType::TodoManagement {
todos,
operation: "write".to_string(),
},
content,
"TODO list updated".to_string(),
)
}
CursorToolCall::Unknown { .. } => (
@@ -735,37 +746,6 @@ pub struct CursorTodoItem {
pub dependencies: Option<Vec<String>>,
}
/* ===========================
Helpers
=========================== */
fn summarize_todos_typed(items: &[CursorTodoItem]) -> String {
let mut out: Vec<String> = Vec::new();
for todo in items {
let content = todo.content.as_str();
let status = todo.status.as_str();
let emoji = if status.eq_ignore_ascii_case("completed")
|| status.contains("COMPLETED")
|| status.eq_ignore_ascii_case("done")
{
""
} else if status.eq_ignore_ascii_case("in_progress")
|| status.eq_ignore_ascii_case("in-progress")
|| status.contains("IN_PROGRESS")
{
"🔄"
} else {
""
};
out.push(format!("{emoji} {content}"));
}
if out.is_empty() {
"Managing TODO list".to_string()
} else {
out.join("\n")
}
}
/* ===========================
Tests
=========================== */

View File

@@ -15,7 +15,7 @@ use crate::{
command::CommandBuilder,
executors::{ExecutorError, StandardCodingAgentExecutor},
logs::{
ActionType, EditDiff, NormalizedEntry, NormalizedEntryType,
ActionType, EditDiff, NormalizedEntry, NormalizedEntryType, TodoItem,
plain_text_processor::{MessageBoundary, PlainTextLogProcessor},
utils::EntryIndexProvider,
},
@@ -724,8 +724,20 @@ impl ToolUtils {
Tool::WebFetch { url, .. } => ActionType::Other {
description: format!("Web fetch: {url}"),
},
Tool::TodoWrite { .. } | Tool::TodoRead => ActionType::Other {
description: "TODO list management".to_string(),
Tool::TodoWrite { todos } => ActionType::TodoManagement {
todos: todos
.iter()
.map(|t| TodoItem {
content: t.content.clone(),
status: t.status.clone(),
priority: t.priority.clone(),
})
.collect(),
operation: "write".to_string(),
},
Tool::TodoRead => ActionType::TodoManagement {
todos: vec![],
operation: "read".to_string(),
},
Tool::Task { description } => ActionType::Other {
description: format!("Task: {description}"),
@@ -787,11 +799,11 @@ impl ToolUtils {
Tool::WebFetch { url, .. } => {
format!("fetch `{url}`")
}
Tool::TodoWrite { todos } => Self::generate_todo_content(todos),
Tool::TodoRead => "Managing TODO list".to_string(),
Tool::Task { description } => {
format!("Task: `{description}`")
}
Tool::TodoWrite { .. } => "TODO list updated".to_string(),
Tool::TodoRead => "TODO list read".to_string(),
Tool::Other { tool_name, .. } => {
// Handle MCP tools (format: client_name_tool_name)
if tool_name.contains('_') {
@@ -802,26 +814,6 @@ impl ToolUtils {
}
}
}
/// Generate formatted content for TODO tools from TodoInfo struct
fn generate_todo_content(todos: &[TodoInfo]) -> String {
if todos.is_empty() {
return "Managing TODO list".to_string();
}
let mut todo_items = Vec::new();
for todo in todos {
let status_emoji = match todo.status.as_str() {
"completed" => "",
"in_progress" => "🔄",
"pending" | "todo" => "",
_ => "📝",
};
let priority = todo.priority.as_deref().unwrap_or("medium");
todo_items.push(format!("{} {} ({})", status_emoji, todo.content, priority));
}
format!("TODO List:\n{}", todo_items.join("\n"))
}
}
// =============================================================================

View File

@@ -45,16 +45,47 @@ pub struct NormalizedEntry {
pub metadata: Option<serde_json::Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[ts(export)]
pub struct TodoItem {
pub content: String,
pub status: String,
#[serde(default)]
pub priority: Option<String>,
}
/// Types of tool actions that can be performed
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[ts(export)]
#[serde(tag = "action", rename_all = "snake_case")]
pub enum ActionType {
FileRead { path: String },
FileEdit { path: String, diffs: Vec<EditDiff> },
CommandRun { command: String },
Search { query: String },
WebFetch { url: String },
TaskCreate { description: String },
PlanPresentation { plan: String },
Other { description: String },
FileRead {
path: String,
},
FileEdit {
path: String,
diffs: Vec<EditDiff>,
},
CommandRun {
command: String,
},
Search {
query: String,
},
WebFetch {
url: String,
},
TaskCreate {
description: String,
},
PlanPresentation {
plan: String,
},
TodoManagement {
todos: Vec<TodoItem>,
operation: String,
},
Other {
description: String,
},
}

View File

@@ -82,6 +82,7 @@ fn generate_types_content() -> String {
executors::logs::NormalizedEntryType::decl(),
executors::logs::EditDiff::decl(),
executors::logs::ActionType::decl(),
executors::logs::TodoItem::decl(),
executors::logs::utils::patch::PatchType::decl(),
serde_json::Value::decl(),
];