Skip to content

Add to-do list functionality to QCLI #2533

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 37 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 26 commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
264c308
feat: update build scripts to build qchat (#2198)
brandonskiser Jul 1, 2025
415624a
feat: Add basic todo list tool
Jul 3, 2025
30fc467
Slash command + persistence in progress commit
Jul 8, 2025
da54512
feat: Add basic persistence functionality to todo_list
Jul 8, 2025
2a09ea9
Add append-only context and "modified files" fields to "complete" com…
Jul 9, 2025
398bdb4
refactor: Change to-do lists to use database over filesystem
Jul 9, 2025
c000ffb
feat: Add `view` subcommand for /todos
Jul 10, 2025
302cecd
feat: Add clear-finished subcommand for /todos
Jul 10, 2025
a9501e7
feat: Add `add` and `remove` functionality for to-do lists
Jul 10, 2025
814a96a
fix: Add feedback after calling todo_list commands
Jul 14, 2025
216ce00
feat: Cleaned up todo list UX and removed /todos show
Jul 15, 2025
714474a
feat: Add /todos show
Jul 15, 2025
06837f2
trying to merge changes
Aug 7, 2025
1a4e7e1
fix: Fix all merge conflicts, back to working state
Aug 7, 2025
3299478
chore: Resolve merge conflicts, run formatter and clippy
Aug 8, 2025
e31c7ed
chore: Fix real clippy errors (hopefully)
Aug 8, 2025
058cc62
Merge branch 'main' of github.com:aws/amazon-q-developer-cli into kir…
Aug 8, 2025
12a70b5
chore: Run formatter
Aug 8, 2025
ef44517
chore: Remove old/unrelated files
Aug 8, 2025
cbbfa20
chore: Remove old/unrelated files again
Aug 8, 2025
5ebf87a
refactor: Several changes made to `todo_list` tool
Aug 11, 2025
5e76bc1
chore: Fix typo in tool_index.json
Aug 11, 2025
7f91788
chore: Remove buildspec files
Aug 11, 2025
70423f9
chore: Edit buildspec files to be identical to main
Aug 11, 2025
81b1dac
chore: Remove debugging in todos.rs
Aug 11, 2025
e7ea8af
chore: Merging changes from main
Aug 12, 2025
d7aa647
refactor: Convert from global database store to per-directory filesys…
Aug 12, 2025
ee6b932
merge commit
Aug 13, 2025
6d00e0f
Merge branch 'main' into kiran-garre/todo-list
Aug 13, 2025
4b684f9
fix: Update dependencies to fix slab issue
Aug 13, 2025
f2fc3d1
chore: Change variable names, add comments, add file extension to tod…
Aug 13, 2025
2fcbe56
Merge commit
Aug 13, 2025
9e0b732
Removed merge markers from Cargo.lock
Aug 13, 2025
030c79a
Fixed Cargo.lock
Aug 13, 2025
cbc8625
fix: Modify system prompt to contain current todo list id
Aug 14, 2025
2bf1c40
Merge branch 'main' of github.com:aws/amazon-q-developer-cli
Aug 14, 2025
2d9c3b4
Merge branch 'main' into kiran-garre/todo-list
Aug 14, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion build-config/buildspec-linux.yml
Original file line number Diff line number Diff line change
Expand Up @@ -44,4 +44,3 @@ artifacts:
# Signatures
- ./*.asc
- ./*.sig

1 change: 0 additions & 1 deletion build-config/buildspec-macos.yml
Original file line number Diff line number Diff line change
Expand Up @@ -38,4 +38,3 @@ artifacts:
- ./*.zip
# Hashes
- ./*.sha256

7 changes: 7 additions & 0 deletions crates/chat-cli/src/cli/chat/cli/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ pub mod persist;
pub mod profile;
pub mod prompts;
pub mod subscribe;
pub mod todos;
pub mod tools;
pub mod usage;

Expand All @@ -25,6 +26,7 @@ use model::ModelArgs;
use persist::PersistSubcommand;
use profile::AgentSubcommand;
use prompts::PromptsArgs;
use todos::TodoSubcommand;
use tools::ToolsArgs;

use crate::cli::chat::cli::subscribe::SubscribeArgs;
Expand Down Expand Up @@ -85,6 +87,9 @@ pub enum SlashCommand {
Persist(PersistSubcommand),
// #[command(flatten)]
// Root(RootSubcommand),
/// View, manage, and resume to-do lists
#[command(subcommand)]
Todos(TodoSubcommand),
}

impl SlashCommand {
Expand Down Expand Up @@ -146,6 +151,7 @@ impl SlashCommand {
// skip_printing_tools: true,
// })
// },
Self::Todos(subcommand) => subcommand.execute(os, session).await,
}
}

Expand All @@ -171,6 +177,7 @@ impl SlashCommand {
PersistSubcommand::Save { .. } => "save",
PersistSubcommand::Load { .. } => "load",
},
Self::Todos(_) => "todos",
}
}

Expand Down
198 changes: 198 additions & 0 deletions crates/chat-cli/src/cli/chat/cli/todos.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
use clap::Subcommand;
use crossterm::execute;
use crossterm::style::{
self,
Stylize,
};
use dialoguer::FuzzySelect;
use eyre::Result;

use crate::cli::chat::tools::todo::TodoState;
use crate::cli::chat::{
ChatError,
ChatSession,
ChatState,
};
use crate::os::Os;

#[derive(Debug, PartialEq, Subcommand)]
pub enum TodoSubcommand {
/// Delete all completed to-do lists
ClearFinished,

/// Resume a selected to-do list
Resume,

/// View a to-do list
View,

/// Delete a to-do list
Delete,
}

/// Used for displaying completed and in-progress todo lists
pub struct TodoDisplayEntry {
pub num_completed: usize,
pub num_tasks: usize,
pub description: String,
pub id: String,
}

impl std::fmt::Display for TodoDisplayEntry {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
if self.num_completed == self.num_tasks {
write!(f, "{} {}", "✓".green().bold(), self.description.clone(),)
} else {
write!(
f,
"{} {} ({}/{})",
"✗".red().bold(),
self.description.clone(),
self.num_completed,
self.num_tasks
)
}
}
}

impl TodoSubcommand {
pub async fn execute(self, os: &mut Os, session: &mut ChatSession) -> Result<ChatState, ChatError> {
match self {
Self::ClearFinished => {
let entries = match os.database.get_all_todos() {
Ok(e) => e,
Err(e) => return Err(ChatError::Custom(format!("Could not get all to-do lists: {e}").into())),
};
let mut cleared_one = false;

for (id, value) in entries.iter() {
let todo_status = match value.as_str() {
Some(s) => match serde_json::from_str::<TodoState>(s) {
Ok(state) => state,

// FIX: Silent fail
Err(_) => continue,
},
None => continue,
};
if todo_status.completed.iter().all(|b| *b) {
match os.database.delete_todo(id) {
Ok(_) => cleared_one = true,
Err(e) => {
return Err(ChatError::Custom(format!("Could not delete to-do list: {e}").into()));
},
};
}
}
if cleared_one {
execute!(
session.stderr,
style::Print("✔ Cleared finished to-do lists!\n".green())
)?;
} else {
execute!(session.stderr, style::Print("No finished to-do lists to clear!\n"))?;
}
},
Self::Resume => match Self::get_descriptions_and_statuses(os) {
Ok(entries) => {
if entries.is_empty() {
execute!(session.stderr, style::Print("No to-do lists to resume!\n"),)?;
} else if let Some(index) = fuzzy_select_todos(&entries, "Select a to-do list to resume:") {
if index < entries.len() {
execute!(
session.stderr,
style::Print(format!(
"{} {}",
"⟳ Resuming:".magenta(),
entries[index].description.clone()
))
)?;
return session.resume_todo_request(os, &entries[index].id).await;
}
}
},
Err(e) => return Err(ChatError::Custom(format!("Could not show to-do lists: {e}").into())),
},
Self::View => match Self::get_descriptions_and_statuses(os) {
Ok(entries) => {
if entries.is_empty() {
execute!(session.stderr, style::Print("No to-do lists to view!\n"))?;
} else if let Some(index) = fuzzy_select_todos(&entries, "Select a to-do list to view:") {
if index < entries.len() {
let list = TodoState::load(os, &entries[index].id).map_err(|e| {
ChatError::Custom(format!("Could not load current to-do list: {e}").into())
})?;
execute!(
session.stderr,
style::Print(format!(
"{} {}\n\n",
"Viewing:".magenta(),
entries[index].description.clone()
))
)?;
if list.display_list(&mut session.stderr).is_err() {
return Err(ChatError::Custom("Could not display the selected to-do list".into()));
}
execute!(session.stderr, style::Print("\n"),)?;
}
}
},
Err(_) => return Err(ChatError::Custom("Could not show to-do lists".into())),
},
Self::Delete => match Self::get_descriptions_and_statuses(os) {
Ok(entries) => {
if entries.is_empty() {
execute!(session.stderr, style::Print("No to-do lists to delete!\n"))?;
} else if let Some(index) = fuzzy_select_todos(&entries, "Select a to-do list to delete:") {
if index < entries.len() {
os.database.delete_todo(&entries[index].id).map_err(|e| {
ChatError::Custom(format!("Could not delete the selected to-do list: {e}").into())
})?;
execute!(
session.stderr,
style::Print("✔ Deleted to-do list: ".green()),
style::Print(format!("{}\n", entries[index].description.clone().dark_grey()))
)?;
}
}
},
Err(_) => return Err(ChatError::Custom("Could not show to-do lists".into())),
},
}
Ok(ChatState::PromptUser {
skip_printing_tools: true,
})
}

/// Convert all to-do list state entries to displayable entries
fn get_descriptions_and_statuses(os: &Os) -> Result<Vec<TodoDisplayEntry>> {
let mut out = Vec::new();
let entries = os.database.get_all_todos()?;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This database.get_all_todos api could instead be (Vec<TodoState>, Vec<DatabaseError>) so we just get a list of valid todos, and todos that failed to load or deserialize for whatever reason, so we don't have to duplicate this deser logic multiple times maybe

for (id, value) in entries.iter() {
let temp_struct = match value.as_str() {
Some(s) => match serde_json::from_str::<TodoState>(s) {
Ok(state) => state,
Err(_) => continue,
},
None => continue,
};

out.push(TodoDisplayEntry {
num_completed: temp_struct.completed.iter().filter(|b| **b).count(),
num_tasks: temp_struct.completed.len(),
description: temp_struct.task_description,
id: id.clone(),
});
}
Ok(out)
}
}

fn fuzzy_select_todos(entries: &[TodoDisplayEntry], prompt_str: &str) -> Option<usize> {
FuzzySelect::new()
.with_prompt(prompt_str)
.items(entries)
.report(false)
.interact_opt()
.unwrap_or(None)
}
1 change: 1 addition & 0 deletions crates/chat-cli/src/cli/chat/conversation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ use crossterm::{
execute,
style,
};
use eyre::Result;
use serde::{
Deserialize,
Serialize,
Expand Down
57 changes: 56 additions & 1 deletion crates/chat-cli/src/cli/chat/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,8 @@ use crate::cli::chat::cli::prompts::{
GetPromptError,
PromptsSubcommand,
};
use crate::cli::chat::message::UserMessage;
use crate::cli::chat::tools::ToolOrigin;
use crate::cli::chat::util::sanitize_unicode_tags;
use crate::database::settings::Setting;
use crate::mcp_client::Prompt;
Expand Down Expand Up @@ -2788,6 +2790,59 @@ impl ChatSession {
tracing::warn!("Failed to send slash command telemetry: {}", e);
}
}

/// Prompts Q to resume a to-do list with the given id by calling the load
/// command of the todo_list tool
pub async fn resume_todo_request(&mut self, os: &mut Os, id: &str) -> Result<ChatState, ChatError> {
// Have to unpack each value separately since Reports can't be converted to
// ChatError
let todo_list_option = match os.database.get_todo(id) {
Ok(option) => option,
Err(e) => {
return Err(ChatError::Custom(
format!("Error getting todo list from database: {e}").into(),
));
},
};
let todo_list = match todo_list_option {
Some(todo_list) => todo_list,
None => return Err(ChatError::Custom(format!("No todo list with id {}", id).into())),
};
let contents = match serde_json::to_string(&todo_list) {
Ok(s) => s,
Err(e) => return Err(ChatError::Custom(format!("Error deserializing todo list: {e}").into())),
};
let summary_content = format!(
"[SYSTEM NOTE: This is an automated request, not from the user]\n
Read the TODO list contents below and understand the task description, completed tasks, and provided context.\n
Call the `load` command of the todo_list tool with the given ID as an argument to display the TODO list to the user and officially resume execution of the TODO list tasks.\n
You do not need to display the tasks to the user yourself. You can begin completing the tasks after calling the `load` command.\n
TODO LIST CONTENTS: {}\n
ID: {}\n",
contents,
id
);

let summary_message = UserMessage::new_prompt(summary_content.clone(), None);

// Only send the todo_list tool
let mut tools = self.conversation.tools.clone();
tools.retain(|k, v| match k {
ToolOrigin::Native => {
v.retain(|tool| match tool {
api_client::model::Tool::ToolSpecification(tool_spec) => tool_spec.name == "todo_list",
});
true
},
ToolOrigin::McpServer(_) => false,
});

Ok(ChatState::HandleInput {
input: summary_message
.into_user_input_message(self.conversation.model.clone(), &tools)
.content,
})
}
}

/// Replaces amzn_codewhisperer_client::types::SubscriptionStatus with a more descriptive type.
Expand Down Expand Up @@ -2848,7 +2903,7 @@ async fn get_subscription_status_with_spinner(
.await;
}

async fn with_spinner<T, E, F, Fut>(output: &mut impl std::io::Write, spinner_text: &str, f: F) -> Result<T, E>
pub async fn with_spinner<T, E, F, Fut>(output: &mut impl std::io::Write, spinner_text: &str, f: F) -> Result<T, E>
where
F: FnOnce() -> Fut,
Fut: std::future::Future<Output = Result<T, E>>,
Expand Down
2 changes: 2 additions & 0 deletions crates/chat-cli/src/cli/chat/tool_manager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ use crate::cli::chat::tools::fs_write::FsWrite;
use crate::cli::chat::tools::gh_issue::GhIssue;
use crate::cli::chat::tools::knowledge::Knowledge;
use crate::cli::chat::tools::thinking::Thinking;
use crate::cli::chat::tools::todo::TodoInput;
use crate::cli::chat::tools::use_aws::UseAws;
use crate::cli::chat::tools::{
Tool,
Expand Down Expand Up @@ -1066,6 +1067,7 @@ impl ToolManager {
"report_issue" => Tool::GhIssue(serde_json::from_value::<GhIssue>(value.args).map_err(map_err)?),
"thinking" => Tool::Thinking(serde_json::from_value::<Thinking>(value.args).map_err(map_err)?),
"knowledge" => Tool::Knowledge(serde_json::from_value::<Knowledge>(value.args).map_err(map_err)?),
"todo_list" => Tool::Todo(serde_json::from_value::<TodoInput>(value.args).map_err(map_err)?),
// Note that this name is namespaced with server_name{DELIMITER}tool_name
name => {
// Note: tn_map also has tools that underwent no transformation. In otherwords, if
Expand Down
Loading
Loading