diff --git a/Cargo.lock b/Cargo.lock index c53beea6..6bc64b65 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3956,6 +3956,7 @@ dependencies = [ "serde_json", "session-store", "tempfile", + "ticket", "tokio", "toml", "unicode-width", diff --git a/crates/tui/Cargo.toml b/crates/tui/Cargo.toml index d34cce3d..dade66c8 100644 --- a/crates/tui/Cargo.toml +++ b/crates/tui/Cargo.toml @@ -19,6 +19,7 @@ secrets = { workspace = true } session-store = { workspace = true } pod-store = { workspace = true } pod-registry = { workspace = true } +ticket = { workspace = true } serde = { workspace = true, features = ["derive"] } pulldown-cmark = { version = "0.13.3", default-features = false } llm-worker.workspace = true diff --git a/crates/tui/src/lib.rs b/crates/tui/src/lib.rs index 51c1a75b..349545ac 100644 --- a/crates/tui/src/lib.rs +++ b/crates/tui/src/lib.rs @@ -15,6 +15,7 @@ mod task; mod tool; mod ui; mod view_mode; +mod workspace_panel; use std::io; use std::path::PathBuf; @@ -51,10 +52,8 @@ pub enum LaunchMode { /// `yoi --session `: skip the picker, go straight to the /// resume name dialog with `id` baked in. ResumeWithSession(SegmentId), - /// `yoi --multi`: open the multi-Pod dashboard. This is intentionally - /// separate from `-r`/`--resume`, which keeps its single-Pod picker - /// meaning. - Multi, + /// `yoi panel`: open the workspace panel from the current workspace. + Panel, } pub async fn launch(options: LaunchOptions) -> ExitCode { @@ -85,7 +84,7 @@ pub async fn launch(options: LaunchOptions) -> ExitCode { LaunchMode::ResumeWithSession(id) => { single_pod::run_spawn(Some(id), None, runtime_command).await } - LaunchMode::Multi => single_pod::run_multi(runtime_command).await, + LaunchMode::Panel => single_pod::run_panel(runtime_command).await, }; // Always restore the terminal first so any pending eprintln below diff --git a/crates/tui/src/multi_pod.rs b/crates/tui/src/multi_pod.rs index e27c09fd..d735a76a 100644 --- a/crates/tui/src/multi_pod.rs +++ b/crates/tui/src/multi_pod.rs @@ -22,6 +22,10 @@ use crate::pod_list::{ PodList, PodListEntry, PodVisibilitySource, StoredMetadataState, read_reachable_live_pod_infos, read_stored_pod_infos, }; +use crate::workspace_panel::{ + ActionPriority, NextUserAction, PanelRow, PanelRowKey, WorkspacePanelViewModel, + build_workspace_panel, +}; const MAX_ENTRIES: usize = 50; const CLOSED_VISIBLE_ROWS: usize = 3; @@ -43,7 +47,7 @@ impl std::fmt::Display for MultiPodError { Self::Store(e) => write!(f, "session store error: {e}"), Self::NoPods => write!( f, - "no pods found — start a fresh pod with `yoi` or restore one with `yoi -r`" + "no Tickets or Pods found — create a Ticket with `yoi ticket create` or restore a Pod with `yoi -r`" ), } } @@ -82,7 +86,7 @@ pub(crate) async fn run( terminal: &mut Terminal>, app: &mut MultiPodApp, ) -> Result { - if app.list.entries.is_empty() { + if app.panel.rows.is_empty() { return Err(MultiPodError::NoPods); } @@ -139,7 +143,7 @@ pub(crate) async fn run( } struct PendingReload { - handle: Option>>, + handle: Option>>, } impl PendingReload { @@ -147,14 +151,14 @@ impl PendingReload { if self.handle.is_some() { return false; } - self.handle = Some(tokio::spawn(async { load_pod_list(None).await })); + self.handle = Some(tokio::spawn(async { load_multi_pod_snapshot(None).await })); true } #[cfg(test)] fn start_with_handle( &mut self, - handle: tokio::task::JoinHandle>, + handle: tokio::task::JoinHandle>, ) -> bool { if self.handle.is_some() { handle.abort(); @@ -164,7 +168,7 @@ impl PendingReload { true } - async fn finish_if_ready(&mut self) -> Option> { + async fn finish_if_ready(&mut self) -> Option> { if !self.handle.as_ref()?.is_finished() { return None; } @@ -231,16 +235,21 @@ pub(crate) struct DirectSendRequest { pub(crate) struct MultiPodApp { pub(crate) list: PodList, + pub(crate) panel: WorkspacePanelViewModel, pub(crate) input: InputBuffer, + selected_row: Option, notice: Option, sending: bool, } impl MultiPodApp { async fn load(selected_name: Option) -> Result { + let snapshot = load_multi_pod_snapshot(selected_name).await?; let mut app = Self { - list: load_pod_list(selected_name).await?, + list: snapshot.list, + panel: snapshot.panel, input: InputBuffer::new(), + selected_row: None, notice: None, sending: false, }; @@ -249,19 +258,20 @@ impl MultiPodApp { } pub(crate) async fn reload_or_notice(&mut self) { - let result = load_pod_list(None).await; + let result = load_multi_pod_snapshot(None).await; self.apply_reload_result(result); } - fn apply_reload_result(&mut self, result: Result) { + fn apply_reload_result(&mut self, result: Result) { match result { - Ok(list) => self.apply_reloaded_list(list), + Ok(snapshot) => self.apply_reloaded_snapshot(snapshot), Err(error) => { self.notice = Some(format!("Refresh failed: {error}")); } } } + #[cfg(test)] fn apply_reloaded_list(&mut self, mut list: PodList) { list.selected_name = self .list @@ -269,20 +279,72 @@ impl MultiPodApp { .clone() .filter(|name| list.entries.iter().any(|entry| entry.name == *name)) .or_else(|| list.entries.first().map(|entry| entry.name.clone())); - self.list = list; + let panel = build_workspace_panel(¤t_workspace_root(), &list); + self.apply_reloaded_snapshot(MultiPodSnapshot { list, panel }); + } + + fn apply_reloaded_snapshot(&mut self, mut snapshot: MultiPodSnapshot) { + let previous_selected_pod = self.list.selected_name.clone(); + snapshot.list.selected_name = previous_selected_pod + .filter(|name| { + snapshot + .list + .entries + .iter() + .any(|entry| entry.name == *name) + }) + .or_else(|| { + snapshot + .list + .entries + .first() + .map(|entry| entry.name.clone()) + }); + let previous_row = self.selected_row.clone(); + self.list = snapshot.list; + self.panel = snapshot.panel; + self.selected_row = previous_row.filter(|key| self.panel.row(key).is_some()); self.ensure_selection_visible(); } + fn selected_panel_row(&self) -> Option<&PanelRow> { + self.selected_row + .as_ref() + .and_then(|key| self.panel.row(key)) + } + + fn selected_pod_entry(&self) -> Option<&PodListEntry> { + match self.selected_row.as_ref() { + Some(PanelRowKey::Pod(name)) => { + self.list.entries.iter().find(|entry| &entry.name == name) + } + _ => None, + } + } + #[cfg(test)] pub(crate) fn selected_send_eligibility(&self) -> SendEligibility { - match self.list.selected_entry() { + match self.selected_pod_entry() { Some(entry) if entry.actions.can_send_now => SendEligibility::SendNow, _ => SendEligibility::Disabled, } } pub(crate) fn selected_send_disabled_reason(&self) -> Option { - let entry = self.list.selected_entry()?; + if let Some(row) = self + .selected_panel_row() + .filter(|row| row.is_ticket_action()) + { + return Some( + row.disabled_reason + .clone() + .or_else(|| row.key_hint.clone()) + .unwrap_or_else(|| { + "Ticket actions are display-only in this first panel slice.".to_string() + }), + ); + } + let entry = self.selected_pod_entry()?; if entry.actions.can_send_now { return None; } @@ -290,63 +352,93 @@ impl MultiPodApp { } pub(crate) fn select_next(&mut self) { - let visible = visible_entry_indices(&self.list); + let visible = visible_panel_keys(&self.panel, &self.list); if visible.is_empty() { + self.selected_row = None; self.list.selected_name = None; return; } - let selected = self.list.selected_index(); - let Some(selected_pos) = visible.iter().position(|index| *index == selected) else { - self.list.select_index(visible[0]); - return; - }; + let selected_pos = self + .selected_row + .as_ref() + .and_then(|key| visible.iter().position(|visible_key| visible_key == key)) + .unwrap_or(0); let next_pos = (selected_pos + 1).min(visible.len() - 1); - self.list.select_index(visible[next_pos]); + self.select_panel_key(visible[next_pos].clone()); } pub(crate) fn select_prev(&mut self) { - let visible = visible_entry_indices(&self.list); + let visible = visible_panel_keys(&self.panel, &self.list); if visible.is_empty() { + self.selected_row = None; self.list.selected_name = None; return; } - let selected = self.list.selected_index(); - let Some(selected_pos) = visible.iter().position(|index| *index == selected) else { - self.list.select_index(visible[0]); - return; - }; - let prev_pos = selected_pos.saturating_sub(1); - self.list.select_index(visible[prev_pos]); + let selected_pos = self + .selected_row + .as_ref() + .and_then(|key| visible.iter().position(|visible_key| visible_key == key)) + .unwrap_or(0); + self.select_panel_key(visible[selected_pos.saturating_sub(1)].clone()); } fn ensure_selection_visible(&mut self) { - let visible = visible_entry_indices(&self.list); + let visible = visible_panel_keys(&self.panel, &self.list); if visible.is_empty() { + self.selected_row = None; self.list.selected_name = None; return; } - let selected = self.list.selected_index(); - if !visible.contains(&selected) { - self.list.select_index(visible[0]); + let selected_visible = self + .selected_row + .as_ref() + .is_some_and(|key| visible.iter().any(|visible_key| visible_key == key)); + if !selected_visible { + let has_action_rows = self.panel.rows.iter().any(|row| row.is_ticket_action()); + if !has_action_rows { + if let Some(selected_name) = self.list.selected_name.as_ref() { + let key = PanelRowKey::Pod(selected_name.clone()); + if visible.iter().any(|visible_key| visible_key == &key) { + self.select_panel_key(key); + return; + } + } + } + self.select_panel_key(visible[0].clone()); + } else if let Some(PanelRowKey::Pod(name)) = self.selected_row.as_ref() { + self.list.selected_name = Some(name.clone()); } } + fn select_panel_key(&mut self, key: PanelRowKey) { + if let PanelRowKey::Pod(name) = &key { + self.list.selected_name = Some(name.clone()); + } + self.selected_row = Some(key); + } + pub(crate) fn prepare_open(&mut self) -> Option { - let entry = match self.list.selected_entry() { - Some(entry) => entry, - None => { - self.notice = Some("No Pod is selected.".to_string()); + let (pod_name, socket_override) = { + let entry = match self.selected_pod_entry() { + Some(entry) => entry, + None => { + self.notice = Some(selected_ticket_notice(self.selected_panel_row())); + return None; + } + }; + if !entry.actions.can_open { + self.notice = Some("Selected Pod cannot be opened from this view.".to_string()); return None; } + ( + entry.name.clone(), + entry.attach_socket_path().map(PathBuf::from), + ) }; - if !entry.actions.can_open { - self.notice = Some("Selected Pod cannot be opened from this view.".to_string()); - return None; - } - self.notice = Some(format!("Opening {}…", entry.name)); + self.notice = Some(format!("Opening {pod_name}…")); Some(OpenPodRequest { - pod_name: entry.name.clone(), - socket_override: entry.attach_socket_path().map(PathBuf::from), + pod_name, + socket_override, }) } @@ -370,20 +462,23 @@ impl MultiPodApp { } pub(crate) fn prepare_send(&mut self) -> Option { - let entry = match self.list.selected_entry() { - Some(entry) => entry, - None => { - self.notice = Some("No Pod is selected.".to_string()); + let (target_name, socket_path) = { + let entry = match self.selected_pod_entry() { + Some(entry) => entry, + None => { + self.notice = Some(selected_ticket_notice(self.selected_panel_row())); + return None; + } + }; + if !entry.actions.can_send_now { + self.notice = Some(send_disabled_reason(entry)); return None; } - }; - if !entry.actions.can_send_now { - self.notice = Some(send_disabled_reason(entry)); - return None; - } - let Some(socket_path) = entry.attach_socket_path().map(PathBuf::from) else { - self.notice = Some("Selected Pod has no reachable socket.".to_string()); - return None; + let Some(socket_path) = entry.attach_socket_path().map(PathBuf::from) else { + self.notice = Some("Selected Pod has no reachable socket.".to_string()); + return None; + }; + (entry.name.clone(), socket_path) }; let segments = self.input.submit_segments(); if segments_are_blank(&segments) { @@ -391,7 +486,7 @@ impl MultiPodApp { return None; } self.sending = true; - self.notice = Some(format!("Sending to {}…", entry.name)); + self.notice = Some(format!("Sending to {target_name}…")); Some(DirectSendRequest { socket_path, segments, @@ -403,8 +498,7 @@ impl MultiPodApp { match result { Ok(()) => { let target = self - .list - .selected_entry() + .selected_pod_entry() .map(|entry| entry.name.clone()) .unwrap_or_else(|| "selected Pod".to_string()); self.input.clear(); @@ -491,6 +585,24 @@ enum MultiPodAction { Send(DirectSendRequest), } +#[derive(Debug, Clone)] +struct MultiPodSnapshot { + list: PodList, + panel: WorkspacePanelViewModel, +} + +async fn load_multi_pod_snapshot( + selected_name: Option, +) -> Result { + let list = load_pod_list(selected_name).await?; + let panel = build_workspace_panel(¤t_workspace_root(), &list); + Ok(MultiPodSnapshot { list, panel }) +} + +fn current_workspace_root() -> PathBuf { + std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")) +} + async fn load_pod_list(selected_name: Option) -> Result { let store_dir = default_store_dir()?; let store = FsStore::new(&store_dir)?; @@ -629,6 +741,19 @@ fn send_disabled_reason(entry: &PodListEntry) -> String { .unwrap_or_else(|| "Selected Pod is not send-eligible.".to_string()) } +fn selected_ticket_notice(row: Option<&PanelRow>) -> String { + match row { + Some(row) if row.is_ticket_action() => { + let action = row.next_action.map(NextUserAction::label).unwrap_or("View"); + format!( + "{action} for Ticket '{}' is display-only in this slice; use Ticket commands/workflows after re-checking state.", + row.title + ) + } + _ => "No Pod is selected.".to_string(), + } +} + fn row_status_label(entry: &PodListEntry) -> (&'static str, Style) { if let Some(live) = entry.live.as_ref() { if !live.reachable { @@ -737,6 +862,22 @@ fn visible_entry_indices(list: &PodList) -> Vec { .collect() } +fn visible_panel_keys(panel: &WorkspacePanelViewModel, list: &PodList) -> Vec { + let mut keys = panel + .rows + .iter() + .filter(|row| row.is_ticket_action()) + .map(|row| row.key.clone()) + .collect::>(); + keys.extend( + visible_entry_indices(list) + .into_iter() + .filter_map(|index| list.entries.get(index)) + .map(|entry| PanelRowKey::Pod(entry.name.clone())), + ); + keys +} + fn visible_section_indices(section: &MultiPodSection) -> Vec { section .entries @@ -838,11 +979,11 @@ fn draw_title(frame: &mut Frame<'_>, area: Rect) { frame.render_widget( Paragraph::new(Line::from(vec![ Span::styled( - "multi-Pod dashboard", + "workspace dashboard", Style::default().add_modifier(Modifier::BOLD), ), Span::styled( - " Enter send to idle live Pod · o open/attach · r refresh", + " Ticket actions are display-only · Enter sends to selected idle Pod · o open/attach · r refresh", Style::default().fg(Color::DarkGray), ), ])), @@ -854,40 +995,135 @@ fn draw_list(frame: &mut Frame<'_>, app: &MultiPodApp, area: Rect) { if area.width == 0 || area.height == 0 { return; } - let lines = list_lines(&app.list, area.width, area.height); + let lines = list_lines(app, area.width, area.height); Paragraph::new(lines).render(area, frame.buffer_mut()); } -fn list_lines(list: &PodList, width: u16, height: u16) -> Vec> { - let sections = sectioned_entries(list); - let selected = list.selected_index(); +fn list_lines(app: &MultiPodApp, width: u16, height: u16) -> Vec> { + let sections = sectioned_entries(&app.list); + let selected = app.selected_row.as_ref(); + let action_lines = panel_action_lines(&app.panel, selected, width); let live_lines = sections .iter() .filter(|section| section.kind != MultiPodSectionKind::Closed) - .flat_map(|section| section_lines(list, section, selected, width)) + .flat_map(|section| section_lines(&app.list, section, selected, width)) .collect::>(); let closed_lines = sections .iter() .find(|section| section.kind == MultiPodSectionKind::Closed) - .map(|section| section_lines(list, section, selected, width)) + .map(|section| section_lines(&app.list, section, selected, width)) .unwrap_or_default(); let available = height as usize; - let closed_len = closed_lines.len().min(available); - let live_len = live_lines.len().min(available.saturating_sub(closed_len)); - let spacer_len = available.saturating_sub(live_len + closed_len); + let action_len = action_lines.len().min(available); + let remaining_after_actions = available.saturating_sub(action_len); + let closed_len = closed_lines.len().min(remaining_after_actions); + let live_len = live_lines + .len() + .min(remaining_after_actions.saturating_sub(closed_len)); + let spacer_len = available.saturating_sub(action_len + live_len + closed_len); let mut lines = Vec::with_capacity(available); + lines.extend(action_lines.into_iter().take(action_len)); lines.extend(live_lines.into_iter().take(live_len)); lines.extend(std::iter::repeat_with(|| Line::from(Span::raw(""))).take(spacer_len)); lines.extend(closed_lines.into_iter().take(closed_len)); lines } +fn panel_action_lines( + panel: &WorkspacePanelViewModel, + selected: Option<&PanelRowKey>, + width: u16, +) -> Vec> { + let rows = panel + .rows + .iter() + .filter(|row| row.is_ticket_action()) + .collect::>(); + if rows.is_empty() { + return Vec::new(); + } + let mut lines = Vec::with_capacity(rows.len() + 1); + lines.push(panel_action_header_line(rows.len(), width)); + for row in rows { + lines.push(panel_row_line(row, selected == Some(&row.key), width)); + } + lines +} + +fn panel_action_header_line(total: usize, width: u16) -> Line<'static> { + let detail = if total == 1 { + " 1 row".to_string() + } else { + format!(" {total} rows") + }; + let text = truncate_with_ellipsis(&format!("--actions{detail}---"), width as usize); + Line::from(Span::styled( + text, + Style::default() + .fg(Color::DarkGray) + .add_modifier(Modifier::BOLD), + )) +} + +fn panel_row_line(row: &PanelRow, selected: bool, width: u16) -> Line<'static> { + let marker = if selected { "▶ " } else { " " }; + let action = row.next_action.map(NextUserAction::label).unwrap_or("View"); + let status_style = panel_priority_style(row.priority); + let mut text = format!( + "{marker}{} [{}] {action}: {}", + row.title, + row.priority.label(), + row.status + ); + if let Some(subtitle) = row.subtitle.as_deref() { + text.push_str(" "); + text.push_str(subtitle); + } + let truncated = truncate_with_ellipsis(&text, width as usize); + let prefix = format!("{marker}{} ", row.title); + let status_prefix = format!("{prefix}[{}]", row.priority.label()); + let mut spans = Vec::new(); + spans.push(Span::styled( + prefix.clone(), + if selected { + Style::default() + .fg(Color::Magenta) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(Color::Magenta) + }, + )); + spans.push(Span::styled( + format!("[{}]", row.priority.label()), + status_style, + )); + let rest = truncated.strip_prefix(&status_prefix).unwrap_or(""); + spans.push(Span::styled( + rest.to_string(), + Style::default().fg(Color::DarkGray), + )); + Line::from(spans) +} + +fn panel_priority_style(priority: ActionPriority) -> Style { + match priority { + ActionPriority::UserReply => Style::default().fg(Color::Red).add_modifier(Modifier::BOLD), + ActionPriority::ReadyForGo => Style::default().fg(Color::Green), + ActionPriority::Decision => Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + ActionPriority::Blocked => Style::default().fg(Color::Red), + ActionPriority::ActiveWork => Style::default().fg(Color::Cyan), + ActionPriority::Background => Style::default().fg(Color::DarkGray), + } +} + fn section_lines( list: &PodList, section: &MultiPodSection, - selected: usize, + selected: Option<&PanelRowKey>, width: u16, ) -> Vec> { let visible = visible_section_indices(section); @@ -904,7 +1140,8 @@ fn section_lines( )); for index in visible { if let Some(entry) = list.entries.get(index) { - lines.push(row_line(entry, index == selected, width)); + let selected = selected == Some(&PanelRowKey::Pod(entry.name.clone())); + lines.push(row_line(entry, selected, width)); } } lines @@ -959,37 +1196,64 @@ fn draw_separator(frame: &mut Frame<'_>, area: Rect) { } fn draw_target_status(frame: &mut Frame<'_>, app: &MultiPodApp, area: Rect) { - let line = match app.list.selected_entry() { - Some(entry) => { - let (status, status_style) = row_status_label(entry); - let send_text = if entry.actions.can_send_now { - "send enabled" - } else { - "send disabled" - }; - Line::from(vec![ - Span::styled("target ", Style::default().fg(Color::DarkGray)), - Span::styled( - entry.name.clone(), - Style::default().add_modifier(Modifier::BOLD), - ), - Span::raw(" "), - Span::styled(format!("[{status}]"), status_style), - Span::raw(" "), - Span::styled( - send_text, - if entry.actions.can_send_now { - Style::default().fg(Color::Green) - } else { - Style::default().fg(Color::DarkGray) - }, - ), - ]) + let line = if let Some(row) = app + .selected_panel_row() + .filter(|row| row.is_ticket_action()) + { + Line::from(vec![ + Span::styled("action ", Style::default().fg(Color::DarkGray)), + Span::styled( + row.title.clone(), + Style::default().add_modifier(Modifier::BOLD), + ), + Span::raw(" "), + Span::styled( + format!("[{}]", row.priority.label()), + panel_priority_style(row.priority), + ), + Span::raw(" "), + Span::styled( + row.next_action.map(NextUserAction::label).unwrap_or("View"), + Style::default().fg(Color::Magenta), + ), + Span::styled( + " display-only; re-check Ticket before dispatch", + Style::default().fg(Color::DarkGray), + ), + ]) + } else { + match app.selected_pod_entry() { + Some(entry) => { + let (status, status_style) = row_status_label(entry); + let send_text = if entry.actions.can_send_now { + "send enabled" + } else { + "send disabled" + }; + Line::from(vec![ + Span::styled("target ", Style::default().fg(Color::DarkGray)), + Span::styled( + entry.name.clone(), + Style::default().add_modifier(Modifier::BOLD), + ), + Span::raw(" "), + Span::styled(format!("[{status}]"), status_style), + Span::raw(" "), + Span::styled( + send_text, + if entry.actions.can_send_now { + Style::default().fg(Color::Green) + } else { + Style::default().fg(Color::DarkGray) + }, + ), + ]) + } + None => Line::from(Span::styled( + "target — none", + Style::default().fg(Color::DarkGray), + )), } - None => Line::from(Span::styled( - "target — none", - Style::default().fg(Color::DarkGray), - )), }; frame.render_widget(Paragraph::new(line), area); } @@ -1068,6 +1332,66 @@ fn truncate_with_ellipsis(s: &str, max_width: usize) -> String { mod tests { use super::*; use crate::pod_list::{LivePodInfo, PodEntrySummary, StoredMetadataState, StoredPodInfo}; + use std::fs; + use tempfile::TempDir; + use ticket::{LocalTicketBackend, NewTicket, TicketBackend}; + + #[test] + fn multi_ticket_action_rows_precede_pods_and_pod_actions_still_work() { + let temp = TempDir::new().unwrap(); + fs::create_dir_all(temp.path().join(".yoi")).unwrap(); + fs::write( + temp.path().join(".yoi/ticket.config.toml"), + "[backend]\nprovider = \"builtin:yoi_local\"\nroot = \".yoi/tickets\"\n", + ) + .unwrap(); + let backend = LocalTicketBackend::new(temp.path().join(".yoi/tickets")); + let mut ticket = NewTicket::new("Needs Human Reply"); + ticket.slug = Some("needs-human-reply".to_string()); + ticket.action_required = Some("answer intake question".to_string()); + ticket.labels = vec!["intake".to_string()]; + backend.create(ticket).unwrap(); + let list = PodList::from_sources( + PodVisibilitySource::ResumePicker, + vec![], + vec![live_info("idle", PodStatus::Idle)], + None, + 10, + ); + let panel = build_workspace_panel(temp.path(), &list); + let mut app = app_with_panel(list, panel); + + assert_eq!(app.selected_panel_row().unwrap().title, "Needs Human Reply"); + assert_eq!(app.selected_send_eligibility(), SendEligibility::Disabled); + let lines = list_lines(&app, 100, 6) + .into_iter() + .map(|line| plain_line(&line)) + .collect::>(); + let ticket_line = lines + .iter() + .position(|line| line.contains("Needs Human Reply")) + .unwrap(); + let pod_line = lines.iter().position(|line| line.contains("idle")).unwrap(); + assert!(ticket_line < pod_line); + + app.select_next(); + assert_eq!(app.list.selected_entry().unwrap().name, "idle"); + assert_eq!(app.selected_send_eligibility(), SendEligibility::SendNow); + let open = app.prepare_open().unwrap(); + assert_eq!(open.pod_name, "idle"); + assert_eq!(open.socket_override, Some(PathBuf::from("/tmp/idle.sock"))); + + app.input.insert_str("send after ticket row"); + let request = match app.handle_key(key(KeyCode::Enter)) { + MultiPodAction::Send(request) => request, + _ => panic!("Pod row should preserve direct send behavior"), + }; + assert_eq!(request.socket_path, PathBuf::from("/tmp/idle.sock")); + assert_eq!( + Segment::flatten_to_text(&request.segments), + "send after ticket row" + ); + } #[test] fn multi_selection_changes_preserve_composer_contents() { @@ -1168,13 +1492,17 @@ mod tests { Err(MultiPodError::Io(io::Error::other("boom"))) }))); assert!(!pending.start_with_handle(tokio::spawn(async { - Ok(PodList::from_sources( + let list = PodList::from_sources( PodVisibilitySource::ResumePicker, vec![], vec![live_info("beta", PodStatus::Idle)], None, 10, - )) + ); + Ok(MultiPodSnapshot { + panel: WorkspacePanelViewModel::empty(Path::new("test")), + list, + }) }))); assert!(pending.finish_if_ready().await.is_none()); @@ -1236,6 +1564,8 @@ mod tests { Some("running".to_string()), 10, ); + app.selected_row = None; + app.ensure_selection_visible(); assert_eq!(app.selected_send_eligibility(), SendEligibility::Disabled); assert!( @@ -1290,14 +1620,15 @@ mod tests { let list = closed_list(5, Some("closed-0")); let visible = visible_entry_indices(&list) .into_iter() - .map(|index| list.entries[index].name.as_str()) + .map(|index| list.entries[index].name.clone()) .collect::>(); let sections = sectioned_entries(&list); let closed = sections .iter() .find(|section| section.kind == MultiPodSectionKind::Closed) .unwrap(); - let lines = list_lines(&list, 80, 8) + let app = app_with_list(list); + let lines = list_lines(&app, 80, 8) .into_iter() .map(|line| plain_line(&line)) .collect::>(); @@ -1356,7 +1687,8 @@ mod tests { Some("idle".to_string()), 20, ); - let lines = list_lines(&list, 80, 12) + let app = app_with_list(list); + let lines = list_lines(&app, 80, 12) .into_iter() .map(|line| plain_line(&line)) .collect::>(); @@ -1532,9 +1864,15 @@ mod tests { } fn app_with_list(list: PodList) -> MultiPodApp { + app_with_panel(list, WorkspacePanelViewModel::empty(Path::new("test"))) + } + + fn app_with_panel(list: PodList, panel: WorkspacePanelViewModel) -> MultiPodApp { let mut app = MultiPodApp { list, + panel, input: InputBuffer::new(), + selected_row: None, notice: None, sending: false, }; diff --git a/crates/tui/src/single_pod.rs b/crates/tui/src/single_pod.rs index 5f3a4dc6..e14574fb 100644 --- a/crates/tui/src/single_pod.rs +++ b/crates/tui/src/single_pod.rs @@ -184,7 +184,7 @@ pub(crate) async fn run_resume( run_pod_name(pod_name, socket_override, runtime_command).await } -pub(crate) async fn run_multi( +pub(crate) async fn run_panel( runtime_command: PodRuntimeCommand, ) -> Result<(), Box> { let mut app = multi_pod::load_app().await?; diff --git a/crates/tui/src/workspace_panel.rs b/crates/tui/src/workspace_panel.rs new file mode 100644 index 00000000..b407e0e0 --- /dev/null +++ b/crates/tui/src/workspace_panel.rs @@ -0,0 +1,866 @@ +use std::path::{Path, PathBuf}; + +use protocol::PodStatus; +use ticket::config::{TICKET_CONFIG_RELATIVE_PATH, TicketConfig}; +use ticket::{ + ExtensibleTicketStatus, LocalTicketBackend, TicketBackend, TicketEvent, TicketEventKind, + TicketFilter, TicketIdOrSlug, TicketReviewResult, TicketStatus, TicketSummary, +}; + +use crate::pod_list::{PodList, PodListEntry, StoredMetadataState}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct WorkspacePanelViewModel { + pub(crate) header: WorkspacePanelHeader, + pub(crate) rows: Vec, +} + +impl WorkspacePanelViewModel { + pub(crate) fn empty(workspace_root: &Path) -> Self { + Self { + header: WorkspacePanelHeader { + workspace_label: workspace_root + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("workspace") + .to_string(), + ticket_root: workspace_root + .join(ticket::config::DEFAULT_TICKET_BACKEND_RELATIVE_PATH), + diagnostics: Vec::new(), + }, + rows: Vec::new(), + } + } + + pub(crate) fn row(&self, key: &PanelRowKey) -> Option<&PanelRow> { + self.rows.iter().find(|row| &row.key == key) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct WorkspacePanelHeader { + pub(crate) workspace_label: String, + pub(crate) ticket_root: PathBuf, + pub(crate) diagnostics: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub(crate) enum PanelRowKey { + Ticket(String), + Pod(String), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum PanelRowKind { + Intake, + Ticket, + Review, + Blocked, + ActiveWork, + Pod, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub(crate) enum ActionPriority { + UserReply, + ReadyForGo, + Decision, + Blocked, + ActiveWork, + Background, +} + +impl ActionPriority { + pub(crate) fn label(self) -> &'static str { + match self { + Self::UserReply => "user action", + Self::ReadyForGo => "ready", + Self::Decision => "decision", + Self::Blocked => "blocked", + Self::ActiveWork => "active", + Self::Background => "background", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum NextUserAction { + Clarify, + ApproveIntake, + Go, + Review, + Close, + Defer, + Edit, + Wait, + OpenPod, + SendToPod, +} + +impl NextUserAction { + pub(crate) fn label(self) -> &'static str { + match self { + Self::Clarify => "Clarify", + Self::ApproveIntake => "Approve", + Self::Go => "Go", + Self::Review => "Review", + Self::Close => "Close", + Self::Defer => "Defer", + Self::Edit => "Edit", + Self::Wait => "Wait", + Self::OpenPod => "Open", + Self::SendToPod => "Send", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum TicketPanelPhase { + Intake, + RequirementsSync, + Preflight, + Spike, + Implementing, + Reviewing, + CloseReady, + Blocked, + Open, + Pending, +} + +impl TicketPanelPhase { + pub(crate) fn label(self) -> &'static str { + match self { + Self::Intake => "intake", + Self::RequirementsSync => "requirements", + Self::Preflight => "preflight", + Self::Spike => "spike", + Self::Implementing => "implementing", + Self::Reviewing => "review", + Self::CloseReady => "close-ready", + Self::Blocked => "blocked", + Self::Open => "open", + Self::Pending => "pending", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct TicketPanelEntry { + pub(crate) id: String, + pub(crate) slug: String, + pub(crate) title: String, + pub(crate) status: String, + pub(crate) kind: String, + pub(crate) priority: String, + pub(crate) labels: Vec, + pub(crate) phase: TicketPanelPhase, + pub(crate) next_action: Option, + pub(crate) updated_at: Option, + pub(crate) latest_event_kind: Option, + pub(crate) latest_event_excerpt: Option, + pub(crate) blocked_reason: Option, + pub(crate) related_pods: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct PanelRow { + pub(crate) key: PanelRowKey, + pub(crate) kind: PanelRowKind, + pub(crate) title: String, + pub(crate) subtitle: Option, + pub(crate) status: String, + pub(crate) priority: ActionPriority, + pub(crate) next_action: Option, + pub(crate) ticket: Option, + pub(crate) related_pods: Vec, + pub(crate) disabled_reason: Option, + pub(crate) key_hint: Option, +} + +impl PanelRow { + pub(crate) fn is_ticket_action(&self) -> bool { + !matches!(self.kind, PanelRowKind::Pod) + && (self.priority != ActionPriority::Background || self.next_action.is_some()) + } +} + +pub(crate) fn build_workspace_panel( + workspace_root: &Path, + pods: &PodList, +) -> WorkspacePanelViewModel { + let mut model = WorkspacePanelViewModel::empty(workspace_root); + let ticket_config_path = workspace_root.join(TICKET_CONFIG_RELATIVE_PATH); + if ticket_config_path.is_file() { + if let Ok(config) = TicketConfig::load_workspace(workspace_root) { + model.header.ticket_root = config.backend_root().to_path_buf(); + let backend = LocalTicketBackend::new(config.backend_root().to_path_buf()); + if let Ok(rows) = build_ticket_rows(&backend, pods) { + model.rows.extend(rows); + } + } + } + + model.rows.extend(pod_rows(pods)); + model.rows.sort_by(|a, b| { + a.priority + .cmp(&b.priority) + .then_with(|| row_updated_at(b).cmp(row_updated_at(a))) + .then_with(|| a.title.cmp(&b.title)) + }); + model +} + +fn build_ticket_rows( + backend: &LocalTicketBackend, + pods: &PodList, +) -> ticket::Result> { + let mut rows = Vec::new(); + for summary in backend.list(TicketFilter::all())? { + if summary.status.as_local() == Some(TicketStatus::Closed) { + continue; + } + let ticket = backend.show(TicketIdOrSlug::Query(summary.slug.clone()))?; + rows.push(ticket_row(summary, &ticket.events, pods)); + } + Ok(rows) +} + +fn ticket_row(summary: TicketSummary, events: &[TicketEvent], pods: &PodList) -> PanelRow { + let related_pods = related_pods_for_ticket(&summary, pods); + let derived = derive_ticket_state(&summary, events); + let latest_event = events.last(); + let entry = TicketPanelEntry { + id: summary.id.clone(), + slug: summary.slug.clone(), + title: summary.title.clone(), + status: summary.status.as_str().to_string(), + kind: summary.kind.clone(), + priority: summary.priority.clone(), + labels: summary.labels.clone(), + phase: derived.phase, + next_action: derived.action, + updated_at: summary.updated_at.clone(), + latest_event_kind: latest_event.map(|event| event.kind.as_str().to_string()), + latest_event_excerpt: latest_event.and_then(|event| excerpt(event.body.as_str(), 72)), + blocked_reason: derived.blocked_reason.clone(), + related_pods: related_pods.clone(), + }; + let subtitle = ticket_subtitle(&entry); + PanelRow { + key: PanelRowKey::Ticket(summary.id), + kind: derived.kind, + title: summary.title, + subtitle, + status: derived.status, + priority: derived.priority, + next_action: derived.action, + ticket: Some(entry), + related_pods, + disabled_reason: derived.disabled_reason, + key_hint: derived.key_hint, + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct DerivedTicketState { + kind: PanelRowKind, + phase: TicketPanelPhase, + status: String, + priority: ActionPriority, + action: Option, + disabled_reason: Option, + key_hint: Option, + blocked_reason: Option, +} + +fn derive_ticket_state(summary: &TicketSummary, events: &[TicketEvent]) -> DerivedTicketState { + let action_required = summary.action_required.as_deref().map(str::trim); + let action_required_lc = action_required.map(lowercase); + let intake = is_intake_ticket(summary); + let spike = is_spike_ticket(summary); + + if let Some(reason) = action_required_lc.as_deref() { + if reason.contains("block") || reason.contains("blocked") { + return DerivedTicketState { + kind: PanelRowKind::Blocked, + phase: TicketPanelPhase::Blocked, + status: "blocked".to_string(), + priority: ActionPriority::Blocked, + action: Some(NextUserAction::Edit), + disabled_reason: Some( + "Requires an explicit human/project decision before work continues." + .to_string(), + ), + key_hint: Some("Edit/decide in Ticket; no automatic unblock".to_string()), + blocked_reason: action_required.map(ToOwned::to_owned), + }; + } + return DerivedTicketState { + kind: if intake { + PanelRowKind::Intake + } else { + PanelRowKind::Ticket + }, + phase: if intake { + TicketPanelPhase::Intake + } else { + TicketPanelPhase::RequirementsSync + }, + status: action_required.unwrap_or("action required").to_string(), + priority: ActionPriority::UserReply, + action: Some(if intake { + NextUserAction::ApproveIntake + } else { + NextUserAction::Clarify + }), + disabled_reason: None, + key_hint: Some( + "Human response is required; dispatch must re-check Ticket state".to_string(), + ), + blocked_reason: None, + }; + } + + let latest_impl = latest_event_index(events, TicketEventKind::ImplementationReport); + let latest_review = latest_event_index(events, TicketEventKind::Review); + let latest_plan = latest_event_index(events, TicketEventKind::Plan); + let latest_review_result = latest_review.and_then(|index| events[index].status.as_deref()); + + if latest_review_result == Some(TicketReviewResult::Approve.as_str()) + && latest_review > latest_impl + { + return DerivedTicketState { + kind: PanelRowKind::Review, + phase: TicketPanelPhase::CloseReady, + status: "review approved".to_string(), + priority: ActionPriority::Decision, + action: Some(NextUserAction::Close), + disabled_reason: None, + key_hint: Some("Close affordance only; closing must write a resolution".to_string()), + blocked_reason: None, + }; + } + + if latest_impl.is_some() && latest_impl > latest_review { + return DerivedTicketState { + kind: PanelRowKind::Review, + phase: TicketPanelPhase::Reviewing, + status: "implementation reported".to_string(), + priority: ActionPriority::Decision, + action: Some(NextUserAction::Review), + disabled_reason: None, + key_hint: Some("Review affordance only; inspect evidence before approving".to_string()), + blocked_reason: None, + }; + } + + if latest_review_result == Some(TicketReviewResult::RequestChanges.as_str()) { + return DerivedTicketState { + kind: PanelRowKind::ActiveWork, + phase: TicketPanelPhase::Implementing, + status: "changes requested".to_string(), + priority: ActionPriority::ActiveWork, + action: Some(NextUserAction::Wait), + disabled_reason: Some("Waiting for implementation changes after review.".to_string()), + key_hint: None, + blocked_reason: None, + }; + } + + if summary.status.as_local() == Some(TicketStatus::Pending) { + return DerivedTicketState { + kind: PanelRowKind::Blocked, + phase: TicketPanelPhase::Pending, + status: "pending/deferred".to_string(), + priority: ActionPriority::Blocked, + action: Some(NextUserAction::Defer), + disabled_reason: Some( + "Pending Ticket is shown for visibility; no automation is implied.".to_string(), + ), + key_hint: None, + blocked_reason: None, + }; + } + + if intake { + return DerivedTicketState { + kind: PanelRowKind::Intake, + phase: TicketPanelPhase::Intake, + status: "intake draft".to_string(), + priority: ActionPriority::UserReply, + action: Some(NextUserAction::ApproveIntake), + disabled_reason: None, + key_hint: Some("Approve/edit intake before routing".to_string()), + blocked_reason: None, + }; + } + + if looks_ready_for_go(summary) { + return DerivedTicketState { + kind: PanelRowKind::Ticket, + phase: if summary.needs_preflight.unwrap_or(false) { + TicketPanelPhase::Preflight + } else { + TicketPanelPhase::Open + }, + status: "ready for Go".to_string(), + priority: ActionPriority::ReadyForGo, + action: Some(NextUserAction::Go), + disabled_reason: None, + key_hint: Some( + "Go is an authorization affordance; routing/preflight gates still apply" + .to_string(), + ), + blocked_reason: None, + }; + } + + if spike && latest_plan.is_some() { + return DerivedTicketState { + kind: PanelRowKind::ActiveWork, + phase: TicketPanelPhase::Spike, + status: "spike running".to_string(), + priority: ActionPriority::ActiveWork, + action: Some(NextUserAction::Wait), + disabled_reason: Some("Spike has a plan but no implementation report yet.".to_string()), + key_hint: None, + blocked_reason: None, + }; + } + + if spike { + return DerivedTicketState { + kind: PanelRowKind::Ticket, + phase: TicketPanelPhase::Spike, + status: "spike needed".to_string(), + priority: ActionPriority::Background, + action: None, + disabled_reason: Some( + "Spike candidate is shown as background until explicitly readied or planned." + .to_string(), + ), + key_hint: None, + blocked_reason: None, + }; + } + + if latest_plan.is_some() { + return DerivedTicketState { + kind: PanelRowKind::ActiveWork, + phase: TicketPanelPhase::Implementing, + status: "planned/active".to_string(), + priority: ActionPriority::ActiveWork, + action: Some(NextUserAction::Wait), + disabled_reason: Some( + "Ticket has a plan but no implementation report yet.".to_string(), + ), + key_hint: None, + blocked_reason: None, + }; + } + + DerivedTicketState { + kind: PanelRowKind::Ticket, + phase: TicketPanelPhase::Open, + status: "open backlog".to_string(), + priority: ActionPriority::Background, + action: None, + disabled_reason: Some( + "Open Ticket is not marked ready; keep it out of the action section for now." + .to_string(), + ), + key_hint: None, + blocked_reason: None, + } +} + +fn looks_ready_for_go(summary: &TicketSummary) -> bool { + summary + .readiness + .as_deref() + .map(lowercase) + .is_some_and(|value| value.contains("ready")) + || summary.needs_preflight.unwrap_or(false) + || summary + .labels + .iter() + .any(|label| lowercase(label).contains("ready")) +} + +fn is_intake_ticket(summary: &TicketSummary) -> bool { + summary.kind == "intake" + || summary.labels.iter().any(|label| label == "intake") + || lowercase(&summary.slug).contains("intake") + || lowercase(&summary.title).contains("intake") +} + +fn is_spike_ticket(summary: &TicketSummary) -> bool { + lowercase(&summary.kind).contains("spike") + || summary + .labels + .iter() + .any(|label| lowercase(label).contains("spike")) + || lowercase(&summary.slug).contains("spike") + || lowercase(&summary.title).contains("spike") +} + +fn latest_event_index(events: &[TicketEvent], kind: TicketEventKind) -> Option { + events.iter().rposition(|event| event.kind == kind) +} + +fn related_pods_for_ticket(summary: &TicketSummary, pods: &PodList) -> Vec { + let slug = lowercase(&summary.slug); + let id = lowercase(&summary.id); + pods.entries + .iter() + .filter_map(|pod| { + let name = lowercase(&pod.name); + if (!slug.is_empty() && name.contains(&slug)) || (!id.is_empty() && name.contains(&id)) + { + Some(pod.name.clone()) + } else { + None + } + }) + .take(5) + .collect() +} + +fn ticket_subtitle(entry: &TicketPanelEntry) -> Option { + let mut parts = vec![format!( + "{} · {} · {}", + entry.slug, + entry.phase.label(), + entry.priority + )]; + if !entry.related_pods.is_empty() { + parts.push(format!("pods: {}", entry.related_pods.join(", "))); + } + if let Some(excerpt) = entry.latest_event_excerpt.as_ref() { + parts.push(format!("latest: {excerpt}")); + } + Some(parts.join(" ")) +} + +fn pod_rows(pods: &PodList) -> Vec { + pods.entries.iter().map(pod_row).collect() +} + +fn pod_row(entry: &PodListEntry) -> PanelRow { + let status = pod_status_label(entry).to_string(); + let next_action = if entry.actions.can_send_now { + Some(NextUserAction::SendToPod) + } else if entry.actions.can_open { + Some(NextUserAction::OpenPod) + } else { + None + }; + let mut subtitle = entry.summary.preview.clone(); + if subtitle.is_none() + && entry + .stored + .as_ref() + .is_some_and(|stored| matches!(stored.metadata_state, StoredMetadataState::Corrupt(_))) + { + subtitle = Some("metadata corrupt".to_string()); + } + + PanelRow { + key: PanelRowKey::Pod(entry.name.clone()), + kind: PanelRowKind::Pod, + title: entry.name.clone(), + subtitle, + status, + priority: ActionPriority::Background, + next_action, + ticket: None, + related_pods: Vec::new(), + disabled_reason: entry.actions.disabled_reason.clone(), + key_hint: Some("Pod rows preserve existing open/direct-send behavior".to_string()), + } +} + +fn pod_status_label(entry: &PodListEntry) -> &'static str { + if let Some(live) = entry.live.as_ref() { + if !live.reachable { + return "unreachable"; + } + return match live.status { + Some(PodStatus::Idle) => "live idle", + Some(PodStatus::Running) => "live running", + Some(PodStatus::Paused) => "live paused", + None => "live", + }; + } + if entry + .stored + .as_ref() + .is_some_and(|stored| matches!(stored.metadata_state, StoredMetadataState::Corrupt(_))) + { + "corrupt" + } else { + "stopped/restorable" + } +} + +fn row_updated_at(row: &PanelRow) -> &str { + row.ticket + .as_ref() + .and_then(|ticket| ticket.updated_at.as_deref()) + .unwrap_or("") +} + +fn excerpt(markdown: &str, max_chars: usize) -> Option { + let collapsed = markdown + .lines() + .map(str::trim) + .filter(|line| !line.is_empty() && !line.starts_with('#')) + .collect::>() + .join(" "); + if collapsed.is_empty() { + None + } else if collapsed.chars().count() <= max_chars { + Some(collapsed) + } else { + let mut value = collapsed + .chars() + .take(max_chars.saturating_sub(1)) + .collect::(); + value.push('…'); + Some(value) + } +} + +fn lowercase(value: &str) -> String { + value.to_ascii_lowercase() +} + +#[allow(dead_code)] +fn _status_label(status: &ExtensibleTicketStatus) -> &str { + status.as_str() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::pod_list::{LivePodInfo, PodEntrySummary}; + use std::fs; + use std::path::{Path, PathBuf}; + use tempfile::TempDir; + use ticket::{MarkdownText, NewTicket, NewTicketEvent, TicketReview}; + + fn empty_pods() -> PodList { + PodList::from_sources( + crate::pod_list::PodVisibilitySource::ResumePicker, + vec![], + vec![], + None, + 10, + ) + } + + fn create_ticket( + backend: &LocalTicketBackend, + title: &str, + slug: &str, + configure: impl FnOnce(&mut NewTicket), + ) { + let mut input = NewTicket::new(title); + input.slug = Some(slug.to_string()); + configure(&mut input); + backend.create(input).unwrap(); + } + + fn write_ticket_config(workspace_root: &Path) { + let config_dir = workspace_root.join(".yoi"); + fs::create_dir_all(&config_dir).unwrap(); + fs::write( + config_dir.join("ticket.config.toml"), + "[backend]\nprovider = \"builtin:yoi_local\"\nroot = \".yoi/tickets\"\n", + ) + .unwrap(); + } + + fn live_pods(names: &[&str]) -> PodList { + PodList::from_sources( + crate::pod_list::PodVisibilitySource::ResumePicker, + vec![], + names + .iter() + .map(|name| LivePodInfo { + pod_name: (*name).to_string(), + socket_path: PathBuf::from(format!("/tmp/{name}.sock")), + status: Some(PodStatus::Idle), + reachable: true, + segment_id: None, + summary: PodEntrySummary::default(), + }) + .collect(), + None, + 10, + ) + } + + #[test] + fn workspace_panel_without_ticket_config_is_pod_only() { + let temp = TempDir::new().unwrap(); + let backend = LocalTicketBackend::new(temp.path().join(".yoi/tickets")); + create_ticket( + &backend, + "Hidden Without Config", + "hidden-without-config", + |input| { + input.action_required = Some("answer me".to_string()); + }, + ); + + let model = build_workspace_panel(temp.path(), &live_pods(&["idle"])); + + assert!(model.header.diagnostics.is_empty()); + assert_eq!(model.rows.len(), 1); + assert_eq!(model.rows[0].key, PanelRowKey::Pod("idle".to_string())); + assert!(model.rows[0].ticket.is_none()); + } + + #[test] + fn workspace_panel_prioritizes_human_actions_before_background_pods() { + let temp = TempDir::new().unwrap(); + write_ticket_config(temp.path()); + let backend = LocalTicketBackend::new(temp.path().join(".yoi/tickets")); + create_ticket(&backend, "Ready Ticket", "ready-ticket", |input| { + input.readiness = Some("implementation-ready".to_string()); + }); + create_ticket(&backend, "Needs User", "needs-user", |input| { + input.action_required = Some("answer clarification".to_string()); + input.labels = vec!["intake".to_string()]; + }); + + let model = build_workspace_panel(temp.path(), &empty_pods()); + let rows = model + .rows + .iter() + .map(|row| (row.title.as_str(), row.priority, row.next_action)) + .collect::>(); + + assert_eq!(rows[0].0, "Needs User"); + assert_eq!(rows[0].1, ActionPriority::UserReply); + assert_eq!(rows[0].2, Some(NextUserAction::ApproveIntake)); + assert_eq!(rows[1].0, "Ready Ticket"); + assert_eq!(rows[1].1, ActionPriority::ReadyForGo); + assert_eq!(rows[1].2, Some(NextUserAction::Go)); + } + + #[test] + fn workspace_panel_derives_spike_phase_without_marking_unready_spikes_ready_for_go() { + let temp = TempDir::new().unwrap(); + write_ticket_config(temp.path()); + let backend = LocalTicketBackend::new(temp.path().join(".yoi/tickets")); + create_ticket( + &backend, + "Investigate Spike", + "investigate-spike", + |input| { + input.labels = vec!["spike".to_string()]; + }, + ); + create_ticket(&backend, "Running Spike", "running-spike", |input| { + input.kind = "spike".to_string(); + }); + backend + .add_event( + TicketIdOrSlug::Query("running-spike".to_string()), + NewTicketEvent::new(TicketEventKind::Plan, "Run the spike."), + ) + .unwrap(); + + let model = build_workspace_panel(temp.path(), &empty_pods()); + let needed = model + .rows + .iter() + .find(|row| row.title == "Investigate Spike") + .unwrap(); + let running = model + .rows + .iter() + .find(|row| row.title == "Running Spike") + .unwrap(); + + assert_eq!( + needed.ticket.as_ref().unwrap().phase, + TicketPanelPhase::Spike + ); + assert_eq!(needed.priority, ActionPriority::Background); + assert_eq!(needed.next_action, None); + assert!(!needed.is_ticket_action()); + assert_eq!( + running.ticket.as_ref().unwrap().phase, + TicketPanelPhase::Spike + ); + assert_eq!(running.priority, ActionPriority::ActiveWork); + assert_eq!(running.next_action, Some(NextUserAction::Wait)); + } + + #[test] + fn workspace_panel_keeps_ordinary_open_backlog_out_of_action_section() { + let temp = TempDir::new().unwrap(); + write_ticket_config(temp.path()); + let backend = LocalTicketBackend::new(temp.path().join(".yoi/tickets")); + create_ticket(&backend, "Plain Backlog", "plain-backlog", |_| {}); + + let model = build_workspace_panel(temp.path(), &empty_pods()); + let row = model + .rows + .iter() + .find(|row| row.title == "Plain Backlog") + .unwrap(); + + assert_eq!(row.priority, ActionPriority::Background); + assert_eq!(row.next_action, None); + assert!(!row.is_ticket_action()); + } + + #[test] + fn workspace_panel_derives_review_and_close_actions_from_thread_roles() { + let temp = TempDir::new().unwrap(); + write_ticket_config(temp.path()); + let backend = LocalTicketBackend::new(temp.path().join(".yoi/tickets")); + create_ticket(&backend, "Needs Review", "needs-review", |_| {}); + create_ticket(&backend, "Close Ready", "close-ready", |_| {}); + backend + .add_event( + TicketIdOrSlug::Query("needs-review".to_string()), + NewTicketEvent::new(TicketEventKind::ImplementationReport, "Implemented."), + ) + .unwrap(); + backend + .add_event( + TicketIdOrSlug::Query("close-ready".to_string()), + NewTicketEvent::new(TicketEventKind::ImplementationReport, "Implemented."), + ) + .unwrap(); + backend + .review( + TicketIdOrSlug::Query("close-ready".to_string()), + TicketReview::approve(MarkdownText::new("Approved.")), + ) + .unwrap(); + + let model = build_workspace_panel(temp.path(), &empty_pods()); + let review = model + .rows + .iter() + .find(|row| row.title == "Needs Review") + .unwrap(); + let close = model + .rows + .iter() + .find(|row| row.title == "Close Ready") + .unwrap(); + + assert_eq!(review.priority, ActionPriority::Decision); + assert_eq!(review.next_action, Some(NextUserAction::Review)); + assert_eq!(close.priority, ActionPriority::Decision); + assert_eq!(close.next_action, Some(NextUserAction::Close)); + } +} diff --git a/crates/yoi/src/main.rs b/crates/yoi/src/main.rs index be5fcc79..812073c1 100644 --- a/crates/yoi/src/main.rs +++ b/crates/yoi/src/main.rs @@ -118,6 +118,12 @@ fn parse_args_slice(args: &[String]) -> Result { ticket_cli::parse_ticket_args(&args[1..]).map_err(|e| ParseError(e.to_string()))?; return Ok(Mode::Ticket(ticket_cli)); } + "panel" => { + if args.len() != 1 { + return Err(ParseError("yoi panel does not accept arguments".into())); + } + return Ok(Mode::Tui(LaunchMode::Panel)); + } "keys" => { if args.len() != 1 { return Err(ParseError("yoi keys does not accept arguments".into())); @@ -147,7 +153,6 @@ fn parse_args_slice(args: &[String]) -> Result { let mut pod_name = None; let mut socket_override = None; let mut profile = None; - let mut multi = false; let mut positional = None; let mut i = 0; @@ -158,10 +163,6 @@ fn parse_args_slice(args: &[String]) -> Result { resume = true; i += 1; } - "--multi" => { - multi = true; - i += 1; - } "--session" => { let value = args .get(i + 1) @@ -256,38 +257,12 @@ fn parse_args_slice(args: &[String]) -> Result { || session.is_some() || pod_name.is_some() || positional.is_some() - || socket_override.is_some() - || multi) + || socket_override.is_some()) { return Err(ParseError( "--profile can only be used for fresh spawn".to_string(), )); } - if multi && resume { - return Err(ParseError( - "--multi and --resume are mutually exclusive".to_string(), - )); - } - if multi && session.is_some() { - return Err(ParseError( - "--multi and --session are mutually exclusive".to_string(), - )); - } - if multi && pod_name.is_some() { - return Err(ParseError( - "--multi and --pod are mutually exclusive".to_string(), - )); - } - if multi && positional.is_some() { - return Err(ParseError( - "--multi cannot be used with a positional Pod name".to_string(), - )); - } - if multi && socket_override.is_some() { - return Err(ParseError( - "--multi and --socket are mutually exclusive".to_string(), - )); - } if pod_name.is_some() && session.is_some() { return Err(ParseError( "--pod and --session are mutually exclusive".to_string(), @@ -314,9 +289,6 @@ fn parse_args_slice(args: &[String]) -> Result { )); } - if multi { - return Ok(Mode::Tui(LaunchMode::Multi)); - } let pod_name = pod_name.or(positional); if let Some(pod_name) = pod_name { return Ok(Mode::Tui(LaunchMode::PodName { @@ -342,7 +314,7 @@ fn parse_session_id(value: &str) -> Result { fn print_help() { println!( - "yoi\n\nUsage:\n yoi [OPTIONS] [POD_NAME]\n yoi keys\n yoi pod [POD_OPTIONS]\n yoi ticket [OPTIONS]\n yoi memory lint [OPTIONS]\n\nOptions:\n -r, --resume Open the Pod picker and resume/attach a Pod\n --multi Open the multi-Pod dashboard\n --pod Attach/restore/create a Pod by name\n --socket Attach to a specific Pod socket with --pod\n --session Resume a specific session segment\n --profile Start a fresh Pod from a profile\n -h, --help Print help\n" + "yoi\n\nUsage:\n yoi [OPTIONS] [POD_NAME]\n yoi panel\n yoi keys\n yoi pod [POD_OPTIONS]\n yoi ticket [OPTIONS]\n yoi memory lint [OPTIONS]\n\nOptions:\n -r, --resume Open the Pod picker and resume/attach a Pod\n --pod Attach/restore/create a Pod by name\n --socket Attach to a specific Pod socket with --pod\n --session Resume a specific session segment\n --profile Start a fresh Pod from a profile\n -h, --help Print help\n" ); } @@ -581,13 +553,19 @@ mod tests { } #[test] - fn parse_multi_mode() { - match parse_args_from(["--multi"]).unwrap() { - Mode::Tui(LaunchMode::Multi) => {} - _ => panic!("expected Multi mode"), + fn parse_panel_mode() { + match parse_args_from(["panel"]).unwrap() { + Mode::Tui(LaunchMode::Panel) => {} + _ => panic!("expected Panel mode"), } } + #[test] + fn parse_multi_flag_is_not_a_launch_alias() { + let err = parse_args_from(["--multi"]).unwrap_err(); + assert_eq!(err.to_string(), "unknown argument: --multi"); + } + #[test] fn parse_top_level_help() { match parse_args_from(["--help"]).unwrap() { @@ -603,44 +581,4 @@ mod tests { _ => panic!("expected MemoryLintHelp mode"), } } - - #[test] - fn parse_multi_conflicts_are_clear() { - let segment_id = session_store::new_segment_id().to_string(); - let cases = [ - ( - vec!["--multi".to_string(), "--resume".to_string()], - "--multi and --resume are mutually exclusive", - ), - ( - vec!["--multi".to_string(), "--session".to_string(), segment_id], - "--multi and --session are mutually exclusive", - ), - ( - vec![ - "--multi".to_string(), - "--pod".to_string(), - "agent".to_string(), - ], - "--multi and --pod are mutually exclusive", - ), - ( - vec!["--multi".to_string(), "agent".to_string()], - "--multi cannot be used with a positional Pod name", - ), - ( - vec![ - "--multi".to_string(), - "--socket".to_string(), - "/tmp/a.sock".to_string(), - ], - "--multi and --socket are mutually exclusive", - ), - ]; - - for (args, message) in cases { - let err = parse_args_from(args).unwrap_err(); - assert_eq!(err.to_string(), message); - } - } } diff --git a/package.nix b/package.nix index ac75d54f..e274ac2e 100644 --- a/package.nix +++ b/package.nix @@ -40,7 +40,7 @@ rustPlatform.buildRustPackage rec { filter = sourceFilter; }; - cargoHash = "sha256-+eIKCBT0NR8OJn8IxuJl2nc7M6OxlPQ+9RHncSz9K2M="; + cargoHash = "sha256-aG07L64sHxGKYou7dzuNuYt6xoHjIgGhlsnI5kxGmUg="; depsExtraArgs = { # Older fetchCargoVendor utilities used crates.io's API download endpoint,