feat: add standalone resume picker

This commit is contained in:
2026-08-30 13:46:24 +09:00
parent c5fd9c01e5
commit 133feb8c76
8 changed files with 373 additions and 11 deletions
+4 -3
View File
@@ -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::{
+79
View File
@@ -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<WorkerResume, TargetError>;
fn standalone_session_list(
&self,
_include_all: bool,
) -> Result<StandaloneSessionListIntent, TargetError> {
Err(TargetError::unsupported(
"standalone session listing",
self.kind(),
))
}
fn standalone_session_resume(
&self,
_session_id: String,
) -> Result<StandaloneSessionResumeIntent, TargetError> {
Err(TargetError::unsupported(
"standalone session restore",
self.kind(),
))
}
fn dashboard(&self) -> Result<Dashboard, TargetError>;
fn list_workers(&self, request: WorkerListRequest) -> Result<WorkerList, TargetError>;
@@ -312,6 +345,29 @@ impl Target for StandaloneTarget {
Err(TargetError::unsupported("Worker restore", self.kind()))
}
fn standalone_session_list(
&self,
include_all: bool,
) -> Result<StandaloneSessionListIntent, TargetError> {
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<StandaloneSessionResumeIntent, TargetError> {
Ok(StandaloneSessionResumeIntent {
state_dir: self.state_dir.clone(),
session_id,
})
}
fn dashboard(&self) -> Result<Dashboard, TargetError> {
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();
+1
View File
@@ -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"
+30 -2
View File
@@ -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<dyn std::error::Error>> {
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<dyn std::error::Error>> {
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()
+11
View File
@@ -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<String>,
profile: Option<String>,
},
/// 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 <name>`: attach to a live Worker by name if possible;
/// otherwise launch the Worker runtime command with `--worker <name>` 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<dyn std::error::Error>),
},
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<dyn std::error::Error>),
}
}
LaunchMode::WorkerName {
worker_name,
socket_override,
+166
View File
@@ -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<Option<StandaloneSessionResumeIntent>, 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<Vec<StandaloneSessionRecord>, 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<StandaloneSessionRecord>,
) -> Result<Option<StandaloneSessionRecord>, 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"));
}
}
+2 -1
View File
@@ -115,7 +115,8 @@ fn standalone_target() -> Result<Box<dyn Target>, ParseError> {
.to_string(),
)
})?
.join("standalone");
.join("client")
.join("standalone-sessions");
Ok(Box::new(StandaloneTarget::new(state_dir)))
}
+80 -5
View File
@@ -502,10 +502,20 @@ fn parse_console_options<R: CliConnectionResolver + ?Sized>(
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<R: CliConnectionResolver + ?Sized>(
&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<R: CliConnectionResolver + ?Sized>(
});
}
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 <PATH>] [--runtime-id <ID>]
yoi [TARGET] resume [--workspace <PATH>|--all] [--runtime-id <ID>]
yoi [TARGET] panel [-r|--stopped] [--workspace <PATH>]
@@ -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 <URL> Use a Workspace Backend explicitly
--workspace-id <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(),