返回 CodeWhale
automations.rs
根目录 / crates / tui / src / tui / views / automations.rs
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
1097 lines RUST