diff --git a/crates/client/src/lib.rs b/crates/client/src/lib.rs index 9969238f..bec7cdde 100644 --- a/crates/client/src/lib.rs +++ b/crates/client/src/lib.rs @@ -37,9 +37,10 @@ pub use backend_workspace::{ }; pub use runtime_command::WorkerRuntimeCommand; pub use target::{ - BackendTarget, Dashboard, LocalTarget, ResolvedTarget, StandaloneTarget, Target, TargetError, - TargetKind, WorkerByName, WorkerConnection, WorkerConnectionSelector, WorkerList, - WorkerListRequest, WorkerResume, WorkerSpawn, + BackendTarget, Dashboard, LocalTarget, ResolvedTarget, StandaloneSessionListIntent, + StandaloneSessionResumeIntent, StandaloneTarget, Target, TargetError, TargetKind, WorkerByName, + WorkerConnection, WorkerConnectionSelector, WorkerList, WorkerListRequest, WorkerResume, + WorkerSpawn, }; pub use spawn::{ diff --git a/crates/client/src/target.rs b/crates/client/src/target.rs index 73eed1b3..eb164da0 100644 --- a/crates/client/src/target.rs +++ b/crates/client/src/target.rs @@ -132,6 +132,19 @@ pub struct WorkerResume { pub runtime_command: WorkerRuntimeCommand, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct StandaloneSessionListIntent { + pub state_dir: PathBuf, + pub cwd: PathBuf, + pub include_all: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct StandaloneSessionResumeIntent { + pub state_dir: PathBuf, + pub session_id: String, +} + #[derive(Debug, Clone, PartialEq, Eq)] pub enum Dashboard { Local { @@ -204,6 +217,26 @@ pub trait Target: fmt::Debug + Send + Sync { fn resume_worker(&self) -> Result; + fn standalone_session_list( + &self, + _include_all: bool, + ) -> Result { + Err(TargetError::unsupported( + "standalone session listing", + self.kind(), + )) + } + + fn standalone_session_resume( + &self, + _session_id: String, + ) -> Result { + Err(TargetError::unsupported( + "standalone session restore", + self.kind(), + )) + } + fn dashboard(&self) -> Result; fn list_workers(&self, request: WorkerListRequest) -> Result; @@ -312,6 +345,29 @@ impl Target for StandaloneTarget { Err(TargetError::unsupported("Worker restore", self.kind())) } + fn standalone_session_list( + &self, + include_all: bool, + ) -> Result { + let cwd = std::env::current_dir() + .map_err(|error| TargetError::invalid(self.kind(), error.to_string()))?; + Ok(StandaloneSessionListIntent { + state_dir: self.state_dir.clone(), + cwd, + include_all, + }) + } + + fn standalone_session_resume( + &self, + session_id: String, + ) -> Result { + Ok(StandaloneSessionResumeIntent { + state_dir: self.state_dir.clone(), + session_id, + }) + } + fn dashboard(&self) -> Result { Err(TargetError::unsupported("Worker dashboard", self.kind())) } @@ -575,6 +631,29 @@ mod tests { ); } + #[test] + fn standalone_target_builds_explicit_session_intents() { + let target = StandaloneTarget::new("/tmp/yoi-client-sessions"); + let list = target.standalone_session_list(true).unwrap(); + assert_eq!(list.state_dir, PathBuf::from("/tmp/yoi-client-sessions")); + assert!(list.include_all); + assert!(list.cwd.is_absolute()); + + let resume = target + .standalone_session_resume("019d1234-0000-7000-8000-000000000000".to_string()) + .unwrap(); + assert_eq!(resume.state_dir, list.state_dir); + assert_eq!(resume.session_id, "019d1234-0000-7000-8000-000000000000"); + + assert!( + LocalTarget::new() + .standalone_session_list(false) + .unwrap_err() + .to_string() + .contains("not supported") + ); + } + #[test] fn local_target_builds_local_worker_list() { let target = LocalTarget::new(); diff --git a/crates/tui/Cargo.toml b/crates/tui/Cargo.toml index f9f2a267..8a99c4a5 100644 --- a/crates/tui/Cargo.toml +++ b/crates/tui/Cargo.toml @@ -11,6 +11,7 @@ e2e-test = [] [dependencies] client = { workspace = true } standalone = { workspace = true } +thiserror.workspace = true protocol = { workspace = true } ratatui = { version = "0.30.0", features = ["scrolling-regions"] } base64 = "0.22.1" diff --git a/crates/tui/src/console/mod.rs b/crates/tui/src/console/mod.rs index 2618fc4f..6da12bbb 100644 --- a/crates/tui/src/console/mod.rs +++ b/crates/tui/src/console/mod.rs @@ -26,7 +26,10 @@ use standalone::{StandaloneHost, StandaloneLaunchConfig}; use tokio::sync::{broadcast, mpsc}; use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD}; -use client::{BackendRuntimeClient, BackendRuntimeTarget, WorkerClient, WorkerRuntimeCommand}; +use client::{ + BackendRuntimeClient, BackendRuntimeTarget, StandaloneSessionResumeIntent, WorkerClient, + WorkerRuntimeCommand, +}; use crate::app::{ActionbarNoticeLevel, ActionbarNoticeSource, App}; use crate::composer_keys::{ComposerEditAction, composer_edit_action}; @@ -277,6 +280,31 @@ pub(crate) async fn run_standalone( let host = StandaloneHost::start(launch) .await .map_err(|error| io::Error::other(format!("Standalone Worker startup failed: {error}")))?; + run_standalone_host(host, worker_name, history_root).await +} + +pub(crate) async fn run_standalone_restore( + intent: StandaloneSessionResumeIntent, +) -> Result<(), Box> { + let session_id = intent.session_id.parse().map_err(|error| { + io::Error::new( + io::ErrorKind::InvalidInput, + format!("Invalid standalone session ID: {error}"), + ) + })?; + let host = StandaloneHost::restore(intent.state_dir, session_id) + .await + .map_err(|error| io::Error::other(format!("Standalone restore failed: {error}")))?; + let worker_label = format!("standalone-{}", session_id.short()); + let history_root = host.record().cwd.canonical_path.clone(); + run_standalone_host(host, worker_label, history_root).await +} + +async fn run_standalone_host( + host: StandaloneHost, + worker_label: String, + history_root: PathBuf, +) -> Result<(), Box> { let mut connection = ConsoleConnection::standalone(host); let mut terminal = match enter_fullscreen() { @@ -286,7 +314,7 @@ pub(crate) async fn run_standalone( return Err(error); } }; - let mut app = App::new_with_persistent_input_history(worker_name, &history_root); + let mut app = App::new_with_persistent_input_history(worker_label, &history_root); let run_result = run_loop(&mut terminal, &mut app, &mut connection, None).await; let shutdown_result = connection .shutdown() diff --git a/crates/tui/src/lib.rs b/crates/tui/src/lib.rs index f30da214..f5f895b5 100644 --- a/crates/tui/src/lib.rs +++ b/crates/tui/src/lib.rs @@ -19,6 +19,7 @@ mod role_session_registry; mod scroll; pub mod setup_model; mod spawn; +mod standalone_picker; mod task; mod text_selection; mod tool; @@ -51,6 +52,9 @@ pub enum LaunchMode { worker_name: Option, profile: Option, }, + /// Restore one client-owned standalone session. The current cwd is the default scope; + /// `include_all` opts into all standalone sessions under the same client data root. + StandaloneResume { include_all: bool }, /// `yoi --worker `: attach to a live Worker by name if possible; /// otherwise launch the Worker runtime command with `--worker ` so it /// resumes from name-keyed state or creates a fresh same-name Worker. @@ -168,6 +172,13 @@ pub async fn launch(options: LaunchOptions) -> ExitCode { } Err(e) => Err(Box::new(e) as Box), }, + LaunchMode::StandaloneResume { include_all } => { + match standalone_picker::pick(target.as_ref(), include_all) { + Ok(Some(intent)) => console::run_standalone_restore(intent).await, + Ok(None) => Ok(()), + Err(error) => Err(Box::new(error) as Box), + } + } LaunchMode::WorkerName { worker_name, socket_override, diff --git a/crates/tui/src/standalone_picker.rs b/crates/tui/src/standalone_picker.rs new file mode 100644 index 00000000..44581429 --- /dev/null +++ b/crates/tui/src/standalone_picker.rs @@ -0,0 +1,166 @@ +use std::io; +use std::time::Duration; + +use client::{StandaloneSessionListIntent, StandaloneSessionResumeIntent, Target}; +use crossterm::event::{self, Event as TermEvent, KeyCode, KeyEventKind, KeyModifiers}; +use ratatui::Terminal; +use ratatui::backend::CrosstermBackend; +use ratatui::layout::{Constraint, Layout}; +use ratatui::prelude::{Color, Line, Modifier, Span, Style}; +use ratatui::widgets::Paragraph; +use ratatui::{TerminalOptions, Viewport}; +use standalone::{StandaloneListScope, StandaloneSessionRecord, StandaloneSessionStore}; +use thiserror::Error; + +const LIMIT: usize = 100; + +pub(crate) fn pick( + target: &dyn Target, + include_all: bool, +) -> Result, StandalonePickerError> { + let intent = target + .standalone_session_list(include_all) + .map_err(StandalonePickerError::Target)?; + let records = load_records(&intent)?; + if records.is_empty() { + return Err(StandalonePickerError::NoSessions { include_all }); + } + let selected = run_picker(records)?; + selected + .map(|record| { + target + .standalone_session_resume(record.session_id.to_string()) + .map_err(StandalonePickerError::Target) + }) + .transpose() +} + +fn load_records( + intent: &StandaloneSessionListIntent, +) -> Result, StandalonePickerError> { + let store = StandaloneSessionStore::open(&intent.state_dir) + .map_err(StandalonePickerError::StateStore)?; + store + .list( + &intent.cwd, + if intent.include_all { + StandaloneListScope::All + } else { + StandaloneListScope::CurrentCwd + }, + LIMIT, + ) + .map_err(StandalonePickerError::StateStore) +} + +fn run_picker( + records: Vec, +) -> Result, StandalonePickerError> { + let height = u16::try_from(records.len().saturating_add(3).min(20)).unwrap_or(20); + let mut terminal = Terminal::with_options( + CrosstermBackend::new(io::stdout()), + TerminalOptions { + viewport: Viewport::Inline(height), + }, + ) + .map_err(StandalonePickerError::Io)?; + let mut selected = 0usize; + loop { + terminal + .draw(|frame| draw(frame, &records, selected)) + .map_err(StandalonePickerError::Io)?; + if !event::poll(Duration::from_millis(100)).map_err(StandalonePickerError::Io)? { + continue; + } + let TermEvent::Key(key) = event::read().map_err(StandalonePickerError::Io)? else { + continue; + }; + if key.kind == KeyEventKind::Release { + continue; + } + let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); + match key.code { + KeyCode::Up | KeyCode::Char('k') if !ctrl => { + selected = selected.saturating_sub(1); + } + KeyCode::Down | KeyCode::Char('j') if !ctrl => { + selected = (selected + 1).min(records.len() - 1); + } + KeyCode::Enter => return Ok(Some(records[selected].clone())), + KeyCode::Esc => return Ok(None), + KeyCode::Char('c') if ctrl => return Ok(None), + _ => {} + } + } +} + +fn draw(frame: &mut ratatui::Frame<'_>, records: &[StandaloneSessionRecord], selected: usize) { + let mut constraints = vec![Constraint::Length(1)]; + constraints.extend(records.iter().map(|_| Constraint::Length(1))); + constraints.push(Constraint::Length(1)); + let rows = Layout::vertical(constraints).split(frame.area()); + frame.render_widget( + Paragraph::new(Line::from(Span::styled( + "resume standalone session", + Style::default().add_modifier(Modifier::BOLD), + ))), + rows[0], + ); + for (index, record) in records.iter().enumerate() { + let active = index == selected; + let marker = if active { "▶ " } else { " " }; + let style = if active { + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(Color::DarkGray) + }; + let cwd = record.cwd.canonical_path.display(); + frame.render_widget( + Paragraph::new(Line::from(vec![ + Span::raw(marker), + Span::styled(record.session_id.short(), style), + Span::raw(format!( + " [{:?}] updated:{} {}", + record.status, record.updated_at_unix_ms, cwd + )), + ])), + rows[index + 1], + ); + } + frame.render_widget( + Paragraph::new(" [↑/↓] select [enter] restore [esc] cancel"), + rows[records.len() + 1], + ); +} + +#[derive(Debug, Error)] +pub(crate) enum StandalonePickerError { + #[error("standalone target error: {0}")] + Target(#[source] client::TargetError), + #[error("standalone session state is unavailable: {0}")] + StateStore(#[source] standalone::StandaloneStoreError), + #[error( + "no standalone sessions found for this cwd; use `yoi --local --resume --all` to include all cwd identities" + )] + NoSessions { include_all: bool }, + #[error("standalone session picker I/O failed: {0}")] + Io(#[source] io::Error), +} + +#[cfg(test)] +mod tests { + use client::StandaloneTarget; + + use super::*; + + #[test] + fn empty_picker_keeps_current_cwd_as_default_scope() { + let temp = tempfile::tempdir().expect("tempdir"); + let target = StandaloneTarget::new(temp.path()); + let error = pick(&target, false).expect_err("empty picker should fail explicitly"); + assert!(error.to_string().contains("this cwd")); + assert!(error.to_string().contains("--all")); + } +} diff --git a/crates/yoi/src/cli_connection.rs b/crates/yoi/src/cli_connection.rs index 7d438eeb..159c6108 100644 --- a/crates/yoi/src/cli_connection.rs +++ b/crates/yoi/src/cli_connection.rs @@ -115,7 +115,8 @@ fn standalone_target() -> Result, ParseError> { .to_string(), ) })? - .join("standalone"); + .join("client") + .join("standalone-sessions"); Ok(Box::new(StandaloneTarget::new(state_dir))) } diff --git a/crates/yoi/src/main.rs b/crates/yoi/src/main.rs index 0db52b6b..11c14c12 100644 --- a/crates/yoi/src/main.rs +++ b/crates/yoi/src/main.rs @@ -502,10 +502,20 @@ fn parse_console_options( let mut socket_override = None; let mut runtime_id = None; let mut worker_id = None; + let mut standalone_resume = false; + let mut standalone_all = false; let mut i = 0; while i < args.len() { let arg = &args[i]; match arg.as_str() { + "--resume" => { + standalone_resume = true; + i += 1; + } + "--all" => { + standalone_all = true; + i += 1; + } "--worker" => { let value = args .get(i + 1) @@ -687,10 +697,33 @@ fn parse_console_options( &workspace_root, )?; + if standalone_all && !standalone_resume { + return Err(ParseError("--all requires --resume".to_string())); + } + if standalone_resume { + if target.kind() != TargetKind::Standalone { + return Err(ParseError( + "--resume is a Standalone option and requires --local".to_string(), + )); + } + if worker_name.is_some() + || profile.is_some() + || session.is_some() + || socket_override.is_some() + || runtime_id.is_some() + || worker_id.is_some() + { + return Err(ParseError( + "--local --resume cannot be combined with Worker, profile, session, socket, or Runtime selectors" + .to_string(), + )); + } + } + if target.kind() == TargetKind::Standalone { if session.is_some() { return Err(ParseError( - "--local starts a fresh Standalone Worker; --session restore requires the legacy local Runtime" + "--local does not accept legacy --session; use --local --resume for Standalone session restore" .to_string(), )); } @@ -725,7 +758,11 @@ fn parse_console_options( }); } - let mode = if let Some(profile) = profile { + let mode = if standalone_resume { + LaunchMode::StandaloneResume { + include_all: standalone_all, + } + } else if let Some(profile) = profile { LaunchMode::Spawn { worker_name, profile: Some(profile), @@ -1661,6 +1698,7 @@ const TOP_LEVEL_HELP: &str = r#"yoi Usage: yoi [TARGET] [CONSOLE_OPTIONS] + yoi --local --resume [--all] yoi [TARGET] workers [-r|--stopped] [--workspace ] [--runtime-id ] yoi [TARGET] resume [--workspace |--all] [--runtime-id ] yoi [TARGET] panel [-r|--stopped] [--workspace ] @@ -1670,7 +1708,9 @@ Usage: Target selection: Target options are top-level options and must appear before the command. - --local Start a one-process Standalone Worker (no Server or Runtime) + --local Start or restore a client-owned one-process Standalone Worker + --resume With --local, open the Standalone session picker for the current cwd + --all With --local --resume, include sessions from every cwd identity --backend Use a Workspace Backend explicitly --workspace-id Scope Backend routes to a Workspace id @@ -2080,6 +2120,42 @@ backend = "shared" )); } + #[test] + fn parser_local_resume_uses_standalone_picker_scope() { + let mode = parse_args_from(["--local", "--resume"]).unwrap(); + let Mode::Tui { target, mode, .. } = mode else { + panic!("expected TUI mode") + }; + assert_eq!(target.kind(), TargetKind::Standalone); + assert!(matches!( + mode, + LaunchMode::StandaloneResume { include_all: false } + )); + let intent = target.standalone_session_list(false).unwrap(); + assert!(intent.state_dir.ends_with("client/standalone-sessions")); + assert!(!intent.include_all); + + let mode = parse_args_from(["--local", "--resume", "--all"]).unwrap(); + let Mode::Tui { mode, .. } = mode else { + panic!("expected TUI mode") + }; + assert!(matches!( + mode, + LaunchMode::StandaloneResume { include_all: true } + )); + + assert_eq!( + parse_args_from(["--resume"]).unwrap_err().to_string(), + "--resume is a Standalone option and requires --local" + ); + assert_eq!( + parse_args_from(["--local", "--all"]) + .unwrap_err() + .to_string(), + "--all requires --resume" + ); + } + #[test] fn parser_rejects_standalone_and_backend_target_together() { let err = parse_args_from(["--local", "--backend", "http://backend.example"]).unwrap_err(); @@ -2101,7 +2177,7 @@ backend = "shared" let err = parse_args_slice_with_connection_resolver(&session_args, &resolver).unwrap_err(); assert_eq!( err.0, - "--local starts a fresh Standalone Worker; --session restore requires the legacy local Runtime" + "--local does not accept legacy --session; use --local --resume for Standalone session restore" ); let socket_args = [ @@ -2567,7 +2643,6 @@ backend = "shared" fn parse_rejects_legacy_resume_flags() { let cases = [ (vec!["-r".to_string()], "unknown argument: -r"), - (vec!["--resume".to_string()], "unknown argument: --resume"), ( vec![ "--worker".to_string(),