merge: workspace panel action model
This commit is contained in:
commit
0fce14bcfd
1
Cargo.lock
generated
1
Cargo.lock
generated
|
|
@ -3956,6 +3956,7 @@ dependencies = [
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"session-store",
|
"session-store",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
|
"ticket",
|
||||||
"tokio",
|
"tokio",
|
||||||
"toml",
|
"toml",
|
||||||
"unicode-width",
|
"unicode-width",
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ secrets = { workspace = true }
|
||||||
session-store = { workspace = true }
|
session-store = { workspace = true }
|
||||||
pod-store = { workspace = true }
|
pod-store = { workspace = true }
|
||||||
pod-registry = { workspace = true }
|
pod-registry = { workspace = true }
|
||||||
|
ticket = { workspace = true }
|
||||||
serde = { workspace = true, features = ["derive"] }
|
serde = { workspace = true, features = ["derive"] }
|
||||||
pulldown-cmark = { version = "0.13.3", default-features = false }
|
pulldown-cmark = { version = "0.13.3", default-features = false }
|
||||||
llm-worker.workspace = true
|
llm-worker.workspace = true
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ mod task;
|
||||||
mod tool;
|
mod tool;
|
||||||
mod ui;
|
mod ui;
|
||||||
mod view_mode;
|
mod view_mode;
|
||||||
|
mod workspace_panel;
|
||||||
|
|
||||||
use std::io;
|
use std::io;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
@ -51,10 +52,8 @@ pub enum LaunchMode {
|
||||||
/// `yoi --session <UUID>`: skip the picker, go straight to the
|
/// `yoi --session <UUID>`: skip the picker, go straight to the
|
||||||
/// resume name dialog with `id` baked in.
|
/// resume name dialog with `id` baked in.
|
||||||
ResumeWithSession(SegmentId),
|
ResumeWithSession(SegmentId),
|
||||||
/// `yoi --multi`: open the multi-Pod dashboard. This is intentionally
|
/// `yoi panel`: open the workspace panel from the current workspace.
|
||||||
/// separate from `-r`/`--resume`, which keeps its single-Pod picker
|
Panel,
|
||||||
/// meaning.
|
|
||||||
Multi,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn launch(options: LaunchOptions) -> ExitCode {
|
pub async fn launch(options: LaunchOptions) -> ExitCode {
|
||||||
|
|
@ -85,7 +84,7 @@ pub async fn launch(options: LaunchOptions) -> ExitCode {
|
||||||
LaunchMode::ResumeWithSession(id) => {
|
LaunchMode::ResumeWithSession(id) => {
|
||||||
single_pod::run_spawn(Some(id), None, runtime_command).await
|
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
|
// Always restore the terminal first so any pending eprintln below
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,10 @@ use crate::pod_list::{
|
||||||
PodList, PodListEntry, PodVisibilitySource, StoredMetadataState, read_reachable_live_pod_infos,
|
PodList, PodListEntry, PodVisibilitySource, StoredMetadataState, read_reachable_live_pod_infos,
|
||||||
read_stored_pod_infos,
|
read_stored_pod_infos,
|
||||||
};
|
};
|
||||||
|
use crate::workspace_panel::{
|
||||||
|
ActionPriority, NextUserAction, PanelRow, PanelRowKey, WorkspacePanelViewModel,
|
||||||
|
build_workspace_panel,
|
||||||
|
};
|
||||||
|
|
||||||
const MAX_ENTRIES: usize = 50;
|
const MAX_ENTRIES: usize = 50;
|
||||||
const CLOSED_VISIBLE_ROWS: usize = 3;
|
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::Store(e) => write!(f, "session store error: {e}"),
|
||||||
Self::NoPods => write!(
|
Self::NoPods => write!(
|
||||||
f,
|
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>>,
|
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
|
||||||
app: &mut MultiPodApp,
|
app: &mut MultiPodApp,
|
||||||
) -> Result<MultiPodOutcome, MultiPodError> {
|
) -> Result<MultiPodOutcome, MultiPodError> {
|
||||||
if app.list.entries.is_empty() {
|
if app.panel.rows.is_empty() {
|
||||||
return Err(MultiPodError::NoPods);
|
return Err(MultiPodError::NoPods);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -139,7 +143,7 @@ pub(crate) async fn run(
|
||||||
}
|
}
|
||||||
|
|
||||||
struct PendingReload {
|
struct PendingReload {
|
||||||
handle: Option<tokio::task::JoinHandle<Result<PodList, MultiPodError>>>,
|
handle: Option<tokio::task::JoinHandle<Result<MultiPodSnapshot, MultiPodError>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PendingReload {
|
impl PendingReload {
|
||||||
|
|
@ -147,14 +151,14 @@ impl PendingReload {
|
||||||
if self.handle.is_some() {
|
if self.handle.is_some() {
|
||||||
return false;
|
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
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
fn start_with_handle(
|
fn start_with_handle(
|
||||||
&mut self,
|
&mut self,
|
||||||
handle: tokio::task::JoinHandle<Result<PodList, MultiPodError>>,
|
handle: tokio::task::JoinHandle<Result<MultiPodSnapshot, MultiPodError>>,
|
||||||
) -> bool {
|
) -> bool {
|
||||||
if self.handle.is_some() {
|
if self.handle.is_some() {
|
||||||
handle.abort();
|
handle.abort();
|
||||||
|
|
@ -164,7 +168,7 @@ impl PendingReload {
|
||||||
true
|
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() {
|
if !self.handle.as_ref()?.is_finished() {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
@ -231,16 +235,21 @@ pub(crate) struct DirectSendRequest {
|
||||||
|
|
||||||
pub(crate) struct MultiPodApp {
|
pub(crate) struct MultiPodApp {
|
||||||
pub(crate) list: PodList,
|
pub(crate) list: PodList,
|
||||||
|
pub(crate) panel: WorkspacePanelViewModel,
|
||||||
pub(crate) input: InputBuffer,
|
pub(crate) input: InputBuffer,
|
||||||
|
selected_row: Option<PanelRowKey>,
|
||||||
notice: Option<String>,
|
notice: Option<String>,
|
||||||
sending: bool,
|
sending: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MultiPodApp {
|
impl MultiPodApp {
|
||||||
async fn load(selected_name: Option<String>) -> Result<Self, MultiPodError> {
|
async fn load(selected_name: Option<String>) -> Result<Self, MultiPodError> {
|
||||||
|
let snapshot = load_multi_pod_snapshot(selected_name).await?;
|
||||||
let mut app = Self {
|
let mut app = Self {
|
||||||
list: load_pod_list(selected_name).await?,
|
list: snapshot.list,
|
||||||
|
panel: snapshot.panel,
|
||||||
input: InputBuffer::new(),
|
input: InputBuffer::new(),
|
||||||
|
selected_row: None,
|
||||||
notice: None,
|
notice: None,
|
||||||
sending: false,
|
sending: false,
|
||||||
};
|
};
|
||||||
|
|
@ -249,19 +258,20 @@ impl MultiPodApp {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) async fn reload_or_notice(&mut self) {
|
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);
|
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 {
|
match result {
|
||||||
Ok(list) => self.apply_reloaded_list(list),
|
Ok(snapshot) => self.apply_reloaded_snapshot(snapshot),
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
self.notice = Some(format!("Refresh failed: {error}"));
|
self.notice = Some(format!("Refresh failed: {error}"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
fn apply_reloaded_list(&mut self, mut list: PodList) {
|
fn apply_reloaded_list(&mut self, mut list: PodList) {
|
||||||
list.selected_name = self
|
list.selected_name = self
|
||||||
.list
|
.list
|
||||||
|
|
@ -269,20 +279,72 @@ impl MultiPodApp {
|
||||||
.clone()
|
.clone()
|
||||||
.filter(|name| list.entries.iter().any(|entry| entry.name == *name))
|
.filter(|name| list.entries.iter().any(|entry| entry.name == *name))
|
||||||
.or_else(|| list.entries.first().map(|entry| entry.name.clone()));
|
.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();
|
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)]
|
#[cfg(test)]
|
||||||
pub(crate) fn selected_send_eligibility(&self) -> SendEligibility {
|
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,
|
Some(entry) if entry.actions.can_send_now => SendEligibility::SendNow,
|
||||||
_ => SendEligibility::Disabled,
|
_ => SendEligibility::Disabled,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn selected_send_disabled_reason(&self) -> Option<String> {
|
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 {
|
if entry.actions.can_send_now {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
@ -290,63 +352,93 @@ impl MultiPodApp {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn select_next(&mut self) {
|
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() {
|
if visible.is_empty() {
|
||||||
|
self.selected_row = None;
|
||||||
self.list.selected_name = None;
|
self.list.selected_name = None;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let selected = self.list.selected_index();
|
let selected_pos = self
|
||||||
let Some(selected_pos) = visible.iter().position(|index| *index == selected) else {
|
.selected_row
|
||||||
self.list.select_index(visible[0]);
|
.as_ref()
|
||||||
return;
|
.and_then(|key| visible.iter().position(|visible_key| visible_key == key))
|
||||||
};
|
.unwrap_or(0);
|
||||||
let next_pos = (selected_pos + 1).min(visible.len() - 1);
|
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) {
|
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() {
|
if visible.is_empty() {
|
||||||
|
self.selected_row = None;
|
||||||
self.list.selected_name = None;
|
self.list.selected_name = None;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let selected = self.list.selected_index();
|
let selected_pos = self
|
||||||
let Some(selected_pos) = visible.iter().position(|index| *index == selected) else {
|
.selected_row
|
||||||
self.list.select_index(visible[0]);
|
.as_ref()
|
||||||
return;
|
.and_then(|key| visible.iter().position(|visible_key| visible_key == key))
|
||||||
};
|
.unwrap_or(0);
|
||||||
let prev_pos = selected_pos.saturating_sub(1);
|
self.select_panel_key(visible[selected_pos.saturating_sub(1)].clone());
|
||||||
self.list.select_index(visible[prev_pos]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn ensure_selection_visible(&mut self) {
|
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() {
|
if visible.is_empty() {
|
||||||
|
self.selected_row = None;
|
||||||
self.list.selected_name = None;
|
self.list.selected_name = None;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let selected = self.list.selected_index();
|
let selected_visible = self
|
||||||
if !visible.contains(&selected) {
|
.selected_row
|
||||||
self.list.select_index(visible[0]);
|
.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> {
|
pub(crate) fn prepare_open(&mut self) -> Option<OpenPodRequest> {
|
||||||
let entry = match self.list.selected_entry() {
|
let (pod_name, socket_override) = {
|
||||||
Some(entry) => entry,
|
let entry = match self.selected_pod_entry() {
|
||||||
None => {
|
Some(entry) => entry,
|
||||||
self.notice = Some("No Pod is selected.".to_string());
|
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;
|
return None;
|
||||||
}
|
}
|
||||||
|
(
|
||||||
|
entry.name.clone(),
|
||||||
|
entry.attach_socket_path().map(PathBuf::from),
|
||||||
|
)
|
||||||
};
|
};
|
||||||
if !entry.actions.can_open {
|
self.notice = Some(format!("Opening {pod_name}…"));
|
||||||
self.notice = Some("Selected Pod cannot be opened from this view.".to_string());
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
self.notice = Some(format!("Opening {}…", entry.name));
|
|
||||||
Some(OpenPodRequest {
|
Some(OpenPodRequest {
|
||||||
pod_name: entry.name.clone(),
|
pod_name,
|
||||||
socket_override: entry.attach_socket_path().map(PathBuf::from),
|
socket_override,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -370,20 +462,23 @@ impl MultiPodApp {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn prepare_send(&mut self) -> Option<DirectSendRequest> {
|
pub(crate) fn prepare_send(&mut self) -> Option<DirectSendRequest> {
|
||||||
let entry = match self.list.selected_entry() {
|
let (target_name, socket_path) = {
|
||||||
Some(entry) => entry,
|
let entry = match self.selected_pod_entry() {
|
||||||
None => {
|
Some(entry) => entry,
|
||||||
self.notice = Some("No Pod is selected.".to_string());
|
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;
|
return None;
|
||||||
}
|
}
|
||||||
};
|
let Some(socket_path) = entry.attach_socket_path().map(PathBuf::from) else {
|
||||||
if !entry.actions.can_send_now {
|
self.notice = Some("Selected Pod has no reachable socket.".to_string());
|
||||||
self.notice = Some(send_disabled_reason(entry));
|
return None;
|
||||||
return None;
|
};
|
||||||
}
|
(entry.name.clone(), socket_path)
|
||||||
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 segments = self.input.submit_segments();
|
let segments = self.input.submit_segments();
|
||||||
if segments_are_blank(&segments) {
|
if segments_are_blank(&segments) {
|
||||||
|
|
@ -391,7 +486,7 @@ impl MultiPodApp {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
self.sending = true;
|
self.sending = true;
|
||||||
self.notice = Some(format!("Sending to {}…", entry.name));
|
self.notice = Some(format!("Sending to {target_name}…"));
|
||||||
Some(DirectSendRequest {
|
Some(DirectSendRequest {
|
||||||
socket_path,
|
socket_path,
|
||||||
segments,
|
segments,
|
||||||
|
|
@ -403,8 +498,7 @@ impl MultiPodApp {
|
||||||
match result {
|
match result {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
let target = self
|
let target = self
|
||||||
.list
|
.selected_pod_entry()
|
||||||
.selected_entry()
|
|
||||||
.map(|entry| entry.name.clone())
|
.map(|entry| entry.name.clone())
|
||||||
.unwrap_or_else(|| "selected Pod".to_string());
|
.unwrap_or_else(|| "selected Pod".to_string());
|
||||||
self.input.clear();
|
self.input.clear();
|
||||||
|
|
@ -491,6 +585,24 @@ enum MultiPodAction {
|
||||||
Send(DirectSendRequest),
|
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(¤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<String>) -> Result<PodList, MultiPodError> {
|
async fn load_pod_list(selected_name: Option<String>) -> Result<PodList, MultiPodError> {
|
||||||
let store_dir = default_store_dir()?;
|
let store_dir = default_store_dir()?;
|
||||||
let store = FsStore::new(&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())
|
.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) {
|
fn row_status_label(entry: &PodListEntry) -> (&'static str, Style) {
|
||||||
if let Some(live) = entry.live.as_ref() {
|
if let Some(live) = entry.live.as_ref() {
|
||||||
if !live.reachable {
|
if !live.reachable {
|
||||||
|
|
@ -737,6 +862,22 @@ fn visible_entry_indices(list: &PodList) -> Vec<usize> {
|
||||||
.collect()
|
.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> {
|
fn visible_section_indices(section: &MultiPodSection) -> Vec<usize> {
|
||||||
section
|
section
|
||||||
.entries
|
.entries
|
||||||
|
|
@ -838,11 +979,11 @@ fn draw_title(frame: &mut Frame<'_>, area: Rect) {
|
||||||
frame.render_widget(
|
frame.render_widget(
|
||||||
Paragraph::new(Line::from(vec![
|
Paragraph::new(Line::from(vec![
|
||||||
Span::styled(
|
Span::styled(
|
||||||
"multi-Pod dashboard",
|
"workspace dashboard",
|
||||||
Style::default().add_modifier(Modifier::BOLD),
|
Style::default().add_modifier(Modifier::BOLD),
|
||||||
),
|
),
|
||||||
Span::styled(
|
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),
|
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 {
|
if area.width == 0 || area.height == 0 {
|
||||||
return;
|
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());
|
Paragraph::new(lines).render(area, frame.buffer_mut());
|
||||||
}
|
}
|
||||||
|
|
||||||
fn list_lines(list: &PodList, width: u16, height: u16) -> Vec<Line<'static>> {
|
fn list_lines(app: &MultiPodApp, width: u16, height: u16) -> Vec<Line<'static>> {
|
||||||
let sections = sectioned_entries(list);
|
let sections = sectioned_entries(&app.list);
|
||||||
let selected = list.selected_index();
|
let selected = app.selected_row.as_ref();
|
||||||
|
let action_lines = panel_action_lines(&app.panel, selected, width);
|
||||||
let live_lines = sections
|
let live_lines = sections
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|section| section.kind != MultiPodSectionKind::Closed)
|
.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<_>>();
|
.collect::<Vec<_>>();
|
||||||
let closed_lines = sections
|
let closed_lines = sections
|
||||||
.iter()
|
.iter()
|
||||||
.find(|section| section.kind == MultiPodSectionKind::Closed)
|
.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();
|
.unwrap_or_default();
|
||||||
|
|
||||||
let available = height as usize;
|
let available = height as usize;
|
||||||
let closed_len = closed_lines.len().min(available);
|
let action_len = action_lines.len().min(available);
|
||||||
let live_len = live_lines.len().min(available.saturating_sub(closed_len));
|
let remaining_after_actions = available.saturating_sub(action_len);
|
||||||
let spacer_len = available.saturating_sub(live_len + closed_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);
|
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(live_lines.into_iter().take(live_len));
|
||||||
lines.extend(std::iter::repeat_with(|| Line::from(Span::raw(""))).take(spacer_len));
|
lines.extend(std::iter::repeat_with(|| Line::from(Span::raw(""))).take(spacer_len));
|
||||||
lines.extend(closed_lines.into_iter().take(closed_len));
|
lines.extend(closed_lines.into_iter().take(closed_len));
|
||||||
lines
|
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(
|
fn section_lines(
|
||||||
list: &PodList,
|
list: &PodList,
|
||||||
section: &MultiPodSection,
|
section: &MultiPodSection,
|
||||||
selected: usize,
|
selected: Option<&PanelRowKey>,
|
||||||
width: u16,
|
width: u16,
|
||||||
) -> Vec<Line<'static>> {
|
) -> Vec<Line<'static>> {
|
||||||
let visible = visible_section_indices(section);
|
let visible = visible_section_indices(section);
|
||||||
|
|
@ -904,7 +1140,8 @@ fn section_lines(
|
||||||
));
|
));
|
||||||
for index in visible {
|
for index in visible {
|
||||||
if let Some(entry) = list.entries.get(index) {
|
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
|
lines
|
||||||
|
|
@ -959,37 +1196,64 @@ fn draw_separator(frame: &mut Frame<'_>, area: Rect) {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn draw_target_status(frame: &mut Frame<'_>, app: &MultiPodApp, area: Rect) {
|
fn draw_target_status(frame: &mut Frame<'_>, app: &MultiPodApp, area: Rect) {
|
||||||
let line = match app.list.selected_entry() {
|
let line = if let Some(row) = app
|
||||||
Some(entry) => {
|
.selected_panel_row()
|
||||||
let (status, status_style) = row_status_label(entry);
|
.filter(|row| row.is_ticket_action())
|
||||||
let send_text = if entry.actions.can_send_now {
|
{
|
||||||
"send enabled"
|
Line::from(vec![
|
||||||
} else {
|
Span::styled("action ", Style::default().fg(Color::DarkGray)),
|
||||||
"send disabled"
|
Span::styled(
|
||||||
};
|
row.title.clone(),
|
||||||
Line::from(vec![
|
Style::default().add_modifier(Modifier::BOLD),
|
||||||
Span::styled("target ", Style::default().fg(Color::DarkGray)),
|
),
|
||||||
Span::styled(
|
Span::raw(" "),
|
||||||
entry.name.clone(),
|
Span::styled(
|
||||||
Style::default().add_modifier(Modifier::BOLD),
|
format!("[{}]", row.priority.label()),
|
||||||
),
|
panel_priority_style(row.priority),
|
||||||
Span::raw(" "),
|
),
|
||||||
Span::styled(format!("[{status}]"), status_style),
|
Span::raw(" "),
|
||||||
Span::raw(" "),
|
Span::styled(
|
||||||
Span::styled(
|
row.next_action.map(NextUserAction::label).unwrap_or("View"),
|
||||||
send_text,
|
Style::default().fg(Color::Magenta),
|
||||||
if entry.actions.can_send_now {
|
),
|
||||||
Style::default().fg(Color::Green)
|
Span::styled(
|
||||||
} else {
|
" display-only; re-check Ticket before dispatch",
|
||||||
Style::default().fg(Color::DarkGray)
|
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);
|
frame.render_widget(Paragraph::new(line), area);
|
||||||
}
|
}
|
||||||
|
|
@ -1068,6 +1332,66 @@ fn truncate_with_ellipsis(s: &str, max_width: usize) -> String {
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::pod_list::{LivePodInfo, PodEntrySummary, StoredMetadataState, StoredPodInfo};
|
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]
|
#[test]
|
||||||
fn multi_selection_changes_preserve_composer_contents() {
|
fn multi_selection_changes_preserve_composer_contents() {
|
||||||
|
|
@ -1168,13 +1492,17 @@ mod tests {
|
||||||
Err(MultiPodError::Io(io::Error::other("boom")))
|
Err(MultiPodError::Io(io::Error::other("boom")))
|
||||||
})));
|
})));
|
||||||
assert!(!pending.start_with_handle(tokio::spawn(async {
|
assert!(!pending.start_with_handle(tokio::spawn(async {
|
||||||
Ok(PodList::from_sources(
|
let list = PodList::from_sources(
|
||||||
PodVisibilitySource::ResumePicker,
|
PodVisibilitySource::ResumePicker,
|
||||||
vec![],
|
vec![],
|
||||||
vec![live_info("beta", PodStatus::Idle)],
|
vec![live_info("beta", PodStatus::Idle)],
|
||||||
None,
|
None,
|
||||||
10,
|
10,
|
||||||
))
|
);
|
||||||
|
Ok(MultiPodSnapshot {
|
||||||
|
panel: WorkspacePanelViewModel::empty(Path::new("test")),
|
||||||
|
list,
|
||||||
|
})
|
||||||
})));
|
})));
|
||||||
assert!(pending.finish_if_ready().await.is_none());
|
assert!(pending.finish_if_ready().await.is_none());
|
||||||
|
|
||||||
|
|
@ -1236,6 +1564,8 @@ mod tests {
|
||||||
Some("running".to_string()),
|
Some("running".to_string()),
|
||||||
10,
|
10,
|
||||||
);
|
);
|
||||||
|
app.selected_row = None;
|
||||||
|
app.ensure_selection_visible();
|
||||||
|
|
||||||
assert_eq!(app.selected_send_eligibility(), SendEligibility::Disabled);
|
assert_eq!(app.selected_send_eligibility(), SendEligibility::Disabled);
|
||||||
assert!(
|
assert!(
|
||||||
|
|
@ -1290,14 +1620,15 @@ mod tests {
|
||||||
let list = closed_list(5, Some("closed-0"));
|
let list = closed_list(5, Some("closed-0"));
|
||||||
let visible = visible_entry_indices(&list)
|
let visible = visible_entry_indices(&list)
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|index| list.entries[index].name.as_str())
|
.map(|index| list.entries[index].name.clone())
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
let sections = sectioned_entries(&list);
|
let sections = sectioned_entries(&list);
|
||||||
let closed = sections
|
let closed = sections
|
||||||
.iter()
|
.iter()
|
||||||
.find(|section| section.kind == MultiPodSectionKind::Closed)
|
.find(|section| section.kind == MultiPodSectionKind::Closed)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let lines = list_lines(&list, 80, 8)
|
let app = app_with_list(list);
|
||||||
|
let lines = list_lines(&app, 80, 8)
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|line| plain_line(&line))
|
.map(|line| plain_line(&line))
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
|
|
@ -1356,7 +1687,8 @@ mod tests {
|
||||||
Some("idle".to_string()),
|
Some("idle".to_string()),
|
||||||
20,
|
20,
|
||||||
);
|
);
|
||||||
let lines = list_lines(&list, 80, 12)
|
let app = app_with_list(list);
|
||||||
|
let lines = list_lines(&app, 80, 12)
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|line| plain_line(&line))
|
.map(|line| plain_line(&line))
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
|
|
@ -1532,9 +1864,15 @@ mod tests {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn app_with_list(list: PodList) -> MultiPodApp {
|
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 {
|
let mut app = MultiPodApp {
|
||||||
list,
|
list,
|
||||||
|
panel,
|
||||||
input: InputBuffer::new(),
|
input: InputBuffer::new(),
|
||||||
|
selected_row: None,
|
||||||
notice: None,
|
notice: None,
|
||||||
sending: false,
|
sending: false,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -184,7 +184,7 @@ pub(crate) async fn run_resume(
|
||||||
run_pod_name(pod_name, socket_override, runtime_command).await
|
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,
|
runtime_command: PodRuntimeCommand,
|
||||||
) -> Result<(), Box<dyn std::error::Error>> {
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
let mut app = multi_pod::load_app().await?;
|
let mut app = multi_pod::load_app().await?;
|
||||||
|
|
|
||||||
866
crates/tui/src/workspace_panel.rs
Normal file
866
crates/tui/src/workspace_panel.rs
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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()))?;
|
ticket_cli::parse_ticket_args(&args[1..]).map_err(|e| ParseError(e.to_string()))?;
|
||||||
return Ok(Mode::Ticket(ticket_cli));
|
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" => {
|
"keys" => {
|
||||||
if args.len() != 1 {
|
if args.len() != 1 {
|
||||||
return Err(ParseError("yoi keys does not accept arguments".into()));
|
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 pod_name = None;
|
||||||
let mut socket_override = None;
|
let mut socket_override = None;
|
||||||
let mut profile = None;
|
let mut profile = None;
|
||||||
let mut multi = false;
|
|
||||||
let mut positional = None;
|
let mut positional = None;
|
||||||
|
|
||||||
let mut i = 0;
|
let mut i = 0;
|
||||||
|
|
@ -158,10 +163,6 @@ fn parse_args_slice(args: &[String]) -> Result<Mode, ParseError> {
|
||||||
resume = true;
|
resume = true;
|
||||||
i += 1;
|
i += 1;
|
||||||
}
|
}
|
||||||
"--multi" => {
|
|
||||||
multi = true;
|
|
||||||
i += 1;
|
|
||||||
}
|
|
||||||
"--session" => {
|
"--session" => {
|
||||||
let value = args
|
let value = args
|
||||||
.get(i + 1)
|
.get(i + 1)
|
||||||
|
|
@ -256,38 +257,12 @@ fn parse_args_slice(args: &[String]) -> Result<Mode, ParseError> {
|
||||||
|| session.is_some()
|
|| session.is_some()
|
||||||
|| pod_name.is_some()
|
|| pod_name.is_some()
|
||||||
|| positional.is_some()
|
|| positional.is_some()
|
||||||
|| socket_override.is_some()
|
|| socket_override.is_some())
|
||||||
|| multi)
|
|
||||||
{
|
{
|
||||||
return Err(ParseError(
|
return Err(ParseError(
|
||||||
"--profile can only be used for fresh spawn".to_string(),
|
"--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() {
|
if pod_name.is_some() && session.is_some() {
|
||||||
return Err(ParseError(
|
return Err(ParseError(
|
||||||
"--pod and --session are mutually exclusive".to_string(),
|
"--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);
|
let pod_name = pod_name.or(positional);
|
||||||
if let Some(pod_name) = pod_name {
|
if let Some(pod_name) = pod_name {
|
||||||
return Ok(Mode::Tui(LaunchMode::PodName {
|
return Ok(Mode::Tui(LaunchMode::PodName {
|
||||||
|
|
@ -342,7 +314,7 @@ fn parse_session_id(value: &str) -> Result<SegmentId, ParseError> {
|
||||||
|
|
||||||
fn print_help() {
|
fn print_help() {
|
||||||
println!(
|
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]
|
#[test]
|
||||||
fn parse_multi_mode() {
|
fn parse_panel_mode() {
|
||||||
match parse_args_from(["--multi"]).unwrap() {
|
match parse_args_from(["panel"]).unwrap() {
|
||||||
Mode::Tui(LaunchMode::Multi) => {}
|
Mode::Tui(LaunchMode::Panel) => {}
|
||||||
_ => panic!("expected Multi mode"),
|
_ => 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]
|
#[test]
|
||||||
fn parse_top_level_help() {
|
fn parse_top_level_help() {
|
||||||
match parse_args_from(["--help"]).unwrap() {
|
match parse_args_from(["--help"]).unwrap() {
|
||||||
|
|
@ -603,44 +581,4 @@ mod tests {
|
||||||
_ => panic!("expected MemoryLintHelp mode"),
|
_ => 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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,7 @@ rustPlatform.buildRustPackage rec {
|
||||||
filter = sourceFilter;
|
filter = sourceFilter;
|
||||||
};
|
};
|
||||||
|
|
||||||
cargoHash = "sha256-+eIKCBT0NR8OJn8IxuJl2nc7M6OxlPQ+9RHncSz9K2M=";
|
cargoHash = "sha256-aG07L64sHxGKYou7dzuNuYt6xoHjIgGhlsnI5kxGmUg=";
|
||||||
|
|
||||||
depsExtraArgs = {
|
depsExtraArgs = {
|
||||||
# Older fetchCargoVendor utilities used crates.io's API download endpoint,
|
# Older fetchCargoVendor utilities used crates.io's API download endpoint,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user