Skip to content

Commit f0c5bd3

Browse files
authored
feat: add to-do list functionality to QCLI (#2533)
1 parent c3276a4 commit f0c5bd3

File tree

13 files changed

+780
-3
lines changed

13 files changed

+780
-3
lines changed

build-config/buildspec-linux.yml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,4 +44,3 @@ artifacts:
4444
# Signatures
4545
- ./*.asc
4646
- ./*.sig
47-

build-config/buildspec-macos.yml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,4 +38,3 @@ artifacts:
3838
- ./*.zip
3939
# Hashes
4040
- ./*.sha256
41-

crates/chat-cli/src/cli/agent/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -810,6 +810,7 @@ impl Agents {
810810
"use_aws" => "trust read-only commands".dark_grey(),
811811
"report_issue" => "trusted".dark_green().bold(),
812812
"thinking" => "trusted (prerelease)".dark_green().bold(),
813+
"todo_list" => "trusted".dark_green().bold(),
813814
_ if self.trust_all_tools => "trusted".dark_grey().bold(),
814815
_ => "not trusted".dark_grey(),
815816
};

crates/chat-cli/src/cli/chat/cli/mod.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ pub mod profile;
1111
pub mod prompts;
1212
pub mod subscribe;
1313
pub mod tangent;
14+
pub mod todos;
1415
pub mod tools;
1516
pub mod usage;
1617

@@ -27,6 +28,7 @@ use persist::PersistSubcommand;
2728
use profile::AgentSubcommand;
2829
use prompts::PromptsArgs;
2930
use tangent::TangentArgs;
31+
use todos::TodoSubcommand;
3032
use tools::ToolsArgs;
3133

3234
use crate::cli::chat::cli::subscribe::SubscribeArgs;
@@ -89,6 +91,9 @@ pub enum SlashCommand {
8991
Persist(PersistSubcommand),
9092
// #[command(flatten)]
9193
// Root(RootSubcommand),
94+
/// View, manage, and resume to-do lists
95+
#[command(subcommand)]
96+
Todos(TodoSubcommand),
9297
}
9398

9499
impl SlashCommand {
@@ -151,6 +156,7 @@ impl SlashCommand {
151156
// skip_printing_tools: true,
152157
// })
153158
// },
159+
Self::Todos(subcommand) => subcommand.execute(os, session).await,
154160
}
155161
}
156162

@@ -177,6 +183,7 @@ impl SlashCommand {
177183
PersistSubcommand::Save { .. } => "save",
178184
PersistSubcommand::Load { .. } => "load",
179185
},
186+
Self::Todos(_) => "todos",
180187
}
181188
}
182189

Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
use clap::Subcommand;
2+
use crossterm::execute;
3+
use crossterm::style::{
4+
self,
5+
Stylize,
6+
};
7+
use dialoguer::FuzzySelect;
8+
use eyre::Result;
9+
10+
use crate::cli::chat::tools::todo::{
11+
TodoListState,
12+
delete_todo,
13+
get_all_todos,
14+
};
15+
use crate::cli::chat::{
16+
ChatError,
17+
ChatSession,
18+
ChatState,
19+
};
20+
use crate::os::Os;
21+
22+
/// Defines subcommands that allow users to view and manage todo lists
23+
#[derive(Debug, PartialEq, Subcommand)]
24+
pub enum TodoSubcommand {
25+
/// Delete all completed to-do lists
26+
ClearFinished,
27+
28+
/// Resume a selected to-do list
29+
Resume,
30+
31+
/// View a to-do list
32+
View,
33+
34+
/// Delete a to-do list
35+
Delete {
36+
#[arg(long, short)]
37+
all: bool,
38+
},
39+
}
40+
41+
/// Used for displaying completed and in-progress todo lists
42+
pub struct TodoDisplayEntry {
43+
pub num_completed: usize,
44+
pub num_tasks: usize,
45+
pub description: String,
46+
pub id: String,
47+
}
48+
49+
impl std::fmt::Display for TodoDisplayEntry {
50+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
51+
if self.num_completed == self.num_tasks {
52+
write!(f, "{} {}", "✓".green().bold(), self.description.clone(),)
53+
} else {
54+
write!(
55+
f,
56+
"{} {} ({}/{})",
57+
"✗".red().bold(),
58+
self.description.clone(),
59+
self.num_completed,
60+
self.num_tasks
61+
)
62+
}
63+
}
64+
}
65+
66+
impl TodoSubcommand {
67+
pub async fn execute(self, os: &mut Os, session: &mut ChatSession) -> Result<ChatState, ChatError> {
68+
TodoListState::init_dir(os)
69+
.await
70+
.map_err(|e| ChatError::Custom(format!("Could not create todos directory: {e}").into()))?;
71+
match self {
72+
Self::ClearFinished => {
73+
let (todos, errors) = match get_all_todos(os).await {
74+
Ok(res) => res,
75+
Err(e) => return Err(ChatError::Custom(format!("Could not get to-do lists: {e}").into())),
76+
};
77+
let mut cleared_one = false;
78+
79+
for todo_status in todos.iter() {
80+
if todo_status.tasks.iter().all(|b| b.completed) {
81+
match delete_todo(os, &todo_status.id).await {
82+
Ok(_) => cleared_one = true,
83+
Err(e) => {
84+
return Err(ChatError::Custom(format!("Could not delete to-do list: {e}").into()));
85+
},
86+
};
87+
}
88+
}
89+
if cleared_one {
90+
execute!(
91+
session.stderr,
92+
style::Print("✔ Cleared finished to-do lists!\n".green())
93+
)?;
94+
} else {
95+
execute!(session.stderr, style::Print("No finished to-do lists to clear!\n"))?;
96+
}
97+
if !errors.is_empty() {
98+
execute!(
99+
session.stderr,
100+
style::Print(format!("* Failed to get {} todo list(s)\n", errors.len()).dark_grey())
101+
)?;
102+
}
103+
},
104+
Self::Resume => match Self::get_descriptions_and_statuses(os).await {
105+
Ok(entries) => {
106+
if entries.is_empty() {
107+
execute!(session.stderr, style::Print("No to-do lists to resume!\n"),)?;
108+
} else if let Some(index) = fuzzy_select_todos(&entries, "Select a to-do list to resume:") {
109+
if index < entries.len() {
110+
execute!(
111+
session.stderr,
112+
style::Print(format!(
113+
"{} {}",
114+
"⟳ Resuming:".magenta(),
115+
entries[index].description.clone()
116+
))
117+
)?;
118+
return session.resume_todo_request(os, &entries[index].id).await;
119+
}
120+
}
121+
},
122+
Err(e) => return Err(ChatError::Custom(format!("Could not show to-do lists: {e}").into())),
123+
},
124+
Self::View => match Self::get_descriptions_and_statuses(os).await {
125+
Ok(entries) => {
126+
if entries.is_empty() {
127+
execute!(session.stderr, style::Print("No to-do lists to view!\n"))?;
128+
} else if let Some(index) = fuzzy_select_todos(&entries, "Select a to-do list to view:") {
129+
if index < entries.len() {
130+
let list = TodoListState::load(os, &entries[index].id).await.map_err(|e| {
131+
ChatError::Custom(format!("Could not load current to-do list: {e}").into())
132+
})?;
133+
execute!(
134+
session.stderr,
135+
style::Print(format!(
136+
"{} {}\n\n",
137+
"Viewing:".magenta(),
138+
entries[index].description.clone()
139+
))
140+
)?;
141+
if list.display_list(&mut session.stderr).is_err() {
142+
return Err(ChatError::Custom("Could not display the selected to-do list".into()));
143+
}
144+
execute!(session.stderr, style::Print("\n"),)?;
145+
}
146+
}
147+
},
148+
Err(e) => return Err(ChatError::Custom(format!("Could not show to-do lists: {e}").into())),
149+
},
150+
Self::Delete { all } => match Self::get_descriptions_and_statuses(os).await {
151+
Ok(entries) => {
152+
if entries.is_empty() {
153+
execute!(session.stderr, style::Print("No to-do lists to delete!\n"))?;
154+
} else if all {
155+
for entry in entries {
156+
delete_todo(os, &entry.id)
157+
.await
158+
.map_err(|_e| ChatError::Custom("Could not delete all to-do lists".into()))?;
159+
}
160+
execute!(session.stderr, style::Print("✔ Deleted all to-do lists!\n".green()),)?;
161+
} else if let Some(index) = fuzzy_select_todos(&entries, "Select a to-do list to delete:") {
162+
if index < entries.len() {
163+
delete_todo(os, &entries[index].id).await.map_err(|e| {
164+
ChatError::Custom(format!("Could not delete the selected to-do list: {e}").into())
165+
})?;
166+
execute!(
167+
session.stderr,
168+
style::Print("✔ Deleted to-do list: ".green()),
169+
style::Print(format!("{}\n", entries[index].description.clone().dark_grey()))
170+
)?;
171+
}
172+
}
173+
},
174+
Err(e) => return Err(ChatError::Custom(format!("Could not show to-do lists: {e}").into())),
175+
},
176+
}
177+
Ok(ChatState::PromptUser {
178+
skip_printing_tools: true,
179+
})
180+
}
181+
182+
/// Convert all to-do list state entries to displayable entries
183+
async fn get_descriptions_and_statuses(os: &Os) -> Result<Vec<TodoDisplayEntry>> {
184+
let mut out = Vec::new();
185+
let (todos, _) = get_all_todos(os).await?;
186+
for todo in todos.iter() {
187+
out.push(TodoDisplayEntry {
188+
num_completed: todo.tasks.iter().filter(|t| t.completed).count(),
189+
num_tasks: todo.tasks.len(),
190+
description: todo.description.clone(),
191+
id: todo.id.clone(),
192+
});
193+
}
194+
Ok(out)
195+
}
196+
}
197+
198+
fn fuzzy_select_todos(entries: &[TodoDisplayEntry], prompt_str: &str) -> Option<usize> {
199+
FuzzySelect::new()
200+
.with_prompt(prompt_str)
201+
.items(entries)
202+
.report(false)
203+
.interact_opt()
204+
.unwrap_or(None)
205+
}

crates/chat-cli/src/cli/chat/conversation.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ use crossterm::{
1212
execute,
1313
style,
1414
};
15+
use eyre::Result;
1516
use serde::{
1617
Deserialize,
1718
Serialize,
@@ -555,12 +556,15 @@ impl ConversationState {
555556
2) Bullet points for all significant tools executed and their results\n\
556557
3) Bullet points for any code or technical information shared\n\
557558
4) A section of key insights gained\n\n\
559+
5) REQUIRED: the ID of the currently loaded todo list, if any\n\n\
558560
FORMAT THE SUMMARY IN THIRD PERSON, NOT AS A DIRECT RESPONSE. Example format:\n\n\
559561
## CONVERSATION SUMMARY\n\
560562
* Topic 1: Key information\n\
561563
* Topic 2: Key information\n\n\
562564
## TOOLS EXECUTED\n\
563565
* Tool X: Result Y\n\n\
566+
## TODO ID\n\
567+
* <id>\n\n\
564568
Remember this is a DOCUMENT not a chat response. The custom instruction above modifies what to prioritize.\n\
565569
FILTER OUT CHAT CONVENTIONS (greetings, offers to help, etc).",
566570
custom_prompt.as_ref()
@@ -575,12 +579,15 @@ impl ConversationState {
575579
2) Bullet points for all significant tools executed and their results\n\
576580
3) Bullet points for any code or technical information shared\n\
577581
4) A section of key insights gained\n\n\
582+
5) REQUIRED: the ID of the currently loaded todo list, if any\n\n\
578583
FORMAT THE SUMMARY IN THIRD PERSON, NOT AS A DIRECT RESPONSE. Example format:\n\n\
579584
## CONVERSATION SUMMARY\n\
580585
* Topic 1: Key information\n\
581586
* Topic 2: Key information\n\n\
582587
## TOOLS EXECUTED\n\
583588
* Tool X: Result Y\n\n\
589+
## TODO ID\n\
590+
* <id>\n\n\
584591
Remember this is a DOCUMENT not a chat response.\n\
585592
FILTER OUT CHAT CONVENTIONS (greetings, offers to help, etc).".to_string()
586593
},

crates/chat-cli/src/cli/chat/mod.rs

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,13 +136,15 @@ use crate::api_client::{
136136
};
137137
use crate::auth::AuthError;
138138
use crate::auth::builder_id::is_idc_user;
139+
use crate::cli::TodoListState;
139140
use crate::cli::agent::Agents;
140141
use crate::cli::chat::cli::SlashCommand;
141142
use crate::cli::chat::cli::model::find_model;
142143
use crate::cli::chat::cli::prompts::{
143144
GetPromptError,
144145
PromptsSubcommand,
145146
};
147+
use crate::cli::chat::message::UserMessage;
146148
use crate::cli::chat::util::sanitize_unicode_tags;
147149
use crate::database::settings::Setting;
148150
use crate::mcp_client::Prompt;
@@ -2859,6 +2861,43 @@ impl ChatSession {
28592861
tracing::warn!("Failed to send slash command telemetry: {}", e);
28602862
}
28612863
}
2864+
2865+
/// Prompts Q to resume a to-do list with the given id by calling the load
2866+
/// command of the todo_list tool
2867+
pub async fn resume_todo_request(&mut self, os: &mut Os, id: &str) -> Result<ChatState, ChatError> {
2868+
// Have to unpack each value separately since Reports can't be converted to
2869+
// ChatError
2870+
let todo_list = match TodoListState::load(os, id).await {
2871+
Ok(todo) => todo,
2872+
Err(e) => {
2873+
return Err(ChatError::Custom(format!("Error getting todo list: {e}").into()));
2874+
},
2875+
};
2876+
let contents = match serde_json::to_string(&todo_list) {
2877+
Ok(s) => s,
2878+
Err(e) => return Err(ChatError::Custom(format!("Error deserializing todo list: {e}").into())),
2879+
};
2880+
let request_content = format!(
2881+
"[SYSTEM NOTE: This is an automated request, not from the user]\n
2882+
Read the TODO list contents below and understand the task description, completed tasks, and provided context.\n
2883+
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
2884+
You do not need to display the tasks to the user yourself. You can begin completing the tasks after calling the `load` command.\n
2885+
TODO LIST CONTENTS: {}\n
2886+
ID: {}\n",
2887+
contents,
2888+
id
2889+
);
2890+
2891+
let summary_message = UserMessage::new_prompt(request_content.clone(), None);
2892+
2893+
ChatSession::reset_user_turn(self);
2894+
2895+
Ok(ChatState::HandleInput {
2896+
input: summary_message
2897+
.into_user_input_message(self.conversation.model.clone(), &self.conversation.tools)
2898+
.content,
2899+
})
2900+
}
28622901
}
28632902

28642903
/// Replaces amzn_codewhisperer_client::types::SubscriptionStatus with a more descriptive type.
@@ -2919,7 +2958,7 @@ async fn get_subscription_status_with_spinner(
29192958
.await;
29202959
}
29212960

2922-
async fn with_spinner<T, E, F, Fut>(output: &mut impl std::io::Write, spinner_text: &str, f: F) -> Result<T, E>
2961+
pub async fn with_spinner<T, E, F, Fut>(output: &mut impl std::io::Write, spinner_text: &str, f: F) -> Result<T, E>
29232962
where
29242963
F: FnOnce() -> Fut,
29252964
Fut: std::future::Future<Output = Result<T, E>>,

crates/chat-cli/src/cli/chat/prompt.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,11 @@ pub const COMMANDS: &[&str] = &[
8888
"/save",
8989
"/load",
9090
"/subscribe",
91+
"/todos",
92+
"/todos resume",
93+
"/todos clear-finished",
94+
"/todos view",
95+
"/todos delete",
9196
];
9297

9398
pub type PromptQuerySender = tokio::sync::broadcast::Sender<PromptQuery>;

0 commit comments

Comments
 (0)