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 all 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
545 changes: 236 additions & 309 deletions Cargo.lock

Large diffs are not rendered by default.

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

1 change: 1 addition & 0 deletions crates/chat-cli/src/cli/agent/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -728,6 +728,7 @@ impl Agents {
"use_aws" => "trust read-only commands".dark_grey(),
"report_issue" => "trusted".dark_green().bold(),
"thinking" => "trusted (prerelease)".dark_green().bold(),
"todo_list" => "trusted".dark_green().bold(),
_ if self.trust_all_tools => "trusted".dark_grey().bold(),
_ => "not trusted".dark_grey(),
};
Expand Down
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
202 changes: 202 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,202 @@
use clap::Subcommand;
use crossterm::execute;
use crossterm::style::{
self,
Stylize,
};
use dialoguer::FuzzySelect;
use eyre::Result;

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

/// Defines subcommands that allow users to view and manage todo lists
#[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 {
#[arg(long, short)]
all: bool,
},
}

/// 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 (todos, errors) = match get_all_todos(os).await {
Ok(res) => res,
Err(e) => return Err(ChatError::Custom(format!("Could not get to-do lists: {e}").into())),
};
let mut cleared_one = false;

for todo_status in todos.iter() {
if todo_status.completed.iter().all(|b| *b) {
match delete_todo(os, &todo_status.id).await {
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"))?;
}
if !errors.is_empty() {
execute!(
session.stderr,
style::Print(format!("* Failed to get {} todo list(s)\n", errors.len()).dark_grey())
)?;
}
},
Self::Resume => match Self::get_descriptions_and_statuses(os).await {
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).await {
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 = TodoListState::load(os, &entries[index].id).await.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(e) => return Err(ChatError::Custom(format!("Could not show to-do lists: {e}").into())),
},
Self::Delete { all } => match Self::get_descriptions_and_statuses(os).await {
Ok(entries) => {
if entries.is_empty() {
execute!(session.stderr, style::Print("No to-do lists to delete!\n"))?;
} else if all {
for entry in entries {
delete_todo(os, &entry.id)
.await
.map_err(|_e| ChatError::Custom("Could not delete all to-do lists".into()))?;
}
execute!(session.stderr, style::Print("✔ Deleted all to-do lists!\n".green()),)?;
} else if let Some(index) = fuzzy_select_todos(&entries, "Select a to-do list to delete:") {
if index < entries.len() {
delete_todo(os, &entries[index].id).await.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(e) => return Err(ChatError::Custom(format!("Could not show to-do lists: {e}").into())),
},
}
Ok(ChatState::PromptUser {
skip_printing_tools: true,
})
}

/// Convert all to-do list state entries to displayable entries
async fn get_descriptions_and_statuses(os: &Os) -> Result<Vec<TodoDisplayEntry>> {
let mut out = Vec::new();
let (todos, _) = get_all_todos(os).await?;
for todo in todos.iter() {
out.push(TodoDisplayEntry {
num_completed: todo.completed.iter().filter(|b| **b).count(),
num_tasks: todo.completed.len(),
description: todo.description.clone(),
id: todo.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)
}
7 changes: 7 additions & 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 Expand Up @@ -489,12 +490,15 @@ impl ConversationState {
2) Bullet points for all significant tools executed and their results\n\
3) Bullet points for any code or technical information shared\n\
4) A section of key insights gained\n\n\
5) REQUIRED: the ID of the currently loaded todo list, if any\n\n\
FORMAT THE SUMMARY IN THIRD PERSON, NOT AS A DIRECT RESPONSE. Example format:\n\n\
## CONVERSATION SUMMARY\n\
* Topic 1: Key information\n\
* Topic 2: Key information\n\n\
## TOOLS EXECUTED\n\
* Tool X: Result Y\n\n\
## TODO ID\n\
* <id>\n\n\
Remember this is a DOCUMENT not a chat response. The custom instruction above modifies what to prioritize.\n\
FILTER OUT CHAT CONVENTIONS (greetings, offers to help, etc).",
custom_prompt.as_ref()
Expand All @@ -509,12 +513,15 @@ impl ConversationState {
2) Bullet points for all significant tools executed and their results\n\
3) Bullet points for any code or technical information shared\n\
4) A section of key insights gained\n\n\
5) REQUIRED: the ID of the currently loaded todo list, if any\n\n\
FORMAT THE SUMMARY IN THIRD PERSON, NOT AS A DIRECT RESPONSE. Example format:\n\n\
## CONVERSATION SUMMARY\n\
* Topic 1: Key information\n\
* Topic 2: Key information\n\n\
## TOOLS EXECUTED\n\
* Tool X: Result Y\n\n\
## TODO ID\n\
* <id>\n\n\
Remember this is a DOCUMENT not a chat response.\n\
FILTER OUT CHAT CONVENTIONS (greetings, offers to help, etc).".to_string()
},
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 @@ -131,13 +131,16 @@ use crate::api_client::{
};
use crate::auth::AuthError;
use crate::auth::builder_id::is_idc_user;
use crate::cli::TodoListState;
use crate::cli::agent::Agents;
use crate::cli::chat::cli::SlashCommand;
use crate::cli::chat::cli::model::find_model;
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 @@ -639,6 +642,11 @@ impl ChatSession {
}
});

// Create for cleaner error handling for todo lists
// This is more of a convenience thing but is not required, so the Result
// is ignored
let _ = TodoListState::init_dir(os).await;
Copy link
Contributor

Choose a reason for hiding this comment

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

Any todo list directory creation should really be happening with the todo list code that depends on it, having it here is error prone should this ever be moved in the future.


Ok(Self {
stdout,
stderr,
Expand Down Expand Up @@ -2778,6 +2786,53 @@ 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 = match TodoListState::load(os, id).await {
Ok(todo) => todo,
Err(e) => {
return Err(ChatError::Custom(format!("Error getting todo list: {e}").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 @@ -2838,7 +2893,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
Loading
Loading