Skip to main content

oxicode/tui_vt/slash/
todo_command.rs

1//! `/todo` slash command — show, mutate, expand/collapse, export/import,
2//! and copy the todo list.
3//!
4//! Mutations route through the same `TodoStateProvider::apply_ops` the
5//! `todo` agent tool uses — a single source of truth. The command runs on
6//! the TUI's tokio runtime, so async `apply_ops` goes through `tokio::spawn`
7//! and replies land on the transcript via the cloned `InlineHandle`
8//! (mirrors `MemoryCommand` in `registry.rs`).
9
10use 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
39/// Spawn an async `apply_ops` and reply once it resolves. `success` is shown
40/// on `Ok`; a wrapped error on `Err`.
41fn 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
70/// Build the append op + reply text from the trailing args.
71fn 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
88/// `TodoOp::Start` on the fuzzy-matched task, or an error.
89fn 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
113/// `TodoOp` + reply text for a done/drop/rm mutation, resolved against the
114/// current phases (task match first, then phase, then all).
115fn 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
157/// Copy text to the system clipboard with a platform shell-out — no new
158/// dependency (`pbcopy`/`xclip -selection clipboard`/`clip`).
159fn 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
184/// Resolve the export/import target path: explicit arg or `cwd/TODO.md`.
185fn 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        // Single token -> no phase prefix -> default "Todos" phase.
345        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        // "oauth providers" matches exactly one task -> unambiguous.
360        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}