-
Notifications
You must be signed in to change notification settings - Fork 270
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
Changes from 34 commits
264c308
415624a
30fc467
da54512
2a09ea9
398bdb4
c000ffb
302cecd
a9501e7
814a96a
216ce00
714474a
06837f2
1a4e7e1
3299478
e31c7ed
058cc62
12a70b5
ef44517
cbbfa20
5ebf87a
5e76bc1
7f91788
70423f9
81b1dac
e7ea8af
d7aa647
ee6b932
6d00e0f
4b684f9
f2fc3d1
2fcbe56
9e0b732
030c79a
cbc8625
2bf1c40
2d9c3b4
3e72ee8
1be09da
7f71a68
d2a8936
b26c006
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -44,4 +44,3 @@ artifacts: | |
# Signatures | ||
- ./*.asc | ||
- ./*.sig | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -38,4 +38,3 @@ artifacts: | |
- ./*.zip | ||
# Hashes | ||
- ./*.sha256 | ||
|
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) | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -12,6 +12,7 @@ use crossterm::{ | |
execute, | ||
style, | ||
}; | ||
use eyre::Result; | ||
use serde::{ | ||
Deserialize, | ||
Serialize, | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -131,12 +131,15 @@ 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::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; | ||
|
@@ -649,6 +652,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; | ||
|
||
Ok(Self { | ||
stdout, | ||
stderr, | ||
|
@@ -2788,6 +2796,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(); | ||
brandonskiser marked this conversation as resolved.
Show resolved
Hide resolved
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This still sends the entire tool spec, you'd have to construct a special request for this Here is a reference for how the compaction request is created - https://github.com/aws/amazon-q-developer-cli/blob/main/crates/chat-cli/src/cli/chat/conversation.rs#L528 I think you can then switch to Also be sure to call |
||
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. | ||
|
@@ -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>>, | ||
|
There was a problem hiding this comment.
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.