From 861c351a96cd52a92cd28ff4137e15c49cb19bbd Mon Sep 17 00:00:00 2001 From: Hare Date: Sun, 21 Jun 2026 01:45:28 +0900 Subject: [PATCH] feat: add explicit resume command --- crates/tui/src/console/mod.rs | 9 +- crates/tui/src/lib.rs | 15 +- crates/tui/src/picker.rs | 113 +++++++++++-- crates/yoi/src/main.rs | 289 +++++++++++++++++++--------------- 4 files changed, 283 insertions(+), 143 deletions(-) diff --git a/crates/tui/src/console/mod.rs b/crates/tui/src/console/mod.rs index 1c622f32..dccd8a58 100644 --- a/crates/tui/src/console/mod.rs +++ b/crates/tui/src/console/mod.rs @@ -275,10 +275,17 @@ async fn connect_live_pod( pub(crate) async fn run_resume( runtime_command: PodRuntimeCommand, + workspace_root: PathBuf, + all: bool, ) -> Result<(), Box> { // Pick a Pod in its own inline viewport, dropping the viewport before // attaching/restoring so each phase gets fresh vertical room. - let (pod_name, socket_override) = match picker::run().await? { + let picker_options = if all { + picker::PickerOptions::all() + } else { + picker::PickerOptions::workspace(workspace_root) + }; + let (pod_name, socket_override) = match picker::run(picker_options).await? { PickerOutcome::Picked { pod_name, socket_override, diff --git a/crates/tui/src/lib.rs b/crates/tui/src/lib.rs index b19694ba..b14be24d 100644 --- a/crates/tui/src/lib.rs +++ b/crates/tui/src/lib.rs @@ -48,16 +48,17 @@ pub enum LaunchMode { pod_name: Option, profile: Option, }, - /// `yoi ` / `yoi --pod `: attach to a live Pod by name if - /// possible; otherwise launch the Pod runtime command with `--pod ` so it + /// `yoi --pod `: attach to a live Pod by name if possible; + /// otherwise launch the Pod runtime command with `--pod ` so it /// resumes from name-keyed state or creates a fresh same-name Pod. PodName { pod_name: String, socket_override: Option, }, - /// `yoi -r` / `yoi --resume`: open the Pod picker, then attach to the - /// selected live Pod or restore the selected stopped Pod by name. - Resume, + /// `yoi resume`: open the Pod picker, then attach to the selected live Pod + /// or restore the selected stopped Pod by name. Without `--all`, the picker + /// is scoped to the current runtime workspace. + Resume { all: bool }, /// `yoi --session `: skip the picker, go straight to the /// resume name dialog with `id` baked in. ResumeWithSession { @@ -101,7 +102,9 @@ pub async fn launch(options: LaunchOptions) -> ExitCode { pod_name, socket_override, } => console::run_pod_name(pod_name, socket_override, runtime_command).await, - LaunchMode::Resume => console::run_resume(runtime_command).await, + LaunchMode::Resume { all } => { + console::run_resume(runtime_command, workspace_root.clone(), all).await + } LaunchMode::ResumeWithSession { id, pod_name } => { console::run_spawn(Some(id), pod_name, None, runtime_command).await } diff --git a/crates/tui/src/picker.rs b/crates/tui/src/picker.rs index 4c607d4f..2b3dfdd8 100644 --- a/crates/tui/src/picker.rs +++ b/crates/tui/src/picker.rs @@ -20,7 +20,7 @@ use ratatui::{Frame, TerminalOptions, Viewport}; use session_store::FsStore; use crate::pod_list::{ - PodList, PodListEntry, PodVisibilitySource, StoredMetadataState, + LivePodInfo, PodList, PodListEntry, PodVisibilitySource, StoredMetadataState, StoredPodInfo, live_socket_for_pod as pod_list_live_socket_for_pod, read_reachable_live_pod_infos, read_stored_pod_infos, }; @@ -73,6 +73,31 @@ pub enum PickerOutcome { Cancelled, } +#[derive(Debug, Clone)] +pub(crate) struct PickerOptions { + scope: PickerScope, +} + +impl PickerOptions { + pub(crate) fn workspace(workspace_root: PathBuf) -> Self { + Self { + scope: PickerScope::Workspace(workspace_root), + } + } + + pub(crate) fn all() -> Self { + Self { + scope: PickerScope::All, + } + } +} + +#[derive(Debug, Clone)] +enum PickerScope { + Workspace(PathBuf), + All, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum PodRowState { Live, @@ -100,7 +125,31 @@ impl PodRowState { } } -pub async fn run() -> Result { +fn list_for_options( + options: &PickerOptions, + stored_pods: Vec, + live_pods: Vec, +) -> PodList { + match &options.scope { + PickerScope::Workspace(workspace_root) => PodList::from_workspace_sources( + PodVisibilitySource::ResumePicker, + stored_pods, + live_pods, + None, + MAX_ROWS, + workspace_root, + ), + PickerScope::All => PodList::from_sources( + PodVisibilitySource::ResumePicker, + stored_pods, + live_pods, + None, + MAX_ROWS, + ), + } +} + +pub async fn run(options: PickerOptions) -> Result { let store_dir = default_store_dir()?; let store = FsStore::new(&store_dir)?; let pod_store = FsPodStore::new(default_pod_store_dir()?).map_err(io::Error::other)?; @@ -108,13 +157,7 @@ pub async fn run() -> Result { let live_pods = read_reachable_live_pod_infos(&store) .await .unwrap_or_default(); - let mut list = PodList::from_sources( - PodVisibilitySource::ResumePicker, - stored_pods, - live_pods, - None, - MAX_ROWS, - ); + let mut list = list_for_options(&options, stored_pods, live_pods); if list.entries.is_empty() { return Err(PickerError::NoPods); } @@ -361,6 +404,58 @@ mod tests { assert_eq!(picker_title(), "resume pod pick a pod"); } + #[test] + fn picker_workspace_options_filter_by_workspace_metadata() { + let list = list_for_options( + &PickerOptions::workspace(PathBuf::from("/workspace/current")), + vec![ + stored_pod("current", Some("/workspace/current"), 3), + stored_pod("other", Some("/workspace/other"), 2), + stored_pod("legacy", None, 1), + ], + vec![], + ); + + let names: Vec<_> = list + .entries + .iter() + .map(|entry| entry.name.as_str()) + .collect(); + assert_eq!(names, vec!["current"]); + } + + #[test] + fn picker_all_options_include_host_wide_and_legacy_pods() { + let list = list_for_options( + &PickerOptions::all(), + vec![ + stored_pod("current", Some("/workspace/current"), 3), + stored_pod("other", Some("/workspace/other"), 2), + stored_pod("legacy", None, 1), + ], + vec![], + ); + + let names: Vec<_> = list + .entries + .iter() + .map(|entry| entry.name.as_str()) + .collect(); + assert_eq!(names, vec!["current", "other", "legacy"]); + } + + fn stored_pod(name: &str, workspace_root: Option<&str>, updated_at: u64) -> StoredPodInfo { + StoredPodInfo { + pod_name: name.to_string(), + metadata_state: StoredMetadataState::Present, + active_session_id: None, + active_segment_id: None, + updated_at, + workspace_root: workspace_root.map(PathBuf::from), + preview: None, + } + } + #[test] fn picker_row_shows_live_pending_preview_and_runtime_segment_id() { let segment_id = session_store::new_segment_id(); diff --git a/crates/yoi/src/main.rs b/crates/yoi/src/main.rs index 4a2e4e81..40f27524 100644 --- a/crates/yoi/src/main.rs +++ b/crates/yoi/src/main.rs @@ -17,6 +17,7 @@ use tui::{LaunchMode, LaunchOptions}; #[derive(Debug)] enum Mode { Help, + ResumeHelp, MemoryLintHelp, MemoryLint(LintCliOptions), Mcp(mcp_cli::McpCliCommand), @@ -60,6 +61,10 @@ async fn main() -> ExitCode { print_help(); ExitCode::SUCCESS } + Mode::ResumeHelp => { + print_resume_help(); + ExitCode::SUCCESS + } Mode::MemoryLintHelp => { print_memory_lint_help(); ExitCode::SUCCESS @@ -168,13 +173,13 @@ fn parse_args_slice(args: &[String]) -> Result { pod_name: None, profile: None, }, - workspace_root: std::env::current_dir() - .map_err(|e| ParseError(format!("failed to resolve current directory: {e}")))?, + workspace_root: current_dir()?, }); } match args[0].as_str() { "--help" | "-h" => return Ok(Mode::Help), + "resume" => return parse_resume_args(&args[1..]), "pod" => return Ok(Mode::PodRuntime(args[1..].to_vec())), "objective" => { let objective_cli = objective_cli::parse_objective_args(&args[1..]) @@ -229,35 +234,30 @@ fn parse_args_slice(args: &[String]) -> Result { return Ok(Mode::MemoryLint(options)); } "memory" => { - return Ok(Mode::Tui { - mode: LaunchMode::PodName { - pod_name: "memory".to_string(), - socket_override: None, - }, - workspace_root: std::env::current_dir() - .map_err(|e| ParseError(format!("failed to resolve current directory: {e}")))?, - }); + return Err(ParseError( + "yoi memory requires the `lint` subcommand".to_string(), + )); + } + other if !other.starts_with('-') => { + return Err(ParseError(format!("unknown command `{other}`"))); } _ => {} } - let mut workspace_root = std::env::current_dir() - .map_err(|e| ParseError(format!("failed to resolve current directory: {e}")))?; - let mut resume = false; + parse_console_options(args) +} + +fn parse_console_options(args: &[String]) -> Result { + let mut workspace_root = current_dir()?; let mut session = None; let mut pod_name = None; let mut socket_override = None; let mut profile = None; - let mut positional = None; let mut i = 0; while i < args.len() { let arg = &args[i]; match arg.as_str() { - "--resume" | "-r" => { - resume = true; - i += 1; - } "--session" => { let value = args .get(i + 1) @@ -349,51 +349,21 @@ fn parse_args_slice(args: &[String]) -> Result { return Err(ParseError(format!("unknown argument: {arg}"))); } value => { - if positional.replace(value.to_string()).is_some() { - return Err(ParseError( - "only one positional Pod name is supported".to_string(), - )); - } - i += 1; + return Err(ParseError(format!( + "unknown command `{value}`; use --pod to open a Pod by name" + ))); } } } - if pod_name.is_some() && positional.is_some() { - return Err(ParseError( - "--pod and a positional Pod name are mutually exclusive".to_string(), - )); - } - - if profile.is_some() - && (resume || session.is_some() || positional.is_some() || socket_override.is_some()) - { + if profile.is_some() && (session.is_some() || socket_override.is_some()) { return Err(ParseError( "--profile can only be used for fresh spawn".to_string(), )); } - if pod_name.is_some() && resume { - return Err(ParseError( - "--pod and --resume are mutually exclusive".to_string(), - )); + if socket_override.is_some() && pod_name.is_none() { + return Err(ParseError("--socket requires --pod".to_string())); } - if positional.is_some() && resume { - return Err(ParseError( - "--resume cannot be used with a positional Pod name".to_string(), - )); - } - if socket_override.is_some() && pod_name.is_none() && positional.is_none() { - return Err(ParseError( - "--socket requires --pod or a positional Pod name".to_string(), - )); - } - if resume && session.is_some() { - return Err(ParseError( - "--resume and --session are mutually exclusive".to_string(), - )); - } - - let pod_name = pod_name.or(positional); if socket_override.is_some() && session.is_some() { return Err(ParseError( "--socket can only be used with --pod attach mode".to_string(), @@ -424,12 +394,6 @@ fn parse_args_slice(args: &[String]) -> Result { workspace_root, }); } - if resume { - return Ok(Mode::Tui { - mode: LaunchMode::Resume, - workspace_root, - }); - } Ok(Mode::Tui { mode: LaunchMode::Spawn { pod_name: None, @@ -439,6 +403,75 @@ fn parse_args_slice(args: &[String]) -> Result { }) } +fn parse_resume_args(args: &[String]) -> Result { + let mut workspace_root = current_dir()?; + let mut workspace_set = false; + let mut all = false; + + let mut i = 0; + while i < args.len() { + let arg = &args[i]; + match arg.as_str() { + "--help" | "-h" => { + if args.len() == 1 { + return Ok(Mode::ResumeHelp); + } + return Err(ParseError( + "yoi resume --help does not accept other arguments".to_string(), + )); + } + "--all" => { + all = true; + i += 1; + } + "--workspace" => { + let value = args + .get(i + 1) + .ok_or_else(|| ParseError("--workspace requires a value".to_string()))?; + if value.starts_with('-') { + return Err(ParseError("--workspace requires a value".to_string())); + } + workspace_root = PathBuf::from(value); + workspace_set = true; + i += 2; + } + arg if arg.starts_with("--workspace=") => { + let value = arg.trim_start_matches("--workspace="); + if value.is_empty() { + return Err(ParseError("--workspace requires a value".to_string())); + } + workspace_root = PathBuf::from(value); + workspace_set = true; + i += 1; + } + arg if arg.starts_with('-') => { + return Err(ParseError(format!("unknown yoi resume option `{arg}`"))); + } + value => { + return Err(ParseError(format!( + "yoi resume does not accept positional argument `{value}`" + ))); + } + } + } + + if all && workspace_set { + return Err(ParseError( + "yoi resume --all and --workspace are mutually exclusive".to_string(), + )); + } + + Ok(Mode::Tui { + mode: LaunchMode::Resume { all }, + workspace_root, + }) +} + +fn current_dir() -> Result { + std::env::current_dir() + .map_err(|e| ParseError(format!("failed to resolve current directory: {e}"))) +} + fn parse_plugin_args(args: &[String]) -> Result { let Some((subcommand, rest)) = args.split_first() else { return Err(ParseError( @@ -777,7 +810,13 @@ fn parse_session_id(value: &str) -> Result { fn print_help() { println!( - "yoi\n\nUsage:\n yoi [OPTIONS] [POD_NAME]\n yoi panel [--workspace ]\n yoi keys\n yoi setup-model\n yoi pod [POD_OPTIONS]\n yoi objective [OPTIONS]\n yoi session analyze --json\n yoi ticket [OPTIONS]\n yoi plugin new rust-component-tool [--json]\n yoi plugin check [--json]\n yoi plugin pack [--output ] [--json]\n yoi plugin list [--workspace ] [--profile ] [--json]\n yoi plugin show [--workspace ] [--profile ] [--json]\n yoi mcp list [--workspace ] [--profile ] [--json]\n yoi mcp show [--workspace ] [--profile ] [--json]\n yoi mcp tools|resources|prompts [SERVER] [--workspace ] [--profile ] [--json]\n yoi memory lint [OPTIONS]\n\nSurfaces:\n Console Single-Pod chat/client surface (default, --pod, --resume)\n Dashboard Workspace cockpit/action surface (yoi panel)\n TUI Terminal UI implementation umbrella for Console and Dashboard\n\nOptions:\n -r, --resume Open the Pod Console picker and resume/attach a Pod\n --workspace Runtime workspace root (defaults to cwd)\n --pod Open the Pod Console by name (attach/restore/create)\n --socket Attach a Pod Console to a specific socket with --pod\n --session Resume a specific session segment in the Pod Console\n --profile Select a reusable Profile recipe\n -h, --help Print help\n" + "yoi\n\nUsage:\n yoi [OPTIONS]\n yoi resume [--workspace ] [--all]\n yoi panel [--workspace ]\n yoi keys\n yoi setup-model\n yoi pod [POD_OPTIONS]\n yoi objective [OPTIONS]\n yoi session analyze --json\n yoi ticket [OPTIONS]\n yoi plugin new rust-component-tool [--json]\n yoi plugin check [--json]\n yoi plugin pack [--output ] [--json]\n yoi plugin list [--workspace ] [--profile ] [--json]\n yoi plugin show [--workspace ] [--profile ] [--json]\n yoi mcp list [--workspace ] [--profile ] [--json]\n yoi mcp show [--workspace ] [--profile ] [--json]\n yoi mcp tools|resources|prompts [SERVER] [--workspace ] [--profile ] [--json]\n yoi memory lint [OPTIONS]\n\nSurfaces:\n Console Single-Pod chat/client surface (default, --pod, yoi resume)\n Dashboard Workspace cockpit/action surface (yoi panel)\n TUI Terminal UI implementation umbrella for Console and Dashboard\n\nOptions:\n --workspace Runtime workspace root for default Console/--pod (defaults to cwd)\n --pod Open the Pod Console by name (attach/restore/create)\n --socket Attach a Pod Console to a specific socket with --pod\n --session Resume a specific session segment in the Pod Console\n --profile Select a reusable Profile recipe\n -h, --help Print help\n" + ); +} + +fn print_resume_help() { + println!( + "yoi resume\n\nUsage:\n yoi resume [--workspace ] [--all]\n\nOptions:\n --workspace Open the Pod Console picker scoped to this workspace (defaults to cwd)\n --all Open the Pod Console picker across this host/data dir\n -h, --help Print help\n" ); } @@ -810,38 +849,50 @@ mod tests { } #[test] - fn parse_positional_name_uses_pod_name_mode() { - match parse_args_from(["agent"]).unwrap() { + fn parse_bare_word_is_unknown_command() { + let err = parse_args_from(["agent"]).unwrap_err(); + assert_eq!(err.to_string(), "unknown command `agent`"); + } + + #[test] + fn parse_memory_without_lint_is_usage_error() { + let err = parse_args_from(["memory"]).unwrap_err(); + assert_eq!(err.to_string(), "yoi memory requires the `lint` subcommand"); + } + + #[test] + fn parse_resume_subcommand_defaults_to_workspace_scope() { + match parse_args_from(["resume"]).unwrap() { Mode::Tui { - mode: - LaunchMode::PodName { - pod_name, - socket_override, - }, + mode: LaunchMode::Resume { all }, .. - } => { - assert_eq!(pod_name, "agent"); - assert_eq!(socket_override, None); - } - _ => panic!("expected PodName mode"), + } => assert!(!all), + _ => panic!("expected Resume mode"), } } #[test] - fn parse_memory_alone_remains_positional_pod_name() { - match parse_args_from(["memory"]).unwrap() { + fn parse_resume_workspace_scope() { + match parse_args_from(["resume", "--workspace", "/tmp/resume-workspace"]).unwrap() { Mode::Tui { - mode: - LaunchMode::PodName { - pod_name, - socket_override, - }, - .. + mode: LaunchMode::Resume { all }, + workspace_root, } => { - assert_eq!(pod_name, "memory"); - assert_eq!(socket_override, None); + assert!(!all); + assert_eq!(workspace_root, PathBuf::from("/tmp/resume-workspace")); } - _ => panic!("expected PodName mode"), + _ => panic!("expected Resume mode"), + } + } + + #[test] + fn parse_resume_all_scope() { + match parse_args_from(["resume", "--all"]).unwrap() { + Mode::Tui { + mode: LaunchMode::Resume { all }, + .. + } => assert!(all), + _ => panic!("expected Resume mode"), } } @@ -1038,14 +1089,9 @@ mod tests { } #[test] - fn memory_lint_with_other_second_word_remains_positional_pod_name() { - match parse_args_from(["memory", "other"]).unwrap() { - Mode::Tui { - mode: LaunchMode::PodName { pod_name, .. }, - .. - } => assert_eq!(pod_name, "memory"), - _ => panic!("expected PodName mode"), - } + fn memory_lint_with_other_second_word_is_usage_error() { + let err = parse_args_from(["memory", "other"]).unwrap_err(); + assert_eq!(err.to_string(), "yoi memory requires the `lint` subcommand"); } #[test] @@ -1075,19 +1121,13 @@ mod tests { } #[test] - fn parse_rejects_resume_and_pod_name_selection() { + fn parse_rejects_legacy_resume_flags() { let cases = [ - ( - vec!["-r".to_string(), "--pod".to_string(), "agent".to_string()], - "--pod and --resume are mutually exclusive", - ), + (vec!["-r".to_string()], "unknown argument: -r"), + (vec!["--resume".to_string()], "unknown argument: --resume"), ( vec!["--pod".to_string(), "agent".to_string(), "-r".to_string()], - "--pod and --resume are mutually exclusive", - ), - ( - vec!["-r".to_string(), "agent".to_string()], - "--resume cannot be used with a positional Pod name", + "unknown argument: -r", ), ]; @@ -1097,6 +1137,15 @@ mod tests { } } + #[test] + fn parse_resume_rejects_workspace_with_all() { + let err = parse_args_from(["resume", "--workspace", "/tmp/ws", "--all"]).unwrap_err(); + assert_eq!( + err.to_string(), + "yoi resume --all and --workspace are mutually exclusive" + ); + } + #[test] fn parse_profile_spawn_mode() { match parse_args_from([ @@ -1125,14 +1174,6 @@ mod tests { fn parse_profile_rejects_resume_attach_modes() { let segment_id = session_store::new_segment_id().to_string(); let cases = [ - ( - vec![ - "--profile".to_string(), - "p.lua".to_string(), - "--resume".to_string(), - ], - "--profile can only be used for fresh spawn", - ), ( vec![ "--profile".to_string(), @@ -1151,14 +1192,6 @@ mod tests { ], "--profile can only be used for fresh spawn", ), - ( - vec![ - "--profile".to_string(), - "p.lua".to_string(), - "agent".to_string(), - ], - "--profile can only be used for fresh spawn", - ), ]; for (args, message) in cases { @@ -1179,15 +1212,9 @@ mod tests { } #[test] - fn parse_dashboard_word_remains_a_pod_console_name_not_an_alias() { - let config = parse_args_from(["dashboard"]).unwrap(); - match config { - Mode::Tui { - mode: LaunchMode::PodName { pod_name, .. }, - .. - } => assert_eq!(pod_name, "dashboard"), - other => panic!("expected PodName TUI mode, got {other:?}"), - } + fn parse_dashboard_word_is_not_an_alias_or_pod_name() { + let err = parse_args_from(["dashboard"]).unwrap_err(); + assert_eq!(err.to_string(), "unknown command `dashboard`"); } #[test] @@ -1204,6 +1231,14 @@ mod tests { } } + #[test] + fn parse_resume_help() { + match parse_args_from(["resume", "--help"]).unwrap() { + Mode::ResumeHelp => {} + _ => panic!("expected ResumeHelp mode"), + } + } + #[test] fn parse_memory_lint_help() { match parse_args_from(["memory", "lint", "--help"]).unwrap() {