From 2f8d0ffbef96ebd06f898a5ec79b2b2d5a853912 Mon Sep 17 00:00:00 2001 From: RoyLin Date: Tue, 28 Jul 2026 23:51:20 +0800 Subject: [PATCH 1/2] feat: add host-owned live artifact preview --- src/api/code_web/mod.rs | 2 + src/api/code_web/module.rs | 2 + src/api/code_web/previews/controller.rs | 37 +++ src/api/code_web/previews/http.rs | 241 ++++++++++++++ src/api/code_web/previews/mod.rs | 13 + src/api/code_web/previews/model.rs | 76 +++++ src/api/code_web/previews/module.rs | 31 ++ src/api/code_web/previews/registry.rs | 317 +++++++++++++++++++ src/api/code_web/previews/service.rs | 33 ++ src/api/code_web/previews/tests.rs | 272 ++++++++++++++++ src/api/code_web/state.rs | 8 + src/api/serve/mod.rs | 8 +- src/tui/app/launch.rs | 3 + src/tui/app/live_preview.rs | 403 ++++++++++++++++++++++++ src/tui/app/submit.rs | 3 + src/tui/app/types.rs | 5 + src/tui/app/update_dispatch.rs | 13 + src/tui/mod.rs | 12 + src/tui/os/remote_ui.rs | 105 +++++- src/tui/panels/workspace/ide.rs | 67 +++- src/tui/tests.rs | 8 + src/tui/ui/chrome.rs | 4 + src/tui/ui/editor_state.rs | 4 + 23 files changed, 1661 insertions(+), 6 deletions(-) create mode 100644 src/api/code_web/previews/controller.rs create mode 100644 src/api/code_web/previews/http.rs create mode 100644 src/api/code_web/previews/mod.rs create mode 100644 src/api/code_web/previews/model.rs create mode 100644 src/api/code_web/previews/module.rs create mode 100644 src/api/code_web/previews/registry.rs create mode 100644 src/api/code_web/previews/service.rs create mode 100644 src/api/code_web/previews/tests.rs create mode 100644 src/tui/app/live_preview.rs diff --git a/src/api/code_web/mod.rs b/src/api/code_web/mod.rs index f7dcdf0..f9599a9 100644 --- a/src/api/code_web/mod.rs +++ b/src/api/code_web/mod.rs @@ -12,6 +12,7 @@ mod module; mod os; mod permissions; mod plugins; +mod previews; mod processes; mod remote; mod session_runtime; @@ -24,5 +25,6 @@ mod workspace_backend_cache; pub(super) use kernel::KernelService; pub(super) use module::CodeWebModule; +pub(in crate::api) use previews::content_router as preview_content_router; pub(super) use session_store::CodeWebSessionRepository; pub(super) use state::CodeWebState; diff --git a/src/api/code_web/module.rs b/src/api/code_web/module.rs index 370a1dc..e27bab0 100644 --- a/src/api/code_web/module.rs +++ b/src/api/code_web/module.rs @@ -13,6 +13,7 @@ use super::knowledge::KnowledgeModule; use super::loops::LoopsModule; use super::os::OsModule; use super::plugins::PluginsModule; +use super::previews::PreviewsModule; use super::processes::ProcessesModule; use super::state::CodeWebState; use super::weixin::WeixinModule; @@ -48,6 +49,7 @@ impl Module for CodeWebModule { Arc::new(EvolutionModule), Arc::new(KernelModule), Arc::new(ProcessesModule), + Arc::new(PreviewsModule), Arc::new(LoopsModule), Arc::new(PluginsModule), Arc::new(OsModule), diff --git a/src/api/code_web/previews/controller.rs b/src/api/code_web/previews/controller.rs new file mode 100644 index 0000000..05aa3e7 --- /dev/null +++ b/src/api/code_web/previews/controller.rs @@ -0,0 +1,37 @@ +use std::sync::Arc; + +use a3s_boot::{controller, Result as BootResult}; + +use super::model::{CreatePreviewRequest, PreviewDescriptor}; +use super::service::PreviewsService; + +pub(super) struct PreviewsController { + service: Arc, +} + +impl PreviewsController { + pub(super) fn new(service: Arc) -> Self { + Self { service } + } +} + +#[controller("/")] +impl PreviewsController { + #[post("/v1/previews")] + async fn create_preview( + &self, + #[body] request: CreatePreviewRequest, + ) -> BootResult { + self.service.create(request).await + } + + #[get("/v1/previews/{id}")] + async fn preview(&self, #[param("id")] id: String) -> BootResult { + self.service.get(&id).await + } + + #[delete("/v1/previews/{id}")] + async fn stop_preview(&self, #[param("id")] id: String) -> BootResult { + self.service.remove(&id).await + } +} diff --git a/src/api/code_web/previews/http.rs b/src/api/code_web/previews/http.rs new file mode 100644 index 0000000..f6f62d0 --- /dev/null +++ b/src/api/code_web/previews/http.rs @@ -0,0 +1,241 @@ +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use axum::body::Body; +use axum::extract::{Path as AxumPath, State}; +use axum::http::{ + header::{CACHE_CONTROL, CONTENT_DISPOSITION, CONTENT_SECURITY_POLICY, CONTENT_TYPE}, + HeaderName, HeaderValue, StatusCode, +}; +use axum::response::Response; +use axum::routing::get; +use axum::Router; + +use super::model::PreviewContentSource; +use super::registry::PreviewRegistry; + +const MAX_PREVIEW_ASSET_BYTES: u64 = 64 * 1024 * 1024; +const PREVIEW_CSP: &str = "default-src 'self' data: blob: http: https:; script-src 'self' 'unsafe-inline' 'unsafe-eval' blob: http: https:; style-src 'self' 'unsafe-inline' data: http: https:; img-src 'self' data: blob: http: https:; font-src 'self' data: http: https:; media-src 'self' data: blob: http: https:; connect-src 'self' data: blob: http://localhost:* https://localhost:* ws://localhost:* wss://localhost:* http://127.0.0.1:* https://127.0.0.1:* ws://127.0.0.1:* wss://127.0.0.1:* http://[::1]:* https://[::1]:* ws://[::1]:* wss://[::1]:*; frame-src http: https:; object-src 'none'; base-uri 'none'; sandbox allow-scripts allow-forms allow-modals allow-popups allow-downloads"; + +pub(in crate::api) fn content_router(registry: Arc) -> Router { + Router::new() + .route("/preview/{token}", get(serve_root)) + .route("/preview/{token}/", get(serve_root)) + .route("/preview/{token}/{*path}", get(serve_asset)) + .with_state(registry) +} + +async fn serve_root( + State(registry): State>, + AxumPath(token): AxumPath, +) -> Response { + serve_content(®istry, &token, "").await +} + +async fn serve_asset( + State(registry): State>, + AxumPath((token, path)): AxumPath<(String, String)>, +) -> Response { + serve_content(®istry, &token, &path).await +} + +pub(super) async fn serve_content( + registry: &PreviewRegistry, + token: &str, + request_path: &str, +) -> Response { + let Some(source) = registry.content(token).await else { + return text_response(StatusCode::NOT_FOUND, "preview session not found"); + }; + let path = match resolve_content_path(source, request_path).await { + Ok(path) => path, + Err(status) => return text_response(status, status.canonical_reason().unwrap_or("error")), + }; + let metadata = match tokio::fs::metadata(&path).await { + Ok(metadata) if metadata.is_file() => metadata, + _ => return text_response(StatusCode::NOT_FOUND, "preview asset not found"), + }; + if metadata.len() > MAX_PREVIEW_ASSET_BYTES { + return text_response(StatusCode::PAYLOAD_TOO_LARGE, "preview asset is too large"); + } + match tokio::fs::read(&path).await { + Ok(body) => preview_response(&path, body), + Err(_) => text_response(StatusCode::NOT_FOUND, "preview asset not found"), + } +} + +async fn resolve_content_path( + source: PreviewContentSource, + request_path: &str, +) -> Result { + match source { + PreviewContentSource::File { path } => { + if request_path.is_empty() { + Ok(path) + } else { + Err(StatusCode::NOT_FOUND) + } + } + PreviewContentSource::Directory { root, entry } => { + if request_path.is_empty() { + return Ok(entry); + } + let segments = safe_segments(request_path)?; + let mut requested = root.clone(); + for segment in segments { + requested.push(segment); + } + let mut canonical = tokio::fs::canonicalize(&requested) + .await + .map_err(|_| StatusCode::NOT_FOUND)?; + if !canonical.starts_with(&root) { + return Err(StatusCode::FORBIDDEN); + } + if canonical.is_dir() { + canonical = tokio::fs::canonicalize(canonical.join("index.html")) + .await + .map_err(|_| StatusCode::NOT_FOUND)?; + if !canonical.starts_with(&root) { + return Err(StatusCode::FORBIDDEN); + } + } + Ok(canonical) + } + } +} + +fn safe_segments(request_path: &str) -> Result, StatusCode> { + let mut segments = Vec::new(); + for segment in request_path.split('/') { + if segment.is_empty() { + continue; + } + if segment == "." + || segment == ".." + || segment.starts_with('.') + || segment.contains(['\\', '\0']) + || is_sensitive_file(segment) + { + return Err(StatusCode::FORBIDDEN); + } + segments.push(segment); + } + Ok(segments) +} + +fn is_sensitive_file(name: &str) -> bool { + let lower = name.to_ascii_lowercase(); + matches!( + lower.as_str(), + "id_rsa" | "id_dsa" | "id_ecdsa" | "id_ed25519" | "credentials" | "credentials.json" + ) || matches!( + Path::new(&lower) + .extension() + .and_then(|value| value.to_str()), + Some("pem" | "key" | "p12" | "pfx") + ) +} + +fn preview_response(path: &Path, body: Vec) -> Response { + let mut response = Response::new(Body::from(body)); + *response.status_mut() = StatusCode::OK; + response.headers_mut().insert( + CONTENT_TYPE, + HeaderValue::from_static(content_type_for(path)), + ); + response.headers_mut().insert( + CACHE_CONTROL, + HeaderValue::from_static("no-cache, no-store, must-revalidate"), + ); + response + .headers_mut() + .insert(CONTENT_DISPOSITION, HeaderValue::from_static("inline")); + response.headers_mut().insert( + HeaderName::from_static("x-content-type-options"), + HeaderValue::from_static("nosniff"), + ); + response.headers_mut().insert( + HeaderName::from_static("referrer-policy"), + HeaderValue::from_static("no-referrer"), + ); + response.headers_mut().insert( + HeaderName::from_static("permissions-policy"), + HeaderValue::from_static("camera=(), microphone=(), geolocation=(), usb=(), serial=()"), + ); + if is_html(path) { + response.headers_mut().insert( + CONTENT_SECURITY_POLICY, + HeaderValue::from_static(PREVIEW_CSP), + ); + } + response +} + +fn text_response(status: StatusCode, body: &'static str) -> Response { + let mut response = Response::new(Body::from(body)); + *response.status_mut() = status; + response.headers_mut().insert( + CONTENT_TYPE, + HeaderValue::from_static("text/plain; charset=utf-8"), + ); + response +} + +fn is_html(path: &Path) -> bool { + matches!( + path.extension() + .and_then(|extension| extension.to_str()) + .map(str::to_ascii_lowercase) + .as_deref(), + Some("html" | "htm") + ) +} + +fn content_type_for(path: &Path) -> &'static str { + match path + .extension() + .and_then(|extension| extension.to_str()) + .unwrap_or_default() + .to_ascii_lowercase() + .as_str() + { + "html" | "htm" => "text/html; charset=utf-8", + "css" => "text/css; charset=utf-8", + "js" | "mjs" | "cjs" | "jsx" | "ts" | "tsx" => "text/javascript; charset=utf-8", + "json" | "map" => "application/json", + "xml" => "application/xml", + "txt" | "md" | "markdown" | "yaml" | "yml" | "toml" | "acl" | "log" | "rs" | "py" + | "sh" | "c" | "h" | "cc" | "cpp" | "java" | "go" | "sql" => "text/plain; charset=utf-8", + "svg" => "image/svg+xml", + "png" => "image/png", + "jpg" | "jpeg" => "image/jpeg", + "gif" => "image/gif", + "webp" => "image/webp", + "bmp" => "image/bmp", + "avif" => "image/avif", + "ico" => "image/x-icon", + "pdf" => "application/pdf", + "wasm" => "application/wasm", + "woff" => "font/woff", + "woff2" => "font/woff2", + "ttf" => "font/ttf", + "otf" => "font/otf", + "webmanifest" => "application/manifest+json", + "mp4" => "video/mp4", + "webm" => "video/webm", + "mp3" => "audio/mpeg", + "wav" => "audio/wav", + "ogg" => "audio/ogg", + "doc" => "application/msword", + "docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "xls" => "application/vnd.ms-excel", + "xlsx" => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "ppt" => "application/vnd.ms-powerpoint", + "pptx" => "application/vnd.openxmlformats-officedocument.presentationml.presentation", + "csv" => "text/csv; charset=utf-8", + "ods" => "application/vnd.oasis.opendocument.spreadsheet", + "odt" => "application/vnd.oasis.opendocument.text", + "odp" => "application/vnd.oasis.opendocument.presentation", + _ => "application/octet-stream", + } +} diff --git a/src/api/code_web/previews/mod.rs b/src/api/code_web/previews/mod.rs new file mode 100644 index 0000000..cb02524 --- /dev/null +++ b/src/api/code_web/previews/mod.rs @@ -0,0 +1,13 @@ +mod controller; +mod http; +mod model; +mod module; +mod registry; +mod service; + +pub(in crate::api) use http::content_router; +pub(super) use module::PreviewsModule; +pub(in crate::api) use registry::PreviewRegistry; + +#[cfg(test)] +mod tests; diff --git a/src/api/code_web/previews/model.rs b/src/api/code_web/previews/model.rs new file mode 100644 index 0000000..9b7f9ca --- /dev/null +++ b/src/api/code_web/previews/model.rs @@ -0,0 +1,76 @@ +use std::path::PathBuf; + +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(in crate::api::code_web) struct CreatePreviewRequest { + pub(in crate::api::code_web) target: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(in crate::api) enum PreviewKind { + StaticSite, + LocalUrl, + Pdf, + Image, + Office, + Text, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde( + rename_all = "camelCase", + rename_all_fields = "camelCase", + tag = "type" +)] +pub(in crate::api) enum PreviewSource { + Path { + path: String, + root_path: String, + name: String, + size: u64, + mtime_ms: Option, + is_directory: bool, + is_binary: bool, + }, + Url { + url: String, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(in crate::api) struct PreviewCapabilities { + pub(in crate::api) live_reload: bool, + pub(in crate::api) responsive: bool, + pub(in crate::api) navigation: bool, + pub(in crate::api) open_external: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(in crate::api) struct PreviewDescriptor { + pub(in crate::api) id: String, + pub(in crate::api) kind: PreviewKind, + pub(in crate::api) title: String, + pub(in crate::api) source: PreviewSource, + pub(in crate::api) content_url: String, + pub(in crate::api) watch_root: Option, + pub(in crate::api) created_at: i64, + pub(in crate::api) expires_at: i64, + pub(in crate::api) capabilities: PreviewCapabilities, +} + +#[derive(Debug, Clone)] +pub(in crate::api) enum PreviewContentSource { + Directory { root: PathBuf, entry: PathBuf }, + File { path: PathBuf }, +} + +#[derive(Debug, Clone)] +pub(in crate::api) struct PreviewSession { + pub(in crate::api) descriptor: PreviewDescriptor, + pub(in crate::api) content: Option, +} diff --git a/src/api/code_web/previews/module.rs b/src/api/code_web/previews/module.rs new file mode 100644 index 0000000..52c42ca --- /dev/null +++ b/src/api/code_web/previews/module.rs @@ -0,0 +1,31 @@ +use std::sync::Arc; + +use a3s_boot::{ControllerDefinition, Module, ModuleRef, ProviderDefinition, Result as BootResult}; + +use super::controller::PreviewsController; +use super::service::PreviewsService; +use crate::api::code_web::state::CodeWebState; + +pub(in crate::api::code_web) struct PreviewsModule; + +impl Module for PreviewsModule { + fn name(&self) -> &'static str { + "a3s-code-web-previews" + } + + fn providers(&self) -> BootResult> { + Ok(vec![ProviderDefinition::factory_arc::( + |module_ref| { + let state = module_ref.get::()?; + Ok(Arc::new(PreviewsService::new(state.preview_registry()))) + }, + )]) + } + + fn controllers(&self, module_ref: &ModuleRef) -> BootResult> { + let service = module_ref.get::()?; + Ok(vec![ + Arc::new(PreviewsController::new(service)).controller()? + ]) + } +} diff --git a/src/api/code_web/previews/registry.rs b/src/api/code_web/previews/registry.rs new file mode 100644 index 0000000..39ed499 --- /dev/null +++ b/src/api/code_web/previews/registry.rs @@ -0,0 +1,317 @@ +use std::collections::HashMap; +use std::net::IpAddr; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::{Duration, UNIX_EPOCH}; + +use a3s_boot::{BootError, Result as BootResult}; +use rand::distributions::{Alphanumeric, DistString}; +use tokio::fs; +use tokio::sync::RwLock; +use url::Url; + +use super::model::{ + PreviewCapabilities, PreviewContentSource, PreviewDescriptor, PreviewKind, PreviewSession, + PreviewSource, +}; + +const MAX_SESSIONS: usize = 32; +const SESSION_TTL: Duration = Duration::from_secs(12 * 60 * 60); + +#[derive(Clone)] +pub(in crate::api) struct PreviewRegistry { + workspace_root: PathBuf, + sessions: Arc>>, +} + +impl PreviewRegistry { + pub(in crate::api) fn new(workspace_root: PathBuf) -> Self { + Self { + workspace_root, + sessions: Arc::new(RwLock::new(HashMap::new())), + } + } + + pub(in crate::api::code_web) async fn create( + &self, + target: String, + ) -> BootResult { + let target = target.trim(); + if target.is_empty() { + return Err(BootError::BadRequest("target is required".to_string())); + } + + let session = if target.starts_with("http://") || target.starts_with("https://") { + self.local_url_session(target)? + } else { + self.path_session(target).await? + }; + let descriptor = session.descriptor.clone(); + let mut sessions = self.sessions.write().await; + prune_sessions(&mut sessions, descriptor.created_at); + sessions.insert(descriptor.id.clone(), session); + Ok(descriptor) + } + + pub(in crate::api::code_web) async fn get(&self, id: &str) -> BootResult { + let mut sessions = self.sessions.write().await; + prune_expired_sessions(&mut sessions, now_millis()); + sessions + .get(id) + .map(|session| session.descriptor.clone()) + .ok_or_else(|| BootError::NotFound(format!("preview session was not found: {id}"))) + } + + pub(in crate::api::code_web) async fn remove(&self, id: &str) -> BootResult<()> { + let mut sessions = self.sessions.write().await; + prune_expired_sessions(&mut sessions, now_millis()); + let removed = sessions.remove(id); + if removed.is_none() { + return Err(BootError::NotFound(format!( + "preview session was not found: {id}" + ))); + } + Ok(()) + } + + pub(in crate::api) async fn content(&self, id: &str) -> Option { + let mut sessions = self.sessions.write().await; + prune_expired_sessions(&mut sessions, now_millis()); + sessions.get(id).and_then(|session| session.content.clone()) + } + + #[cfg(test)] + pub(super) async fn expire_for_test(&self, id: &str) { + if let Some(session) = self.sessions.write().await.get_mut(id) { + session.descriptor.expires_at = now_millis() - 1; + } + } + + fn local_url_session(&self, target: &str) -> BootResult { + let mut url = Url::parse(target) + .map_err(|error| BootError::BadRequest(format!("target URL is invalid: {error}")))?; + if !matches!(url.scheme(), "http" | "https") { + return Err(BootError::BadRequest( + "only HTTP and HTTPS preview URLs are supported".to_string(), + )); + } + if !url.username().is_empty() || url.password().is_some() { + return Err(BootError::BadRequest( + "preview URLs cannot contain credentials".to_string(), + )); + } + let host = url + .host_str() + .ok_or_else(|| BootError::BadRequest("preview URL must include a host".to_string()))?; + if !is_loopback_host(host) { + return Err(BootError::BadRequest( + "preview URL must use localhost or a loopback IP address".to_string(), + )); + } + url.set_fragment(None); + let url = url.to_string(); + let now = now_millis(); + let id = new_session_id(); + let title = Url::parse(&url) + .ok() + .and_then(|value| value.host_str().map(str::to_string)) + .unwrap_or_else(|| "Local app".to_string()); + Ok(PreviewSession { + descriptor: PreviewDescriptor { + id, + kind: PreviewKind::LocalUrl, + title, + source: PreviewSource::Url { url: url.clone() }, + content_url: url, + watch_root: None, + created_at: now, + expires_at: now + SESSION_TTL.as_millis() as i64, + capabilities: PreviewCapabilities { + live_reload: false, + responsive: true, + navigation: true, + open_external: true, + }, + }, + content: None, + }) + } + + async fn path_session(&self, target: &str) -> BootResult { + let workspace_root = canonicalize_existing(&self.workspace_root, "workspace root").await?; + let requested = PathBuf::from(target); + let requested = if requested.is_absolute() { + requested + } else { + workspace_root.join(requested) + }; + let path = canonicalize_existing(&requested, "preview target").await?; + if !path.starts_with(&workspace_root) { + return Err(BootError::BadRequest( + "preview target must stay inside the active workspace".to_string(), + )); + } + let metadata = fs::metadata(&path).await.map_err(|error| { + BootError::BadRequest(format!("preview target is unavailable: {error}")) + })?; + let is_directory = metadata.is_dir(); + let (kind, content, root_path, title) = if is_directory { + let entry = canonicalize_existing(&path.join("index.html"), "preview entry file") + .await + .map_err(|_| { + BootError::BadRequest( + "preview directory must contain an index.html file".to_string(), + ) + })?; + if !entry.starts_with(&path) { + return Err(BootError::BadRequest( + "preview index.html cannot resolve outside its directory".to_string(), + )); + } + ( + PreviewKind::StaticSite, + PreviewContentSource::Directory { + root: path.clone(), + entry, + }, + path.clone(), + file_name(&path), + ) + } else { + let kind = preview_kind_for_path(&path)?; + let parent = path.parent().ok_or_else(|| { + BootError::BadRequest("preview target must have a parent directory".to_string()) + })?; + let content = if kind == PreviewKind::StaticSite { + PreviewContentSource::Directory { + root: parent.to_path_buf(), + entry: path.clone(), + } + } else { + PreviewContentSource::File { path: path.clone() } + }; + (kind, content, parent.to_path_buf(), file_name(&path)) + }; + let now = now_millis(); + let id = new_session_id(); + let size = if metadata.is_file() { + metadata.len() + } else { + 0 + }; + let mtime_ms = metadata.modified().ok().and_then(|value| { + value + .duration_since(UNIX_EPOCH) + .ok() + .and_then(|duration| u64::try_from(duration.as_millis()).ok()) + }); + let responsive = kind == PreviewKind::StaticSite; + Ok(PreviewSession { + descriptor: PreviewDescriptor { + content_url: format!("/preview/{id}/"), + id, + kind, + title: title.clone(), + source: PreviewSource::Path { + path: path.display().to_string(), + root_path: root_path.display().to_string(), + name: title, + size, + mtime_ms, + is_directory, + is_binary: is_binary_kind(kind), + }, + watch_root: Some(root_path.display().to_string()), + created_at: now, + expires_at: now + SESSION_TTL.as_millis() as i64, + capabilities: PreviewCapabilities { + live_reload: true, + responsive, + navigation: responsive, + open_external: true, + }, + }, + content: Some(content), + }) + } +} + +fn preview_kind_for_path(path: &Path) -> BootResult { + let extension = path + .extension() + .and_then(|extension| extension.to_str()) + .unwrap_or_default() + .to_ascii_lowercase(); + let kind = match extension.as_str() { + "html" | "htm" => PreviewKind::StaticSite, + "pdf" => PreviewKind::Pdf, + "png" | "jpg" | "jpeg" | "gif" | "webp" | "bmp" | "avif" => PreviewKind::Image, + "docx" | "xls" | "xlsx" | "ods" | "csv" | "pptx" => PreviewKind::Office, + "txt" | "md" | "markdown" | "json" | "xml" | "svg" | "yaml" | "yml" | "toml" | "acl" + | "log" | "css" | "scss" | "js" | "jsx" | "ts" | "tsx" | "rs" | "py" | "sh" | "c" | "h" + | "cc" | "cpp" | "java" | "go" | "sql" => PreviewKind::Text, + _ => { + return Err(BootError::BadRequest(format!( + "preview format is not supported: {}", + path.display() + ))) + } + }; + Ok(kind) +} + +fn is_binary_kind(kind: PreviewKind) -> bool { + matches!( + kind, + PreviewKind::Pdf | PreviewKind::Image | PreviewKind::Office + ) +} + +fn is_loopback_host(host: &str) -> bool { + let lower = host.to_ascii_lowercase(); + lower == "localhost" + || lower.ends_with(".localhost") + || lower + .parse::() + .is_ok_and(|address| address.is_loopback()) +} + +fn prune_sessions(sessions: &mut HashMap, now: i64) { + prune_expired_sessions(sessions, now); + while sessions.len() >= MAX_SESSIONS { + let Some(oldest) = sessions + .iter() + .min_by_key(|(_, session)| session.descriptor.created_at) + .map(|(id, _)| id.clone()) + else { + break; + }; + sessions.remove(&oldest); + } +} + +fn prune_expired_sessions(sessions: &mut HashMap, now: i64) { + sessions.retain(|_, session| session.descriptor.expires_at > now); +} + +async fn canonicalize_existing(path: &Path, label: &str) -> BootResult { + fs::canonicalize(path) + .await + .map_err(|error| BootError::BadRequest(format!("{label} is unavailable: {error}"))) +} + +fn file_name(path: &Path) -> String { + path.file_name() + .and_then(|name| name.to_str()) + .filter(|name| !name.is_empty()) + .unwrap_or("Preview") + .to_string() +} + +fn new_session_id() -> String { + Alphanumeric.sample_string(&mut rand::thread_rng(), 32) +} + +fn now_millis() -> i64 { + chrono::Utc::now().timestamp_millis() +} diff --git a/src/api/code_web/previews/service.rs b/src/api/code_web/previews/service.rs new file mode 100644 index 0000000..0a48ef5 --- /dev/null +++ b/src/api/code_web/previews/service.rs @@ -0,0 +1,33 @@ +use std::sync::Arc; + +use a3s_boot::Result as BootResult; +use serde_json::{json, Value}; + +use super::model::{CreatePreviewRequest, PreviewDescriptor}; +use super::registry::PreviewRegistry; + +pub(in crate::api::code_web) struct PreviewsService { + registry: Arc, +} + +impl PreviewsService { + pub(in crate::api::code_web) fn new(registry: Arc) -> Self { + Self { registry } + } + + pub(in crate::api::code_web) async fn create( + &self, + request: CreatePreviewRequest, + ) -> BootResult { + self.registry.create(request.target).await + } + + pub(in crate::api::code_web) async fn get(&self, id: &str) -> BootResult { + self.registry.get(id).await + } + + pub(in crate::api::code_web) async fn remove(&self, id: &str) -> BootResult { + self.registry.remove(id).await?; + Ok(json!({ "id": id, "stopped": true })) + } +} diff --git a/src/api/code_web/previews/tests.rs b/src/api/code_web/previews/tests.rs new file mode 100644 index 0000000..f6288eb --- /dev/null +++ b/src/api/code_web/previews/tests.rs @@ -0,0 +1,272 @@ +use std::sync::Arc; + +use a3s_boot::{ + BootApplication, BootError, BootRequest, ControllerDefinition, HttpMethod, Module, ModuleRef, + Result as BootResult, +}; +use axum::body::to_bytes; +use axum::http::{header::CONTENT_SECURITY_POLICY, StatusCode}; + +use super::controller::PreviewsController; +use super::http::serve_content; +use super::model::PreviewKind; +use super::registry::PreviewRegistry; +use super::service::PreviewsService; + +#[tokio::test] +async fn registers_static_sites_documents_and_loopback_urls() { + let temporary = tempfile::tempdir().expect("temporary preview workspace"); + let site = temporary.path().join("site"); + tokio::fs::create_dir_all(&site).await.expect("create site"); + tokio::fs::write(site.join("index.html"), "

Live

") + .await + .expect("write index"); + tokio::fs::write(temporary.path().join("notes.md"), "# Notes") + .await + .expect("write notes"); + tokio::fs::write(temporary.path().join("icon.svg"), "") + .await + .expect("write SVG"); + tokio::fs::write(temporary.path().join("legacy.doc"), "unsupported") + .await + .expect("write legacy document"); + let registry = PreviewRegistry::new(temporary.path().to_path_buf()); + + let static_site = registry + .create("site".to_string()) + .await + .expect("register static site"); + assert_eq!(static_site.kind, PreviewKind::StaticSite); + assert!(static_site.content_url.starts_with("/preview/")); + let canonical_site = tokio::fs::canonicalize(&site) + .await + .expect("canonical site path"); + assert_eq!( + static_site.watch_root.as_deref(), + Some(canonical_site.to_string_lossy().as_ref()) + ); + assert!(static_site.capabilities.live_reload); + assert!(static_site.capabilities.responsive); + + let text = registry + .create("notes.md".to_string()) + .await + .expect("register text preview"); + assert_eq!(text.kind, PreviewKind::Text); + assert!(!text.capabilities.responsive); + + let svg = registry + .create("icon.svg".to_string()) + .await + .expect("register SVG source preview"); + assert_eq!(svg.kind, PreviewKind::Text); + + let legacy = registry + .create("legacy.doc".to_string()) + .await + .expect_err("unsupported legacy Office formats must be rejected"); + assert!(matches!(legacy, BootError::BadRequest(message) if message.contains("not supported"))); + + let local = registry + .create("http://localhost:3000/app#section".to_string()) + .await + .expect("register local URL"); + assert_eq!(local.kind, PreviewKind::LocalUrl); + assert_eq!(local.content_url, "http://localhost:3000/app"); + assert!(!local.capabilities.live_reload); +} + +#[tokio::test] +async fn expires_preview_descriptors_and_content_at_the_declared_deadline() { + let workspace = tempfile::tempdir().expect("temporary preview workspace"); + tokio::fs::write(workspace.path().join("notes.md"), "# Notes") + .await + .expect("write notes"); + let registry = PreviewRegistry::new(workspace.path().to_path_buf()); + let preview = registry + .create("notes.md".to_string()) + .await + .expect("register preview"); + + registry.expire_for_test(&preview.id).await; + + assert!(matches!( + registry.get(&preview.id).await, + Err(BootError::NotFound(_)) + )); + assert!(registry.content(&preview.id).await.is_none()); + assert!(matches!( + registry.remove(&preview.id).await, + Err(BootError::NotFound(_)) + )); +} + +#[tokio::test] +async fn rejects_targets_outside_the_workspace_and_non_loopback_urls() { + let workspace = tempfile::tempdir().expect("temporary preview workspace"); + let outside = tempfile::NamedTempFile::new().expect("outside file"); + let registry = PreviewRegistry::new(workspace.path().to_path_buf()); + + let error = registry + .create(outside.path().display().to_string()) + .await + .expect_err("outside path must be rejected"); + assert!( + matches!(error, BootError::BadRequest(message) if message.contains("active workspace")) + ); + + let error = registry + .create("https://example.com".to_string()) + .await + .expect_err("public URL must be rejected"); + assert!(matches!(error, BootError::BadRequest(message) if message.contains("loopback"))); +} + +#[tokio::test] +async fn serves_assets_with_isolation_headers_and_blocks_sensitive_paths() { + let workspace = tempfile::tempdir().expect("temporary preview workspace"); + let site = workspace.path().join("site"); + tokio::fs::create_dir_all(&site).await.expect("create site"); + tokio::fs::write( + site.join("index.html"), + "

Live

", + ) + .await + .expect("write index"); + tokio::fs::write(site.join("style.css"), "h1{color:red}") + .await + .expect("write stylesheet"); + tokio::fs::write(site.join(".env"), "SECRET=never") + .await + .expect("write secret fixture"); + let registry = PreviewRegistry::new(workspace.path().to_path_buf()); + let preview = registry + .create(site.display().to_string()) + .await + .expect("register site"); + + let index = serve_content(®istry, &preview.id, "").await; + assert_eq!(index.status(), StatusCode::OK); + let csp = index + .headers() + .get(CONTENT_SECURITY_POLICY) + .expect("HTML isolation policy") + .to_str() + .expect("CSP text"); + assert!(csp.contains("sandbox allow-scripts")); + assert!(!csp.contains("allow-same-origin")); + let body = to_bytes(index.into_body(), 1024).await.expect("read index"); + assert!(String::from_utf8_lossy(&body).contains("Live")); + + let stylesheet = serve_content(®istry, &preview.id, "style.css").await; + assert_eq!(stylesheet.status(), StatusCode::OK); + assert_eq!( + stylesheet.headers()["content-type"], + "text/css; charset=utf-8" + ); + + let hidden = serve_content(®istry, &preview.id, ".env").await; + assert_eq!(hidden.status(), StatusCode::FORBIDDEN); + let traversal = serve_content(®istry, &preview.id, "../.env").await; + assert_eq!(traversal.status(), StatusCode::FORBIDDEN); +} + +#[tokio::test] +#[cfg(unix)] +async fn blocks_symlinks_that_escape_the_preview_root() { + use std::os::unix::fs::symlink; + + let workspace = tempfile::tempdir().expect("temporary preview workspace"); + let outside = tempfile::tempdir().expect("outside directory"); + let site = workspace.path().join("site"); + tokio::fs::create_dir_all(&site).await.expect("create site"); + tokio::fs::write(site.join("index.html"), "

Live

") + .await + .expect("write index"); + tokio::fs::write(outside.path().join("secret.txt"), "never") + .await + .expect("write outside secret"); + symlink(outside.path().join("secret.txt"), site.join("linked.txt")).expect("create symlink"); + let registry = PreviewRegistry::new(workspace.path().to_path_buf()); + let preview = registry + .create(site.display().to_string()) + .await + .expect("register site"); + + let response = serve_content(®istry, &preview.id, "linked.txt").await; + assert_eq!(response.status(), StatusCode::FORBIDDEN); +} + +#[tokio::test] +async fn controller_creates_reads_and_stops_preview_sessions() { + let workspace = tempfile::tempdir().expect("temporary preview workspace"); + tokio::fs::write(workspace.path().join("index.html"), "

Live

") + .await + .expect("write page"); + let service = Arc::new(PreviewsService::new(Arc::new(PreviewRegistry::new( + workspace.path().to_path_buf(), + )))); + let app = BootApplication::builder() + .global_prefix("/api") + .import(TestPreviewsModule { + service: Arc::clone(&service), + }) + .build() + .expect("build preview test app"); + + let created = app + .call( + BootRequest::new(HttpMethod::Post, "/api/v1/previews") + .with_content_type("application/json") + .with_body(r#"{"target":"index.html"}"#), + ) + .await + .expect("create preview through controller") + .body_json::() + .expect("decode descriptor"); + assert_eq!(created["kind"], "staticSite"); + assert_eq!(created["source"]["type"], "path"); + assert!(created["source"]["rootPath"].is_string()); + assert!(created["source"]["mtimeMs"].is_number()); + assert_eq!(created["source"]["isDirectory"], false); + assert_eq!(created["source"]["isBinary"], false); + assert!(created["source"].get("root_path").is_none()); + let id = created["id"].as_str().expect("preview id"); + + let fetched = app + .call(BootRequest::new( + HttpMethod::Get, + format!("/api/v1/previews/{id}"), + )) + .await + .expect("fetch preview"); + assert_eq!(fetched.status(), 200); + + let stopped = app + .call(BootRequest::new( + HttpMethod::Delete, + format!("/api/v1/previews/{id}"), + )) + .await + .expect("stop preview") + .body_json::() + .expect("decode stop response"); + assert_eq!(stopped["stopped"], true); +} + +struct TestPreviewsModule { + service: Arc, +} + +impl Module for TestPreviewsModule { + fn name(&self) -> &'static str { + "test-previews" + } + + fn controllers(&self, _module_ref: &ModuleRef) -> BootResult> { + Ok(vec![Arc::new(PreviewsController::new(Arc::clone( + &self.service, + ))) + .controller()?]) + } +} diff --git a/src/api/code_web/state.rs b/src/api/code_web/state.rs index 2356379..44c4ca7 100644 --- a/src/api/code_web/state.rs +++ b/src/api/code_web/state.rs @@ -10,6 +10,7 @@ use tokio::sync::Mutex; use tokio_util::sync::CancellationToken; use super::kernel::turn_queue::CodeWebSessionTurnQueue; +use super::previews::PreviewRegistry; use super::session_store::{CodeWebSessionMetadata, CodeWebSessionRepository}; use super::workspace_backend_cache::WorkspaceBackendCache; use crate::budget::DEFAULT_CODE_WEB_EFFORT_ID; @@ -135,6 +136,7 @@ pub(in crate::api) struct CodeWebState { Mutex>>, use_registry: RwLock>, workspace_backends: WorkspaceBackendCache, + preview_registry: Arc, } impl CodeWebState { @@ -146,6 +148,7 @@ impl CodeWebState { session_repository: Arc, ) -> Self { let auto_compact_threshold = crate::config::auto_compact_threshold_for_path(&config_path); + let preview_registry = Arc::new(PreviewRegistry::new(default_workspace.clone())); Self { agent, config_path, @@ -166,9 +169,14 @@ impl CodeWebState { active_research_runs: Mutex::new(HashMap::new()), use_registry: RwLock::new(None), workspace_backends: WorkspaceBackendCache::default(), + preview_registry, } } + pub(in crate::api) fn preview_registry(&self) -> Arc { + Arc::clone(&self.preview_registry) + } + pub(in crate::api) fn install_use_registry( &self, registry: crate::use_registry::UseRegistryHandle, diff --git a/src/api/serve/mod.rs b/src/api/serve/mod.rs index 820cb0a..3784f03 100644 --- a/src/api/serve/mod.rs +++ b/src/api/serve/mod.rs @@ -22,7 +22,9 @@ use crate::config; use self::api_gateway::ApiGateway; use self::options::ServeOptions; -use super::code_web::{CodeWebModule, CodeWebSessionRepository, CodeWebState, KernelService}; +use super::code_web::{ + preview_content_router, CodeWebModule, CodeWebSessionRepository, CodeWebState, KernelService, +}; use super::web::{api_only_fallback, prepare_default_web_dir, serve_static}; mod api_gateway; @@ -209,7 +211,9 @@ async fn run_foreground( .map_err(boot_to_anyhow)?; app.bootstrap().await.map_err(boot_to_anyhow)?; - let api_router = ApiGateway::new(app.clone(), BODY_LIMIT_BYTES).router(); + let api_router = ApiGateway::new(app.clone(), BODY_LIMIT_BYTES) + .router() + .merge(preview_content_router(state.preview_registry())); let router = if options.api_only { api_router.fallback(api_only_fallback) diff --git a/src/tui/app/launch.rs b/src/tui/app/launch.rs index 209ad77..90cb52c 100644 --- a/src/tui/app/launch.rs +++ b/src/tui/app/launch.rs @@ -1048,6 +1048,9 @@ pub(crate) async fn run_in( loop_remaining: 0, runtime: RuntimeProjection::default(), agent_presence: agent_presence::AgentPresenceRuntime::new(webview_resolution.executable), + live_preview: None, + preview_launch_seq: 0, + live_preview_pending: None, background_subagent_watches: HashSet::new(), subagent_snapshot_request_id: 0, deep_research_subagent_settlement_inflight: false, diff --git a/src/tui/app/live_preview.rs b/src/tui/app/live_preview.rs new file mode 100644 index 0000000..d3f5e72 --- /dev/null +++ b/src/tui/app/live_preview.rs @@ -0,0 +1,403 @@ +//! Host-owned live-preview lifecycle for `/preview` and `/ide` shortcuts. + +use super::*; +use std::net::{IpAddr, SocketAddr}; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(super) enum LivePreviewCommand { + Open(String), + Status, + Stop, +} + +pub(super) struct LivePreviewLaunch { + pub(super) target: String, + pub(super) url: String, + pub(super) window: remote_ui::LivePreviewWindow, +} + +pub(super) struct LivePreviewState { + pub(super) target: String, + pub(super) url: String, + pub(super) window: remote_ui::LivePreviewWindow, +} + +pub(super) fn parse_live_preview_command(rest: &str) -> Result { + let target = strip_matching_quotes(rest.trim()); + match target { + "" => Err("usage: /preview | status | stop"), + "status" => Ok(LivePreviewCommand::Status), + "stop" => Ok(LivePreviewCommand::Stop), + _ => Ok(LivePreviewCommand::Open(normalize_live_preview_target( + target, + ))), + } +} + +fn strip_matching_quotes(value: &str) -> &str { + if value.len() < 2 { + return value; + } + let bytes = value.as_bytes(); + if matches!( + (bytes.first(), bytes.last()), + (Some(b'"'), Some(b'"')) | (Some(b'\''), Some(b'\'')) + ) { + &value[1..value.len() - 1] + } else { + value + } +} + +fn normalize_live_preview_target(target: &str) -> String { + if target.contains(char::is_whitespace) { + return target.to_string(); + } + let candidate = format!("http://{target}"); + if url::Url::parse(&candidate) + .ok() + .and_then(|url| url.host_str().map(is_loopback_host)) + .unwrap_or(false) + { + candidate + } else { + target.to_string() + } +} + +fn is_loopback_host(host: &str) -> bool { + let host = host.to_ascii_lowercase(); + host == "localhost" + || host.ends_with(".localhost") + || host + .parse::() + .is_ok_and(|address| address.is_loopback()) +} + +fn preview_server_args(workspace: &Path, config_path: &Path) -> Result, String> { + let workspace = workspace + .to_str() + .ok_or_else(|| "preview workspace must be valid UTF-8".to_string())?; + let config_path = config_path + .to_str() + .ok_or_else(|| "preview config path must be valid UTF-8".to_string())?; + Ok(vec![ + "--detach".to_string(), + "--host".to_string(), + "127.0.0.1".to_string(), + // Reuse an existing managed instance for this workspace. A fresh + // preview server receives an ephemeral port so an unrelated service on + // the product default can never be replaced. + "--port".to_string(), + "0".to_string(), + "--workspace".to_string(), + workspace.to_string(), + "--config".to_string(), + config_path.to_string(), + ]) +} + +fn live_preview_url(address: SocketAddr, target: &str) -> Result { + let mut url = url::Url::parse(&format!("http://{address}/")) + .map_err(|error| format!("could not build the A3S Web URL: {error}"))?; + url.query_pairs_mut().append_pair("preview", target); + url.set_fragment(Some("home")); + Ok(url.into()) +} + +fn environment_flag(name: &str) -> bool { + std::env::var(name) + .ok() + .is_some_and(|value| matches!(value.trim(), "1" | "true" | "yes" | "on")) +} + +async fn launch_live_preview( + workspace: PathBuf, + config_path: PathBuf, + target: String, + preferred_webview: Option, +) -> Result { + let args = preview_server_args(&workspace, &config_path)?; + let cancellation = tokio_util::sync::CancellationToken::new(); + let outcome = crate::api::run_web( + &args, + &cancellation, + environment_flag("A3S_OFFLINE"), + !environment_flag("A3S_NO_AUTO_INSTALL"), + ) + .await + .map_err(|error| format!("could not start A3S Web: {error:#}"))?; + + let (address, api_only) = match outcome { + crate::api::ServeOutcome::Detached { instance, .. } => { + (instance.address, instance.api_only) + } + crate::api::ServeOutcome::Existing(instance) => { + (instance.address, instance.api_only.unwrap_or(false)) + } + crate::api::ServeOutcome::Help | crate::api::ServeOutcome::ForegroundStopped => { + return Err("A3S Web stopped before live preview was ready".to_string()) + } + }; + if api_only { + return Err(format!( + "A3S Web at http://{address}/ is API-only; stop that instance before opening live preview" + )); + } + + let url = live_preview_url(address, &target)?; + let window = remote_ui::open_live_preview_window_with(&url, preferred_webview.as_deref()) + .map_err(|error| format!("could not open the live-preview window: {error}"))?; + Ok(LivePreviewLaunch { + target, + url, + window, + }) +} + +impl App { + pub(super) fn submit_live_preview_command(&mut self, rest: &str) -> Option> { + self.textarea.clear(); + let command = match parse_live_preview_command(rest) { + Ok(command) => command, + Err(usage) => { + self.push_line(&Style::new().fg(TN_YELLOW).render(&format!(" {usage}"))); + return None; + } + }; + + match command { + LivePreviewCommand::Status => { + self.show_live_preview_status(); + None + } + LivePreviewCommand::Stop => { + self.preview_launch_seq = self.preview_launch_seq.wrapping_add(1); + let pending = self.live_preview_pending.take(); + let launching = pending.is_some(); + if let Some((_, _, status_entry)) = pending { + self.replace_tracked_line( + status_entry, + &Style::new() + .fg(TN_YELLOW) + .render(" live-preview launch cancelled"), + ); + } + let previous = self.live_preview.take(); + let had_preview = previous.is_some(); + let browser = previous.as_ref().is_some_and(|state| { + state.window.opened_with() == remote_ui::OpenedWith::Browser + }); + drop(previous); + let text = if browser { + " ✓ stopped tracking live preview · the browser tab remains open · A3S Web remains running" + } else if had_preview { + " ✓ stopped live preview · A3S Web remains running" + } else if launching { + " ✓ cancelled the pending live preview · A3S Web remains running" + } else { + " live preview is not running" + }; + self.push_line(&Style::new().fg(TN_GRAY).render(text)); + None + } + LivePreviewCommand::Open(target) => { + if let Some((_, _, status_entry)) = self.live_preview_pending.take() { + self.replace_tracked_line( + status_entry, + &Style::new() + .fg(TN_YELLOW) + .render(" live-preview launch replaced by a newer request"), + ); + } + self.preview_launch_seq = self.preview_launch_seq.wrapping_add(1); + let request_id = self.preview_launch_seq; + let status_entry = self.push_tracked_line( + &Style::new() + .fg(TN_GRAY) + .render(&format!(" opening live preview for {target}…")), + ); + self.live_preview_pending = Some((request_id, target.clone(), status_entry)); + let workspace = PathBuf::from(&self.cwd); + let config_path = self.config_path.clone(); + let preferred_webview = self.agent_presence.webview_binary().map(Path::to_path_buf); + Some(cmd::cmd(move || async move { + Msg::LivePreviewLaunched { + request_id, + status_entry, + result: Box::new( + launch_live_preview(workspace, config_path, target, preferred_webview) + .await, + ), + } + })) + } + } + } + + pub(super) fn apply_live_preview_launch( + &mut self, + request_id: u64, + status_entry: TranscriptEntryId, + result: Result, + ) { + if self + .live_preview_pending + .as_ref() + .map(|(pending_id, _, _)| *pending_id) + != Some(request_id) + { + // Dropping a stale successful result closes its tracked native + // window through `LivePreviewWindow::drop`. + return; + } + self.live_preview_pending = None; + match result { + Ok(launch) => { + let opened_with = launch.window.opened_with(); + let target = launch.target.clone(); + let url = launch.url.clone(); + self.live_preview = Some(LivePreviewState { + target, + url: url.clone(), + window: launch.window, + }); + let surface = match opened_with { + remote_ui::OpenedWith::Webview => "native window", + remote_ui::OpenedWith::Browser => "browser fallback", + }; + self.replace_tracked_line( + status_entry, + &Style::new() + .fg(TN_GREEN) + .render(&format!(" ✓ live preview opened in {surface} · {url}")), + ); + } + Err(error) => { + self.replace_tracked_line( + status_entry, + &Style::new() + .fg(TN_RED) + .render(&format!(" live preview failed: {error}")), + ); + } + } + } + + fn show_live_preview_status(&mut self) { + if let Some((_, target, _)) = self.live_preview_pending.as_ref() { + self.push_line( + &Style::new() + .fg(TN_GRAY) + .render(&format!(" live preview is starting · {target}")), + ); + return; + } + + let Some(state) = self.live_preview.as_mut() else { + self.push_line( + &Style::new() + .fg(TN_GRAY) + .render(" live preview is not running"), + ); + return; + }; + let target = state.target.clone(); + let url = state.url.clone(); + let status = state.window.webview_running(); + match status { + Ok(Some(true)) => self.push_line( + &Style::new() + .fg(TN_GREEN) + .render(&format!(" ✓ live preview is running · {target} · {url}")), + ), + Ok(None) => self.push_line(&Style::new().fg(TN_GREEN).render(&format!( + " ✓ live preview is open in the browser · {target} · {url}" + ))), + Ok(Some(false)) => { + self.live_preview = None; + self.push_line( + &Style::new() + .fg(TN_GRAY) + .render(" live-preview window was closed · A3S Web remains running"), + ); + } + Err(error) => self.push_line( + &Style::new() + .fg(TN_YELLOW) + .render(&format!(" live-preview status is unavailable: {error}")), + ), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_open_status_stop_and_quoted_paths() { + assert_eq!( + parse_live_preview_command("site/index.html"), + Ok(LivePreviewCommand::Open("site/index.html".to_string())) + ); + assert_eq!( + parse_live_preview_command("\"docs/Product brief.pdf\""), + Ok(LivePreviewCommand::Open( + "docs/Product brief.pdf".to_string() + )) + ); + assert_eq!( + parse_live_preview_command("status"), + Ok(LivePreviewCommand::Status) + ); + assert_eq!( + parse_live_preview_command("stop"), + Ok(LivePreviewCommand::Stop) + ); + assert!(parse_live_preview_command("").is_err()); + } + + #[test] + fn normalizes_loopback_shorthand_without_rewriting_workspace_paths() { + assert_eq!( + parse_live_preview_command("localhost:5173/dashboard"), + Ok(LivePreviewCommand::Open( + "http://localhost:5173/dashboard".to_string() + )) + ); + assert_eq!( + parse_live_preview_command("127.0.0.1:4173"), + Ok(LivePreviewCommand::Open( + "http://127.0.0.1:4173".to_string() + )) + ); + assert_eq!( + parse_live_preview_command("artifacts/report.html"), + Ok(LivePreviewCommand::Open( + "artifacts/report.html".to_string() + )) + ); + } + + #[test] + fn deep_link_percent_encodes_the_target_and_routes_to_home() { + let address = "127.0.0.1:29653".parse().unwrap(); + let url = live_preview_url(address, "docs/Product brief.pdf").unwrap(); + + assert_eq!( + url, + "http://127.0.0.1:29653/?preview=docs%2FProduct+brief.pdf#home" + ); + } + + #[test] + fn detached_server_uses_an_ephemeral_loopback_port() { + let args = preview_server_args(Path::new("/workspace"), Path::new("/config.acl")).unwrap(); + + assert!(args.windows(2).any(|pair| pair == ["--host", "127.0.0.1"])); + assert!(args.windows(2).any(|pair| pair == ["--port", "0"])); + assert!(args.iter().any(|arg| arg == "--detach")); + assert!(!args.iter().any(|arg| arg == "--replace")); + } +} diff --git a/src/tui/app/submit.rs b/src/tui/app/submit.rs index 0ce2900..71156eb 100644 --- a/src/tui/app/submit.rs +++ b/src/tui/app/submit.rs @@ -734,6 +734,9 @@ impl App { } } } + if let Some(rest) = slash_tail(trimmed, "/preview") { + return self.submit_live_preview_command(rest); + } if let Some(rest) = slash_tail(trimmed, "/fork") { return self.submit_fork_command(rest); } diff --git a/src/tui/app/types.rs b/src/tui/app/types.rs index 86113d2..c1a11c3 100644 --- a/src/tui/app/types.rs +++ b/src/tui/app/types.rs @@ -350,6 +350,11 @@ pub(super) enum Msg { jump_request_id: u64, result: Result, }, + LivePreviewLaunched { + request_id: u64, + status_entry: TranscriptEntryId, + result: Box>, + }, SpinnerTick, /// Advance Codex-style Markdown commit animation independently from the /// slower status spinner. diff --git a/src/tui/app/update_dispatch.rs b/src/tui/app/update_dispatch.rs index 884e8a0..f3c71f2 100644 --- a/src/tui/app/update_dispatch.rs +++ b/src/tui/app/update_dispatch.rs @@ -265,6 +265,11 @@ impl App { return command; } self.ide_key(&key); + if let Some(target) = + self.ide.as_mut().and_then(|ide| ide.preview_request.take()) + { + return self.submit_live_preview_command(&target.to_string_lossy()); + } return None; } // `/tasks` / Ctrl+B is a modal delegated-work inspector. It @@ -953,6 +958,14 @@ impl App { self.apply_ide_intelligence_jump(request_id, jump_request_id, result); } + Msg::LivePreviewLaunched { + request_id, + status_entry, + result, + } => { + self.apply_live_preview_launch(request_id, status_entry, *result); + } + Msg::Interrupted { goal_cancelled, status_entry, diff --git a/src/tui/mod.rs b/src/tui/mod.rs index 404e16d..195a71f 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -381,6 +381,8 @@ mod app_events; mod app_fork; #[path = "app/launch.rs"] mod app_launch; +#[path = "app/live_preview.rs"] +mod app_live_preview; #[path = "app/permission_rules.rs"] mod app_permission_rules; #[path = "app/permissions.rs"] @@ -771,6 +773,13 @@ struct App { /// Exact local lifecycle publishing and the system-level island bridge. /// Rendering belongs to the independent native `a3s-webview` process. agent_presence: agent_presence::AgentPresenceRuntime, + /// Current host-owned artifact preview. The native window is tracked so it + /// can be replaced or closed without stopping the shared A3S Web server. + live_preview: Option, + /// Monotonic invalidation guard for asynchronous preview launches. + preview_launch_seq: u64, + /// Exact launch currently starting, including its user-visible target. + live_preview_pending: Option<(u64, String, TranscriptEntryId)>, /// Active background completion watchers, keyed by rebuild generation and /// task id so session replacement cannot leak stale results into history. background_subagent_watches: HashSet<(u64, String)>, @@ -1041,6 +1050,9 @@ impl App { ide.intelligence_cancellation.cancel(); ide.intelligence_jump_cancellation.cancel(); } + self.preview_launch_seq = self.preview_launch_seq.wrapping_add(1); + self.live_preview_pending = None; + self.live_preview = None; self.stream_start_token = self.stream_start_token.wrapping_add(1); self.deep_research_stream_timeout_token = self.deep_research_stream_timeout_token.wrapping_add(1); diff --git a/src/tui/os/remote_ui.rs b/src/tui/os/remote_ui.rs index 915c1fe..6fbfba1 100644 --- a/src/tui/os/remote_ui.rs +++ b/src/tui/os/remote_ui.rs @@ -11,7 +11,7 @@ use std::collections::{HashMap, VecDeque}; use std::io::{Read, Write}; use std::net::{Shutdown, TcpListener, TcpStream}; use std::path::{Path, PathBuf}; -use std::process::{Command, Stdio}; +use std::process::{Child, Command, Stdio}; use std::sync::{Mutex, OnceLock}; use std::thread; use std::time::Duration; @@ -43,6 +43,44 @@ pub(crate) enum OpenedWith { Browser, } +/// A live-preview window whose native helper remains owned by the TUI. Plain +/// RemoteUI popups are intentionally detached, but `/preview stop` must be able +/// to close the exact preview window without stopping the shared A3S Web +/// server. Browser fallbacks cannot be closed safely by the host. +pub(crate) enum LivePreviewWindow { + Webview(Child), + Browser, +} + +impl LivePreviewWindow { + pub(crate) fn opened_with(&self) -> OpenedWith { + match self { + Self::Webview(_) => OpenedWith::Webview, + Self::Browser => OpenedWith::Browser, + } + } + + /// `Some(true)` means the native helper is still running, `Some(false)` + /// means the user closed it, and `None` denotes an untracked browser tab. + pub(crate) fn webview_running(&mut self) -> std::io::Result> { + match self { + Self::Webview(child) => child.try_wait().map(|status| Some(status.is_none())), + Self::Browser => Ok(None), + } + } +} + +impl Drop for LivePreviewWindow { + fn drop(&mut self) { + if let Self::Webview(child) = self { + if child.try_wait().ok().flatten().is_none() { + let _ = child.kill(); + let _ = child.wait(); + } + } + } +} + /// Build a trusted local-file view after the caller has decided this path is /// safe to surface. Generic tool output must still flow through `find_view_url`, /// which intentionally rejects `file://` URLs. @@ -569,6 +607,22 @@ fn webview_args(spec: &ViewSpec) -> Vec { args } +fn live_preview_webview_args(url: &str) -> Vec { + vec![ + "--url".to_string(), + url.to_string(), + "--title".to_string(), + "A3S Live Preview".to_string(), + // The preview shell and every artifact it embeds are local. Never + // export OS access or refresh tokens into this window. + "--no-auth".to_string(), + "--width".to_string(), + "1440".to_string(), + "--height".to_string(), + "960".to_string(), + ] +} + pub(crate) fn is_local_file_view(spec: &ViewSpec) -> bool { local_view_requires_no_auth(&spec.url) } @@ -631,6 +685,34 @@ pub(crate) fn open_window_with( }) } +/// Open the persistent A3S live-preview shell. Unlike ordinary RemoteUI +/// popups, the native child is returned to the TUI so `/preview stop`, preview +/// replacement, and TUI shutdown can close precisely that window. +pub(crate) fn open_live_preview_window_with( + url: &str, + preferred: Option<&Path>, +) -> std::io::Result { + Command::new(resolve_webview_bin(preferred)) + .args(live_preview_webview_args(url)) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .map(LivePreviewWindow::Webview) + .or_else(|webview_error| { + open_in_browser(url) + .map(|()| LivePreviewWindow::Browser) + .map_err(|browser_error| { + std::io::Error::new( + browser_error.kind(), + format!( + "a3s-webview failed: {webview_error}; browser fallback failed: {browser_error}" + ), + ) + }) + }) +} + fn browser_open_command(url: &str) -> (&'static str, Vec) { if cfg!(target_os = "macos") { ("open", vec![url.to_string()]) @@ -903,6 +985,27 @@ mod tests { ); } + #[test] + fn live_preview_webview_is_no_auth_and_uses_a_product_window() { + let args = + live_preview_webview_args("http://127.0.0.1:29653/?preview=site%2Findex.html#home"); + + assert_eq!( + args, + vec![ + "--url", + "http://127.0.0.1:29653/?preview=site%2Findex.html#home", + "--title", + "A3S Live Preview", + "--no-auth", + "--width", + "1440", + "--height", + "960", + ] + ); + } + #[test] fn browser_fallback_command_tracks_platform() { let (program, args) = browser_open_command("https://os.x/p?embed=1"); diff --git a/src/tui/panels/workspace/ide.rs b/src/tui/panels/workspace/ide.rs index bf773ce..bf4fb93 100644 --- a/src/tui/panels/workspace/ide.rs +++ b/src/tui/panels/workspace/ide.rs @@ -194,6 +194,15 @@ impl App { ide.focus_editor = true; } } + // Workspace tree only: open the selected file or static-site + // directory in the host-owned live-preview surface. + KeyCode::Char('p') + if ide.surface == IdeSurface::Workspace && !ide.entries.is_empty() => + { + let sel = ide.sel.min(ide.entries.len() - 1); + ide.preview_request = Some(ide.entries[sel].path.clone()); + ide.flash = Some(ide_flash_line(ToastKind::Info, "opening live preview…")); + } // `/kb` browser only: `x` opens a shared Confirm row for deletion. KeyCode::Char('x') if ide.kb_root.is_some() && !ide.entries.is_empty() => { let sel = ide.sel.min(ide.entries.len() - 1); @@ -211,7 +220,7 @@ impl App { // Any tree key other than `x` disarms a pending delete AND dismisses a // lingering flash (mirrors the editor, where any key clears it) — else // a "✔ deleted …" result masks the key hints for the whole session. - if !matches!(key.code, KeyCode::Char('x')) { + if !matches!(key.code, KeyCode::Char('x' | 'p')) { ide.armed_delete = None; ide.flash = None; } @@ -419,14 +428,17 @@ impl App { "-- INSERT -- · paste Cmd/Ctrl+V · Ctrl+Z undo · Ctrl+S save".to_string() } _ if ide.supports_code_intelligence() => { - format!("{} · :w/:q · / search", ide_intelligence_command_hint()) + format!( + "{} · :preview · :w/:q · / search", + ide_intelligence_command_hint() + ) } _ => "-- NORMAL -- · / search · V visual-line · :w/:q/:wq · . repeat".to_string(), } } else if ide.kb_root.is_some() { "Tab edit · ↑↓ nav · Enter open · x delete · Esc close".to_string() } else { - "Tab edit · ↑↓ nav · Enter open · Esc close".to_string() + "Tab edit · ↑↓ nav · Enter open · p preview · Esc close".to_string() }; let meta_w = (width * 3) / 5; let keys_w = width.saturating_sub(meta_w); @@ -643,6 +655,29 @@ fn apply_ide_command( } msg } + "preview" => { + if ide.surface != IdeSurface::Workspace { + return ide_flash_line( + ToastKind::Warning, + "live preview is available only in the workspace IDE", + ); + } + let Some(path) = ide.file.as_ref().map(|file| file.path.clone()) else { + return ide_flash_line(ToastKind::Warning, "open a file first"); + }; + if ide.file.as_ref().is_some_and(|file| file.dirty) { + let save = ide + .file + .as_mut() + .map(|file| save_ide_file(file, workspace_manifest, workspace)) + .unwrap_or_else(|| ide_flash_line(ToastKind::Warning, "open a file first")); + if ide.file.as_ref().is_some_and(|file| file.dirty) { + return save; + } + } + ide.preview_request = Some(path); + ide_flash_line(ToastKind::Info, "opening live preview…") + } "" => ide_flash_line(ToastKind::Warning, "empty command"), other => ide_flash_line(ToastKind::Warning, format!("unknown command: {other}")), } @@ -2065,6 +2100,32 @@ mod vim_tests { assert_eq!(f.mode, EditMode::Normal); } + #[tokio::test] + async fn preview_command_saves_the_open_buffer_and_queues_its_path() { + let root = temp_root("live-preview-command"); + let path = root.join("index.html"); + std::fs::write(&path, "old").unwrap(); + let manifest = LocalWorkspaceManifest::start(&root); + let mut ide = Ide::workspace(Vec::new()); + let mut file = IdeFile::new(path.clone(), vec!["new".to_string()], false, false); + file.dirty = true; + ide.file = Some(file); + + let result = apply_ide_command( + &mut ide, + "preview", + &manifest, + root.to_string_lossy().as_ref(), + ); + + assert_eq!(ide.preview_request.as_deref(), Some(path.as_path())); + assert_eq!(std::fs::read_to_string(&path).unwrap(), "new\n"); + assert!(!ide.file.as_ref().unwrap().dirty); + assert!(a3s_tui::style::strip_ansi(&result).contains("opening live preview")); + drop(manifest); + let _ = std::fs::remove_dir_all(root); + } + #[test] fn horizontal_scroll_follows_the_cursor() { let mut f = buf(&["abcdefghijklmnopqrst"]); // 20 cols diff --git a/src/tui/tests.rs b/src/tui/tests.rs index b6f163b..901b30c 100644 --- a/src/tui/tests.rs +++ b/src/tui/tests.rs @@ -5514,6 +5514,7 @@ fn registered_slash_commands_have_declared_handler_paths() { "/use", "/copy", "/export", + "/preview", ]); let exact = HashSet::from([ "/logout", @@ -5731,6 +5732,12 @@ fn slash_audit_rows() -> Vec { idle_only: false, scope: Local, }, + SlashAuditRow { + command: "/preview", + handler: Parameterized, + idle_only: false, + scope: Local, + }, SlashAuditRow { command: "/memory", handler: Exact, @@ -5885,6 +5892,7 @@ fn slash_command_audit_matrix_matches_registry_and_policies() { "/use", "/copy", "/export", + "/preview", ]); for row in &rows { match row.handler { diff --git a/src/tui/ui/chrome.rs b/src/tui/ui/chrome.rs index bbd77e7..12601cf 100644 --- a/src/tui/ui/chrome.rs +++ b/src/tui/ui/chrome.rs @@ -200,6 +200,10 @@ pub(super) const SLASH_COMMANDS: &[(&str, &str)] = &[ ("/reload", "re-scan skills/plugins (hot-reload the / menu)"), ("/update", "upgrade a3s to the latest release"), ("/ide", "superfile-style file browser + editor"), + ( + "/preview", + "open a live artifact preview · path/localhost URL, status, or stop", + ), ( "/memory", "browse memory as an event/entity graph with tiers and forget candidates", diff --git a/src/tui/ui/editor_state.rs b/src/tui/ui/editor_state.rs index e7be520..ddf702b 100644 --- a/src/tui/ui/editor_state.rs +++ b/src/tui/ui/editor_state.rs @@ -515,6 +515,9 @@ pub(super) struct Ide { pub(super) preview: Option<(std::path::PathBuf, Vec)>, /// Active command prompt inside the editor footer (`/`, `?`, `:`). pub(super) prompt: Option, + /// One-shot artifact path requested by tree `p` or editor `:preview`. + /// The outer TUI takes it after key handling and owns the async launch. + pub(super) preview_request: Option, /// `/kb` browser: the vault root. Enables `x` delete, hard-bounded to /// paths inside this root. `None` for /ide and /config. pub(super) kb_root: Option, @@ -548,6 +551,7 @@ impl Ide { surface: IdeSurface::ReusedEditor, preview: None, prompt: None, + preview_request: None, kb_root: None, armed_delete: None, delete_confirm_yes: true, From 4030266283f0359796a77c2cbcd02f9288c2af04 Mon Sep 17 00:00:00 2001 From: RoyLin Date: Tue, 28 Jul 2026 23:58:46 +0800 Subject: [PATCH 2/2] chore(release): prepare 0.10.12 --- .github/workflows/release.yml | 2 +- CHANGELOG.md | 23 +++++++++++++++++++++++ Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 30 +++++++++++++++++++++++++++--- 5 files changed, 53 insertions(+), 6 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0d7997b..29b1040 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -15,7 +15,7 @@ concurrency: cancel-in-progress: false env: # Immutable commit in A3S-Lab/a3s containing the Web workspace sources. - A3S_WEB_SOURCE_REF: dadabffcadc86b643da8622607823f6d32583e06 + A3S_WEB_SOURCE_REF: f5db7e68b5e91d21b5c0fbe003aae92b3d4efb75 # Exact companion prerequisite; preflight rejects an absent or incomplete release. A3S_WEBVIEW_VERSION: 0.1.5 A3S_CODE_CORE_VERSION: 6.5.1 diff --git a/CHANGELOG.md b/CHANGELOG.md index 6fc60aa..d6a24b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.10.12] - 2026-07-28 + +### Added + +- Added a persistent, resizable A3S Work live-preview panel for static sites, + loopback development servers, text, images, PDFs, and supported Office files. + Static sites reload after debounced workspace changes and provide responsive + device, zoom, refresh, pause, and external-window controls. +- Added `/preview `, `/preview status`, and `/preview stop` + to the Code TUI. The workspace IDE also opens the selected item with `p`, and + `:preview` saves a dirty editor buffer before opening it. + +### Security + +- Confined file previews to canonical active-workspace paths, blocked traversal, + hidden and sensitive files, escaping symbolic links, and oversized assets, + and limited URL previews to HTTP(S) loopback hosts. Static HTML runs in an + opaque-origin CSP sandbox, and preview descriptors and content now expire at + their declared deadline. +- Kept native preview-window ownership separate from the shared Web process. + Preview replacement, stop, and TUI exit close only the exact tracked window, + and the window receives no A3S OS access or refresh token. + ### Fixed - Kept structured generation compatible with Kimi thinking-only models by diff --git a/Cargo.lock b/Cargo.lock index 7d6399f..c846873 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,7 +4,7 @@ version = 4 [[package]] name = "a3s" -version = "0.10.11" +version = "0.10.12" dependencies = [ "a3s-acl 0.2.2", "a3s-boot", diff --git a/Cargo.toml b/Cargo.toml index 78c3747..277d28c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "a3s" -version = "0.10.11" +version = "0.10.12" edition = "2021" description = "a3s — A3S coding agent CLI; `a3s code` launches the interactive TUI" license = "MIT" diff --git a/README.md b/README.md index 1ec56d1..7f9634f 100644 --- a/README.md +++ b/README.md @@ -564,6 +564,28 @@ never terminated. Port binding and asset validation happen before configuration-heavy session restoration, so conflicts and broken installations fail quickly without unrelated restore warnings. +Open a workspace artifact or local development server from inside the TUI: + +```text +/preview site/index.html +/preview docs/Product brief.pdf +/preview localhost:5173 +/preview status +/preview stop +``` + +`/preview` reuses a compatible managed Web instance for the current workspace +or starts one on an ephemeral loopback port. It opens Work's persistent preview +panel in a native `a3s-webview` window when available and uses the system browser +as a fallback. Replacing a preview, `/preview stop`, and TUI exit close only the +exact native preview window; the shared Web service remains running. A browser +fallback is user-owned and cannot be closed safely by the TUI. + +Inside `/ide`, press `p` on the selected workspace item. In an open editor, run +`:preview`; a dirty buffer is saved through the normal workspace path before +the preview opens. Static sites reload after debounced workspace changes, while +loopback URLs retain their development server's own navigation and HMR. + Web tasks, visible messages, titles, goals, effort, model selection, and execution mode are saved under `~/.a3s/code-web` and restored before the API starts accepting requests. Set `A3S_CODE_WEB_STATE_DIR` to isolate that store. @@ -872,6 +894,7 @@ input prefixes: /tasks /permissions /ide +/preview site/index.html /login /agent /mcp @@ -893,7 +916,7 @@ input prefixes: | Coding loop | Chat with the coding agent, stream semantic tool cards, choose Default, Plan, or Auto execution, inspect and control pending follow-ups with `/queue`, and inspect or safely cancel delegated work with `/tasks` or `Ctrl+B`. Run direct shell turns with `!`, run a durable Ultracode `/goal`, and fork, rewind, or clear sessions when needed. `/relay` pins the current session, searches a bounded 64-row catalog per source, preserves semantic selection across refreshes, shows saved state, model, age, unfinished runs, and live background-agent counts, or hands the latest task from a workspace-scoped external transcript to the active session. | | Permission review | Gated calls enter a FIFO approval queue backed by the authoritative tool name and arguments. The overlay can allow once, grant that exact capability for the current session, atomically add the reviewed capability to `.a3s/permissions.acl`, or collect denial feedback for the agent. `/permissions` searches session and project grants, opens their canonical arguments, and revokes only after a second matching action. Project rules are bounded, parsed and generated with `a3s-acl`, reject symbolic-link targets, and remain narrower than hard workspace guardrails. Revocation affects future checks, not tools already running. | | Execution modes | Default runs bounded workspace file changes directly. A shared Rust guardrail silently admits a narrow, proven read-only host Bash subset; unproven commands, protected metadata, mutating Git operations, and annotated external side effects enter HITL, while critical commands fail closed. Plan exposes only read-only discovery tools and denies Bash. Auto never enters HITL: it admits only Rust-proven read-only Bash and denies other host commands, protected metadata, and mutating Git operations. A queued turn retains the mode captured when it was submitted. | -| Workspace UI | `/ide` opens a superfile-style tree and editor with terminal-stable file marks, `/config` edits the active config in the same editor, `Ctrl+T` opens the complete semantic transcript, and file edits render bounded diffs through the shared `DiffView` component. Diff headers use green `+N` and red `-N` counts; Markdown uses Codex-spaced section headings, responsive tables, syntax highlighting, and terminal hyperlinks. | +| Workspace UI | `/ide` opens a superfile-style tree and editor with terminal-stable file marks, `p` previews the selected item, and `:preview` saves and previews the open buffer. `/preview ` opens the same persistent Work surface from the transcript. `/config` edits the active config in the shared editor, `Ctrl+T` opens the complete semantic transcript, and file edits render bounded diffs through the shared `DiffView` component. | | Models and effort | `/model` switches configured providers, OS gateway models, and signed-in account tabs. Codex account discovery delegates refresh and entitlement checks to the installed Codex CLI, so an expired identity token does not hide models while reusable account access remains. WorkBuddy `hy3` tagged calls are converted into native tool events without exposing protocol markup in streamed messages. `/effort` scales thinking budget, tool-round budget, auto-continuation, and model-agnostic rigor guidance from `low` through `max` and `ultracode`. A3S Code 5.2.4 structured calls use native JSON Schema or forced-tool output only when the active client advertises that capability; unknown custom OpenAI-compatible endpoints retain the bounded prompt fallback instead of receiving an assumed `tool_choice`. | | Dynamic workflows | `ultracode` and `?` DeepResearch can use `DynamicWorkflowRuntime`, a local A3S Flow-backed workflow runner. It records workflow/step history while PTC scripts perform ordinary tool work. This is separate from `/flow`, which is OS Workflow as a Service for persisted workflow assets. | | Local and remote parallelism | Local subagent fan-out uses the host-side `parallel_task` tool. QuickJS/PTC scripts do not call `parallel_task` directly; dynamic workflows schedule a Flow step named `parallel_task`, and the host executes it natively. After `/login`, the signed-in `runtime` tool is available to workflow steps and model turns for OS Runtime batch execution. | @@ -1038,6 +1061,7 @@ permissions, tools, panels, and follow-up evidence are needed. | --- | --- | --- | | Repository orientation | Start with `/init`, ask for a map of the codebase, attach files with `@`, and open `/ide` when you need to browse or edit directly. | `/init`, `/ide`, `@`, `/ctx`, `/help` | | Focused coding | Ask for a change, review streamed reads/searches/diffs, approve gated writes, and let the agent run focused checks before summarizing what changed. | Tool cards, approval overlay, `DiffView`, `Ctrl+T`, `! ` | +| Artifact inspection | Keep a static site, local development server, document, image, PDF, or source file visible in Work while coding continues; use the IDE shortcuts when already browsing or editing. | `/preview `, `/preview status`, `/preview stop`, IDE `p`, editor `:preview` | | Debugging and verification | Let the model inspect logs, grep call sites, run shell or test commands, and keep the exact tool evidence visible in the semantic transcript. | `grep`, `read`, `bash`, `git`, `Ctrl+T`, `a3s top` | | Context carry-over | Search previous sessions, attach relevant transcript windows, save durable facts, and compact when the context meter gets high. | `/ctx `, `/ctx `, `/ctx save `, `/memory`, `/sleep`, `/compact` | | Deep work | Raise `/effort`, use `ultracode` for complex turns, and let the host decide whether planning, goal tracking, dynamic workflow execution, or parallel fan-out is justified. | `/effort`, `/goal`, `dynamic_workflow`, `task`, `parallel_task` | @@ -1127,7 +1151,7 @@ cells adding or doubling their own outer padding. | System agent island | Enabled by default. `/island on`, `/island off`, and `/island status` persist or inspect the user preference, and the expanded island also offers `Turn off`. A fresh exact non-idle A3S lifecycle or recognized coding-agent process requests one native per-user window at the physical screen's top center; the shared lock prevents multiple `a3s code` TUIs from rendering duplicate islands. On notched Macs, native safe-area geometry makes the surface meet the physical top edge while its compact content occupies the two unobstructed side wings. A dedicated handle moves the window; periodic centering stops after a successful drag, and expand/collapse preserves the moved surface's top-center. Live `All`, `Needs you`, `Running`, and `Recent` filters preserve parent context and show direct-child progress. Every row shows state and elapsed time with an original vendor-colored robot; terminal durations freeze. Exact approval rows display a bounded reason, expose larger `Allow` / `Always` / `Deny` controls, and offer a direct reply composer; live parent rows can also accept replies or expose `Stop`, while running children may expose `Cancel`. Recognized Codex and other process-only rows are labeled `detected / process`, count as running evidence, trigger and keep the island visible, and never receive controls. Any exact planning/working row or recognized process enables the diffuse multicolor neon breathing border. The standalone Tao/Wry helper embeds offline HTML/CSS/JavaScript and does not use the GUI crate, React, or Next.js. Standard Wayland compositors may constrain exact global placement. | | Tool calls | Live tool status appears inline while running. Inline `program` calls summarize structured intent, research scope, workflow phase, and completed nested-call results instead of repeating JavaScript wrapper source. | | Semantic transcript | `Ctrl+T` opens the complete live session transcript in a dedicated full-width viewport, preserving user-surface, tool-state, and diff colors while showing reasoning, plans, every tool lifecycle and full output, subagent state, and the current live Markdown tail. | -| Workspace editor | `/ide` opens a full-screen file browser/editor. `/config` reuses the editor for the active ACL config. Both surfaces use terminal-safe, type-aware file and folder sigils, semantic icon colors, aligned disclosure rows, icon-bearing breadcrumbs, and a ruled line-number gutter while keeping edits inside the workspace backend and normal permission path. | +| Workspace editor | `/ide` opens a full-screen file browser/editor. Press `p` on a workspace-tree item to open Live Preview, or use `:preview` in the editor to save a dirty buffer and preview it. `/config` reuses the editor for the active ACL config. These surfaces keep edits and preview targets inside the workspace backend and normal permission path. | | Memory and knowledge | `/memory` opens the durable memory graph. `/ctx` searches past sessions and can attach or save hits. `/kb` opens the local personal knowledge vault. `/okf` manages shareable knowledge packages. | | Asset panels | `/agent`, `/mcp`, `/skill`, and `/okf` keep an active local asset visible while you iterate. `/flow` selects or drafts workflow DAG assets for OS Workflow as a Service rather than entering a persistent local dev mode. | | Operations panels | `/model`, `/effort`, `/history`, `/tasks`, `/permissions`, `/loop`, `/plugin`, `/theme`, `/help`, `/terminal`, and asset `activity` commands open focused panels or diagnostics without losing the current conversation. | @@ -1143,7 +1167,6 @@ Key interactions: | `Up` / `Down` | Recall input history or move through menus/panels. | | `PgUp` / `PgDn` | Scroll the transcript or the active full-screen panel. | | `Shift+End` | Jump to the latest transcript output. | -| `Ctrl+R` | Fuzzy-search prompts from the current session without replacing the current draft until a result is accepted. Press it again to cycle matches. | | `Ctrl+T` | Open the complete live semantic session transcript, including full tool output and the current streaming tail. | | `Ctrl+R` | Fuzzy-search current-session prompts; repeated Ctrl+R cycles matches. | | `Ctrl+B` | Open or close delegated-task control without interrupting the parent turn. | @@ -1650,6 +1673,7 @@ These commands are available outside the asset-specific flows: | `/theme` | Cycle syntax highlighting themes. | | `/login` / `/logout` | Sign in or out of the configured OS account; login registers OS capabilities and the `runtime` tool. | | `/ide` | Open the workspace file browser and editor. | +| `/preview ` | Open the target in Work's persistent Live Preview. `/preview status` reports the tracked window and `/preview stop` closes only the owned native window, leaving the shared Web service running. | | `/memory` | Browse durable memory as an event/entity graph with tiers, aliases, relations, conflicts, and forget candidates. | | `/evolution` | Review LLM-authored reusable preferences, Skills, and OKF candidates; inspect evidence, activation state, audit history, and immutable versions; save, reject, reconsider, restore a version, or return to the unmaterialized baseline. Mature conflict-free candidates may save locally automatically, but are never published. | | `/ctx ` | Search past ctx-indexed sessions. |