返回 CodeWhale
workflows_manager.rs
根目录 / crates / tui / src / tui / views / workflows_manager.rs
1 //! `/workflows` — the live workflow **run** dashboard.
2 //!
3 //! Lists every run this workspace's journal knows for the current session —
4 //! active and retained, not saved definitions — with the run's status, label,
5 //! elapsed time, child count, and latest progress. The detail pane adds the
6 //! phase order, the child-agent roster, the retained progress tail, and the
7 //! run id `/workflow cancel` accepts. `x` cancels the selected running run
8 //! through the same host path as `/workflow cancel` (no model turn, no
9 //! confirmation friction — matching the workflow panel's one-press cancel);
10 //! `r` re-reads the journal.
11 //!
12 //! This view never asks the model anything: it reads
13 //! [`crate::tools::workflow::host_workflow_run_details`] and writes only
14 //! through [`crate::tools::workflow::host_cancel_workflow`].
15
16 use std::cell::Cell;
17 use std::path::PathBuf;
18 use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
19
20 use crossterm::event::{KeyCode, KeyEvent, MouseButton, MouseEvent, MouseEventKind};
21 use ratatui::{
22 buffer::Buffer,
23 layout::{Constraint, Direction, Layout, Rect},
24 style::{Modifier, Style},
25 text::{Line, Span},
26 widgets::{Block, Clear, Paragraph, Widget, Wrap},
27 };
28
29 use super::{ActionHint, ModalKind, ModalView, ViewAction, render_modal_footer};
30 use crate::tools::workflow::{
31 HostWorkflowChildRow, HostWorkflowRunDetail, host_cancel_workflow, host_workflow_run_details,
32 };
33 use crate::tui::app::App;
34 use crate::tui::list_nav::wrap_index;
35 use codewhale_palette as palette;
36
37 fn now_ms() -> u64 {
38 SystemTime::now()
39 .duration_since(UNIX_EPOCH)
40 .map(|d| d.as_millis() as u64)
41 .unwrap_or_default()
42 }
43
44 /// Status ink follows the workflow panel's grammar: running is working ink,
45 /// completion is outcome ink, only failure (including budget and replay
46 /// failures) spends red.
47 fn status_style(status: &str) -> Style {
48 match status {
49 "queued" | "running" | "waiting" | "pending" => {
50 Style::default().fg(palette::STATUS_WARNING)
51 }
52 "completed" | "succeeded" => Style::default().fg(palette::STATUS_SUCCESS),
53 "degraded" => Style::default().fg(palette::STATUS_WARNING),
54 "failed" | "budget_exceeded" | "replay_diverged" => {
55 Style::default().fg(palette::STATUS_ERROR)
56 }
57 _ => Style::default().fg(palette::TEXT_MUTED),
58 }
59 }
60
61 fn child_state_glyph(state: &str) -> &'static str {
62 match state {
63 "running" | "pending" => "•",
64 "succeeded" => "✓",
65 _ => "✗",
66 }
67 }
68
69 pub struct WorkflowsManagerView {
70 /// Newest first, so the live run is the first thing read.
71 runs: Vec<HostWorkflowRunDetail>,
72 row: usize,
73 detail_open: bool,
74 detail_scroll: usize,
75 /// Receipt line for the last host action (cancel), shown under the header.
76 status: Option<String>,
77 workspace: PathBuf,
78 owner_session_id: Option<String>,
79 /// The manager follows owner-state changes while open without polling the
80 /// journal on every terminal frame.
81 last_refresh_at: Instant,
82 /// Screen rect of the run list body, recorded at render for mouse parity.
83 list_body: Cell<Rect>,
84 }
85
86 impl WorkflowsManagerView {
87 #[must_use]
88 pub fn new(app: &App) -> Self {
89 let mut view = Self {
90 runs: Vec::new(),
91 row: 0,
92 detail_open: false,
93 detail_scroll: 0,
94 status: None,
95 workspace: app.workspace.clone(),
96 owner_session_id: app.current_session_id.clone(),
97 last_refresh_at: Instant::now(),
98 list_body: Cell::new(Rect::ZERO),
99 };
100 view.refresh();
101 view
102 }
103
104 /// Re-read the journal (newest first), preserving the selected run id when
105 /// a newer run arrives rather than silently moving focus to a different
106 /// row.
107 fn refresh(&mut self) {
108 let selected_id = self.selected().map(|detail| detail.line.run_id.clone());
109 self.runs = host_workflow_run_details(&self.workspace, self.owner_session_id.as_deref())
110 .into_iter()
111 .rev()
112 .collect();
113 self.row = selected_id
114 .and_then(|run_id| {
115 self.runs
116 .iter()
117 .position(|detail| detail.line.run_id == run_id)
118 })
119 .unwrap_or_else(|| self.row.min(self.runs.len().saturating_sub(1)));
120 self.last_refresh_at = Instant::now();
121 }
122
123 fn selected(&self) -> Option<&HostWorkflowRunDetail> {
124 self.runs.get(self.row)
125 }
126
127 fn move_row(&mut self, delta: isize) {
128 let rows = self.runs.len();
129 if rows == 0 {
130 return;
131 }
132 self.row = wrap_index(self.row, rows, delta);
133 }
134
135 /// Cancel the selected run through the host path — the same one
136 /// `/workflow cancel <run_id>` takes, so receipts and journal state are
137 /// identical. No model turn.
138 fn cancel_selected(&mut self) {
139 let Some(detail) = self.selected() else {
140 return;
141 };
142 if !detail.line.active {
143 self.status = Some(format!(
144 "Run {} already {} — nothing to cancel.",
145 detail.line.run_id, detail.line.status
146 ));
147 return;
148 }
149 let run_id = detail.line.run_id.clone();
150 match host_cancel_workflow(&self.workspace, &run_id, self.owner_session_id.as_deref()) {
151 Ok(line) => {
152 self.status = Some(format!(
153 "Workflow {} {} · {}",
154 line.run_id, line.status, line.label
155 ));
156 }
157 Err(reason) => {
158 self.status = Some(format!("Cancel failed: {reason}"));
159 }
160 }
161 self.refresh();
162 }
163
164 fn footer_hints(&self) -> Vec<ActionHint> {
165 let mut hints = vec![ActionHint::new("↑/↓", "move")];
166 if self.detail_open {
167 hints.push(ActionHint::new("←", "runs"));
168 } else {
169 hints.push(ActionHint::new("Enter", "detail"));
170 }
171 if self.selected().is_some_and(|d| d.line.active) {
172 hints.push(ActionHint::new("x", "cancel"));
173 }
174 hints.push(ActionHint::new("r", "refresh"));
175 hints.push(ActionHint::new("Esc", "close"));
176 hints
177 }
178
179 fn header_lines(&self) -> Vec<Line<'static>> {
180 let active = self.runs.iter().filter(|d| d.line.active).count();
181 let finished = self.runs.len() - active;
182 let mut header = vec![
183 Line::from(vec![
184 Span::styled(
185 "─ Workflow runs ",
186 Style::default().fg(palette::WHALE_ACTION).bold(),
187 ),
188 Span::styled(
189 format!("· {active} active · {finished} finished"),
190 Style::default().fg(palette::TEXT_MUTED),
191 ),
192 ]),
193 Line::from(""),
194 ];
195 if let Some(status) = &self.status {
196 header.push(Line::from(Span::styled(
197 format!(" {status}"),
198 Style::default().fg(palette::WHALE_HUMAN),
199 )));
200 }
201 header
202 }
203
204 fn render_list(&self, area: Rect, buf: &mut Buffer) {
205 self.list_body.set(area);
206 if self.runs.is_empty() {
207 Paragraph::new(Line::from(vec![
208 Span::styled(
209 " No workflow runs in this workspace yet.",
210 Style::default().fg(palette::TEXT_MUTED),
211 ),
212 Span::styled(
213 " /workflow <objective> starts one.",
214 Style::default().fg(palette::TEXT_DIM),
215 ),
216 ]))
217 .render(area, buf);
218 return;
219 }
220
221 let now = now_ms();
222 let rows_visible = usize::from(area.height).max(1);
223 let scroll = self.row.saturating_sub(rows_visible.saturating_sub(1));
224 for (idx, detail) in self.runs.iter().enumerate() {
225 if idx < scroll || idx >= scroll + rows_visible {
226 continue;
227 }
228 let y = area.y + u16::try_from(idx - scroll).unwrap_or(u16::MAX);
229 if y >= area.y + area.height {
230 break;
231 }
232 let selected = idx == self.row;
233 let row_rect = Rect {
234 x: area.x,
235 y,
236 width: area.width,
237 height: 1,
238 };
239 let elapsed = detail
240 .line
241 .completed_at_ms
242 .unwrap_or(now)
243 .saturating_sub(detail.line.started_at_ms)
244 / 1000;
245 let marker = if selected { "▸ " } else { " " };
246 let base = if selected {
247 Style::default().fg(palette::WHALE_ACTION).bold()
248 } else {
249 Style::default().fg(palette::TEXT_SECONDARY)
250 };
251 let mut spans = vec![
252 Span::styled(marker, base),
253 Span::styled(
254 format!("{:<9}", detail.line.status),
255 status_style(detail.line.status).add_modifier(if selected {
256 Modifier::BOLD
257 } else {
258 Modifier::empty()
259 }),
260 ),
261 Span::styled(detail.line.label.clone(), base),
262 Span::styled(
263 format!(
264 " · {} · {} children",
265 crate::elapsed::format_elapsed_secs(elapsed),
266 detail.line.child_count
267 ),
268 Style::default().fg(palette::TEXT_DIM),
269 ),
270 ];
271 if let Some(progress) = detail.line.last_progress.as_deref() {
272 spans.push(Span::styled(
273 format!(" · {progress}"),
274 Style::default().fg(palette::TEXT_DIM),
275 ));
276 }
277 Line::from(spans).render(row_rect, buf);
278 }
279 }
280
281 fn detail_lines(&self, detail: &HostWorkflowRunDetail) -> Vec<Line<'static>> {
282 let now = now_ms();
283 let elapsed = detail
284 .line
285 .completed_at_ms
286 .unwrap_or(now)
287 .saturating_sub(detail.line.started_at_ms)
288 / 1000;
289 let mut lines = vec![
290 Line::from(vec![
291 Span::styled("─ ", Style::default().fg(palette::WHALE_ACTION).bold()),
292 Span::styled(
293 detail.line.label.clone(),
294 Style::default().fg(palette::TEXT_PRIMARY).bold(),
295 ),
296 Span::styled(
297 format!(" · {}", detail.line.status),
298 status_style(detail.line.status),
299 ),
300 ]),
301 Line::from(Span::styled(
302 format!(
303 " run {} · {} · {} children",
304 detail.line.run_id,
305 crate::elapsed::format_elapsed_secs(elapsed),
306 detail.line.child_count
307 ),
308 Style::default().fg(palette::TEXT_DIM),
309 )),
310 Line::from(""),
311 ];
312 if !detail.phases.is_empty() {
313 lines.push(Line::from(Span::styled(
314 " Phases",
315 Style::default().fg(palette::TEXT_PRIMARY).bold(),
316 )));
317 lines.push(Line::from(Span::styled(
318 format!(" {}", detail.phases.join(" → ")),
319 Style::default().fg(palette::TEXT_SECONDARY),
320 )));
321 lines.push(Line::from(""));
322 }
323 if !detail.children.is_empty() {
324 lines.push(Line::from(Span::styled(
325 format!(" Children ({})", detail.children.len()),
326 Style::default().fg(palette::TEXT_PRIMARY).bold(),
327 )));
328 for child in &detail.children {
329 lines.push(child_row_line(child));
330 }
331 lines.push(Line::from(""));
332 }
333 if !detail.progress_tail.is_empty() {
334 lines.push(Line::from(Span::styled(
335 " Recent progress",
336 Style::default().fg(palette::TEXT_PRIMARY).bold(),
337 )));
338 for progress in &detail.progress_tail {
339 lines.push(Line::from(Span::styled(
340 format!(" {progress}"),
341 Style::default().fg(palette::TEXT_SECONDARY),
342 )));
343 }
344 lines.push(Line::from(""));
345 }
346 if let Some(error) = detail.line.error.as_deref() {
347 lines.push(Line::from(vec![
348 Span::styled(" Error ", Style::default().fg(palette::STATUS_ERROR)),
349 Span::styled(
350 error.to_string(),
351 Style::default().fg(palette::TEXT_SECONDARY),
352 ),
353 ]));
354 }
355 lines.push(Line::from(Span::styled(
356 if detail.has_result {
357 " Result retained in the run journal (.codewhale/workflow-runs.jsonl)."
358 } else {
359 " No result recorded yet."
360 },
361 Style::default().fg(palette::TEXT_DIM),
362 )));
363 lines
364 }
365
366 fn render_detail(&self, area: Rect, buf: &mut Buffer) {
367 let Some(detail) = self.selected() else {
368 self.render_list(area, buf);
369 return;
370 };
371 let lines = self.detail_lines(detail);
372 let visible = usize::from(area.height).max(1);
373 let max_scroll = lines.len().saturating_sub(visible);
374 let scroll = self.detail_scroll.min(max_scroll);
375 Paragraph::new(lines.iter().skip(scroll).cloned().collect::<Vec<_>>())
376 .wrap(Wrap { trim: false })
377 .render(area, buf);
378 }
379 }
380
381 fn child_row_line(child: &HostWorkflowChildRow) -> Line<'static> {
382 let name = child.label.clone().unwrap_or_else(|| child.task_id.clone());
383 let mut spans = vec![
384 Span::styled(
385 format!(" {} ", child_state_glyph(child.state)),
386 status_style(child.state),
387 ),
388 Span::styled(name, Style::default().fg(palette::TEXT_SECONDARY)),
389 Span::styled(format!(" · {}", child.state), status_style(child.state)),
390 ];
391 let mut meta = Vec::new();
392 if let Some(role) = child.role.as_deref() {
393 meta.push(role.to_string());
394 }
395 if let Some(model) = child.model.as_deref() {
396 meta.push(model.to_string());
397 }
398 if let Some(phase) = child.phase.as_deref() {
399 meta.push(phase.to_string());
400 }
401 if !meta.is_empty() {
402 spans.push(Span::styled(
403 format!(" · {}", meta.join(" · ")),
404 Style::default().fg(palette::TEXT_DIM),
405 ));
406 }
407 Line::from(spans)
408 }
409
410 impl ModalView for WorkflowsManagerView {
411 fn kind(&self) -> ModalKind {
412 ModalKind::WorkflowsManager
413 }
414
415 fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
416 self
417 }
418
419 fn handle_key(&mut self, key: KeyEvent) -> ViewAction {
420 match key.code {
421 KeyCode::Esc => {
422 if self.detail_open {
423 self.detail_open = false;
424 self.detail_scroll = 0;
425 ViewAction::None
426 } else {
427 ViewAction::Close
428 }
429 }
430 KeyCode::Char('q') => ViewAction::Close,
431 KeyCode::Up | KeyCode::Char('k') => {
432 if self.detail_open {
433 self.detail_scroll = self.detail_scroll.saturating_sub(1);
434 } else {
435 self.move_row(-1);
436 }
437 ViewAction::None
438 }
439 KeyCode::Down | KeyCode::Char('j') => {
440 if self.detail_open {
441 // The render path clamps to the last scrollable line.
442 self.detail_scroll = self.detail_scroll.saturating_add(1);
443 } else {
444 self.move_row(1);
445 }
446 ViewAction::None
447 }
448 KeyCode::Enter | KeyCode::Char('l') | KeyCode::Right => {
449 if !self.runs.is_empty() {
450 self.detail_open = true;
451 }
452 ViewAction::None
453 }
454 KeyCode::Left | KeyCode::Tab => {
455 self.detail_open = false;
456 self.detail_scroll = 0;
457 ViewAction::None
458 }
459 KeyCode::Char('x') | KeyCode::Char('c') => {
460 self.cancel_selected();
461 ViewAction::None
462 }
463 KeyCode::Char('r') => {
464 self.refresh();
465 ViewAction::None
466 }
467 KeyCode::Char('g') => ViewAction::Close,
468 _ => ViewAction::None,
469 }
470 }
471
472 fn handle_mouse(&mut self, mouse: MouseEvent) -> ViewAction {
473 if self.detail_open {
474 return ViewAction::None;
475 }
476 // The wheel moves this list, not the transcript behind it.
477 match mouse.kind {
478 MouseEventKind::ScrollUp => {
479 self.move_row(-1);
480 return ViewAction::None;
481 }
482 MouseEventKind::ScrollDown => {
483 self.move_row(1);
484 return ViewAction::None;
485 }
486 _ => {}
487 }
488 if let MouseEventKind::Down(MouseButton::Left) = mouse.kind {
489 let body = self.list_body.get();
490 if body.width > 0
491 && mouse.row >= body.y
492 && mouse.row < body.y + body.height
493 && mouse.column >= body.x
494 && mouse.column < body.x + body.width
495 {
496 let offset = usize::from(mouse.row - body.y);
497 let scroll = self
498 .row
499 .saturating_sub(usize::from(body.height).saturating_sub(1));
500 let idx = scroll + offset;
501 if idx < self.runs.len() {
502 self.row = idx;
503 }
504 }
505 }
506 ViewAction::None
507 }
508
509 fn tick(&mut self) -> ViewAction {
510 let interval = if self.runs.iter().any(|detail| detail.line.active) {
511 Duration::from_millis(250)
512 } else {
513 Duration::from_secs(2)
514 };
515 if self.last_refresh_at.elapsed() >= interval {
516 self.refresh();
517 }
518 ViewAction::None
519 }
520
521 fn render(&self, area: Rect, buf: &mut Buffer) {
522 Clear.render(area, buf);
523 Block::default()
524 .style(Style::default().bg(palette::WHALE_BG))
525 .render(area, buf);
526
527 let hints = self.footer_hints();
528 let content = render_modal_footer(area, buf, &hints);
529
530 let chunks = Layout::default()
531 .direction(Direction::Vertical)
532 .constraints([Constraint::Length(4), Constraint::Min(1)])
533 .split(content);
534
535 Paragraph::new(self.header_lines())
536 .wrap(Wrap { trim: false })
537 .render(chunks[0], buf);
538
539 if self.detail_open {
540 self.render_detail(chunks[1], buf);
541 } else {
542 self.render_list(chunks[1], buf);
543 }
544 }
545 }
546
546 lines RUST