use ratatui::layout::Rect;
use crate::localization::MessageId;
use crate::tools::todo::{TodoItem, TodoStatus};
use crate::tui::app::{App, SidebarRowAction};
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum WorkSurfacePlacement {
#[default]
Top,
Left,
Right,
}
impl WorkSurfacePlacement {
#[must_use]
pub fn parse(value: &str) -> Self {
match value.trim().to_ascii_lowercase().as_str() {
"left" => Self::Left,
"right" => Self::Right,
_ => Self::Top,
}
}
#[must_use]
pub const fn as_setting(self) -> &'static str {
match self {
Self::Top => "top",
Self::Left => "left",
Self::Right => "right",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct WorkRowId(pub String);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum WorkTone {
Heading,
Live,
Attention,
Success,
Muted,
Worker,
}
#[derive(Debug, Clone)]
pub(super) struct WorkRow {
pub id: WorkRowId,
pub mark: &'static str,
pub label: String,
pub detail: String,
pub tone: WorkTone,
pub selectable: bool,
pub primary_action: Option<SidebarRowAction>,
pub stop_action: Option<SidebarRowAction>,
}
#[derive(Debug, Clone)]
pub(super) struct WorkHitbox {
pub id: WorkRowId,
pub row_y: u16,
pub open_zone_start_col: Option<u16>,
pub open_zone_end_col: Option<u16>,
pub stop_zone_start_col: Option<u16>,
pub stop_zone_end_col: Option<u16>,
}
#[derive(Debug, Clone)]
pub struct WorkSurfaceState {
pub placement: WorkSurfacePlacement,
pub(super) effective_placement: WorkSurfacePlacement,
pub focused: bool,
pub selected: Option<WorkRowId>,
pub opened: Option<WorkRowId>,
pub scroll_offset: usize,
pub last_area: Option<Rect>,
pub visible_rows: usize,
pub total_rows: usize,
pub(super) hovered: Option<WorkRowId>,
pub(super) stop_arm: Option<super::interaction::StopArm>,
pub(super) stopping: Option<WorkRowId>,
pub(super) hitboxes: Vec<WorkHitbox>,
pub(super) cached_todos: Vec<TodoItem>,
pub(super) latest_rows: Vec<WorkRow>,
}
impl Default for WorkSurfaceState {
fn default() -> Self {
Self::with_placement(WorkSurfacePlacement::Top)
}
}
impl WorkSurfaceState {
#[must_use]
pub fn with_placement(placement: WorkSurfacePlacement) -> Self {
Self {
placement,
effective_placement: placement,
focused: false,
selected: None,
opened: None,
scroll_offset: 0,
last_area: None,
visible_rows: 0,
total_rows: 0,
hovered: None,
stop_arm: None,
stopping: None,
hitboxes: Vec::new(),
cached_todos: Vec::new(),
latest_rows: Vec::new(),
}
}
pub(super) fn selected_index(&self, rows: &[WorkRow]) -> Option<usize> {
self.selected
.as_ref()
.and_then(|selected| rows.iter().position(|row| &row.id == selected))
}
pub(super) fn clamp_selection(&mut self, rows: &[WorkRow]) {
let selectable = rows.iter().filter(|row| row.selectable).collect::<Vec<_>>();
if selectable.is_empty() {
self.selected = None;
self.focused = false;
self.scroll_offset = 0;
return;
}
if !selectable
.iter()
.any(|row| Some(&row.id) == self.selected.as_ref())
{
self.selected = Some(selectable[0].id.clone());
}
let selected = self.selected_index(rows).unwrap_or_default();
if selected < self.scroll_offset {
self.scroll_offset = selected;
} else if self.visible_rows > 0
&& selected >= self.scroll_offset.saturating_add(self.visible_rows)
{
self.scroll_offset = selected.saturating_add(1).saturating_sub(self.visible_rows);
}
self.scroll_offset = self
.scroll_offset
.min(rows.len().saturating_sub(self.visible_rows.max(1)));
}
}
pub(super) fn project(app: &mut App) -> Vec<WorkRow> {
if let Ok(todos) = app.todos.try_lock() {
app.work_surface.cached_todos = todos.snapshot().items;
}
let live = super::live_projection::LiveWorkProjection::from_app(app);
let attention_hold = live
.rows
.iter()
.any(|row| row.state == super::live_projection::LiveWorkState::Waiting);
let todos = app.work_surface.cached_todos.clone();
let mut rows = Vec::new();
if live.counts.active > 0 || !live.rows.is_empty() {
rows.push(section(
"active",
&format!(
"Active {} · Tasks {} · Runs {} · Workers {}",
live.counts.active, live.counts.tasks, live.counts.runs, live.counts.workers
),
live.counts.active,
));
rows.extend(live.rows.iter().map(|row| live_row(row, attention_hold)));
}
if !todos.is_empty() {
let completed = todos
.iter()
.filter(|item| item.status == TodoStatus::Completed)
.count();
let label = app.tr(MessageId::SidebarTodoLabel).into_owned();
rows.push(section(
"todo",
&format!("{label} {completed}/{}", todos.len()),
todos.len(),
));
rows.extend(todos.into_iter().map(todo_row));
}
app.work_surface.latest_rows = rows.clone();
let stoppable: Vec<_> = rows
.iter()
.filter(|row| row.stop_action.is_some())
.map(|row| row.id.clone())
.collect();
super::interaction::clear_stale_stopping(app, &stoppable);
if let Some(opened) = app.work_surface.opened.as_ref()
&& !rows.iter().any(|row| &row.id == opened)
{
app.work_surface.opened = None;
}
rows
}
fn section(id: &str, label: &str, count: usize) -> WorkRow {
WorkRow {
id: WorkRowId(format!("section:{id}")),
mark: "▾",
label: if label.chars().any(char::is_numeric) {
label.to_string()
} else {
format!("{label} {count}")
},
detail: label.to_string(),
tone: WorkTone::Heading,
selectable: false,
primary_action: None,
stop_action: None,
}
}
fn live_row(row: &super::live_projection::LiveWorkRow, attention_hold: bool) -> WorkRow {
let namespace = if row.kind == super::live_projection::LiveWorkKind::Run {
"jobs"
} else {
"task"
};
let source_id = match row.kind {
super::live_projection::LiveWorkKind::Worker => row
.identity
.strip_prefix("worker:")
.unwrap_or(&row.identity)
.to_string(),
super::live_projection::LiveWorkKind::Run => row
.identity
.strip_prefix("shell:")
.unwrap_or(&row.detail)
.to_string(),
_ => row.detail.clone(),
};
let open = format!("/{namespace} show {source_id}");
let stoppable = row.state != super::live_projection::LiveWorkState::Settled
&& matches!(
row.kind,
super::live_projection::LiveWorkKind::Task | super::live_projection::LiveWorkKind::Run
);
let (mark, tone) = match row.state {
super::live_projection::LiveWorkState::Active if attention_hold => ("·", WorkTone::Muted),
_ => match row.state {
super::live_projection::LiveWorkState::Active => (
"›",
if row.kind == super::live_projection::LiveWorkKind::Worker {
WorkTone::Worker
} else {
WorkTone::Live
},
),
super::live_projection::LiveWorkState::Waiting => ("◆", WorkTone::Attention),
super::live_projection::LiveWorkState::Settled => match row.status.as_str() {
"completed" | "success" | "done" => ("✓", WorkTone::Success),
"failed" | "canceled" | "cancelled" | "interrupted" => ("✕", WorkTone::Attention),
_ => ("☐", WorkTone::Muted),
},
},
};
let (primary_action, stop_action) = match row.kind {
super::live_projection::LiveWorkKind::Worker => {
let agent_id = source_id
.strip_prefix("worker:")
.unwrap_or(&source_id)
.to_string();
(
Some(SidebarRowAction::OpenAgentDetail {
agent_id: agent_id.clone(),
}),
(row.state != super::live_projection::LiveWorkState::Settled)
.then_some(SidebarRowAction::CancelAgent { agent_id }),
)
}
_ => (
Some(SidebarRowAction::Command(open)),
stoppable
.then(|| SidebarRowAction::Command(format!("/{namespace} cancel {source_id}"))),
),
};
WorkRow {
id: WorkRowId(row.identity.clone()),
mark,
label: row.label.clone(),
detail: row.detail.clone(),
tone,
selectable: true,
primary_action,
stop_action,
}
}
fn todo_row(item: TodoItem) -> WorkRow {
let (mark, tone) = match item.status {
TodoStatus::Completed => ("✓", WorkTone::Success),
TodoStatus::InProgress => ("▸", WorkTone::Live),
TodoStatus::Pending => ("☐", WorkTone::Muted),
};
WorkRow {
id: WorkRowId(format!("todo:{}", item.id)),
mark,
label: item.content.clone(),
detail: format!("#{}", item.id),
tone,
selectable: true,
primary_action: Some(SidebarRowAction::InspectText {
label: item.content,
detail: format!("#{}", item.id),
}),
stop_action: None,
}
}