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 34 commits into
base: main
Choose a base branch
from
Open

Conversation

kiran-garre
Copy link

Adds a to-do list tool (called todo_list) with several commands that allow Q to create a to-do list and update it as it completes tasks, along with a slash command (/todos) that allows users to view and manage their in-progress to-do lists.

By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice.

brandonskiser and others added 18 commits July 1, 2025 15:27
Current functionality:
- Supports interally creating a todo list and completing tasks
- Displays list to the user
- Can be somewhat reliably invoked by explicitly asking Q for a todo list
- Q sometimes automatically generates a todo list on some prompts

Future chagnes:
- Add context so todo lists can be used across sessions
- Allow Q to add and remove tasks
Updates:
- Users can select past to-do lists to complete
- Q will load the selected to-do list and finish the remaining tasks

Future changes
- Improve consistency of todo_list tool
- Add more checks for argument validity
- Add more detailed context to todo_lists for cross-session usage
Updates:
- To-do lists now only use the database rather than the filesystem for managing
past and unfinished tasks
Also:
- Ran formatter
- Resolved most relevant clippy issues
- Switched from printing to error handling in todos.rs
- Changed /todos subcommand name from "select" to "resume"
Also:
- Formatted
- Added command and subcommand descriptions
- Added newlines and things for cleanliness
Updates:
- The model now sees the updated todo list after invoking the todo_list tool
- Todo lists shouldn't get stuck on marking a task as complete (in theory)
Updates:
- As tasks are completed, only the completed tasks and next task are shown
- Full todo list is only displayed on creation and after finishign
- /todos show was removed (view is enough)
Updates:
- `/todos show` displays the current todo list
- Note: This is different from the previous /todos show
Updates:
- Todo lists now seem to work as they did before
- Added spinner for resuming

Future changes:
- Clean up and refactor code before merge
@@ -28,10 +28,10 @@ phases:
- mise install
- eval "$(mise activate bash --shims)"
# Install python deps
- pip3 install -r scripts/requirements.txt
- pip3 install -r build-scripts/requirements.txt
Copy link
Contributor

Choose a reason for hiding this comment

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

I think there are some errors here with how the merge conflict is handled.
This is the current state of the file: https://github.com/aws/amazon-q-developer-cli/blob/main/build-config/buildspec-linux.yml#L31

@@ -25,11 +25,11 @@ phases:
- eval "$(mise activate zsh --shims)"
Copy link
Contributor

Choose a reason for hiding this comment

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

Same here.

#[derive(Debug, Clone, Deserialize)]
#[serde(tag = "command")]
pub enum TodoInput {
#[serde(rename = "create")]
Copy link
Contributor

Choose a reason for hiding this comment

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

I think you can just use #[serde(rename_all = "camelCase")].

Comment on lines 390 to 397
pub fn get_current_todo_id(&self) -> Result<Option<String>, DatabaseError> {
self.get_entry(Table::State, "current_todo_id")
}

pub fn set_current_todo_id(&self, id: &str) -> Result<(), DatabaseError> {
self.set_entry(Table::State, "current_todo_id", id)?;
Ok(())
}
Copy link
Contributor

Choose a reason for hiding this comment

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

I think there is an edge case here.

What happens if there are multiple instances of q chat running at the same time. Is the value going to be clobbered / overridden?

If it is, there are a couple of paths forward:

  • We make the look up key dynamic. We can derive it from conversation id + some sort of suffix. The downside here is that we might need some sort of logic to clean up to rid of ones that are stale. (Maybe introduce a TTL or something)
  • Make store this id somewhere else on the conversation state, serializable and deserializable.

.set_next_user_message(state.user_input_message.content)
.await;
},
Err(_) => bail!("Todo could not be resumed"),
Copy link
Contributor

Choose a reason for hiding this comment

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

Might want to propagate the error upwards here.

None
};

let todo_result = session.resume_todo(os, &entries[index].id).await;
Copy link
Contributor

Choose a reason for hiding this comment

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

Do we want to just make resume_todo return a Result<ChatState, ChatError> and have the spinner logic within resume_todo.

Comment on lines 2803 to 2824
let mut response = match self.send_message(os, conv_state, request_metadata_clone, None).await {
Ok(res) => res,
Err(_) => bail!("Turn summary could not be created"),
};

// Since this is an internal tool call, manually handle the tool requests from Q
let mut tool_uses = Vec::new();
while let Some(res) = response.recv().await {
let res = res?;
match res {
parser::ResponseEvent::AssistantText(_) => (),
parser::ResponseEvent::EndStream { .. } => break,
parser::ResponseEvent::ToolUse(e) => tool_uses.push(e),
parser::ResponseEvent::ToolUseStart { .. } => (),
}
}
if !tool_uses.is_empty() {
self.validate_tools(os, tool_uses).await?;
}

// FIX: hacky? not really sure how this works honestly LOL
self.conversation.reset_next_user_message();
Copy link
Contributor

Choose a reason for hiding this comment

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

This seems like a deviation of the established control flow. Is there a reason why we can't just utilize the existing event loop for this?

Comment on lines 154 to 159
let list = match TodoState::load(os, &entries[index].id) {
Ok(list) => list,
Err(_) => {
return Err(ChatError::Custom("Could not load the selected to-do list".into()));
},
};
Copy link
Contributor

Choose a reason for hiding this comment

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

let list = TodoState::load(os, &entries[index].id).ok_or(ChatError::Custom("Could not load the selected to-do list".into())?;

Though looking at fuzzy_select_todos it is not clear to me whether or not this should be an error.

Comment on lines 201 to 212
let state = match TodoState::load(os, &id) {
Ok(s) => s,
Err(_) => {
return Err(ChatError::Custom("Could not load current to-do list".into()));
},
};
match state.display_list(&mut session.stderr) {
Ok(_) => execute!(session.stderr, style::Print("\n"))?,
Err(_) => {
return Err(ChatError::Custom("Could not display current to-do list".into()));
},
};
Copy link
Contributor

Choose a reason for hiding this comment

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

Do we want to propagate the error here as well.

Also a less verbose way of writing this would be:

let state = TodoSate::load(os, &id).map_err(|_| ChatError::Custom("Could not load current to-do list".into())?;

};
let mut cleared_one = false;
for (id, value) in entries.iter() {
let temp_struct = match value.as_str() {
Copy link
Contributor

Choose a reason for hiding this comment

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

Perhaps a better name here would be item_status or todo_status or something more descriptive.

Also, instead of turning to value to str and turning the str to TodoState, maybe we can just use serde_json::from_value.

kiran-garre added 6 commits August 11, 2025 14:48
Updates:
- Removed separation between `create_summary_request` and `resume_todo`;
resuming a todo is now done in a single function (`resume_todo_request`)
and is handled by the main event loop
- Removed `current_id` entry in the database for tracking state; the
model is now responsible for providing the id of the current todo list
- Removed `/todos current` because the current todo list id is not
tracked
- Added more error propagation in slash command
- Unstaged build script files
"type": "string"
}
},
"task_description": {
Copy link
Contributor

Choose a reason for hiding this comment

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

Is this the description for the todo list as a whole? It seems that a todo list is some number of tasks (so the create command requires a tasks array), but task_description is only a single parameter?

}
},
"new_description": {
"description": "Optional parameter of `add` and `remove` containing a new task description. Use this when the updated set of tasks significantly change the goal or overall procedure of the task.",
Copy link
Contributor

Choose a reason for hiding this comment

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

What is the definition of a task? It seems that tasks and task are being used interchangeably when the singular task is actually referring to an instance of a todo list that contains tasks.

Ok(())
}

pub fn get_all_todos(&self) -> Result<Map<String, Value>, DatabaseError> {
Copy link
Contributor

Choose a reason for hiding this comment

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

Could this map to a TodoState instead of Value?

Copy link
Contributor

Choose a reason for hiding this comment

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

I wonder how much sense it makes to have todo lists stored in state (accessible globally) versus in a current working directory (e.g. inside .amazonq/todo_lists/).

What use cases do we have where it makes sense that todo lists should be stored in state regardless of cwd? I could imagine things like MCP flows that never use the filesystem and instead just call API's like updating docs, etc. but then that brings into question how to handle todo lists that are working on feature changes, and then you try to resume a todo list in a completely different directory.

/// 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

kiran-garre added 4 commits August 12, 2025 16:55
…tem store

Updates:
- Todo lists are now local to the user's cwd and are stored in files
- Changed names from 'task_description'/'tasks' to 'todo_list_description'/'tasks'
Copy link
Contributor

@brandonskiser brandonskiser left a comment

Choose a reason for hiding this comment

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

Appears to be some issue with the trust behavior with /tools - should be trusted
Screenshot 2025-08-13 at 11 27 01 AM

Also can you add tab completion? Add it to the array here - https://github.com/aws/amazon-q-developer-cli/blob/main/crates/chat-cli/src/cli/chat/prompt.rs#L43


#[derive(Debug, Clone, Deserialize)]
#[serde(tag = "command", rename_all = "camelCase")]
pub enum TodoInput {
Copy link
Contributor

Choose a reason for hiding this comment

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

nit - call this just TodoList to be aligned with the actual tool name in the tool index. Also add some small doc comments to these pub types.

also put the impl right below the enum definition


/// Displays a single empty or marked off to-do list task depending on
/// the completion status
fn queue_next_without_newline(output: &mut impl Write, task: String, completed: bool) -> Result<()> {
Copy link
Contributor

Choose a reason for hiding this comment

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

In general, if functions don't require self then they should probably just be free functions, e.g. delete_todo parameterized on an id doesn't make sense to be associated with a single in-memory todo

}

pub fn id_to_path(os: &Os, id: &str) -> Result<PathBuf> {
Ok(os.env.current_dir()?.join(TODO_LIST_DIR).join(id))
Copy link
Contributor

Choose a reason for hiding this comment

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

Wonder if we should also store these with a json extension? Just a personal preference of mine (for getting easier IDE support if for whatever reason we need to check these files directly)

}

#[derive(Debug, Default, Serialize, Deserialize, Clone)]
pub struct TodoState {
Copy link
Contributor

Choose a reason for hiding this comment

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

TodoListState ?

if has_duplicates(remove_indices) {
bail!("Removal indices must be unique")
} else if remove_indices.iter().any(|i| *i > state.tasks.len()) {
bail!("Index is out of bounds");
Copy link
Contributor

Choose a reason for hiding this comment

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

We could prob make the error messaging here better for the model in case it hallucinates, e.g. tell it which index is out of bounds or give the length of tasks


/// Loads a TodoState with the given id
pub async fn load(os: &Os, id: &str) -> Result<Self> {
let Ok(state_str) = os.fs.read_to_string(Self::id_to_path(os, id)?).await else {
Copy link
Contributor

Choose a reason for hiding this comment

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

Can we print the actual error here in the error message? This could fail for a reason other than the to-do list not existing

// 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants