| 1 | //! `/automation` — the scheduled-automation room. |
| 2 | //! |
| 3 | //! One list, one detail pane, one key grammar shared with the other rooms |
| 4 | //! (workflow runs, fleet, extensions): ↑↓ move, Enter opens the detail, Esc |
| 5 | //! backs out, Tab flips list ↔ detail. The actions an automation affords — |
| 6 | //! pause / resume, run now, cancel a live run, delete — are single keys and |
| 7 | //! every one of those actions goes through the same `/automation …` and `/task cancel` |
| 8 | //! commands the transcript already accepts, so a keypress here and a typed |
| 9 | //! command leave identical receipts. |
| 10 | //! Create/edit keep a local draft until Save calls the shared manager; Cancel |
| 11 | //! discards that draft. The room then shows the persisted definition and receipt. |
| 12 | //! |
| 13 | //! Automations are user-global (`~/.codewhale/automations`): they follow the |
| 14 | //! person into every repository, which is why the room says where each one |
| 15 | //! runs (`cwds`) rather than assuming this workspace. |
| 16 | //! |
| 17 | //! The view reads the shared [`AutomationManager`] with `try_lock`; a frame |
| 18 | //! that finds it busy keeps the last snapshot rather than blocking the UI. |
| 19 | |
| 20 | use std::cell::Cell; |
| 21 | use std::time::{Duration, Instant}; |
| 22 | |
| 23 | use crossterm::event::{KeyCode, KeyEvent, MouseButton, MouseEvent, MouseEventKind}; |
| 24 | use ratatui::{ |
| 25 | buffer::Buffer, |
| 26 | layout::{Constraint, Direction, Layout, Rect}, |
| 27 | style::{Modifier, Style}, |
| 28 | text::{Line, Span}, |
| 29 | widgets::{Block, Clear, Paragraph, Widget, Wrap}, |
| 30 | }; |
| 31 | |
| 32 | use super::{ |
| 33 | ActionHint, CommandPaletteAction, ModalKind, ModalView, ViewAction, ViewEvent, |
| 34 | render_modal_footer, |
| 35 | }; |
| 36 | use crate::automation_manager::{ |
| 37 | AutomationRecord, AutomationRunRecord, AutomationRunStatus, AutomationStatus, |
| 38 | SharedAutomationManager, |
| 39 | }; |
| 40 | use crate::tui::app::App; |
| 41 | use crate::tui::list_nav::wrap_index; |
| 42 | use codewhale_localization::{Locale, MessageId, tr}; |
| 43 | use codewhale_palette as palette; |
| 44 | |
| 45 | mod editor; |
| 46 | use editor::{AutomationEditor, EditorAction}; |
| 47 | |
| 48 | /// Recent runs kept per automation in the detail pane. |
| 49 | const RECENT_RUNS: usize = 5; |
| 50 | |
| 51 | /// One automation with the runs the detail pane and the cancel key need. |
| 52 | #[derive(Debug, Clone)] |
| 53 | pub(crate) struct AutomationRow { |
| 54 | pub(crate) record: AutomationRecord, |
| 55 | /// Newest first. |
| 56 | pub(crate) runs: Vec<AutomationRunRecord>, |
| 57 | } |
| 58 | |
| 59 | impl AutomationRow { |
| 60 | /// The run in flight, if any — the one `x` cancels. |
| 61 | fn live_run(&self) -> Option<&AutomationRunRecord> { |
| 62 | self.runs.iter().find(|run| { |
| 63 | matches!( |
| 64 | run.status, |
| 65 | AutomationRunStatus::Queued | AutomationRunStatus::Running |
| 66 | ) |
| 67 | }) |
| 68 | } |
| 69 | } |
| 70 | |
| 71 | pub struct AutomationsView { |
| 72 | rows: Vec<AutomationRow>, |
| 73 | row: usize, |
| 74 | detail_open: bool, |
| 75 | detail_scroll: usize, |
| 76 | locale: Locale, |
| 77 | manager: Option<SharedAutomationManager>, |
| 78 | /// Refreshes ride the modal tick, not every frame. |
| 79 | last_refresh_at: Instant, |
| 80 | /// A snapshot that could not be read: the manager is missing or its |
| 81 | /// store failed. Painted in place of the list. |
| 82 | problem: Option<String>, |
| 83 | /// Screen rect of the list body, recorded at render for mouse parity. |
| 84 | list_body: Cell<Rect>, |
| 85 | config: crate::config::Config, |
| 86 | workspace: std::path::PathBuf, |
| 87 | editor: Option<AutomationEditor>, |
| 88 | notice: Option<String>, |
| 89 | new_button: Cell<Rect>, |
| 90 | edit_button: Cell<Rect>, |
| 91 | } |
| 92 | |
| 93 | impl AutomationsView { |
| 94 | /// Open the room, optionally focused on one automation id. |
| 95 | #[must_use] |
| 96 | pub fn new(app: &App, config: &crate::config::Config, focus: Option<&str>) -> Self { |
| 97 | let mut view = Self { |
| 98 | rows: Vec::new(), |
| 99 | row: 0, |
| 100 | detail_open: false, |
| 101 | detail_scroll: 0, |
| 102 | locale: app.ui_locale, |
| 103 | manager: app.runtime_services.automations.clone(), |
| 104 | last_refresh_at: Instant::now(), |
| 105 | problem: None, |
| 106 | list_body: Cell::new(Rect::ZERO), |
| 107 | config: config.clone(), |
| 108 | workspace: app.workspace.clone(), |
| 109 | editor: None, |
| 110 | notice: None, |
| 111 | new_button: Cell::new(Rect::ZERO), |
| 112 | edit_button: Cell::new(Rect::ZERO), |
| 113 | }; |
| 114 | view.refresh(); |
| 115 | if let Some(focus) = focus |
| 116 | && let Some(index) = view.rows.iter().position(|row| row.record.id == focus) |
| 117 | { |
| 118 | view.row = index; |
| 119 | view.detail_open = true; |
| 120 | } |
| 121 | view |
| 122 | } |
| 123 | |
| 124 | #[cfg(test)] |
| 125 | pub(crate) fn from_rows(rows: Vec<AutomationRow>, locale: Locale) -> Self { |
| 126 | Self { |
| 127 | rows, |
| 128 | row: 0, |
| 129 | detail_open: false, |
| 130 | detail_scroll: 0, |
| 131 | locale, |
| 132 | manager: None, |
| 133 | last_refresh_at: Instant::now(), |
| 134 | problem: None, |
| 135 | list_body: Cell::new(Rect::ZERO), |
| 136 | config: crate::config::Config::default(), |
| 137 | workspace: std::env::temp_dir(), |
| 138 | editor: None, |
| 139 | notice: None, |
| 140 | new_button: Cell::new(Rect::ZERO), |
| 141 | edit_button: Cell::new(Rect::ZERO), |
| 142 | } |
| 143 | } |
| 144 | |
| 145 | fn open_editor(&mut self, edit: bool) { |
| 146 | let original = if edit { |
| 147 | let Some(row) = self.selected() else { |
| 148 | return; |
| 149 | }; |
| 150 | Some(row.record.clone()) |
| 151 | } else { |
| 152 | None |
| 153 | }; |
| 154 | self.notice = None; |
| 155 | self.editor = Some(AutomationEditor::new( |
| 156 | &self.config, |
| 157 | &self.workspace, |
| 158 | self.locale, |
| 159 | original, |
| 160 | )); |
| 161 | } |
| 162 | |
| 163 | fn editor_action(&mut self, action: EditorAction) -> ViewAction { |
| 164 | match action { |
| 165 | EditorAction::Cancel => self.editor = None, |
| 166 | EditorAction::Save => { |
| 167 | let result = self |
| 168 | .manager |
| 169 | .as_ref() |
| 170 | .ok_or_else(|| { |
| 171 | tr(self.locale, MessageId::AutomationManagerUnavailable).into_owned() |
| 172 | }) |
| 173 | .and_then(|manager| { |
| 174 | manager.try_lock().map_err(|_| { |
| 175 | tr(self.locale, MessageId::AutomationEditorBusy).into_owned() |
| 176 | }) |
| 177 | }) |
| 178 | .and_then(|manager| { |
| 179 | self.editor |
| 180 | .as_ref() |
| 181 | .unwrap() |
| 182 | .save(&manager) |
| 183 | .map_err(|error| error.to_string()) |
| 184 | }); |
| 185 | match result { |
| 186 | Ok(record) => { |
| 187 | self.editor = None; |
| 188 | self.refresh(); |
| 189 | if let Some(index) = |
| 190 | self.rows.iter().position(|row| row.record.id == record.id) |
| 191 | { |
| 192 | self.row = index; |
| 193 | } |
| 194 | self.detail_open = true; |
| 195 | self.detail_scroll = 0; |
| 196 | self.notice = Some( |
| 197 | tr(self.locale, MessageId::AutomationEditorSaved) |
| 198 | .replace("{name}", &display_text(&record.name)), |
| 199 | ); |
| 200 | } |
| 201 | Err(error) => { |
| 202 | self.editor.as_mut().unwrap().problem = Some( |
| 203 | tr(self.locale, MessageId::AutomationEditorSaveFailed) |
| 204 | .replace("{error}", &error), |
| 205 | ) |
| 206 | } |
| 207 | } |
| 208 | } |
| 209 | EditorAction::None => {} |
| 210 | } |
| 211 | ViewAction::None |
| 212 | } |
| 213 | |
| 214 | /// Re-read definitions and recent runs, keeping the selected id when the |
| 215 | /// list reorders. A busy manager keeps the previous snapshot. |
| 216 | fn refresh(&mut self) { |
| 217 | self.last_refresh_at = Instant::now(); |
| 218 | let Some(manager) = self.manager.as_ref() else { |
| 219 | self.problem = |
| 220 | Some(tr(self.locale, MessageId::AutomationManagerUnavailable).into_owned()); |
| 221 | return; |
| 222 | }; |
| 223 | let Ok(manager) = manager.try_lock() else { |
| 224 | return; |
| 225 | }; |
| 226 | let records = match manager.list_automations() { |
| 227 | Ok(records) => records, |
| 228 | Err(error) => { |
| 229 | self.problem = Some( |
| 230 | tr(self.locale, MessageId::AutomationListFailed) |
| 231 | .replace("{error}", &error.to_string()), |
| 232 | ); |
| 233 | return; |
| 234 | } |
| 235 | }; |
| 236 | let selected_id = self.selected().map(|row| row.record.id.clone()); |
| 237 | self.rows = records |
| 238 | .into_iter() |
| 239 | .map(|record| { |
| 240 | let runs = manager |
| 241 | .list_runs(&record.id, Some(RECENT_RUNS)) |
| 242 | .unwrap_or_default(); |
| 243 | AutomationRow { record, runs } |
| 244 | }) |
| 245 | .collect(); |
| 246 | self.problem = None; |
| 247 | self.row = selected_id |
| 248 | .and_then(|id| self.rows.iter().position(|row| row.record.id == id)) |
| 249 | .unwrap_or_else(|| self.row.min(self.rows.len().saturating_sub(1))); |
| 250 | } |
| 251 | |
| 252 | fn selected(&self) -> Option<&AutomationRow> { |
| 253 | self.rows.get(self.row) |
| 254 | } |
| 255 | |
| 256 | pub(crate) fn show_action_receipt(&mut self, receipt: String) { |
| 257 | self.notice = Some(receipt); |
| 258 | self.refresh(); |
| 259 | } |
| 260 | |
| 261 | fn move_row(&mut self, delta: isize) { |
| 262 | if self.rows.is_empty() { |
| 263 | return; |
| 264 | } |
| 265 | self.row = wrap_index(self.row, self.rows.len(), delta); |
| 266 | self.detail_scroll = 0; |
| 267 | } |
| 268 | |
| 269 | /// Every mutation is the typed command, so the receipt in the transcript |
| 270 | /// is the same one `/automation …` leaves; the next tick re-reads. |
| 271 | fn command(command: String) -> ViewAction { |
| 272 | ViewAction::Emit(ViewEvent::CommandPaletteSelected { |
| 273 | action: CommandPaletteAction::ExecuteCommand { command }, |
| 274 | }) |
| 275 | } |
| 276 | |
| 277 | fn toggle_pause(&self) -> ViewAction { |
| 278 | let Some(row) = self.selected() else { |
| 279 | return ViewAction::None; |
| 280 | }; |
| 281 | let verb = match row.record.status { |
| 282 | AutomationStatus::Active => "pause", |
| 283 | AutomationStatus::Paused => "resume", |
| 284 | }; |
| 285 | Self::command(format!("/automation {verb} {}", row.record.id)) |
| 286 | } |
| 287 | |
| 288 | fn run_now(&self) -> ViewAction { |
| 289 | match self.selected() { |
| 290 | Some(row) => Self::command(format!("/automation run {}", row.record.id)), |
| 291 | None => ViewAction::None, |
| 292 | } |
| 293 | } |
| 294 | |
| 295 | /// Cancel the live run: an automation run is a durable task, so the |
| 296 | /// task's own cancel is the one path. |
| 297 | fn cancel_live_run(&self) -> ViewAction { |
| 298 | match self |
| 299 | .selected() |
| 300 | .and_then(AutomationRow::live_run) |
| 301 | .and_then(|run| run.task_id.as_deref()) |
| 302 | { |
| 303 | Some(task_id) => Self::command(format!("/task cancel {task_id}")), |
| 304 | None => ViewAction::None, |
| 305 | } |
| 306 | } |
| 307 | |
| 308 | /// Delete opens the shared review control with the exact snapshot token. |
| 309 | fn delete(&self) -> ViewAction { |
| 310 | match self.selected() { |
| 311 | Some(row) => Self::command(format!("/automation delete {}", row.record.id)), |
| 312 | None => ViewAction::None, |
| 313 | } |
| 314 | } |
| 315 | |
| 316 | fn footer_hints(&self) -> Vec<ActionHint> { |
| 317 | let locale = self.locale; |
| 318 | let mut hints = vec![ActionHint::new("↑↓", tr(locale, MessageId::LaunchHintMove))]; |
| 319 | if self.detail_open { |
| 320 | hints.push(ActionHint::new( |
| 321 | "Tab", |
| 322 | tr(locale, MessageId::AutomationListHeading), |
| 323 | )); |
| 324 | } else { |
| 325 | hints.push(ActionHint::new( |
| 326 | "Enter", |
| 327 | tr(locale, MessageId::AutomationActionInspect), |
| 328 | )); |
| 329 | } |
| 330 | if let Some(row) = self.selected() { |
| 331 | hints.push(ActionHint::new( |
| 332 | "p", |
| 333 | match row.record.status { |
| 334 | AutomationStatus::Active => tr(locale, MessageId::AutomationActionPause), |
| 335 | AutomationStatus::Paused => tr(locale, MessageId::AutomationActionResume), |
| 336 | }, |
| 337 | )); |
| 338 | hints.push(ActionHint::new( |
| 339 | "r", |
| 340 | tr(locale, MessageId::AutomationActionRun), |
| 341 | )); |
| 342 | if row.live_run().is_some() { |
| 343 | hints.push(ActionHint::new( |
| 344 | "x", |
| 345 | tr(locale, MessageId::AutomationActionCancel), |
| 346 | )); |
| 347 | } |
| 348 | hints.push(ActionHint::new( |
| 349 | "d", |
| 350 | tr(locale, MessageId::AutomationActionDelete), |
| 351 | )); |
| 352 | } |
| 353 | hints.push(ActionHint::new( |
| 354 | "Esc", |
| 355 | tr(locale, MessageId::SessionsActionClose), |
| 356 | )); |
| 357 | hints |
| 358 | } |
| 359 | |
| 360 | fn header_lines(&self) -> Vec<Line<'static>> { |
| 361 | let active = self |
| 362 | .rows |
| 363 | .iter() |
| 364 | .filter(|row| row.record.status == AutomationStatus::Active) |
| 365 | .count(); |
| 366 | let live = self |
| 367 | .rows |
| 368 | .iter() |
| 369 | .filter(|row| row.live_run().is_some()) |
| 370 | .count(); |
| 371 | vec![ |
| 372 | Line::from(vec![ |
| 373 | Span::styled( |
| 374 | format!("─ {} ", tr(self.locale, MessageId::AutomationListHeading)), |
| 375 | Style::default().fg(palette::WHALE_ACTION).bold(), |
| 376 | ), |
| 377 | Span::styled( |
| 378 | format!( |
| 379 | "· {active} {} · {live} {}", |
| 380 | tr(self.locale, MessageId::AutomationStatusActive), |
| 381 | tr(self.locale, MessageId::AutomationRunStatusRunning) |
| 382 | ), |
| 383 | Style::default().fg(palette::TEXT_MUTED), |
| 384 | ), |
| 385 | ]), |
| 386 | Line::from(Span::styled( |
| 387 | format!(" {}", tr(self.locale, MessageId::AutomationScopeNote)), |
| 388 | Style::default().fg(palette::TEXT_DIM), |
| 389 | )), |
| 390 | Line::from(self.notice.clone().unwrap_or_default()), |
| 391 | ] |
| 392 | } |
| 393 | |
| 394 | fn render_list(&self, area: Rect, buf: &mut Buffer) { |
| 395 | self.list_body.set(area); |
| 396 | if let Some(problem) = self.problem.as_deref() { |
| 397 | Paragraph::new(Line::from(Span::styled( |
| 398 | format!(" {problem}"), |
| 399 | Style::default().fg(palette::STATUS_ERROR), |
| 400 | ))) |
| 401 | .wrap(Wrap { trim: false }) |
| 402 | .render(area, buf); |
| 403 | return; |
| 404 | } |
| 405 | if self.rows.is_empty() { |
| 406 | Paragraph::new(Line::from(Span::styled( |
| 407 | format!(" {}", tr(self.locale, MessageId::AutomationEmpty)), |
| 408 | Style::default().fg(palette::TEXT_MUTED), |
| 409 | ))) |
| 410 | .wrap(Wrap { trim: false }) |
| 411 | .render(area, buf); |
| 412 | return; |
| 413 | } |
| 414 | let rows_visible = usize::from(area.height).max(1); |
| 415 | let scroll = self.row.saturating_sub(rows_visible.saturating_sub(1)); |
| 416 | for (idx, row) in self.rows.iter().enumerate().skip(scroll).take(rows_visible) { |
| 417 | let y = area.y + u16::try_from(idx - scroll).unwrap_or(u16::MAX); |
| 418 | let selected = idx == self.row; |
| 419 | let base = if selected { |
| 420 | Style::default().fg(palette::WHALE_ACTION).bold() |
| 421 | } else { |
| 422 | Style::default().fg(palette::TEXT_SECONDARY) |
| 423 | }; |
| 424 | let (mark, mark_style) = match (row.live_run(), row.record.status) { |
| 425 | (Some(_), _) => ("●", Style::default().fg(palette::STATUS_WARNING)), |
| 426 | (None, AutomationStatus::Active) => ("○", Style::default().fg(palette::TEXT_MUTED)), |
| 427 | (None, AutomationStatus::Paused) => ("‖", Style::default().fg(palette::TEXT_DIM)), |
| 428 | }; |
| 429 | let state = match (row.live_run(), row.record.status) { |
| 430 | (Some(_), _) => tr(self.locale, MessageId::AutomationRunStatusRunning), |
| 431 | (None, AutomationStatus::Active) => { |
| 432 | tr(self.locale, MessageId::AutomationStatusActive) |
| 433 | } |
| 434 | (None, AutomationStatus::Paused) => { |
| 435 | tr(self.locale, MessageId::AutomationStatusPaused) |
| 436 | } |
| 437 | }; |
| 438 | let line = Line::from(vec![ |
| 439 | Span::styled(if selected { "▸ " } else { " " }, base), |
| 440 | Span::styled(format!("{mark} "), mark_style), |
| 441 | Span::styled(display_text(&row.record.name), base), |
| 442 | Span::styled( |
| 443 | format!( |
| 444 | " · {state} · {}: {}", |
| 445 | tr(self.locale, MessageId::AutomationNextLabel), |
| 446 | next_run_label(row.record.next_run_at) |
| 447 | ), |
| 448 | Style::default() |
| 449 | .fg(palette::TEXT_DIM) |
| 450 | .add_modifier(if selected { |
| 451 | Modifier::BOLD |
| 452 | } else { |
| 453 | Modifier::empty() |
| 454 | }), |
| 455 | ), |
| 456 | ]); |
| 457 | line.render(Rect::new(area.x, y, area.width, 1), buf); |
| 458 | } |
| 459 | } |
| 460 | |
| 461 | fn detail_lines(&self, row: &AutomationRow) -> Vec<Line<'static>> { |
| 462 | let locale = self.locale; |
| 463 | let label = |id: MessageId| { |
| 464 | Span::styled( |
| 465 | format!(" {} ", tr(locale, id)), |
| 466 | Style::default().fg(palette::TEXT_PRIMARY).bold(), |
| 467 | ) |
| 468 | }; |
| 469 | let value = |text: String| Span::styled(text, Style::default().fg(palette::TEXT_SECONDARY)); |
| 470 | let record = &row.record; |
| 471 | let mut lines = vec![ |
| 472 | Line::from(vec![ |
| 473 | Span::styled("─ ", Style::default().fg(palette::WHALE_ACTION).bold()), |
| 474 | Span::styled( |
| 475 | display_text(&record.name), |
| 476 | Style::default().fg(palette::TEXT_PRIMARY).bold(), |
| 477 | ), |
| 478 | Span::styled( |
| 479 | format!( |
| 480 | " · {}", |
| 481 | match record.status { |
| 482 | AutomationStatus::Active => |
| 483 | tr(locale, MessageId::AutomationStatusActive), |
| 484 | AutomationStatus::Paused => |
| 485 | tr(locale, MessageId::AutomationStatusPaused), |
| 486 | } |
| 487 | ), |
| 488 | Style::default().fg(palette::TEXT_MUTED), |
| 489 | ), |
| 490 | ]), |
| 491 | Line::from(Span::styled( |
| 492 | format!(" {}", record.id), |
| 493 | Style::default().fg(palette::TEXT_DIM), |
| 494 | )), |
| 495 | Line::from(""), |
| 496 | Line::from(vec![ |
| 497 | label(MessageId::AutomationRruleLabel), |
| 498 | value(record.rrule.clone()), |
| 499 | ]), |
| 500 | Line::from(vec![ |
| 501 | label(MessageId::AutomationNextLabel), |
| 502 | value(next_run_label(record.next_run_at)), |
| 503 | ]), |
| 504 | ]; |
| 505 | if !record.cwds.is_empty() { |
| 506 | lines.push(Line::from(vec![ |
| 507 | label(MessageId::AutomationCwdLabel), |
| 508 | value( |
| 509 | record |
| 510 | .cwds |
| 511 | .iter() |
| 512 | .map(|cwd| cwd.display().to_string()) |
| 513 | .collect::<Vec<_>>() |
| 514 | .join(", "), |
| 515 | ), |
| 516 | ])); |
| 517 | } |
| 518 | if let Some(model) = record.model.as_deref() { |
| 519 | lines.push(Line::from(vec![ |
| 520 | label(MessageId::SetupCardModelLabel), |
| 521 | value( |
| 522 | record |
| 523 | .model_provider_id |
| 524 | .as_ref() |
| 525 | .or(record.model_provider.as_ref()) |
| 526 | .map_or_else( |
| 527 | || model.to_string(), |
| 528 | |provider| format!("{provider} / {model}"), |
| 529 | ), |
| 530 | ), |
| 531 | ])); |
| 532 | } |
| 533 | lines.push(Line::from("")); |
| 534 | lines.push(Line::from(Span::styled( |
| 535 | format!(" {}", tr(locale, MessageId::AutomationPromptLabel)), |
| 536 | Style::default().fg(palette::TEXT_PRIMARY).bold(), |
| 537 | ))); |
| 538 | for line in display_text(&record.prompt).lines().take(12) { |
| 539 | lines.push(Line::from(Span::styled( |
| 540 | format!(" {line}"), |
| 541 | Style::default().fg(palette::TEXT_SECONDARY), |
| 542 | ))); |
| 543 | } |
| 544 | lines.push(Line::from("")); |
| 545 | lines.push(Line::from(Span::styled( |
| 546 | format!(" {}", tr(locale, MessageId::AutomationRecentRunsLabel)), |
| 547 | Style::default().fg(palette::TEXT_PRIMARY).bold(), |
| 548 | ))); |
| 549 | if row.runs.is_empty() { |
| 550 | lines.push(Line::from(Span::styled( |
| 551 | format!(" {}", tr(locale, MessageId::AutomationNoRuns)), |
| 552 | Style::default().fg(palette::TEXT_DIM), |
| 553 | ))); |
| 554 | } |
| 555 | for run in &row.runs { |
| 556 | let (glyph, style) = match run.status { |
| 557 | AutomationRunStatus::Queued | AutomationRunStatus::Running => { |
| 558 | ("●", Style::default().fg(palette::STATUS_WARNING)) |
| 559 | } |
| 560 | AutomationRunStatus::Completed => { |
| 561 | ("✓", Style::default().fg(palette::STATUS_SUCCESS)) |
| 562 | } |
| 563 | AutomationRunStatus::Failed => ("✗", Style::default().fg(palette::STATUS_ERROR)), |
| 564 | AutomationRunStatus::Canceled => ("–", Style::default().fg(palette::TEXT_DIM)), |
| 565 | }; |
| 566 | let mut spans = vec![ |
| 567 | Span::styled(format!(" {glyph} "), style), |
| 568 | Span::styled( |
| 569 | run.scheduled_for.format("%Y-%m-%d %H:%M UTC").to_string(), |
| 570 | Style::default().fg(palette::TEXT_SECONDARY), |
| 571 | ), |
| 572 | ]; |
| 573 | if let Some(task) = run.task_id.as_deref() { |
| 574 | spans.push(Span::styled( |
| 575 | format!(" · {} {task}", tr(locale, MessageId::AutomationTaskLabel)), |
| 576 | Style::default().fg(palette::TEXT_DIM), |
| 577 | )); |
| 578 | } |
| 579 | if let Some(error) = run.error.as_deref() { |
| 580 | spans.push(Span::styled( |
| 581 | format!(" · {error}"), |
| 582 | Style::default().fg(palette::STATUS_ERROR), |
| 583 | )); |
| 584 | } |
| 585 | lines.push(Line::from(spans)); |
| 586 | } |
| 587 | lines |
| 588 | } |
| 589 | |
| 590 | fn render_detail(&self, area: Rect, buf: &mut Buffer) { |
| 591 | let Some(row) = self.selected() else { |
| 592 | self.render_list(area, buf); |
| 593 | return; |
| 594 | }; |
| 595 | let lines = self.detail_lines(row); |
| 596 | let visible = usize::from(area.height).max(1); |
| 597 | let scroll = self.detail_scroll.min(lines.len().saturating_sub(visible)); |
| 598 | Paragraph::new(lines.into_iter().skip(scroll).collect::<Vec<_>>()) |
| 599 | .wrap(Wrap { trim: false }) |
| 600 | .render(area, buf); |
| 601 | } |
| 602 | } |
| 603 | |
| 604 | fn next_run_label(value: Option<chrono::DateTime<chrono::Utc>>) -> String { |
| 605 | value.map_or_else( |
| 606 | || "-".to_string(), |
| 607 | |at| at.format("%Y-%m-%d %H:%M UTC").to_string(), |
| 608 | ) |
| 609 | } |
| 610 | |
| 611 | /// Stored names and prompts are untrusted text: one line, no control |
| 612 | /// characters, so a name cannot reflow the room. |
| 613 | fn display_text(value: &str) -> String { |
| 614 | value |
| 615 | .chars() |
| 616 | .map(|ch| { |
| 617 | if ch.is_control() && ch != '\n' { |
| 618 | ' ' |
| 619 | } else { |
| 620 | ch |
| 621 | } |
| 622 | }) |
| 623 | .collect::<String>() |
| 624 | .trim() |
| 625 | .to_string() |
| 626 | } |
| 627 | |
| 628 | impl ModalView for AutomationsView { |
| 629 | fn kind(&self) -> ModalKind { |
| 630 | ModalKind::Automations |
| 631 | } |
| 632 | |
| 633 | fn as_any_mut(&mut self) -> &mut dyn std::any::Any { |
| 634 | self |
| 635 | } |
| 636 | |
| 637 | fn handle_key(&mut self, key: KeyEvent) -> ViewAction { |
| 638 | if let Some(editor) = self.editor.as_mut() { |
| 639 | let action = editor.key(key); |
| 640 | return self.editor_action(action); |
| 641 | } |
| 642 | match key.code { |
| 643 | KeyCode::Char('n') => { |
| 644 | self.open_editor(false); |
| 645 | ViewAction::None |
| 646 | } |
| 647 | KeyCode::Char('e') => { |
| 648 | self.open_editor(true); |
| 649 | ViewAction::None |
| 650 | } |
| 651 | KeyCode::Esc => { |
| 652 | if self.detail_open { |
| 653 | self.detail_open = false; |
| 654 | self.detail_scroll = 0; |
| 655 | ViewAction::None |
| 656 | } else { |
| 657 | ViewAction::Close |
| 658 | } |
| 659 | } |
| 660 | KeyCode::Char('q') => ViewAction::Close, |
| 661 | KeyCode::Up | KeyCode::Char('k') => { |
| 662 | if self.detail_open { |
| 663 | self.detail_scroll = self.detail_scroll.saturating_sub(1); |
| 664 | } else { |
| 665 | self.move_row(-1); |
| 666 | } |
| 667 | ViewAction::None |
| 668 | } |
| 669 | KeyCode::Down | KeyCode::Char('j') => { |
| 670 | if self.detail_open { |
| 671 | self.detail_scroll = self.detail_scroll.saturating_add(1); |
| 672 | } else { |
| 673 | self.move_row(1); |
| 674 | } |
| 675 | ViewAction::None |
| 676 | } |
| 677 | KeyCode::Enter | KeyCode::Right | KeyCode::Char('l') => { |
| 678 | if !self.rows.is_empty() { |
| 679 | self.detail_open = true; |
| 680 | } |
| 681 | ViewAction::None |
| 682 | } |
| 683 | KeyCode::Left | KeyCode::Char('h') => { |
| 684 | self.detail_open = false; |
| 685 | self.detail_scroll = 0; |
| 686 | ViewAction::None |
| 687 | } |
| 688 | KeyCode::Tab | KeyCode::BackTab => { |
| 689 | if !self.rows.is_empty() { |
| 690 | self.detail_open = !self.detail_open; |
| 691 | self.detail_scroll = 0; |
| 692 | } |
| 693 | ViewAction::None |
| 694 | } |
| 695 | KeyCode::Char('p') | KeyCode::Char(' ') => self.toggle_pause(), |
| 696 | KeyCode::Char('r') => self.run_now(), |
| 697 | KeyCode::Char('x') | KeyCode::Char('c') => self.cancel_live_run(), |
| 698 | KeyCode::Char('d') => self.delete(), |
| 699 | _ => ViewAction::None, |
| 700 | } |
| 701 | } |
| 702 | |
| 703 | fn handle_mouse(&mut self, mouse: MouseEvent) -> ViewAction { |
| 704 | if let Some(editor) = self.editor.as_mut() { |
| 705 | let action = editor.mouse(mouse); |
| 706 | return self.editor_action(action); |
| 707 | } |
| 708 | // The wheel moves this list, not the transcript behind it. |
| 709 | match mouse.kind { |
| 710 | MouseEventKind::ScrollUp => { |
| 711 | self.move_row(-1); |
| 712 | return ViewAction::None; |
| 713 | } |
| 714 | MouseEventKind::ScrollDown => { |
| 715 | self.move_row(1); |
| 716 | return ViewAction::None; |
| 717 | } |
| 718 | _ => {} |
| 719 | } |
| 720 | if mouse.kind == MouseEventKind::Down(MouseButton::Left) { |
| 721 | let point = (mouse.column, mouse.row).into(); |
| 722 | if self.new_button.get().contains(point) { |
| 723 | self.open_editor(false); |
| 724 | return ViewAction::None; |
| 725 | } |
| 726 | if self.edit_button.get().contains(point) { |
| 727 | self.open_editor(true); |
| 728 | return ViewAction::None; |
| 729 | } |
| 730 | } |
| 731 | if self.detail_open { |
| 732 | return ViewAction::None; |
| 733 | } |
| 734 | if let MouseEventKind::Down(MouseButton::Left) = mouse.kind { |
| 735 | let body = self.list_body.get(); |
| 736 | if body.width > 0 && body.contains((mouse.column, mouse.row).into()) { |
| 737 | let offset = usize::from(mouse.row - body.y); |
| 738 | let scroll = self |
| 739 | .row |
| 740 | .saturating_sub(usize::from(body.height).saturating_sub(1)); |
| 741 | let idx = scroll + offset; |
| 742 | if idx < self.rows.len() { |
| 743 | if self.row == idx { |
| 744 | self.detail_open = true; |
| 745 | } |
| 746 | self.row = idx; |
| 747 | } |
| 748 | } |
| 749 | } |
| 750 | ViewAction::None |
| 751 | } |
| 752 | |
| 753 | fn handle_paste(&mut self, text: &str) -> bool { |
| 754 | if let Some(editor) = self.editor.as_mut() { |
| 755 | editor.paste(text); |
| 756 | return true; |
| 757 | } |
| 758 | false |
| 759 | } |
| 760 | |
| 761 | fn tick(&mut self) -> ViewAction { |
| 762 | let interval = if self.rows.iter().any(|row| row.live_run().is_some()) { |
| 763 | Duration::from_millis(500) |
| 764 | } else { |
| 765 | Duration::from_secs(2) |
| 766 | }; |
| 767 | if self.last_refresh_at.elapsed() >= interval { |
| 768 | self.refresh(); |
| 769 | } |
| 770 | ViewAction::None |
| 771 | } |
| 772 | |
| 773 | fn render(&self, area: Rect, buf: &mut Buffer) { |
| 774 | Clear.render(area, buf); |
| 775 | Block::default() |
| 776 | .style(Style::default().bg(palette::WHALE_BG)) |
| 777 | .render(area, buf); |
| 778 | if let Some(editor) = &self.editor { |
| 779 | editor.render(area, buf); |
| 780 | return; |
| 781 | } |
| 782 | let hints = self.footer_hints(); |
| 783 | let content = render_modal_footer(area, buf, &hints); |
| 784 | let header = Paragraph::new(self.header_lines()).wrap(Wrap { trim: false }); |
| 785 | let header_height = u16::try_from(header.line_count(content.width)) |
| 786 | .unwrap_or(u16::MAX) |
| 787 | .saturating_add(1); |
| 788 | let chunks = Layout::default() |
| 789 | .direction(Direction::Vertical) |
| 790 | .constraints([Constraint::Length(header_height), Constraint::Min(1)]) |
| 791 | .split(content); |
| 792 | // Keep action feedback visible while this panel covers the transcript. |
| 793 | // Reserve wrapped rows before the New/Edit buttons, including for CJK. |
| 794 | header.render(chunks[0], buf); |
| 795 | let mut x = chunks[0].x; |
| 796 | self.new_button.set(Rect::ZERO); |
| 797 | self.edit_button.set(Rect::ZERO); |
| 798 | for (key, label, hit) in [ |
| 799 | ("n", MessageId::AutomationEditorNew, &self.new_button), |
| 800 | ("e", MessageId::AutomationEditorEdit, &self.edit_button), |
| 801 | ] { |
| 802 | if key == "e" && self.selected().is_none() { |
| 803 | continue; |
| 804 | } |
| 805 | let text = format!("[{key} {}] ", tr(self.locale, label)); |
| 806 | let width = u16::try_from(crate::tui::ui_text::text_display_width(&text)) |
| 807 | .unwrap_or(u16::MAX) |
| 808 | .min(chunks[0].right().saturating_sub(x)); |
| 809 | let rect = Rect::new( |
| 810 | x, |
| 811 | chunks[0].y + chunks[0].height.saturating_sub(1), |
| 812 | width, |
| 813 | 1, |
| 814 | ); |
| 815 | Paragraph::new(text) |
| 816 | .style(Style::default().fg(palette::WHALE_ACTION)) |
| 817 | .render(rect, buf); |
| 818 | hit.set(rect); |
| 819 | x += width; |
| 820 | } |
| 821 | if self.detail_open { |
| 822 | self.render_detail(chunks[1], buf); |
| 823 | } else { |
| 824 | self.render_list(chunks[1], buf); |
| 825 | } |
| 826 | } |
| 827 | } |
| 828 | |
| 829 | #[cfg(test)] |
| 830 | mod tests { |
| 831 | use super::*; |
| 832 | use chrono::{TimeZone, Utc}; |
| 833 | use crossterm::event::KeyModifiers; |
| 834 | |
| 835 | fn record(id: &str, status: AutomationStatus) -> AutomationRecord { |
| 836 | let at = Utc.with_ymd_and_hms(2026, 9, 4, 17, 0, 0).unwrap(); |
| 837 | AutomationRecord { |
| 838 | schema_version: 1, |
| 839 | execution_scope: Some(crate::task_manager::test_execution_scope("test")), |
| 840 | id: id.to_string(), |
| 841 | name: format!("cwc daily {id}"), |
| 842 | prompt: "patrol".to_string(), |
| 843 | rrule: "FREQ=DAILY;BYHOUR=17".to_string(), |
| 844 | cwds: vec![std::path::PathBuf::from("/tmp/cwc")], |
| 845 | model: None, |
| 846 | model_provider: None, |
| 847 | model_provider_id: None, |
| 848 | mode: None, |
| 849 | allow_shell: None, |
| 850 | trust_mode: None, |
| 851 | auto_approve: None, |
| 852 | delivery_mode: None, |
| 853 | status, |
| 854 | created_at: at, |
| 855 | updated_at: at, |
| 856 | next_run_at: Some(at), |
| 857 | last_run_at: None, |
| 858 | } |
| 859 | } |
| 860 | |
| 861 | fn run(automation_id: &str, status: AutomationRunStatus) -> AutomationRunRecord { |
| 862 | let at = Utc.with_ymd_and_hms(2026, 9, 3, 17, 0, 0).unwrap(); |
| 863 | AutomationRunRecord { |
| 864 | schema_version: 1, |
| 865 | id: format!("run-{automation_id}"), |
| 866 | automation_id: automation_id.to_string(), |
| 867 | scheduled_for: at, |
| 868 | status, |
| 869 | created_at: at, |
| 870 | started_at: Some(at), |
| 871 | ended_at: None, |
| 872 | task_id: Some(format!("task-{automation_id}")), |
| 873 | thread_id: None, |
| 874 | turn_id: None, |
| 875 | error: None, |
| 876 | dispatch: None, |
| 877 | } |
| 878 | } |
| 879 | |
| 880 | fn view() -> AutomationsView { |
| 881 | AutomationsView::from_rows( |
| 882 | vec![ |
| 883 | AutomationRow { |
| 884 | record: record("vision", AutomationStatus::Active), |
| 885 | runs: vec![run("vision", AutomationRunStatus::Running)], |
| 886 | }, |
| 887 | AutomationRow { |
| 888 | record: record("infra", AutomationStatus::Paused), |
| 889 | runs: Vec::new(), |
| 890 | }, |
| 891 | ], |
| 892 | Locale::En, |
| 893 | ) |
| 894 | } |
| 895 | |
| 896 | fn key(code: KeyCode) -> KeyEvent { |
| 897 | KeyEvent::new(code, KeyModifiers::NONE) |
| 898 | } |
| 899 | |
| 900 | fn command_of(action: ViewAction) -> String { |
| 901 | match action { |
| 902 | ViewAction::Emit(ViewEvent::CommandPaletteSelected { |
| 903 | action: CommandPaletteAction::ExecuteCommand { command }, |
| 904 | }) => command, |
| 905 | other => panic!("expected a command, got {other:?}"), |
| 906 | } |
| 907 | } |
| 908 | |
| 909 | #[test] |
| 910 | fn every_affordance_is_the_typed_command() { |
| 911 | let mut view = view(); |
| 912 | assert_eq!( |
| 913 | command_of(view.handle_key(key(KeyCode::Char('p')))), |
| 914 | "/automation pause vision" |
| 915 | ); |
| 916 | assert_eq!( |
| 917 | command_of(view.handle_key(key(KeyCode::Char('r')))), |
| 918 | "/automation run vision" |
| 919 | ); |
| 920 | assert_eq!( |
| 921 | command_of(view.handle_key(key(KeyCode::Char('x')))), |
| 922 | "/task cancel task-vision" |
| 923 | ); |
| 924 | assert_eq!( |
| 925 | command_of(view.handle_key(key(KeyCode::Char('d')))), |
| 926 | "/automation delete vision" |
| 927 | ); |
| 928 | |
| 929 | view.handle_key(key(KeyCode::Down)); |
| 930 | assert_eq!( |
| 931 | command_of(view.handle_key(key(KeyCode::Char('p')))), |
| 932 | "/automation resume infra" |
| 933 | ); |
| 934 | // Nothing live to cancel on the paused row. |
| 935 | assert!(matches!( |
| 936 | view.handle_key(key(KeyCode::Char('x'))), |
| 937 | ViewAction::None |
| 938 | )); |
| 939 | } |
| 940 | |
| 941 | #[test] |
| 942 | fn tab_flips_list_and_detail_and_esc_backs_out() { |
| 943 | let mut view = view(); |
| 944 | assert!(!view.detail_open); |
| 945 | view.handle_key(key(KeyCode::Tab)); |
| 946 | assert!(view.detail_open); |
| 947 | assert!(matches!( |
| 948 | view.handle_key(key(KeyCode::Esc)), |
| 949 | ViewAction::None |
| 950 | )); |
| 951 | assert!(!view.detail_open); |
| 952 | assert!(matches!( |
| 953 | view.handle_key(key(KeyCode::Esc)), |
| 954 | ViewAction::Close |
| 955 | )); |
| 956 | } |
| 957 | |
| 958 | #[test] |
| 959 | fn editor_save_and_cancel_require_explicit_room_actions() { |
| 960 | let _env = crate::test_support::lock_test_env(); |
| 961 | let root = tempfile::tempdir().unwrap(); |
| 962 | let manager = std::sync::Arc::new(tokio::sync::Mutex::new( |
| 963 | crate::automation_manager::AutomationManager::open_for_test(root.path().join("store")) |
| 964 | .unwrap(), |
| 965 | )); |
| 966 | let mut view = AutomationsView::from_rows(Vec::new(), Locale::En); |
| 967 | view.workspace = root.path().to_path_buf(); |
| 968 | view.manager = Some(manager.clone()); |
| 969 | view.handle_key(key(KeyCode::Char('n'))); |
| 970 | assert!(view.handle_paste("draft")); |
| 971 | view.handle_key(key(KeyCode::Tab)); |
| 972 | view.handle_paste("line one\nline two"); |
| 973 | assert!( |
| 974 | manager |
| 975 | .try_lock() |
| 976 | .unwrap() |
| 977 | .list_automations() |
| 978 | .unwrap() |
| 979 | .is_empty() |
| 980 | ); |
| 981 | let area = Rect::new(0, 0, 40, 12); |
| 982 | let mut buffer = Buffer::empty(area); |
| 983 | view.render(area, &mut buffer); |
| 984 | let cancel = MouseEvent { |
| 985 | kind: MouseEventKind::Down(MouseButton::Left), |
| 986 | column: 17, |
| 987 | row: 11, |
| 988 | modifiers: KeyModifiers::NONE, |
| 989 | }; |
| 990 | view.handle_mouse(cancel); |
| 991 | assert!(view.editor.is_none()); |
| 992 | assert!( |
| 993 | manager |
| 994 | .try_lock() |
| 995 | .unwrap() |
| 996 | .list_automations() |
| 997 | .unwrap() |
| 998 | .is_empty() |
| 999 | ); |
| 1000 | |
| 1001 | view.handle_key(key(KeyCode::Char('n'))); |
| 1002 | view.handle_paste("saved"); |
| 1003 | view.handle_key(key(KeyCode::Tab)); |
| 1004 | view.handle_paste("prompt"); |
| 1005 | view.render(area, &mut buffer); |
| 1006 | view.handle_mouse(MouseEvent { |
| 1007 | column: 2, |
| 1008 | ..cancel |
| 1009 | }); |
| 1010 | assert!(view.editor.is_none()); |
| 1011 | assert_eq!( |
| 1012 | manager |
| 1013 | .try_lock() |
| 1014 | .unwrap() |
| 1015 | .list_automations() |
| 1016 | .unwrap() |
| 1017 | .len(), |
| 1018 | 1 |
| 1019 | ); |
| 1020 | view.render(area, &mut buffer); |
| 1021 | let receipt = rendered_text(area, &buffer); |
| 1022 | assert!( |
| 1023 | receipt.contains("Saved saved"), |
| 1024 | "compact receipt: {receipt}" |
| 1025 | ); |
| 1026 | } |
| 1027 | |
| 1028 | fn rendered_text(area: Rect, buffer: &Buffer) -> String { |
| 1029 | (0..area.height) |
| 1030 | .map(|y| { |
| 1031 | (0..area.width) |
| 1032 | .map(|x| buffer[(x, y)].symbol()) |
| 1033 | .collect::<String>() |
| 1034 | }) |
| 1035 | .collect::<Vec<_>>() |
| 1036 | .join("\n") |
| 1037 | } |
| 1038 | |
| 1039 | #[test] |
| 1040 | fn refused_action_stays_visible_in_list_and_detail_after_refresh() { |
| 1041 | for (locale, receipt) in [ |
| 1042 | ( |
| 1043 | Locale::En, |
| 1044 | "Could not run automation: Automation belongs to another Runtime execution scope", |
| 1045 | ), |
| 1046 | ( |
| 1047 | Locale::ZhHans, |
| 1048 | "无法运行此自动化:它属于另一会话,请返回原会话后重试。", |
| 1049 | ), |
| 1050 | ] { |
| 1051 | for width in [40, 80, 120] { |
| 1052 | for detail in [false, true] { |
| 1053 | let mut view = view(); |
| 1054 | view.locale = locale; |
| 1055 | view.detail_open = detail; |
| 1056 | view.show_action_receipt(receipt.to_string()); |
| 1057 | view.refresh(); |
| 1058 | let area = Rect::new(0, 0, width, 20); |
| 1059 | let mut buffer = Buffer::empty(area); |
| 1060 | view.render(area, &mut buffer); |
| 1061 | let text = rendered_text(area, &buffer); |
| 1062 | let compact = |
| 1063 | |s: &str| s.chars().filter(|c| !c.is_whitespace()).collect::<String>(); |
| 1064 | assert!( |
| 1065 | compact(&text).contains(&compact(receipt)), |
| 1066 | "{locale:?}/{width}/{detail}: {text}" |
| 1067 | ); |
| 1068 | assert!(!view.new_button.get().is_empty(), "New remains accessible"); |
| 1069 | assert_eq!(view.rows.len(), 2, "feedback preserves definitions"); |
| 1070 | } |
| 1071 | } |
| 1072 | } |
| 1073 | } |
| 1074 | |
| 1075 | #[test] |
| 1076 | fn renders_state_next_run_and_the_live_cancel_hint() { |
| 1077 | let view = view(); |
| 1078 | let area = Rect::new(0, 0, 100, 16); |
| 1079 | let mut buf = Buffer::empty(area); |
| 1080 | view.render(area, &mut buf); |
| 1081 | let text = (0..area.height) |
| 1082 | .map(|y| { |
| 1083 | (0..area.width) |
| 1084 | .map(|x| buf[(x, y)].symbol()) |
| 1085 | .collect::<String>() |
| 1086 | }) |
| 1087 | .collect::<Vec<_>>() |
| 1088 | .join("\n"); |
| 1089 | assert!(text.contains("cwc daily vision"), "{text}"); |
| 1090 | assert!(text.contains("running"), "{text}"); |
| 1091 | assert!(text.contains("2026-09-04 17:00 UTC"), "{text}"); |
| 1092 | assert!(text.contains("x cancel"), "{text}"); |
| 1093 | assert!(text.contains("p pause"), "{text}"); |
| 1094 | assert!(text.contains("follow you into every repository"), "{text}"); |
| 1095 | } |
| 1096 | } |
| 1097 |