| 1 | //! Queue commands: queue list/edit/drop/clear |
| 2 | |
| 3 | use crate::tui::app::App; |
| 4 | |
| 5 | use super::CommandResult; |
| 6 | |
| 7 | const PREVIEW_LIMIT: usize = 120; |
| 8 | |
| 9 | pub fn queue(app: &mut App, args: Option<&str>) -> CommandResult { |
| 10 | let arg = args.unwrap_or("").trim(); |
| 11 | if arg.is_empty() || arg.eq_ignore_ascii_case("list") { |
| 12 | return list_queue(app); |
| 13 | } |
| 14 | |
| 15 | let mut parts = arg.split_whitespace(); |
| 16 | let action = parts.next().unwrap_or("").to_lowercase(); |
| 17 | |
| 18 | match action.as_str() { |
| 19 | "edit" => edit_queue(app, parts.next()), |
| 20 | "drop" | "remove" | "rm" => drop_queue(app, parts.next()), |
| 21 | "clear" => clear_queue(app), |
| 22 | _ => CommandResult::error("Usage: /queue [list|edit <n>|drop <n>|clear]"), |
| 23 | } |
| 24 | } |
| 25 | |
| 26 | fn list_queue(app: &mut App) -> CommandResult { |
| 27 | let mut lines = Vec::new(); |
| 28 | let queued = app.queued_message_count(); |
| 29 | |
| 30 | if let Some(draft) = app.queued_draft.as_ref() { |
| 31 | lines.push("Editing queued message:".to_string()); |
| 32 | lines.push(format!("- {}", truncate_preview(&draft.display))); |
| 33 | } |
| 34 | |
| 35 | if queued == 0 { |
| 36 | if lines.is_empty() { |
| 37 | return CommandResult::message("No queued messages"); |
| 38 | } |
| 39 | return CommandResult::message(lines.join("\n")); |
| 40 | } |
| 41 | |
| 42 | lines.push(format!("Queued messages ({queued}):")); |
| 43 | for (idx, message) in app.queued_messages.iter().enumerate() { |
| 44 | lines.push(format!( |
| 45 | "{}. {}", |
| 46 | idx + 1, |
| 47 | truncate_preview(&message.display) |
| 48 | )); |
| 49 | } |
| 50 | |
| 51 | lines.push("Tip: /queue edit <n> to edit, /queue drop <n> to remove".to_string()); |
| 52 | |
| 53 | CommandResult::message(lines.join("\n")) |
| 54 | } |
| 55 | |
| 56 | fn edit_queue(app: &mut App, index: Option<&str>) -> CommandResult { |
| 57 | if app.queued_draft.is_some() { |
| 58 | return CommandResult::error( |
| 59 | "Already editing a queued message. Send it or /queue clear to discard.", |
| 60 | ); |
| 61 | } |
| 62 | let index = match parse_index(index) { |
| 63 | Ok(index) => index, |
| 64 | Err(err) => return CommandResult::error(err), |
| 65 | }; |
| 66 | |
| 67 | let Some(message) = app.remove_queued_message(index) else { |
| 68 | return CommandResult::error("Queued message not found"); |
| 69 | }; |
| 70 | |
| 71 | app.input = message.display.clone(); |
| 72 | app.cursor_position = app.input.len(); |
| 73 | app.queued_draft = Some(message); |
| 74 | app.status_message = Some(format!("Editing queued message {}", index + 1)); |
| 75 | |
| 76 | CommandResult::message(format!( |
| 77 | "Editing queued message {} (press Enter to re-queue/send)", |
| 78 | index + 1 |
| 79 | )) |
| 80 | } |
| 81 | |
| 82 | fn drop_queue(app: &mut App, index: Option<&str>) -> CommandResult { |
| 83 | let index = match parse_index(index) { |
| 84 | Ok(index) => index, |
| 85 | Err(err) => return CommandResult::error(err), |
| 86 | }; |
| 87 | |
| 88 | if app.remove_queued_message(index).is_none() { |
| 89 | return CommandResult::error("Queued message not found"); |
| 90 | } |
| 91 | |
| 92 | CommandResult::message(format!("Dropped queued message {}", index + 1)) |
| 93 | } |
| 94 | |
| 95 | fn clear_queue(app: &mut App) -> CommandResult { |
| 96 | let queued = app.queued_message_count(); |
| 97 | let had_draft = app.queued_draft.take().is_some(); |
| 98 | app.queued_messages.clear(); |
| 99 | if queued == 0 && !had_draft { |
| 100 | return CommandResult::message("Queue already empty"); |
| 101 | } |
| 102 | |
| 103 | CommandResult::message("Queue cleared") |
| 104 | } |
| 105 | |
| 106 | fn parse_index(input: Option<&str>) -> Result<usize, &'static str> { |
| 107 | let Some(input) = input else { |
| 108 | return Err("Missing index. Usage: /queue edit <n> or /queue drop <n>"); |
| 109 | }; |
| 110 | let raw = input |
| 111 | .parse::<usize>() |
| 112 | .map_err(|_| "Index must be a positive number")?; |
| 113 | if raw == 0 { |
| 114 | return Err("Index must be >= 1"); |
| 115 | } |
| 116 | Ok(raw - 1) |
| 117 | } |
| 118 | |
| 119 | fn truncate_preview(text: &str) -> String { |
| 120 | if text.chars().count() <= PREVIEW_LIMIT { |
| 121 | return text.to_string(); |
| 122 | } |
| 123 | let mut out = String::new(); |
| 124 | for ch in text.chars().take(PREVIEW_LIMIT.saturating_sub(3)) { |
| 125 | out.push(ch); |
| 126 | } |
| 127 | out.push_str("..."); |
| 128 | out |
| 129 | } |
| 130 | |
| 131 | #[cfg(test)] |
| 132 | mod tests { |
| 133 | use super::*; |
| 134 | use crate::config::Config; |
| 135 | use crate::tui::app::{App, QueuedMessage, TuiOptions}; |
| 136 | use tempfile::TempDir; |
| 137 | |
| 138 | fn create_test_app_with_tmpdir(tmpdir: &TempDir) -> App { |
| 139 | let options = TuiOptions { |
| 140 | model: "deepseek-v4-pro".to_string(), |
| 141 | workspace: tmpdir.path().to_path_buf(), |
| 142 | config_path: None, |
| 143 | config_profile: None, |
| 144 | allow_shell: false, |
| 145 | use_alt_screen: true, |
| 146 | use_mouse_capture: false, |
| 147 | use_bracketed_paste: true, |
| 148 | max_subagents: 1, |
| 149 | skills_dir: tmpdir.path().join("skills"), |
| 150 | memory_path: tmpdir.path().join("memory.md"), |
| 151 | notes_path: tmpdir.path().join("notes.txt"), |
| 152 | mcp_config_path: tmpdir.path().join("mcp.json"), |
| 153 | use_memory: false, |
| 154 | start_in_agent_mode: false, |
| 155 | skip_onboarding: true, |
| 156 | yolo: false, |
| 157 | resume_session_id: None, |
| 158 | initial_input: None, |
| 159 | }; |
| 160 | App::new(options, &Config::default()) |
| 161 | } |
| 162 | |
| 163 | #[test] |
| 164 | fn test_queue_list_empty() { |
| 165 | let tmpdir = TempDir::new().unwrap(); |
| 166 | let mut app = create_test_app_with_tmpdir(&tmpdir); |
| 167 | let result = queue(&mut app, None); |
| 168 | assert!(result.message.is_some()); |
| 169 | let msg = result.message.unwrap(); |
| 170 | assert!(msg.contains("No queued messages")); |
| 171 | } |
| 172 | |
| 173 | #[test] |
| 174 | fn test_queue_list_with_messages() { |
| 175 | let tmpdir = TempDir::new().unwrap(); |
| 176 | let mut app = create_test_app_with_tmpdir(&tmpdir); |
| 177 | app.queued_messages |
| 178 | .push_back(QueuedMessage::new("First message".to_string(), None)); |
| 179 | app.queued_messages |
| 180 | .push_back(QueuedMessage::new("Second message".to_string(), None)); |
| 181 | let result = queue(&mut app, Some("list")); |
| 182 | assert!(result.message.is_some()); |
| 183 | let msg = result.message.unwrap(); |
| 184 | assert!(msg.contains("Queued messages (2)")); |
| 185 | assert!(msg.contains("1. First message")); |
| 186 | assert!(msg.contains("2. Second message")); |
| 187 | } |
| 188 | |
| 189 | #[test] |
| 190 | fn test_queue_edit_missing_index() { |
| 191 | let tmpdir = TempDir::new().unwrap(); |
| 192 | let mut app = create_test_app_with_tmpdir(&tmpdir); |
| 193 | app.queued_messages |
| 194 | .push_back(QueuedMessage::new("Test".to_string(), None)); |
| 195 | let result = queue(&mut app, Some("edit")); |
| 196 | assert!(result.message.is_some()); |
| 197 | assert!(result.message.unwrap().contains("Missing index")); |
| 198 | } |
| 199 | |
| 200 | #[test] |
| 201 | fn test_queue_edit_invalid_index() { |
| 202 | let tmpdir = TempDir::new().unwrap(); |
| 203 | let mut app = create_test_app_with_tmpdir(&tmpdir); |
| 204 | let result = queue(&mut app, Some("edit abc")); |
| 205 | assert!(result.message.is_some()); |
| 206 | assert!( |
| 207 | result |
| 208 | .message |
| 209 | .unwrap() |
| 210 | .contains("must be a positive number") |
| 211 | ); |
| 212 | } |
| 213 | |
| 214 | #[test] |
| 215 | fn test_queue_edit_not_found() { |
| 216 | let tmpdir = TempDir::new().unwrap(); |
| 217 | let mut app = create_test_app_with_tmpdir(&tmpdir); |
| 218 | let result = queue(&mut app, Some("edit 1")); |
| 219 | assert!(result.message.is_some()); |
| 220 | assert!(result.message.unwrap().contains("not found")); |
| 221 | } |
| 222 | |
| 223 | #[test] |
| 224 | fn test_queue_edit_already_editing() { |
| 225 | let tmpdir = TempDir::new().unwrap(); |
| 226 | let mut app = create_test_app_with_tmpdir(&tmpdir); |
| 227 | app.queued_messages |
| 228 | .push_back(QueuedMessage::new("First".to_string(), None)); |
| 229 | app.queued_messages |
| 230 | .push_back(QueuedMessage::new("Second".to_string(), None)); |
| 231 | // Start editing |
| 232 | queue(&mut app, Some("edit 1")); |
| 233 | // Try to edit another |
| 234 | let result = queue(&mut app, Some("edit 2")); |
| 235 | assert!(result.message.is_some()); |
| 236 | assert!(result.message.unwrap().contains("Already editing")); |
| 237 | } |
| 238 | |
| 239 | #[test] |
| 240 | fn test_queue_edit_success() { |
| 241 | let tmpdir = TempDir::new().unwrap(); |
| 242 | let mut app = create_test_app_with_tmpdir(&tmpdir); |
| 243 | app.queued_messages |
| 244 | .push_back(QueuedMessage::new("Original message".to_string(), None)); |
| 245 | let result = queue(&mut app, Some("edit 1")); |
| 246 | assert!(result.message.is_some()); |
| 247 | assert_eq!(app.input, "Original message"); |
| 248 | assert_eq!(app.cursor_position, app.input.len()); |
| 249 | assert!(app.queued_draft.is_some()); |
| 250 | } |
| 251 | |
| 252 | #[test] |
| 253 | fn test_queue_drop_success() { |
| 254 | let tmpdir = TempDir::new().unwrap(); |
| 255 | let mut app = create_test_app_with_tmpdir(&tmpdir); |
| 256 | app.queued_messages |
| 257 | .push_back(QueuedMessage::new("To drop".to_string(), None)); |
| 258 | let initial_count = app.queued_messages.len(); |
| 259 | let result = queue(&mut app, Some("drop 1")); |
| 260 | assert!(result.message.is_some()); |
| 261 | assert!(result.message.unwrap().contains("Dropped queued message")); |
| 262 | assert_eq!(app.queued_messages.len(), initial_count - 1); |
| 263 | } |
| 264 | |
| 265 | #[test] |
| 266 | fn test_queue_clear() { |
| 267 | let tmpdir = TempDir::new().unwrap(); |
| 268 | let mut app = create_test_app_with_tmpdir(&tmpdir); |
| 269 | app.queued_messages |
| 270 | .push_back(QueuedMessage::new("Message 1".to_string(), None)); |
| 271 | app.queued_messages |
| 272 | .push_back(QueuedMessage::new("Message 2".to_string(), None)); |
| 273 | let result = queue(&mut app, Some("clear")); |
| 274 | assert!(result.message.is_some()); |
| 275 | assert!(result.message.unwrap().contains("Queue cleared")); |
| 276 | assert!(app.queued_messages.is_empty()); |
| 277 | } |
| 278 | |
| 279 | #[test] |
| 280 | fn test_queue_clear_already_empty() { |
| 281 | let tmpdir = TempDir::new().unwrap(); |
| 282 | let mut app = create_test_app_with_tmpdir(&tmpdir); |
| 283 | let result = queue(&mut app, Some("clear")); |
| 284 | assert!(result.message.is_some()); |
| 285 | assert!(result.message.unwrap().contains("Queue already empty")); |
| 286 | } |
| 287 | |
| 288 | #[test] |
| 289 | fn test_truncate_preview_short_text() { |
| 290 | let result = truncate_preview("Short text"); |
| 291 | assert_eq!(result, "Short text"); |
| 292 | } |
| 293 | |
| 294 | #[test] |
| 295 | fn test_truncate_preview_long_text() { |
| 296 | let long_text = "x".repeat(200); |
| 297 | let result = truncate_preview(&long_text); |
| 298 | assert!(result.len() <= PREVIEW_LIMIT + 3); |
| 299 | assert!(result.ends_with("...")); |
| 300 | } |
| 301 | |
| 302 | #[test] |
| 303 | fn test_truncate_preview_unicode() { |
| 304 | let text = "Hello 世界 🌍"; |
| 305 | let result = truncate_preview(text); |
| 306 | assert_eq!(result, text); |
| 307 | } |
| 308 | } |
| 309 |