1use oxicode_agent::tools::TodoStateProvider;
11use oxicode_agent::tools::todo::{
12 TodoOp, find_phase_fuzzy, find_task_fuzzy, markdown_to_phases, phases_to_markdown,
13 tokenize_quoted,
14};
15use oxicode_vtui::tui::core::{InlineHandle, InlineMessageKind};
16
17use crate::tui_vt::main_loop::plain_segment;
18use crate::tui_vt::slash::registry::{SlashCommand, SlashCtx, SlashOutcome};
19
20const TODO_USAGE: &str = "Usage: /todo <verb> [args]\n\
21 /todo Show current todos\n\
22 /todo expand Expand the sticky HUD\n\
23 /todo collapse Collapse the sticky HUD\n\
24 /todo copy Copy todos as Markdown to clipboard\n\
25 /todo export [<path>] Write todos to file (default: TODO.md)\n\
26 /todo import [<path>] Replace todos from file (default: TODO.md)\n\
27 /todo append [<phase>] <task...> Append a task; phase fuzzy-matched or auto-created\n\
28 /todo start <task> Mark task in_progress (fuzzy content match)\n\
29 /todo done [<task|phase>] Mark task/phase/all completed\n\
30 /todo drop [<task|phase>] Mark task/phase/all abandoned\n\
31 /todo rm [<task|phase>] Remove task/phase/all";
32
33fn append_reply(handle: &InlineHandle, kind: InlineMessageKind, text: String) {
34 for line in text.split('\n') {
35 handle.append_line(kind, vec![plain_segment(line.to_string())]);
36 }
37}
38
39fn spawn_apply(
42 provider: std::sync::Arc<dyn TodoStateProvider>,
43 ops: Vec<TodoOp>,
44 handle: InlineHandle,
45 success: String,
46) {
47 tokio::spawn(async move {
48 match provider.apply_ops(ops).await {
49 Ok(_) => append_reply(&handle, InlineMessageKind::Info, success),
50 Err(e) => append_reply(&handle, InlineMessageKind::Error, e),
51 }
52 });
53}
54
55fn show_current(provider: &std::sync::Arc<dyn TodoStateProvider>, ctx: &mut SlashCtx<'_>) {
56 let phases = provider.get_phases();
57 if phases.is_empty() {
58 ctx.reply(
59 InlineMessageKind::Info,
60 "No todos. Use /todo append <task> to start one.",
61 );
62 } else {
63 ctx.reply(
64 InlineMessageKind::Info,
65 phases_to_markdown(&phases).trim_end(),
66 );
67 }
68}
69
70fn append_op(rest: &str) -> (TodoOp, String) {
72 let tokens = tokenize_quoted(rest);
73 let (phase_name, content) = if tokens.len() == 1 {
74 (None, tokens[0].clone())
75 } else {
76 (Some(tokens[0].clone()), tokens[1..].join(" "))
77 };
78 let target = phase_name.unwrap_or_else(|| "Todos".to_string());
79 (
80 TodoOp::Append {
81 phase: target.clone(),
82 items: vec![content.clone()],
83 },
84 format!("Appended to {target}: {content}"),
85 )
86}
87
88fn start_op(
90 rest: &str,
91 phases: &[oxicode_agent::tools::todo::TodoPhase],
92) -> Result<(TodoOp, String), String> {
93 if rest.is_empty() {
94 return Err("Usage: /todo start <task>".to_string());
95 }
96 match find_task_fuzzy(phases, rest) {
97 Some((task, _)) => {
98 let content = task.content.clone();
99 Ok((
100 TodoOp::Start {
101 task: Some(content.clone()),
102 phase: None,
103 },
104 format!("Started: {content}"),
105 ))
106 }
107 None => Err(format!(
108 "No task matched \"{rest}\". Use /todo to list current tasks."
109 )),
110 }
111}
112
113fn mutate_op(
116 verb: &str,
117 rest: &str,
118 phases: &[oxicode_agent::tools::todo::TodoPhase],
119) -> Result<(TodoOp, String), String> {
120 let make_op = |task: Option<String>, phase: Option<String>| match verb {
121 "done" => TodoOp::Done { task, phase },
122 "drop" => TodoOp::Drop { task, phase },
123 _ => TodoOp::Rm { task, phase },
124 };
125 let label = match verb {
126 "done" => "Marked completed",
127 "drop" => "Marked abandoned",
128 _ => "Removed",
129 };
130 if rest.is_empty() {
131 return Ok((
132 make_op(None, None),
133 match verb {
134 "done" => "Marked all tasks completed.".to_string(),
135 "drop" => "Marked all tasks abandoned.".to_string(),
136 _ => "Cleared all todos.".to_string(),
137 },
138 ));
139 }
140 if let Some((task, _)) = find_task_fuzzy(phases, rest) {
141 let content = task.content.clone();
142 return Ok((
143 make_op(Some(content.clone()), None),
144 format!("{label}: {content}"),
145 ));
146 }
147 if let Some(phase) = find_phase_fuzzy(phases, rest) {
148 let name = phase.name.clone();
149 return Ok((
150 make_op(None, Some(name.clone())),
151 format!("{label} phase: {name}"),
152 ));
153 }
154 Err(format!("No task or phase matched \"{rest}\"."))
155}
156
157fn copy_to_clipboard(text: &str) -> std::io::Result<()> {
160 use std::io::Write;
161 use std::process::{Command, Stdio};
162
163 #[cfg(target_os = "macos")]
164 let mut cmd = Command::new("pbcopy");
165 #[cfg(target_os = "linux")]
166 let mut cmd = {
167 let mut c = Command::new("xclip");
168 c.args(["-selection", "clipboard"]);
169 c
170 };
171 #[cfg(target_os = "windows")]
172 let mut cmd = Command::new("clip");
173
174 let mut child = cmd.stdin(Stdio::piped()).spawn()?;
175 child
176 .stdin
177 .as_mut()
178 .expect("piped stdin")
179 .write_all(text.as_bytes())?;
180 child.wait()?;
181 Ok(())
182}
183
184fn resolve_todo_path(rest: &str, cwd: &str) -> std::path::PathBuf {
186 let p = std::path::PathBuf::from(rest.trim());
187 if rest.trim().is_empty() || p.is_absolute() {
188 if rest.trim().is_empty() {
189 std::path::PathBuf::from(cwd).join("TODO.md")
190 } else {
191 p
192 }
193 } else {
194 std::path::PathBuf::from(cwd).join(p)
195 }
196}
197
198pub struct TodoCommand;
199
200impl SlashCommand for TodoCommand {
201 fn name(&self) -> &'static str {
202 "todo"
203 }
204
205 fn description(&self) -> &'static str {
206 "Show or mutate the todo list"
207 }
208
209 fn execute(&self, args: &str, ctx: &mut SlashCtx<'_>) -> SlashOutcome {
210 let trimmed = args.trim();
211 let Some(provider) = ctx.session.todo_provider() else {
212 ctx.reply(InlineMessageKind::Error, "Todo not configured");
213 return SlashOutcome::Handled;
214 };
215 if trimmed.is_empty() {
216 show_current(&provider, ctx);
217 return SlashOutcome::Handled;
218 }
219 let (verb, rest) = trimmed
220 .split_once(char::is_whitespace)
221 .unwrap_or((trimmed, ""));
222 let rest = rest.trim();
223 let handle = ctx.handle.clone();
224 match verb.to_ascii_lowercase().as_str() {
225 "help" | "?" => ctx.reply(InlineMessageKind::Info, TODO_USAGE),
226 "expand" => {
227 ctx.state.todo_expanded = true;
228 ctx.reply(InlineMessageKind::Info, "Expanded the todo HUD.");
229 }
230 "collapse" => {
231 ctx.state.todo_expanded = false;
232 ctx.reply(InlineMessageKind::Info, "Collapsed the todo HUD.");
233 }
234 "append" => {
235 let (op, msg) = append_op(rest);
236 spawn_apply(provider, vec![op], handle, msg);
237 }
238 "start" => match start_op(rest, &provider.get_phases()) {
239 Ok((op, msg)) => spawn_apply(provider, vec![op], handle, msg),
240 Err(e) => ctx.reply(InlineMessageKind::Error, e),
241 },
242 "done" | "drop" | "rm" => match mutate_op(verb, rest, &provider.get_phases()) {
243 Ok((op, msg)) => spawn_apply(provider, vec![op], handle, msg),
244 Err(e) => ctx.reply(InlineMessageKind::Error, e),
245 },
246 "copy" => {
247 let phases = provider.get_phases();
248 if phases.is_empty() {
249 ctx.reply(InlineMessageKind::Warning, "No todos to copy.");
250 } else {
251 match copy_to_clipboard(&phases_to_markdown(&phases)) {
252 Ok(()) => ctx.reply(
253 InlineMessageKind::Info,
254 "Copied todos as Markdown to clipboard.",
255 ),
256 Err(e) => ctx.reply(InlineMessageKind::Error, e.to_string()),
257 }
258 }
259 }
260 "export" => {
261 let phases = provider.get_phases();
262 if phases.is_empty() {
263 ctx.reply(InlineMessageKind::Warning, "No todos to export.");
264 } else {
265 let target = resolve_todo_path(rest, ctx.session.cwd());
266 match std::fs::write(&target, phases_to_markdown(&phases)) {
267 Ok(()) => ctx.reply(
268 InlineMessageKind::Info,
269 format!("Wrote todos to {}", target.display()),
270 ),
271 Err(e) => ctx.reply(
272 InlineMessageKind::Error,
273 format!("Failed to write todos: {e}"),
274 ),
275 }
276 }
277 }
278 "import" => {
279 let target = resolve_todo_path(rest, ctx.session.cwd());
280 let content = match std::fs::read_to_string(&target) {
281 Ok(c) => c,
282 Err(e) => {
283 ctx.reply(
284 InlineMessageKind::Error,
285 format!("Failed to read todos: {e}"),
286 );
287 return SlashOutcome::Handled;
288 }
289 };
290 match markdown_to_phases(&content) {
291 Ok(phases) => {
292 let task_count: usize = phases.iter().map(|p| p.tasks.len()).sum();
293 provider.set_phases_sync(phases.clone());
294 ctx.reply(
295 InlineMessageKind::Info,
296 format!(
297 "Imported {} phase(s), {task_count} task(s) from {}.",
298 phases.len(),
299 target.display()
300 ),
301 );
302 }
303 Err(e) => ctx.reply(
304 InlineMessageKind::Error,
305 format!("Could not parse {}:\n {e}", target.display()),
306 ),
307 }
308 }
309 other => ctx.reply(
310 InlineMessageKind::Error,
311 format!("Unknown /todo verb \"{other}\".\n{TODO_USAGE}"),
312 ),
313 }
314 SlashOutcome::Handled
315 }
316}
317
318#[cfg(test)]
319mod tests {
320 use super::*;
321 use oxicode_agent::tools::todo::{TodoItem, TodoPhase, TodoStatus};
322
323 fn make_task(content: &str, status: TodoStatus) -> TodoItem {
324 TodoItem {
325 content: content.into(),
326 status,
327 notes: None,
328 block_reason: None,
329 }
330 }
331
332 fn phases() -> Vec<TodoPhase> {
333 vec![TodoPhase {
334 name: "Auth".into(),
335 tasks: vec![
336 make_task("Wire OAuth providers", TodoStatus::Pending),
337 make_task("Wire OAuth refresh", TodoStatus::InProgress),
338 ],
339 }]
340 }
341
342 #[test]
343 fn append_op_defaults_phase_to_todos() {
344 let (op, msg) = append_op("OAuth");
346 assert!(matches!(&op, TodoOp::Append { phase, .. } if phase == "Todos"));
347 assert!(msg.contains("OAuth"));
348 }
349
350 #[test]
351 fn append_op_respects_leading_phase_token() {
352 let (op, msg) = append_op("Auth Wire OAuth");
353 assert!(matches!(&op, TodoOp::Append { phase, .. } if phase == "Auth"));
354 assert!(msg.contains("Auth"));
355 }
356
357 #[test]
358 fn start_op_builds_start_for_fuzzy_task() {
359 let (op, msg) = start_op("oauth providers", &phases()).unwrap();
361 assert!(
362 matches!(&op, TodoOp::Start { task, .. } if task.as_deref() == Some("Wire OAuth providers"))
363 );
364 assert!(msg.contains("Started"));
365 }
366
367 #[test]
368 fn start_op_errors_on_no_match() {
369 assert!(start_op("nonexistent task", &phases()).is_err());
370 }
371
372 #[test]
373 fn mutate_op_done_on_task_fuzzy_match() {
374 let (op, msg) = mutate_op("done", "oauth refresh", &phases()).unwrap();
375 assert!(
376 matches!(&op, TodoOp::Done { task, .. } if task.as_deref() == Some("Wire OAuth refresh"))
377 );
378 assert!(msg.contains("Marked completed"));
379 }
380
381 #[test]
382 fn mutate_op_done_with_no_arg_targets_all() {
383 let (op, msg) = mutate_op("done", "", &phases()).unwrap();
384 assert!(matches!(
385 &op,
386 TodoOp::Done {
387 task: None,
388 phase: None
389 }
390 ));
391 assert!(msg.contains("all tasks"));
392 }
393
394 #[test]
395 fn mutate_op_done_on_phase_fuzzy_match() {
396 let (op, msg) = mutate_op("done", "auth", &phases()).unwrap();
397 assert!(matches!(&op, TodoOp::Done { phase: Some(p), .. } if p == "Auth"));
398 assert!(msg.contains("phase"));
399 }
400
401 #[test]
402 fn mutate_op_errors_on_no_match() {
403 assert!(mutate_op("rm", "nonexistent", &phases()).is_err());
404 }
405
406 #[test]
407 fn resolve_todo_path_defaults_to_cwd_todo_md() {
408 let p = resolve_todo_path("", "/tmp/work");
409 assert_eq!(p, std::path::PathBuf::from("/tmp/work/TODO.md"));
410 }
411
412 #[test]
413 fn resolve_todo_path_keeps_absolute_and_joins_relative() {
414 assert_eq!(
415 resolve_todo_path("/abs/out.md", "/tmp/work"),
416 std::path::PathBuf::from("/abs/out.md")
417 );
418 assert_eq!(
419 resolve_todo_path("out.md", "/tmp/work"),
420 std::path::PathBuf::from("/tmp/work/out.md")
421 );
422 }
423}