feat: add workspace panel action model

This commit is contained in:
Keisuke Hirata 2026-06-06 07:54:55 +09:00
parent a40627ab9f
commit cf1cc72255
No known key found for this signature in database
8 changed files with 1336 additions and 193 deletions

1
Cargo.lock generated
View File

@ -3956,6 +3956,7 @@ dependencies = [
"serde_json",
"session-store",
"tempfile",
"ticket",
"tokio",
"toml",
"unicode-width",

View File

@ -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

View File

@ -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 <UUID>`: 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

View File

@ -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<CrosstermBackend<io::Stdout>>,
app: &mut MultiPodApp,
) -> Result<MultiPodOutcome, MultiPodError> {
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<tokio::task::JoinHandle<Result<PodList, MultiPodError>>>,
handle: Option<tokio::task::JoinHandle<Result<MultiPodSnapshot, MultiPodError>>>,
}
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<Result<PodList, MultiPodError>>,
handle: tokio::task::JoinHandle<Result<MultiPodSnapshot, MultiPodError>>,
) -> bool {
if self.handle.is_some() {
handle.abort();
@ -164,7 +168,7 @@ impl PendingReload {
true
}
async fn finish_if_ready(&mut self) -> Option<Result<PodList, MultiPodError>> {
async fn finish_if_ready(&mut self) -> Option<Result<MultiPodSnapshot, MultiPodError>> {
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<PanelRowKey>,
notice: Option<String>,
sending: bool,
}
impl MultiPodApp {
async fn load(selected_name: Option<String>) -> Result<Self, MultiPodError> {
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<PodList, MultiPodError>) {
fn apply_reload_result(&mut self, result: Result<MultiPodSnapshot, MultiPodError>) {
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(&current_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<String> {
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<OpenPodRequest> {
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<DirectSendRequest> {
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<String>,
) -> Result<MultiPodSnapshot, MultiPodError> {
let list = load_pod_list(selected_name).await?;
let panel = build_workspace_panel(&current_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<String>) -> Result<PodList, MultiPodError> {
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<usize> {
.collect()
}
fn visible_panel_keys(panel: &WorkspacePanelViewModel, list: &PodList) -> Vec<PanelRowKey> {
let mut keys = panel
.rows
.iter()
.filter(|row| row.is_ticket_action())
.map(|row| row.key.clone())
.collect::<Vec<_>>();
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<usize> {
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<Line<'static>> {
let sections = sectioned_entries(list);
let selected = list.selected_index();
fn list_lines(app: &MultiPodApp, width: u16, height: u16) -> Vec<Line<'static>> {
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::<Vec<_>>();
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<Line<'static>> {
let rows = panel
.rows
.iter()
.filter(|row| row.is_ticket_action())
.collect::<Vec<_>>();
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<Line<'static>> {
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::<Vec<_>>();
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::<Vec<_>>();
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::<Vec<_>>();
@ -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::<Vec<_>>();
@ -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,
};

View File

@ -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<dyn std::error::Error>> {
let mut app = multi_pod::load_app().await?;

View File

@ -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<PanelRow>,
}
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<String>,
}
#[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<String>,
pub(crate) phase: TicketPanelPhase,
pub(crate) next_action: Option<NextUserAction>,
pub(crate) updated_at: Option<String>,
pub(crate) latest_event_kind: Option<String>,
pub(crate) latest_event_excerpt: Option<String>,
pub(crate) blocked_reason: Option<String>,
pub(crate) related_pods: Vec<String>,
}
#[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<String>,
pub(crate) status: String,
pub(crate) priority: ActionPriority,
pub(crate) next_action: Option<NextUserAction>,
pub(crate) ticket: Option<TicketPanelEntry>,
pub(crate) related_pods: Vec<String>,
pub(crate) disabled_reason: Option<String>,
pub(crate) key_hint: Option<String>,
}
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<Vec<PanelRow>> {
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<NextUserAction>,
disabled_reason: Option<String>,
key_hint: Option<String>,
blocked_reason: Option<String>,
}
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<usize> {
events.iter().rposition(|event| event.kind == kind)
}
fn related_pods_for_ticket(summary: &TicketSummary, pods: &PodList) -> Vec<String> {
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<String> {
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<PanelRow> {
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<String> {
let collapsed = markdown
.lines()
.map(str::trim)
.filter(|line| !line.is_empty() && !line.starts_with('#'))
.collect::<Vec<_>>()
.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::<String>();
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::<Vec<_>>();
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));
}
}

View File

@ -118,6 +118,12 @@ fn parse_args_slice(args: &[String]) -> Result<Mode, ParseError> {
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<Mode, ParseError> {
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<Mode, ParseError> {
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<Mode, ParseError> {
|| 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<Mode, ParseError> {
));
}
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<SegmentId, ParseError> {
fn print_help() {
println!(
"yoi\n\nUsage:\n yoi [OPTIONS] [POD_NAME]\n yoi keys\n yoi pod [POD_OPTIONS]\n yoi ticket <COMMAND> [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 <NAME> Attach/restore/create a Pod by name\n --socket <PATH> Attach to a specific Pod socket with --pod\n --session <UUID> Resume a specific session segment\n --profile <REF> 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 <COMMAND> [OPTIONS]\n yoi memory lint [OPTIONS]\n\nOptions:\n -r, --resume Open the Pod picker and resume/attach a Pod\n --pod <NAME> Attach/restore/create a Pod by name\n --socket <PATH> Attach to a specific Pod socket with --pod\n --session <UUID> Resume a specific session segment\n --profile <REF> 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);
}
}
}

View File

@ -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,