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