diff --git a/Cargo.lock b/Cargo.lock index 0d0cfa0..be4e5ac 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -400,7 +400,7 @@ checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" [[package]] name = "cli-box-cli" -version = "0.2.4" +version = "0.2.8" dependencies = [ "anyhow", "base64", @@ -419,7 +419,7 @@ dependencies = [ [[package]] name = "cli-box-core" -version = "0.2.4" +version = "0.2.8" dependencies = [ "async-trait", "axum", @@ -448,7 +448,7 @@ dependencies = [ [[package]] name = "cli-box-daemon" -version = "0.2.4" +version = "0.2.8" dependencies = [ "cli-box-core", "tokio", diff --git a/Cargo.toml b/Cargo.toml index 0c8e5e4..afed192 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,7 +7,7 @@ members = [ ] [workspace.package] -version = "0.2.7" +version = "0.2.8" edition = "2021" rust-version = "1.88" license = "Apache-2.0" diff --git a/README.md b/README.md index b4e05ac..c69d44f 100644 --- a/README.md +++ b/README.md @@ -106,11 +106,15 @@ cli-box start node # Node.js ```bash # Sandbox lifecycle cli-box start [command] # Start sandbox (default: zsh) +cli-box start "cd /app && claude -r" # Compound commands run via zsh -lc cli-box list # List active sandboxes cli-box close # Close sandbox # Screenshot + input cli-box screenshot --id -o shot.png +cli-box screenshot --id --up 100 -o history.png # Slide window up 100 lines +cli-box screenshot --id --top -o top.png # Jump to scrollback top +cli-box scrollback --id # Dump full session text (clean) cli-box type --id "hello world" cli-box key --id Return cli-box click --id 100 200 diff --git a/README.zh-cn.md b/README.zh-cn.md index 0acb743..cbc4597 100644 --- a/README.zh-cn.md +++ b/README.zh-cn.md @@ -108,11 +108,15 @@ cli-box start node # Node.js ```bash # 沙箱生命周期 cli-box start [command] # 启动沙箱(默认 zsh) +cli-box start "cd /app && claude -r" # 复合命令经 zsh -lc 执行 cli-box list # 列出活跃沙箱 cli-box close # 关闭沙箱 # 截图 + 输入 cli-box screenshot --id -o shot.png +cli-box screenshot --id --up 100 -o history.png # 截图窗口上移 100 行 +cli-box screenshot --id --top -o top.png # 跳到 scrollback 最顶部 +cli-box scrollback --id # 导出整段会话文本(干净文本) cli-box type --id "你好世界" cli-box key --id Return cli-box click --id 100 200 diff --git a/crates/cli-box-cli/src/client.rs b/crates/cli-box-cli/src/client.rs index 7885d94..5cea4b7 100644 --- a/crates/cli-box-cli/src/client.rs +++ b/crates/cli-box-cli/src/client.rs @@ -155,14 +155,31 @@ pub async fn daemon_list_sandboxes() -> Result> { /// Take a screenshot of a sandbox via the daemon HTTP API. /// Returns PNG data along with fallback info from response headers. -pub async fn daemon_screenshot(sandbox_id: &str, with_frame: bool) -> Result { +/// +/// `scroll` scrolls the viewport UP N lines from the latest viewport before +/// capturing. `top` jumps to the very top of the scrollback. `top` takes +/// precedence over `scroll` when both are supplied. +pub async fn daemon_screenshot( + sandbox_id: &str, + with_frame: bool, + scroll: Option, + top: bool, +) -> Result { let base = daemon_base_url()?; let client = reqwest_client(); - let url = if with_frame { - format!("{base}/box/{sandbox_id}/screenshot?with_frame=true") - } else { - format!("{base}/box/{sandbox_id}/screenshot") - }; + let mut url = format!("{base}/box/{sandbox_id}/screenshot"); + let mut sep = '?'; + if with_frame { + url.push_str("?with_frame=true"); + sep = '&'; + } + if top { + url.push(sep); + url.push_str("top=true"); + } else if let Some(n) = scroll { + url.push(sep); + url.push_str(&format!("scroll={n}")); + } let resp = client .get(url) .send() @@ -191,6 +208,47 @@ pub async fn daemon_screenshot(sandbox_id: &str, with_frame: bool) -> Result, + to_line: Option, +) -> Result { + let base = daemon_base_url()?; + let client = reqwest_client(); + let mut url = format!("{base}/box/{sandbox_id}/scrollback"); + let mut sep = '?'; + let mut append = |u: &mut String, kv: &str| { + u.push(sep); + u.push_str(kv); + sep = '&'; + }; + if raw { + append(&mut url, "raw=true"); + } + if let Some(n) = from_line { + append(&mut url, &format!("from_line={n}")); + } + if let Some(n) = to_line { + append(&mut url, &format!("to_line={n}")); + } + let resp = client + .get(url) + .send() + .await + .with_context(|| "scrollback request to daemon failed")?; + let status = resp.status(); + if !status.is_success() { + let text = resp.text().await.unwrap_or_default(); + anyhow::bail!("scrollback failed (HTTP {status}): {text}"); + } + Ok(resp.text().await?) +} + /// Click in a sandbox via the daemon HTTP API. pub async fn daemon_click(sandbox_id: &str, x: f64, y: f64, button: &str) -> Result<()> { let base = daemon_base_url()?; diff --git a/crates/cli-box-cli/src/main.rs b/crates/cli-box-cli/src/main.rs index b801aaf..911fb0b 100644 --- a/crates/cli-box-cli/src/main.rs +++ b/crates/cli-box-cli/src/main.rs @@ -105,6 +105,37 @@ enum Commands { /// Use ScreenCaptureKit to capture the full window frame (requires Screen Recording permission) #[arg(long)] with_frame: bool, + + /// Scroll the capture window UP N lines from the latest viewport (see older output) + #[arg(long, name = "up")] + up: Option, + + /// Jump to the very top of the scrollback + #[arg(long)] + top: bool, + }, + + /// Dump the full session text of a CLI/TUI sandbox (clean, ANSI-free) + Scrollback { + /// Sandbox instance ID + #[arg(long)] + id: String, + + /// Write to a file instead of stdout + #[arg(short, long)] + output: Option, + + /// Preserve trailing whitespace (no per-line trim) + #[arg(long)] + raw: bool, + + /// Start line (1-based, inclusive) + #[arg(long)] + from_line: Option, + + /// End line (1-based, inclusive) + #[arg(long)] + to_line: Option, }, /// List all visible windows on the system @@ -240,8 +271,19 @@ async fn main() -> anyhow::Result<()> { id, window_id: _window_id, with_frame, + up, + top, } => { - cmd_screenshot_daemon(&output, id.as_deref(), with_frame).await?; + cmd_screenshot_daemon(&output, id.as_deref(), with_frame, up, top).await?; + } + Commands::Scrollback { + id, + output, + raw, + from_line, + to_line, + } => { + cmd_scrollback(&id, output.as_deref(), raw, from_line, to_line).await?; } Commands::Windows => { cmd_windows()?; @@ -670,6 +712,8 @@ async fn cmd_screenshot_daemon( output: &std::path::Path, id: Option<&str>, with_frame: bool, + up: Option, + top: bool, ) -> anyhow::Result<()> { let sandbox_id = id.ok_or_else(|| { anyhow::anyhow!( @@ -677,7 +721,7 @@ async fn cmd_screenshot_daemon( ) })?; - let result = client::daemon_screenshot(sandbox_id, with_frame) + let result = client::daemon_screenshot(sandbox_id, with_frame, up, top) .await .map_err(|e| { eprintln!("Error: Failed to connect to daemon: {e}"); @@ -705,6 +749,36 @@ async fn cmd_screenshot_daemon( Ok(()) } +/// Dump the full scrollback of a sandbox via the daemon API. +async fn cmd_scrollback( + id: &str, + output: Option<&std::path::Path>, + raw: bool, + from_line: Option, + to_line: Option, +) -> anyhow::Result<()> { + let text = client::daemon_scrollback(id, raw, from_line, to_line) + .await + .map_err(|e| { + eprintln!("Error: Failed to fetch scrollback: {e}"); + e + })?; + if let Some(path) = output { + if let Some(parent) = path.parent() { + if !parent.as_os_str().is_empty() { + std::fs::create_dir_all(parent) + .with_context(|| format!("Failed to create directory {:?}", parent))?; + } + } + std::fs::write(path, &text) + .with_context(|| format!("Failed to write scrollback to {:?}", path))?; + println!("Scrollback saved to {:?} ({} bytes)", path, text.len()); + } else { + print!("{text}"); + } + Ok(()) +} + /// Shutdown the daemon via HTTP. async fn cmd_shutdown_daemon() -> anyhow::Result<()> { client::daemon_shutdown().await?; @@ -1248,12 +1322,28 @@ fn mcp_tools() -> serde_json::Value { }, { "name": "screenshot_sandbox", - "description": "Take a screenshot of a sandbox (returns base64 PNG). Default: renderer capture (no permission needed). Use with_frame=true for full window capture (requires Screen Recording permission).", + "description": "Take a screenshot of a sandbox (returns base64 PNG). Default: renderer capture (no permission needed). Use with_frame=true for full window capture (requires Screen Recording permission). Use up=N to scroll the viewport up N lines, or top=true to jump to the very top of the scrollback.", "inputSchema": { "type": "object", "properties": { "sandbox_id": { "type": "string" }, - "with_frame": { "type": "boolean", "description": "Use ScreenCaptureKit for full window frame capture (requires Screen Recording permission)", "default": false } + "with_frame": { "type": "boolean", "description": "Use ScreenCaptureKit for full window frame capture (requires Screen Recording permission)", "default": false }, + "up": { "type": "integer", "description": "Scroll the viewport UP N lines before capturing (lets you see older output)", "minimum": 0 }, + "top": { "type": "boolean", "description": "Jump to the very top of the scrollback before capturing", "default": false } + }, + "required": ["sandbox_id"] + } + }, + { + "name": "scrollback_sandbox", + "description": "Dump the full session text of a CLI/TUI sandbox (clean, ANSI-free). Useful for reading everything ever printed. Use from_line/to_line (1-based, inclusive) to slice.", + "inputSchema": { + "type": "object", + "properties": { + "sandbox_id": { "type": "string" }, + "raw": { "type": "boolean", "description": "Preserve trailing whitespace (no per-line trim)", "default": false }, + "from_line": { "type": "integer", "description": "Start line (1-based, inclusive)", "minimum": 1 }, + "to_line": { "type": "integer", "description": "End line (1-based, inclusive)", "minimum": 1 } }, "required": ["sandbox_id"] } @@ -1375,7 +1465,9 @@ async fn handle_mcp_tool(name: &str, args: &serde_json::Value) -> serde_json::Va "screenshot_sandbox" => { let id = args["sandbox_id"].as_str().unwrap_or(""); let with_frame = args["with_frame"].as_bool().unwrap_or(false); - let result = client::daemon_screenshot(id, with_frame).await?; + let up = args["up"].as_u64().map(|n| n as u32); + let top = args["top"].as_bool().unwrap_or(false); + let result = client::daemon_screenshot(id, with_frame, up, top).await?; let b64 = base64_encode(&result.png_data); let mut response = serde_json::json!({ "sandbox_id": id, "image_base64": b64 }); if let Some(ref source) = result.source { @@ -1386,6 +1478,14 @@ async fn handle_mcp_tool(name: &str, args: &serde_json::Value) -> serde_json::Va } Ok(response) } + "scrollback_sandbox" => { + let id = args["sandbox_id"].as_str().unwrap_or(""); + let raw = args["raw"].as_bool().unwrap_or(false); + let from_line = args["from_line"].as_u64().map(|n| n as u32); + let to_line = args["to_line"].as_u64().map(|n| n as u32); + let text = client::daemon_scrollback(id, raw, from_line, to_line).await?; + Ok(serde_json::json!({ "sandbox_id": id, "text": text })) + } "type_text" => { let id = args["sandbox_id"].as_str().unwrap_or(""); let text = args["text"].as_str().unwrap_or(""); diff --git a/crates/cli-box-core/src/daemon/mod.rs b/crates/cli-box-core/src/daemon/mod.rs index fdcab21..31e8298 100644 --- a/crates/cli-box-core/src/daemon/mod.rs +++ b/crates/cli-box-core/src/daemon/mod.rs @@ -85,6 +85,8 @@ pub struct DaemonState { pub screenshot_ws_tx: Option>, /// Pending screenshot requests awaiting renderer responses. pub pending_screenshots: HashMap, String>>>, + /// Pending scrollback (text) requests awaiting renderer responses. + pub pending_scrollback: HashMap>>, /// Counter for generating unique request IDs. pub screenshot_request_counter: u64, /// Sandboxes whose xterm.js terminal has been mounted and is ready. @@ -290,6 +292,7 @@ pub fn build_daemon_router(state: Arc>) -> Router { .route("/box/create", post(create_sandbox_handler)) .route("/box/{id}/close", post(close_sandbox_handler)) .route("/box/{id}/screenshot", get(screenshot_handler)) + .route("/box/{id}/scrollback", get(scrollback_handler)) .route( "/box/{id}/screenshot/region", get(screenshot_region_handler), @@ -507,6 +510,12 @@ async fn close_sandbox_handler( struct ScreenshotQuery { #[serde(default)] with_frame: bool, + /// Lines to scroll UP from the current viewport (0 = visible viewport). + #[serde(default)] + scroll: Option, + /// Jump to the very top of the scrollback. + #[serde(default)] + top: bool, } async fn screenshot_handler( @@ -527,8 +536,14 @@ async fn screenshot_handler( return screenshot_with_frame(state, &id).await; } + // top => very large offset so the renderer clamps to the scrollback top. + let offset: u32 = if q.top { + u32::MAX + } else { + q.scroll.unwrap_or(0) + }; // Default: renderer only, no SCK fallback - match request_renderer_screenshot(state.clone(), &id).await { + match request_renderer_screenshot(state.clone(), &id, offset).await { Ok(png_data) => { tracing::info!( "[screenshot] sandbox {} captured via renderer ({} bytes)", @@ -551,6 +566,41 @@ async fn screenshot_handler( } } +#[derive(Deserialize)] +struct ScrollbackQuery { + #[serde(default)] + raw: bool, + #[serde(default)] + from_line: Option, + #[serde(default)] + to_line: Option, +} + +async fn scrollback_handler( + State(state): State>>, + Path(id): Path, + axum::extract::Query(q): axum::extract::Query, +) -> Result { + { + let s = state.lock().await; + if !s.sandboxes.contains_key(&id) { + return Err(AppError::Instance(format!("Sandbox '{id}' not found"))); + } + } + + match request_renderer_scrollback(state.clone(), &id, q.raw, q.from_line, q.to_line).await { + Ok(text) => { + let mut headers = HeaderMap::new(); + headers.insert( + axum::http::header::CONTENT_TYPE, + HeaderValue::from_static("text/plain; charset=utf-8"), + ); + Ok((StatusCode::OK, headers, text).into_response()) + } + Err(reason) => Err(AppError::Screenshot(format!("Scrollback failed: {reason}"))), + } +} + /// Build a screenshot HTTP response with source/fallback headers. fn screenshot_response(png_data: Vec, source: &str, fallback_reason: Option<&str>) -> Response { let mut headers = HeaderMap::new(); @@ -641,6 +691,7 @@ async fn screenshot_with_frame( async fn request_renderer_screenshot( state: Arc>, sandbox_id: &str, + scroll: u32, ) -> Result, String> { let (request_id, response_rx, mut ws_tx) = { let mut s = state.lock().await; @@ -662,6 +713,7 @@ async fn request_renderer_screenshot( "type": "capture_request", "request_id": request_id, "sandbox_id": sandbox_id, + "scroll": scroll, }); if ws_tx @@ -693,6 +745,67 @@ async fn request_renderer_screenshot( } } +/// Request the full session text (xterm buffer) from the renderer via WebSocket. +async fn request_renderer_scrollback( + state: Arc>, + sandbox_id: &str, + raw: bool, + from_line: Option, + to_line: Option, +) -> Result { + let (request_id, response_rx, mut ws_tx) = { + let mut s = state.lock().await; + let ws_tx = s + .screenshot_ws_tx + .take() + .ok_or("WebSocket not connected (renderer may be closed or not yet connected)")?; + + s.screenshot_request_counter += 1; + let request_id = s.screenshot_request_counter; + + let (response_tx, response_rx) = oneshot::channel(); + s.pending_scrollback.insert(request_id, response_tx); + + (request_id, response_rx, ws_tx) + }; + + let msg = serde_json::json!({ + "type": "scrollback_request", + "request_id": request_id, + "sandbox_id": sandbox_id, + "raw": raw, + "from_line": from_line, + "to_line": to_line, + }); + + if ws_tx + .send(Message::Text(msg.to_string().into())) + .await + .is_err() + { + let mut s = state.lock().await; + s.pending_scrollback.remove(&request_id); + s.screenshot_ws_tx = Some(ws_tx); + return Err("Failed to send scrollback request over WebSocket".to_string()); + } + + { + let mut s = state.lock().await; + s.screenshot_ws_tx = Some(ws_tx); + } + + match tokio::time::timeout(std::time::Duration::from_secs(2), response_rx).await { + Ok(Ok(Ok(text))) => Ok(text), + Ok(Ok(Err(e))) => Err(format!("Renderer returned error: {e}")), + Ok(Err(_)) => Err("Response channel dropped (renderer may have disconnected)".to_string()), + Err(_) => { + let mut s = state.lock().await; + s.pending_scrollback.remove(&request_id); + Err("Renderer did not respond within 2s timeout".to_string()) + } + } +} + /// Request the renderer to switch to a specific tab via WebSocket. /// Waits for the renderer to acknowledge the tab switch. async fn request_switch_tab( @@ -807,6 +920,30 @@ async fn handle_screenshot_ws(state: Arc>, socket: WebSocket) } } } + Some("scrollback_response") => { + if let (Some(req_id), Some(text)) = ( + request_id, + msg.get("text").and_then(|v| v.as_str()), + ) { + let mut s = state.lock().await; + if let Some(tx) = s.pending_scrollback.remove(&req_id) { + let _ = tx.send(Ok(text.to_string())); + } + } + } + Some("scrollback_error") => { + let error = msg + .get("error") + .and_then(|v| v.as_str()) + .unwrap_or("Unknown error") + .to_string(); + if let Some(req_id) = request_id { + let mut s = state.lock().await; + if let Some(tx) = s.pending_scrollback.remove(&req_id) { + let _ = tx.send(Err(error)); + } + } + } Some("switch_tab_response") => { if let Some(req_id) = request_id { let mut s = state.lock().await; @@ -1466,6 +1603,7 @@ pub async fn run_daemon(port: u16) -> Result<(), Box> { started_at: Instant::now(), screenshot_ws_tx: None, pending_screenshots: HashMap::new(), + pending_scrollback: HashMap::new(), screenshot_request_counter: 0, terminal_ready_sandboxes: HashSet::new(), })); @@ -1671,6 +1809,7 @@ mod tests { started_at: Instant::now(), screenshot_ws_tx: None, pending_screenshots: HashMap::new(), + pending_scrollback: HashMap::new(), screenshot_request_counter: 0, terminal_ready_sandboxes: HashSet::new(), })) @@ -1698,6 +1837,7 @@ mod tests { started_at: Instant::now(), screenshot_ws_tx: None, pending_screenshots: HashMap::new(), + pending_scrollback: HashMap::new(), screenshot_request_counter: 0, terminal_ready_sandboxes: HashSet::new(), })) @@ -2062,7 +2202,7 @@ mod tests { #[tokio::test] async fn request_renderer_screenshot_returns_error_when_ws_not_connected() { let state = test_daemon_state_with_sandbox(); - let result = request_renderer_screenshot(state, "test-sb").await; + let result = request_renderer_screenshot(state, "test-sb", 0).await; assert!(result.is_err()); let err = result.unwrap_err(); assert!( diff --git a/crates/cli-box-core/src/process/mod.rs b/crates/cli-box-core/src/process/mod.rs index ee04126..a6d973e 100644 --- a/crates/cli-box-core/src/process/mod.rs +++ b/crates/cli-box-core/src/process/mod.rs @@ -117,6 +117,52 @@ pub fn cleanup_chromium_data(sandbox_id: &str) { } } +/// Shell metacharacters that require the command to run through a shell. +const SHELL_METACHARS: &[char] = &[ + '&', ';', '|', '<', '>', '$', '`', '(', ')', '*', '?', '\n', '!', +]; + +/// Returns true when `command` must be interpreted by a shell: it either +/// contains a space (command-with-args passed as one token) or any shell +/// metacharacter (`&&`, `;`, pipes, redirects, `$`, glob chars, ...). +pub fn needs_shell(command: &str) -> bool { + command.contains(' ') || command.chars().any(|c| SHELL_METACHARS.contains(&c)) +} + +/// Re-wrap a (command, args) pair into a login-shell invocation +/// `zsh -lc ""`. The full line is `command` + `args` joined by +/// single spaces. The caller has already decided wrapping is needed +/// (see [`needs_shell`]). +/// +/// # No escaping is performed +/// +/// `args` are concatenated with single spaces, NOT shell-quoted. This is +/// intentional: the whole line is meant to be re-interpreted by the shell +/// (so `&&`, `|`, `$VAR`, globs, and quoted substrings all work). Callers +/// pass a single already-formed command line; they must NOT pass individual +/// args that should be preserved as distinct shell tokens. For an arg that +/// itself contains spaces or metacharacters and must survive as one token, +/// the caller is responsible for pre-quoting it. +pub fn wrap_shell_command(command: &str, args: &[String]) -> (String, Vec) { + let mut line = String::from(command); + for a in args { + line.push(' '); + line.push_str(a); + } + ("zsh".to_string(), vec!["-lc".to_string(), line]) +} + +/// Decide the actual (command, args) to spawn. Compound commands (those that +/// need a shell) are re-wrapped as `zsh -lc ""`; plain commands pass +/// through unchanged. +pub fn prepare_spawn(command: &str, args: &[String]) -> (String, Vec) { + if needs_shell(command) { + wrap_shell_command(command, args) + } else { + (command.to_string(), args.to_vec()) + } +} + /// Process manager for launching and managing apps/CLIs in the sandbox pub struct ProcessManager; @@ -324,6 +370,9 @@ impl ProcessManager { cols: u16, rows: u16, ) -> Result { + let (command, args) = prepare_spawn(command, args); + let command = command.as_str(); + let args = args.as_slice(); let pty_system = native_pty_system(); let pty_pair = pty_system .openpty(PtySize { @@ -731,3 +780,89 @@ impl ProcessManager { )) } } + +#[cfg(test)] +mod shell_wrap_tests { + use super::{needs_shell, prepare_spawn, wrap_shell_command}; + + #[test] + fn plain_token_needs_no_shell() { + assert!(!needs_shell("claude")); + assert!(!needs_shell("zsh")); + assert!(!needs_shell("/usr/local/bin/node")); + } + + #[test] + fn spaced_command_needs_shell() { + assert!(needs_shell("claude -p hi")); + assert!(needs_shell("echo hello world")); + } + + #[test] + fn metacharacters_need_shell() { + for cmd in [ + "cd /x && claude -r", + "a;b", + "a|b", + "a>b", + "a Arc> { started_at: std::time::Instant::now(), screenshot_ws_tx: None, pending_screenshots: HashMap::new(), + pending_scrollback: HashMap::new(), screenshot_request_counter: 0, terminal_ready_sandboxes: HashSet::new(), })) @@ -50,6 +51,7 @@ fn state_with_sandbox() -> Arc> { started_at: std::time::Instant::now(), screenshot_ws_tx: None, pending_screenshots: HashMap::new(), + pending_scrollback: HashMap::new(), screenshot_request_counter: 0, terminal_ready_sandboxes: HashSet::new(), })) @@ -218,3 +220,54 @@ async fn readyz_returns_not_ready_without_renderer() { assert_eq!(json["status"], "not_ready"); assert_eq!(json["renderer_connected"], false); } + +#[tokio::test] +async fn screenshot_query_parses_scroll_and_top() { + let resp = router_with_sandbox() + .oneshot( + Request::builder() + .uri("/box/test-sb/screenshot?scroll=100") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_ne!( + resp.status(), + StatusCode::NOT_FOUND, + "scroll query must be parsed" + ); + + let resp = router_with_sandbox() + .oneshot( + Request::builder() + .uri("/box/test-sb/screenshot?top=true") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_ne!( + resp.status(), + StatusCode::NOT_FOUND, + "top query must be parsed" + ); +} + +#[tokio::test] +async fn scrollback_route_exists() { + let resp = router_with_sandbox() + .oneshot( + Request::builder() + .uri("/box/test-sb/scrollback") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_ne!( + resp.status(), + StatusCode::NOT_FOUND, + "scrollback route must exist" + ); +} diff --git a/crates/cli-box-daemon/src/main.rs b/crates/cli-box-daemon/src/main.rs index 5b0a9a0..b5c1d8a 100644 --- a/crates/cli-box-daemon/src/main.rs +++ b/crates/cli-box-daemon/src/main.rs @@ -1,5 +1,23 @@ // crates/cli-box-daemon/src/main.rs fn main() { + // Handle simple introspection flags before starting the runtime, so the + // daemon can be queried like the CLI (`cli-box-daemon --version`). + let args: Vec = std::env::args().collect(); + if args.iter().any(|a| a == "--version" || a == "-V") { + println!("cli-box-daemon {}", env!("CARGO_PKG_VERSION")); + return; + } + if args.iter().any(|a| a == "--help" || a == "-h") { + eprintln!( + "cli-box-daemon {} — sandbox daemon (managed automatically by cli-box)\n", + env!("CARGO_PKG_VERSION") + ); + eprintln!("This binary is normally launched by `cli-box`. Flags:"); + eprintln!(" -V, --version Print version and exit"); + eprintln!(" -h, --help Print this help and exit"); + return; + } + tracing_subscriber::fmt::init(); let port = cli_box_core::daemon::find_available_port(15801, 15899) diff --git a/docs/superpowers/plans/2026-06-17-start-shell-screenshot-openclaw.md b/docs/superpowers/plans/2026-06-17-start-shell-screenshot-openclaw.md new file mode 100644 index 0000000..f3dbc7e --- /dev/null +++ b/docs/superpowers/plans/2026-06-17-start-shell-screenshot-openclaw.md @@ -0,0 +1,1573 @@ +# Compound `start`, Viewport Screenshot & OpenClaw Skill — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Fix three test-found issues for v0.2.8 — (1) OpenClaw-specific screenshot-path note in the installed SKILL.md, (2) shell wrapping so `cli-box start "cd /x && claude -r"` works, (3) viewport-correct screenshots (latest content), scroll-over-buffer, and a full-session text dump. + +**Architecture:** Installer gains per-target SKILL.md composition (JS). The daemon's PTY spawn gains a `needs_shell` heuristic that rewrites compound commands to `zsh -lc ""`. The Electron renderer's buffer-render fallback is extracted into a pure, testable module anchored at `buffer.baseY` (plus a scroll offset), and a new text mode over the existing screenshot WebSocket exposes the whole xterm buffer as clean text. + +**Tech Stack:** Rust (tokio/axum, portable-pty), TypeScript (React + xterm.js), vitest, Node ESM (installer), shell E2E. + +**Spec:** `docs/superpowers/specs/2026-06-17-start-shell-screenshot-openclaw-design.md` + +**Branch:** `feat/start-shell-screenshot-openclaw` (already created; spec already committed). + +--- + +## File Structure + +| File | Responsibility | Change | +|------|----------------|--------| +| `packages/cli-box-skill/installer/shared.mjs` | Installer pure logic | Add `targetSpecificNote(id)`; compose per-target SKILL.md body | +| `packages/cli-box-skill/test/shared.test.mjs` | Installer unit tests | Assert openclaw body contains `/tmp/openclaw/`; others don't | +| `crates/cli-box-core/src/process/mod.rs` | PTY process spawn | Add `needs_shell` + `wrap_shell_command` pure fns; apply in `spawn_cli_with_size` | +| `electron-app/src/renderer/terminalBuffer.ts` | NEW — pure buffer→PNG renderer | Extract viewport/offset rendering | +| `electron-app/src/renderer/components/Terminal.tsx` | xterm component | `captureToPng(offset)` uses extracted module; canvas only at offset 0 | +| `electron-app/src/__tests__/mocks/xterm.ts` | xterm mock | Add `baseY`/`length` to `MockBuffer` | +| `electron-app/src/__tests__/captureToPng.test.ts` | buffer render tests | Import extracted module; test viewport (`baseY`) + offset; drop duplication | +| `electron-app/src/__tests__/scrollback.test.ts` | NEW — scrollback text tests | Pure text extraction + range/trim | +| `electron-app/src/renderer/scrollback.ts` | NEW — pure scrollback text extractor | Read all buffer lines, range, trim | +| `electron-app/src/renderer/main.tsx` | renderer WS handler | Handle `capture_request.scroll`; `scrollback_request`/`scrollback_response` | +| `crates/cli-box-core/src/daemon/mod.rs` | daemon HTTP + WS | `scroll`/`top` screenshot query; `/box/{id}/scrollback` route + handler; `pending_scrollback`; WS arms | +| `crates/cli-box-core/tests/daemon_integration.rs` | daemon IT | screenshot query parse; scrollback route | +| `crates/cli-box-cli/src/client.rs` | daemon HTTP client | `daemon_screenshot` scroll param; `daemon_scrollback` | +| `crates/cli-box-cli/src/main.rs` | CLI | `screenshot --up/--top`; `scrollback` subcommand | +| `tests/e2e-compound-start-screenshot.sh` | NEW — E2E | compound start + screenshot scroll + scrollback | +| `Cargo.toml`, `electron-app/package.json`, `packages/cli-box-skill/package.json` | version | bump | + +--- + +## WebSocket contract (locked here; both sides must match) + +**Screenshot (existing, extended):** +- Request → renderer: `{ "type": "capture_request", "request_id": , "sandbox_id": "", "scroll": }` (`scroll` = lines up from current viewport; 0 = visible viewport; very large = top) +- Response → daemon: `{ "type": "capture_response", "request_id": , "sandbox_id": "", "image_base64": "" }` +- Error → daemon: `{ "type": "capture_error", "request_id": , "sandbox_id": "", "error": "" }` + +**Scrollback (new):** +- Request → renderer: `{ "type": "scrollback_request", "request_id": , "sandbox_id": "", "raw": , "from_line": , "to_line": }` (1-based, inclusive) +- Response → daemon: `{ "type": "scrollback_response", "request_id": , "sandbox_id": "", "text": "" }` +- Error → daemon: `{ "type": "scrollback_error", "request_id": , "sandbox_id": "", "error": "" }` + +--- + +## Task 1: OpenClaw per-target SKILL.md note + +**Files:** +- Modify: `packages/cli-box-skill/installer/shared.mjs` +- Test: `packages/cli-box-skill/test/shared.test.mjs` + +- [ ] **Step 1: Write the failing test** + +Append to `packages/cli-box-skill/test/shared.test.mjs` (inside the existing `describe` block, or a new one): + +```javascript +import { installSkillToTargets } from "../installer/shared.mjs"; +import assert from "node:assert"; +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +describe("per-target SKILL.md customization", () => { + let home; + beforeEach(() => { + home = mkdtempSync(path.join(os.tmpdir(), "cli-box-skill-")); + }); + afterEach(() => { + rmSync(home, { recursive: true, force: true }); + }); + + it("openclaw body documents /tmp/openclaw screenshot path", () => { + const results = installSkillToTargets(["openclaw"], { home }); + const body = readFileSync( + path.join(home, ".openclaw", "skills", "cli-box", "SKILL.md"), + "utf8" + ); + assert.ok(results[0].ok, "install should succeed"); + assert.ok(body.includes("/tmp/openclaw/"), "should mention /tmp/openclaw/"); + assert.ok(/screenshot.*\/tmp\/openclaw/s.test(body), "should tie screenshots to the path"); + }); + + it("claude body does NOT mention /tmp/openclaw", () => { + installSkillToTargets(["claude"], { home }); + const body = readFileSync( + path.join(home, ".claude", "skills", "cli-box", "SKILL.md"), + "utf8" + ); + assert.ok(!body.includes("/tmp/openclaw/"), "claude body must stay generic"); + }); + + it("opencode body does NOT mention /tmp/openclaw", () => { + installSkillToTargets(["opencode"], { home }); + const body = readFileSync( + path.join(home, ".config", "opencode", "skills", "cli-box", "SKILL.md"), + "utf8" + ); + assert.ok(!body.includes("/tmp/openclaw/"), "opencode body must stay generic"); + }); +}); +``` + +If the test file already imports `installSkillToTargets`, do not re-import; reuse the existing import. Check the top of `shared.test.mjs` first and merge imports. + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `cd packages/cli-box-skill && node --test test/shared.test.mjs` +Expected: FAIL — openclaw body does not contain `/tmp/openclaw/`. + +- [ ] **Step 3: Implement `targetSpecificNote` + per-target composition** + +In `packages/cli-box-skill/installer/shared.mjs`, add after `readBundledSkill`: + +```javascript +// Per-harness additions appended to the bundled SKILL.md body. +// Returns "" when the target needs no customization. +export function targetSpecificNote(id) { + if (id === "openclaw") { + return [ + "", + "## Notes for OpenClaw", + "", + "OpenClaw can only read files under `/tmp/openclaw/`. When you take a", + "screenshot, **write the output there** or OpenClaw cannot read or send the", + "image:", + "", + "```bash", + "cli-box screenshot --id -o /tmp/openclaw/screenshot.png", + "```", + "", + "The directory is created automatically. Do not write screenshots to the", + "current working directory when driving an OpenClaw agent.", + "", + ].join("\n"); + } + return ""; +} +``` + +Replace the body of `installSkillToTargets` with: + +```javascript +export function installSkillToTargets(ids, { home = os.homedir(), content } = {}) { + return ids.map((id) => { + const dir = HARNESS_TARGETS[id].skillDir(home); + try { + const body = (content ?? readBundledSkill()) + targetSpecificNote(id); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, "SKILL.md"), body); + return { id, dir, ok: true }; + } catch (e) { + return { id, dir, ok: false, error: e.message }; + } + }); +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `cd packages/cli-box-skill && node --test test/shared.test.mjs` +Expected: PASS — all three assertions hold. + +- [ ] **Step 5: Commit** + +```bash +git add packages/cli-box-skill/installer/shared.mjs packages/cli-box-skill/test/shared.test.mjs +git commit -m "feat(skill): openclaw SKILL.md note to save screenshots under /tmp/openclaw" +``` + +--- + +## Task 2: `needs_shell` + `wrap_shell_command` pure functions + +**Files:** +- Modify: `crates/cli-box-core/src/process/mod.rs` +- Test: inline `#[cfg(test)] mod tests` in the same file + +- [ ] **Step 1: Write the failing tests** + +Add a test module at the end of `crates/cli-box-core/src/process/mod.rs` (if a `#[cfg(test)] mod tests` already exists, merge into it). These test only the pure helpers (no PTY spawn): + +```rust +#[cfg(test)] +mod shell_wrap_tests { + use super::{needs_shell, wrap_shell_command}; + + #[test] + fn plain_token_needs_no_shell() { + assert!(!needs_shell("claude")); + assert!(!needs_shell("zsh")); + assert!(!needs_shell("/usr/local/bin/node")); + } + + #[test] + fn spaced_command_needs_shell() { + assert!(needs_shell("claude -p hi")); + assert!(needs_shell("echo hello world")); + } + + #[test] + fn metacharacters_need_shell() { + for cmd in [ + "cd /x && claude -r", + "a;b", + "a|b", + "a>b", + "a', '$', '`', '(', ')', '*', '?', '\n', '!', +]; + +/// Returns true when `command` must be interpreted by a shell: it either +/// contains a space (command-with-args passed as one token) or any shell +/// metacharacter (`&&`, `;`, pipes, redirects, `$`, glob chars, ...). +pub fn needs_shell(command: &str) -> bool { + command.contains(' ') || command.chars().any(|c| SHELL_METACHARS.contains(&c)) +} + +/// Re-wrap a (command, args) pair into a login-shell invocation +/// `zsh -lc ""`. The full line is `command` + `args` joined by +/// single spaces. The caller has already decided wrapping is needed +/// (see [`needs_shell`]). +pub fn wrap_shell_command(command: &str, args: &[String]) -> (String, Vec) { + let mut line = String::from(command); + for a in args { + line.push(' '); + line.push_str(a); + } + ( + "zsh".to_string(), + vec!["-lc".to_string(), line], + ) +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `cargo test -p cli-box-core shell_wrap_tests` +Expected: PASS. + +- [ ] **Step 5: Run clippy + fmt** + +Run: `cargo clippy -p cli-box-core --all-targets -- -D warnings && cargo fmt -p cli-box-core -- --check` +Expected: no warnings; no diff. + +- [ ] **Step 6: Commit** + +```bash +git add crates/cli-box-core/src/process/mod.rs +git commit -m "feat(process): needs_shell + wrap_shell_command helpers for compound commands" +``` + +--- + +## Task 3: Apply shell wrap in `spawn_cli_with_size` + +**Files:** +- Modify: `crates/cli-box-core/src/process/mod.rs` (macOS `spawn_cli_with_size`) + +- [ ] **Step 1: Write a test that asserts the wrap is applied at spawn entry** + +Add to the `shell_wrap_tests` module from Task 2: + +```rust + #[test] + fn prepare_spawn_wraps_when_needed() { + let (cmd, args) = super::prepare_spawn("cd /x && claude -r", &[]); + assert_eq!(cmd, "zsh"); + assert_eq!(args, vec!["-lc".to_string(), "cd /x && claude -r".to_string()]); + } + + #[test] + fn prepare_spawn_passes_through_plain_command() { + let args = vec!["-p".to_string(), "hi".to_string()]; + let (cmd, args2) = super::prepare_spawn("claude", &args); + assert_eq!(cmd, "claude"); + assert_eq!(args2, args); + } +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `cargo test -p cli-box-core prepare_spawn` +Expected: FAIL — `prepare_spawn` not found. + +- [ ] **Step 3: Implement `prepare_spawn` and call it from `spawn_cli_with_size`** + +Add next to the Task 2 helpers: + +```rust +/// Decide the actual (command, args) to spawn. Compound commands (those that +/// need a shell) are re-wrapped as `zsh -lc ""`; plain commands pass +/// through unchanged. +pub fn prepare_spawn(command: &str, args: &[String]) -> (String, Vec) { + if needs_shell(command) { + wrap_shell_command(command, args) + } else { + (command.to_string(), args.to_vec()) + } +} +``` + +In the macOS `spawn_cli_with_size` impl, immediately after the function signature line `) -> Result {`, add (before `let pty_system = ...`): + +```rust + let (command, args) = prepare_spawn(command, args); + let command = command.as_str(); + let args = args.as_slice(); +``` + +This shadows the parameters so the rest of the function (`CommandBuilder::new(command)`, `cmd.args(args)`, the `command.to_string()` stored in `PtySession`, logging) uses the prepared values unchanged. + +- [ ] **Step 4: Run the tests** + +Run: `cargo test -p cli-box-core prepare_spawn && cargo test -p cli-box-core shell_wrap_tests` +Expected: PASS. + +- [ ] **Step 5: Build the whole workspace** + +Run: `cargo build -p cli-box-core -p cli-box-cli -p cli-box-daemon` +Expected: compiles with no errors. + +- [ ] **Step 6: Run clippy + fmt** + +Run: `cargo clippy --all-targets -- -D warnings && cargo fmt --all -- --check` +Expected: clean. + +- [ ] **Step 7: Commit** + +```bash +git add crates/cli-box-core/src/process/mod.rs +git commit -m "feat(process): run compound start commands through zsh -lc" +``` + +--- + +## Task 4: Extract pure `renderBufferToPng` (viewport + offset) + +**Files:** +- Create: `electron-app/src/renderer/terminalBuffer.ts` +- Modify: `electron-app/src/__tests__/mocks/xterm.ts` +- Modify: `electron-app/src/__tests__/captureToPng.test.ts` + +- [ ] **Step 1: Extend the xterm mock with `baseY` / `length`** + +Replace the `MockBuffer` class in `electron-app/src/__tests__/mocks/xterm.ts` with: + +```typescript +export class MockBuffer { + baseY: number; + private lines: MockBufferLine[]; + constructor(lines: MockBufferLine[], baseY: number = 0) { + this.lines = lines; + this.baseY = baseY; + } + get length() { return this.lines.length; } + getLine(y: number) { return this.lines[y] ?? null; } +} +``` + +(The `MockTerminal` constructor already wires `buffer = { active: new MockBuffer(lines) }`; existing callers passing only `lines` still work via the default `baseY = 0`.) + +- [ ] **Step 2: Write the failing test** + +Create `electron-app/src/__tests__/terminalBuffer.test.ts`: + +```typescript +import { describe, it, expect, beforeEach } from "vitest"; +import { MockBufferLine, MockTerminal } from "./mocks/xterm"; +import { renderBufferToPng, type RenderableTerminal } from "../renderer/terminalBuffer"; + +let drawCalls: { method: string; args: unknown[] }[]; + +beforeEach(() => { + drawCalls = []; + const ctx = { + fillStyle: "#000", + font: "", + textBaseline: "", + fillRect(...args: unknown[]) { drawCalls.push({ method: "fillRect", args }); }, + fillText(...args: unknown[]) { drawCalls.push({ method: "fillText", args }); }, + }; + vi.spyOn(document, "createElement").mockImplementation((tag: string): any => { + if (tag === "canvas") { + return { + width: 0, height: 0, + getContext: () => ctx, + toDataURL: () => "data:image/png;base64,AAAA", + }; + } + return (document as any).__origCreate?.(tag); + }); +}); + +// Build a terminal with N numbered lines "L00".."L{N-1}", given baseY + rows. +function termWith(lines: string[], baseY: number) { + const t = new MockTerminal(lines.map((s) => new MockBufferLine(s))); + (t.buffer.active as any).baseY = baseY; + return t as unknown as RenderableTerminal; +} + +const charsDrawn = () => + drawCalls.filter((d) => d.method === "fillText").map((d) => d.args[0] as string); + +describe("renderBufferToPng viewport", () => { + it("renders the VISIBLE viewport (baseY..baseY+rows), not the top", () => { + // 6 lines of scrollback above a 2-row viewport: lines 0..5 hidden, 6..7 visible. + const t = termWith(["L00","L01","L02","L03","L04","L05","L06","L07"], 6); + renderBufferToPng(t, 2, 2, 0); // cols=2, rows=2, offset=0 + // First char of each visible row should be from L06 / L07, not L00 / L01. + expect(charsDrawn()[0]).toBe("L"); + // The first row's content: ensure we read line index 6 (the "6" in "L06"). + expect(drawCalls.some((d) => d.method === "fillText" && (d.args[0] === "6"))).toBe(true); + expect(drawCalls.some((d) => d.method === "fillText" && (d.args[0] === "0") && false)).toBe(false); + }); + + it("scrolls the window UP by offset lines", () => { + const t = termWith(["L00","L01","L02","L03","L04","L05","L06","L07"], 6); + renderBufferToPng(t, 2, 2, 3); // offset 3 → start = baseY-3 = 3 → L03,L04 + // Line "L03": chars L,0,3 → we expect a '3' from index 3 and NO '7' from L07. + expect(drawCalls.some((d) => d.method === "fillText" && d.args[0] === "3")).toBe(true); + expect(drawCalls.some((d) => d.method === "fillText" && d.args[0] === "7")).toBe(false); + }); + + it("clamps offset so start line never goes below 0 (--top)", () => { + const t = termWith(["L00","L01","L02","L03"], 2); + renderBufferToPng(t, 2, 2, 9999); // huge offset → start clamped to 0 + expect(drawCalls.some((d) => d.method === "fillText" && d.args[0] === "0")).toBe(true); + }); +}); +``` + +(Add `import { vi } from "vitest";` at the top if not present.) + +- [ ] **Step 3: Run the test to verify it fails** + +Run: `cd electron-app && pnpm vitest run src/__tests__/terminalBuffer.test.ts` +Expected: FAIL — module `../renderer/terminalBuffer` not found. + +- [ ] **Step 4: Implement `terminalBuffer.ts`** + +Create `electron-app/src/renderer/terminalBuffer.ts`: + +```typescript +// Pure, testable extraction of the xterm.js buffer → PNG renderer used by +// Terminal.tsx's captureToPng fallback. Kept free of React so it can be unit +// tested with the xterm mock. + +export interface BufferCellLike { + getChars(): string; + getFgColor(): number; +} + +export interface BufferLineLike { + readonly length: number; + getCell(x: number): BufferCellLike | null | undefined; +} + +export interface BufferLike { + readonly baseY: number; + getLine(y: number): BufferLineLike | null; +} + +export interface RenderableTerminal { + readonly cols: number; + readonly buffer: { readonly active: BufferLike }; +} + +const FONT_SIZE = 13; +const LINE_HEIGHT = Math.ceil(FONT_SIZE * 1.4); +const CHAR_WIDTH = Math.ceil(FONT_SIZE * 0.6); + +/** + * Render an xterm.js terminal buffer window to a base64 PNG string. + * + * `scrollOffset` is the number of lines to scroll UP from the current viewport + * top (`buffer.baseY`). 0 = the visible viewport (latest content). The start + * line is clamped to >= 0, so a very large offset jumps to the very top. + */ +export function renderBufferToPng( + term: RenderableTerminal, + cols: number, + rows: number, + scrollOffset: number = 0, +): string { + const canvas = document.createElement("canvas"); + canvas.width = cols * CHAR_WIDTH; + canvas.height = rows * LINE_HEIGHT; + const ctx = canvas.getContext("2d"); + if (!ctx) throw new Error("Failed to get 2d context for buffer render"); + + ctx.fillStyle = "#1a1a1a"; + ctx.fillRect(0, 0, canvas.width, canvas.height); + ctx.font = `${FONT_SIZE}px "SF Mono", "Menlo", "Monaco", monospace`; + ctx.textBaseline = "top"; + + const buffer = term.buffer.active; + const baseY = buffer.baseY ?? 0; + const startLine = Math.max(0, baseY - Math.max(0, scrollOffset)); + + for (let y = 0; y < rows; y++) { + const line = buffer.getLine(startLine + y); + if (!line) continue; + for (let x = 0; x < line.length; x++) { + const cell = line.getCell(x); + const char = cell?.getChars() || " "; + const fg = cell?.getFgColor(); + if (fg && fg !== 0) { + ctx.fillStyle = `rgb(${(fg >> 16) & 0xff},${(fg >> 8) & 0xff},${fg & 0xff})`; + } else { + ctx.fillStyle = "#cccccc"; + } + ctx.fillText(char, x * CHAR_WIDTH, y * LINE_HEIGHT); + } + } + return canvas.toDataURL("image/png").split(",")[1]; +} +``` + +- [ ] **Step 5: Run the new test** + +Run: `cd electron-app && pnpm vitest run src/__tests__/terminalBuffer.test.ts` +Expected: PASS. + +- [ ] **Step 6: Migrate `captureToPng.test.ts` onto the extracted module** + +In `electron-app/src/__tests__/captureToPng.test.ts`, delete the local `renderBufferToDataUrl` function and its duplicate loop, and replace usages with an import: + +```typescript +import { renderBufferToPng } from "../renderer/terminalBuffer"; +``` + +Replace each `renderBufferToDataUrl(term, cols, rows)` call with `renderBufferToPng(term as any, cols, rows, 0)`. The existing assertions (dark background `#1a1a1a`, canvas dimensions, text color) still hold because the rendering is identical, just relocated. Re-run: + +Run: `cd electron-app && pnpm vitest run src/__tests__/captureToPng.test.ts` +Expected: PASS (existing background/dimension/color tests still green). + +- [ ] **Step 7: Commit** + +```bash +git add electron-app/src/renderer/terminalBuffer.ts electron-app/src/__tests__/mocks/xterm.ts electron-app/src/__tests__/captureToPng.test.ts electron-app/src/__tests__/terminalBuffer.test.ts +git commit -m "refactor(renderer): extract buffer renderer anchored at baseY + scroll offset" +``` + +--- + +## Task 5: Wire `captureToPng(offset)` in `Terminal.tsx` + +**Files:** +- Modify: `electron-app/src/renderer/components/Terminal.tsx` + +- [ ] **Step 1: Update the handle type and `captureToPng`** + +In `electron-app/src/renderer/components/Terminal.tsx`: + +Add the import at the top (after the existing imports): + +```typescript +import { renderBufferToPng } from "../terminalBuffer"; +``` + +Change the handle interface: + +```typescript +export interface SandboxTerminalHandle { + captureToPng(scrollOffset?: number): Promise; +} +``` + +Replace the entire `captureToPng` method body inside `useImperativeHandle` with: + +```typescript + async captureToPng(scrollOffset: number = 0): Promise { + const term = xtermRef.current; + if (!term) throw new Error("Terminal not initialized"); + + // The live xterm canvas only ever shows the current viewport (offset 0). + // For any scroll offset we must render from the text buffer instead. + if (scrollOffset === 0) { + const canvasEl = term.element?.querySelector("canvas"); + if (canvasEl) { + const dataUrl = canvasEl.toDataURL("image/png"); + return dataUrl.split(",")[1]; + } + } + + const fitAddon = fitAddonRef.current; + if (fitAddon) fitAddon.fit(); + return renderBufferToPng(term, term.cols, term.rows, scrollOffset); + }, +``` + +- [ ] **Step 2: Typecheck + run renderer tests** + +Run: `cd electron-app && pnpm typecheck && pnpm vitest run` +Expected: typecheck clean; all vitest tests pass. + +- [ ] **Step 3: Commit** + +```bash +git add electron-app/src/renderer/components/Terminal.tsx +git commit -m "feat(renderer): captureToPng honors scroll offset; canvas only at offset 0" +``` + +--- + +## Task 6: Daemon — screenshot scroll/top + `/box/{id}/scrollback` + +**Files:** +- Modify: `crates/cli-box-core/src/daemon/mod.rs` +- Test: `crates/cli-box-core/tests/daemon_integration.rs` + +- [ ] **Step 1: Write failing IT for query parsing + scrollback route** + +Add to `crates/cli-box-core/tests/daemon_integration.rs`: + +```rust +#[tokio::test] +async fn screenshot_query_parses_scroll_and_top() { + use axum::http::{Request, StatusCode}; + use axum::body::Body; + use tower::ServiceExt; + + let app = test_daemon_router_with_sandbox().await; + // scroll/top are accepted query params (sandbox exists). The renderer is not + // connected in the test, so we expect a non-404 (route matched + parsed). + let resp = app + .oneshot( + Request::builder() + .uri("/box/test-sb/screenshot?scroll=100") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_ne!(resp.status(), StatusCode::NOT_FOUND, "scroll query must be parsed"); + + let app = test_daemon_router_with_sandbox().await; + let resp = app + .oneshot( + Request::builder() + .uri("/box/test-sb/screenshot?top=true") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_ne!(resp.status(), StatusCode::NOT_FOUND, "top query must be parsed"); +} + +#[tokio::test] +async fn scrollback_route_exists() { + use axum::http::{Request, StatusCode}; + use axum::body::Body; + use tower::ServiceExt; + + let app = test_daemon_router_with_sandbox().await; + let resp = app + .oneshot( + Request::builder() + .uri("/box/test-sb/scrollback") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + // Renderer not connected in-test → we expect a handled error (500), NOT 404. + assert_ne!(resp.status(), StatusCode::NOT_FOUND, "scrollback route must exist"); +} +``` + +If `test_daemon_router_with_sandbox` does not exist as an `async` helper, reuse whatever helper the existing screenshot IT tests in this file use (look for `test_daemon_router_with_sandbox` or the pattern around the `/box/test-sb/screenshot?with_frame=true` test) and match its signature exactly. Do not invent a new helper. + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `cargo test -p cli-box-core --test daemon_integration screenshot_query_parses_scroll_and_top scrollback_route_exists` +Expected: FAIL (params not parsed / route absent). + +- [ ] **Step 3: Extend `ScreenshotQuery` and `screenshot_handler`** + +In `crates/cli-box-core/src/daemon/mod.rs`, replace the `ScreenshotQuery` struct: + +```rust +#[derive(Deserialize)] +struct ScreenshotQuery { + #[serde(default)] + with_frame: bool, + /// Lines to scroll UP from the current viewport (0 = visible viewport). + #[serde(default)] + scroll: Option, + /// Jump to the very top of the scrollback. + #[serde(default)] + top: bool, +} +``` + +In `screenshot_handler`, change the default-renderer branch to compute an offset and pass it. Replace the line: + +```rust + match request_renderer_screenshot(state.clone(), &id).await { +``` + +with: + +```rust + // top => very large offset so the renderer clamps to the scrollback top. + let offset: u32 = if q.top { u32::MAX } else { q.scroll.unwrap_or(0) }; + match request_renderer_screenshot(state.clone(), &id, offset).await { +``` + +- [ ] **Step 4: Add the `offset` param to `request_renderer_screenshot`** + +Change the signature and the outgoing message. Replace: + +```rust +async fn request_renderer_screenshot( + state: Arc>, + sandbox_id: &str, +) -> Result, String> { +``` + +with: + +```rust +async fn request_renderer_screenshot( + state: Arc>, + sandbox_id: &str, + scroll: u32, +) -> Result, String> { +``` + +Replace the `let msg = serde_json::json!({ ... })` block with: + +```rust + let msg = serde_json::json!({ + "type": "capture_request", + "request_id": request_id, + "sandbox_id": sandbox_id, + "scroll": scroll, + }); +``` + +- [ ] **Step 5: Add `pending_scrollback` to `DaemonState`** + +Find the `pub pending_screenshots: HashMap, String>>>` field in the `DaemonState` struct definition and add immediately after it: + +```rust + /// Pending scrollback (text) requests awaiting renderer responses. + pub pending_scrollback: HashMap>>, +``` + +Add `pending_scrollback: HashMap::new(),` in **every** `DaemonState { ... }` construction (search for `pending_screenshots: HashMap::new()` — there are at least three; add the scrollback line right after each). + +- [ ] **Step 6: Add `request_renderer_scrollback`** + +Add this function right after `request_renderer_screenshot`: + +```rust +/// Request the full session text (xterm buffer) from the renderer via WebSocket. +async fn request_renderer_scrollback( + state: Arc>, + sandbox_id: &str, + raw: bool, + from_line: Option, + to_line: Option, +) -> Result { + let (request_id, response_rx, mut ws_tx) = { + let mut s = state.lock().await; + let ws_tx = s + .screenshot_ws_tx + .take() + .ok_or("WebSocket not connected (renderer may be closed or not yet connected)")?; + + s.screenshot_request_counter += 1; + let request_id = s.screenshot_request_counter; + + let (response_tx, response_rx) = oneshot::channel(); + s.pending_scrollback.insert(request_id, response_tx); + + (request_id, response_rx, ws_tx) + }; + + let msg = serde_json::json!({ + "type": "scrollback_request", + "request_id": request_id, + "sandbox_id": sandbox_id, + "raw": raw, + "from_line": from_line, + "to_line": to_line, + }); + + if ws_tx + .send(Message::Text(msg.to_string().into())) + .await + .is_err() + { + let mut s = state.lock().await; + s.pending_scrollback.remove(&request_id); + s.screenshot_ws_tx = Some(ws_tx); + return Err("Failed to send scrollback request over WebSocket".to_string()); + } + + { + let mut s = state.lock().await; + s.screenshot_ws_tx = Some(ws_tx); + } + + match tokio::time::timeout(std::time::Duration::from_secs(2), response_rx).await { + Ok(Ok(Ok(text))) => Ok(text), + Ok(Ok(Err(e))) => Err(format!("Renderer returned error: {e}")), + Ok(Err(_)) => Err("Response channel dropped (renderer may have disconnected)".to_string()), + Err(_) => { + let mut s = state.lock().await; + s.pending_scrollback.remove(&request_id); + Err("Renderer did not respond within 2s timeout".to_string()) + } + } +} +``` + +- [ ] **Step 7: Add the scrollback route, query, and handler** + +Register the route — find the line `.route("/box/{id}/screenshot", get(screenshot_handler))` and add after it: + +```rust + .route("/box/{id}/scrollback", get(scrollback_handler)) +``` + +Add the query struct and handler near `screenshot_handler`: + +```rust +#[derive(Deserialize)] +struct ScrollbackQuery { + #[serde(default)] + raw: bool, + #[serde(default)] + from_line: Option, + #[serde(default)] + to_line: Option, +} + +async fn scrollback_handler( + State(state): State>>, + Path(id): Path, + axum::extract::Query(q): axum::extract::Query, +) -> Result { + { + let s = state.lock().await; + if !s.sandboxes.contains_key(&id) { + return Err(AppError::Instance(format!("Sandbox '{id}' not found"))); + } + } + + match request_renderer_scrollback(state.clone(), &id, q.raw, q.from_line, q.to_line).await { + Ok(text) => { + let mut headers = HeaderMap::new(); + headers.insert( + axum::http::header::CONTENT_TYPE, + HeaderValue::from_static("text/plain; charset=utf-8"), + ); + Ok((StatusCode::OK, headers, text).into_response()) + } + Err(reason) => Err(AppError::Screenshot(format!( + "Scrollback failed: {reason}" + ))), + } +} +``` + +- [ ] **Step 8: Handle the new WS response/error arms** + +In `handle_screenshot_ws`, inside the `match msg_type { ... }`, add two new arms (place them next to the existing `Some("capture_response")` / `Some("capture_error")` arms): + +```rust + Some("scrollback_response") => { + if let (Some(req_id), Some(text)) = ( + request_id, + msg.get("text").and_then(|v| v.as_str()), + ) { + let mut s = state.lock().await; + if let Some(tx) = s.pending_scrollback.remove(&req_id) { + let _ = tx.send(Ok(text.to_string())); + } + } + } + Some("scrollback_error") => { + let error = msg + .get("error") + .and_then(|v| v.as_str()) + .unwrap_or("Unknown error") + .to_string(); + if let Some(req_id) = request_id { + let mut s = state.lock().await; + if let Some(tx) = s.pending_scrollback.remove(&req_id) { + let _ = tx.send(Err(error)); + } + } + } +``` + +- [ ] **Step 9: Build + run IT** + +Run: `cargo build -p cli-box-core && cargo test -p cli-box-core --test daemon_integration screenshot_query_parses_scroll_and_top scrollback_route_exists` +Expected: builds; both ITs PASS. + +- [ ] **Step 10: Run full core test suite + clippy + fmt** + +Run: `cargo test -p cli-box-core && cargo clippy -p cli-box-core --all-targets -- -D warnings && cargo fmt -p cli-box-core -- --check` +Expected: all pass; clean. + +- [ ] **Step 11: Commit** + +```bash +git add crates/cli-box-core/src/daemon/mod.rs crates/cli-box-core/tests/daemon_integration.rs +git commit -m "feat(daemon): screenshot scroll/top + /box/{id}/scrollback session text" +``` + +--- + +## Task 7: Renderer WS handling for scroll + scrollback + +**Files:** +- Modify: `electron-app/src/renderer/main.tsx` +- Create: `electron-app/src/renderer/scrollback.ts` +- Test: `electron-app/src/__tests__/scrollback.test.ts` + +- [ ] **Step 1: Write the failing test for the scrollback extractor** + +Create `electron-app/src/__tests__/scrollback.test.ts`: + +```typescript +import { describe, it, expect } from "vitest"; +import { readScrollback, type ScrollbackTerminal } from "../renderer/scrollback"; +import { MockBufferLine, MockTerminal } from "./mocks/xterm"; + +function term(lines: string[]): ScrollbackTerminal { + return new MockTerminal(lines.map((l) => new MockBufferLine(l))) as unknown as ScrollbackTerminal; +} + +describe("readScrollback", () => { + it("joins all lines, trailing whitespace trimmed by default", () => { + const t = term(["hello ", "world"]); + expect(readScrollback(t, { raw: false })).toBe("hello\nworld"); + }); + + it("raw preserves trailing whitespace", () => { + const t = term(["hi ", "yo"]); + expect(readScrollback(t, { raw: true })).toBe("hi \nyo"); + }); + + it("from_line / to_line are 1-based inclusive", () => { + const t = term(["a", "b", "c", "d"]); + expect(readScrollback(t, { raw: false, fromLine: 2, toLine: 3 })).toBe("b\nc"); + }); + + it("clamps range to buffer length", () => { + const t = term(["a", "b"]); + expect(readScrollback(t, { raw: false, fromLine: 1, toLine: 99 })).toBe("a\nb"); + }); +}); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `cd electron-app && pnpm vitest run src/__tests__/scrollback.test.ts` +Expected: FAIL — module not found. + +- [ ] **Step 3: Implement `scrollback.ts`** + +Create `electron-app/src/renderer/scrollback.ts`: + +```typescript +// Pure extraction of an xterm.js buffer into clean session text. +// The xterm buffer is already ANSI-free (escape sequences are interpreted into +// cells), so this returns readable text. `raw` preserves trailing whitespace; +// the default trims each line's trailing whitespace. + +export interface ScrollbackCell { getChars(): string; } +export interface ScrollbackLine { readonly length: number; getCell(x: number): ScrollbackCell | null | undefined; } +export interface ScrollbackBuffer { getLine(y: number): ScrollbackLine | null; } +export interface ScrollbackTerminal { readonly buffer: { readonly active: ScrollbackBuffer }; } + +export interface ScrollbackOptions { + raw: boolean; + fromLine?: number | null; // 1-based inclusive + toLine?: number | null; // 1-based inclusive +} + +export function readScrollback(term: ScrollbackTerminal, opts: ScrollbackOptions): string { + const buffer = term.buffer.active; + const total = countLines(buffer); + const start = opts.fromLine != null ? Math.max(0, opts.fromLine - 1) : 0; + const end = opts.toLine != null ? Math.min(total, opts.toLine) : total; + + const out: string[] = []; + for (let y = start; y < end; y++) { + const line = buffer.getLine(y); + if (!line) continue; + let s = ""; + for (let x = 0; x < line.length; x++) s += line.getCell(x)?.getChars() || " "; + out.push(opts.raw ? s : s.replace(/\s+$/, "")); + } + return out.join("\n"); +} + +function countLines(buffer: ScrollbackBuffer): number { + // Walk until getLine returns null; xterm buffers expose no .length on the + // active buffer in all versions, so probe defensively. + let n = 0; + while (buffer.getLine(n)) n++; + return n; +} +``` + +- [ ] **Step 4: Run the scrollback test** + +Run: `cd electron-app && pnpm vitest run src/__tests__/scrollback.test.ts` +Expected: PASS. + +> Note: `MockBuffer` now has a `length` getter (Task 4 Step 1), but the production `xterm` buffer's `.active` has `.length` directly; the defensive `countLines` works for both. If `MockBuffer.getLine(n)` ever returns a truthy placeholder past the end, adjust the mock — but the current mock returns `null` past the array, so this is correct. + +- [ ] **Step 5: Wire scroll + scrollback into the renderer WS handler** + +In `electron-app/src/renderer/main.tsx`: + +Add the import near the top: + +```typescript +import { readScrollback } from "./scrollback"; +``` + +In the `ws.onmessage` handler, find the existing `} else if (msg.type === "capture_request") {` block. Change the `captureToPng()` call to pass the scroll offset: + +```typescript + } else if (msg.type === "capture_request") { + const { sandbox_id, request_id, scroll } = msg; + const tabRef = terminalRefs.current.get(sandbox_id); + if (tabRef?.current) { + try { + const base64 = await tabRef.current.captureToPng(Number(scroll) || 0); + ws?.send(JSON.stringify({ + type: "capture_response", + request_id, + sandbox_id, + image_base64: base64, + })); + } catch (err) { + ws?.send(JSON.stringify({ + type: "capture_error", + request_id, + sandbox_id, + error: String(err), + })); + } + } else { + ws?.send(JSON.stringify({ + type: "capture_error", + request_id, + sandbox_id, + error: "Terminal not found or not mounted", + })); + } + } else if (msg.type === "scrollback_request") { + const { sandbox_id, request_id, raw, from_line, to_line } = msg; + const tabRef = terminalRefs.current.get(sandbox_id); + if (tabRef?.current) { + try { + const terminal = (tabRef.current as any).terminal; + if (!terminal) throw new Error("Terminal not available"); + const text = readScrollback(terminal, { + raw: Boolean(raw), + fromLine: from_line ?? null, + toLine: to_line ?? null, + }); + ws?.send(JSON.stringify({ + type: "scrollback_response", + request_id, + sandbox_id, + text, + })); + } catch (err) { + ws?.send(JSON.stringify({ + type: "scrollback_error", + request_id, + sandbox_id, + error: String(err), + })); + } + } else { + ws?.send(JSON.stringify({ + type: "scrollback_error", + request_id, + sandbox_id, + error: "Terminal not found or not mounted", + })); + } + } +``` + +The scrollback handler needs access to the underlying xterm `Terminal` instance. Expose it from `Terminal.tsx` by adding to the `SandboxTerminalHandle` interface a `terminal` getter: + +```typescript +export interface SandboxTerminalHandle { + captureToPng(scrollOffset?: number): Promise; + readonly terminal: unknown; // the underlying @xterm/xterm Terminal +} +``` + +and in `Terminal.tsx` `useImperativeHandle`, add alongside `captureToPng`: + +```typescript + get terminal() { + return xtermRef.current; + }, +``` + +- [ ] **Step 6: Typecheck + run all renderer tests** + +Run: `cd electron-app && pnpm typecheck && pnpm vitest run` +Expected: clean; all pass. + +- [ ] **Step 7: Commit** + +```bash +git add electron-app/src/renderer/scrollback.ts electron-app/src/renderer/main.tsx electron-app/src/renderer/components/Terminal.tsx electron-app/src/__tests__/scrollback.test.ts +git commit -m "feat(renderer): handle scroll offset + scrollback_request over screenshot WS" +``` + +--- + +## Task 8: CLI client — screenshot scroll + scrollback + +**Files:** +- Modify: `crates/cli-box-cli/src/client.rs` + +- [ ] **Step 1: Extend `daemon_screenshot` with a scroll offset** + +In `crates/cli-box-cli/src/client.rs`, change the signature and URL building of `daemon_screenshot`: + +```rust +pub async fn daemon_screenshot(sandbox_id: &str, with_frame: bool, scroll: Option, top: bool) -> Result { + let base = daemon_base_url()?; + let client = reqwest_client(); + let mut url = format!("{base}/box/{sandbox_id}/screenshot"); + let mut sep = '?'; + if with_frame { + url.push_str("?with_frame=true"); + sep = '&'; + } + if top { + url.push(sep); + url.push_str("top=true"); + sep = '&'; + } else if let Some(n) = scroll { + url.push(sep); + url.push_str(&format!("scroll={n}")); + sep = '&'; + } + let resp = client.get(url).send().await.with_context(|| "screenshot request to daemon failed")?; +``` + +(Leave the rest of the function — status check, header reads, `png_data`, `Ok(ScreenshotResult { ... })` — unchanged.) + +- [ ] **Step 2: Add `daemon_scrollback`** + +Append to `crates/cli-box-cli/src/client.rs`: + +```rust +/// Fetch the full session text for a sandbox via the daemon. +pub async fn daemon_scrollback( + sandbox_id: &str, + raw: bool, + from_line: Option, + to_line: Option, +) -> Result { + let base = daemon_base_url()?; + let client = reqwest_client(); + let mut url = format!("{base}/box/{sandbox_id}/scrollback"); + let mut sep = '?'; + let mut push = |u: &mut String, kv: &str| { + u.push(sep); + u.push_str(kv); + sep = '&'; + }; + if raw { + push(&mut url, "raw=true"); + } + if let Some(n) = from_line { + push(&mut url, &format!("from_line={n}")); + } + if let Some(n) = to_line { + push(&mut url, &format!("to_line={n}")); + } + let resp = client.get(url).send().await.with_context(|| "scrollback request to daemon failed")?; + let status = resp.status(); + if !status.is_success() { + let text = resp.text().await.unwrap_or_default(); + anyhow::bail!("scrollback failed (HTTP {status}): {text}"); + } + Ok(resp.text().await?) +} +``` + +- [ ] **Step 3: Update the one call site of `daemon_screenshot`** + +`cmd_screenshot_daemon` (in `main.rs`) currently calls `client::daemon_screenshot(sandbox_id, with_frame)`. That call site is updated in Task 9. For now, fix only the `client.rs`-internal compile — run: + +Run: `cargo build -p cli-box-cli` +Expected: a compile error at the `cmd_screenshot_daemon` call site (signature changed) — this is expected and resolved in Task 9. Do NOT commit yet; proceed to Task 9. + +--- + +## Task 9: CLI — `screenshot --up/--top` + `scrollback` subcommand + +**Files:** +- Modify: `crates/cli-box-cli/src/main.rs` + +- [ ] **Step 1: Add `--up`/`--top` to the `Screenshot` command** + +In the `Commands::Screenshot { ... }` variant (around line 92), add two fields: + +```rust + /// Scroll the capture window UP N lines from the latest viewport (see older output) + #[arg(long, name = "up")] + up: Option, + + /// Jump to the very top of the scrollback + #[arg(long)] + top: bool, +``` + +Update the `match cli.command` arm to thread them through: + +```rust + Commands::Screenshot { + output, + id, + window_id: _window_id, + with_frame, + up, + top, + } => { + cmd_screenshot_daemon(&output, id.as_deref(), with_frame, up, top).await?; + } +``` + +- [ ] **Step 2: Update `cmd_screenshot_daemon`** + +Change its signature and the client call: + +```rust +async fn cmd_screenshot_daemon( + output: &std::path::Path, + id: Option<&str>, + with_frame: bool, + up: Option, + top: bool, +) -> anyhow::Result<()> { + let sandbox_id = id.ok_or_else(|| { + anyhow::anyhow!( + "--id is required for screenshots. Use: cli-box screenshot --id " + ) + })?; + + let result = client::daemon_screenshot(sandbox_id, with_frame, up, top) + .await + .map_err(|e| { + eprintln!("Error: Failed to connect to daemon: {e}"); + eprintln!("Hint: Run 'cli-box start' in another terminal to start the daemon."); + e + })?; +``` + +(Keep the rest of the function — `source` print, `create_dir_all`, `std::fs::write`, final `println!` — unchanged.) + +- [ ] **Step 3: Add the `scrollback` subcommand** + +Add a variant to the `Commands` enum (place it near `Screenshot`): + +```rust + /// Dump the full session text of a CLI/TUI sandbox (clean, ANSI-free) + Scrollback { + /// Sandbox instance ID + #[arg(long)] + id: String, + + /// Write to a file instead of stdout + #[arg(short, long)] + output: Option, + + /// Preserve trailing whitespace (no per-line trim) + #[arg(long)] + raw: bool, + + /// Start line (1-based, inclusive) + #[arg(long)] + from_line: Option, + + /// End line (1-based, inclusive) + #[arg(long)] + to_line: Option, + }, +``` + +Add the match arm: + +```rust + Commands::Scrollback { id, output, raw, from_line, to_line } => { + cmd_scrollback(&id, output.as_deref(), raw, from_line, to_line).await?; + } +``` + +Add the implementation near `cmd_screenshot_daemon`: + +```rust +async fn cmd_scrollback( + id: &str, + output: Option<&std::path::Path>, + raw: bool, + from_line: Option, + to_line: Option, +) -> anyhow::Result<()> { + let text = client::daemon_scrollback(id, raw, from_line, to_line) + .await + .map_err(|e| { + eprintln!("Error: Failed to fetch scrollback: {e}"); + e + })?; + if let Some(path) = output { + if let Some(parent) = path.parent() { + if !parent.as_os_str().is_empty() { + std::fs::create_dir_all(parent) + .with_context(|| format!("Failed to create directory {:?}", parent))?; + } + } + std::fs::write(path, &text) + .with_context(|| format!("Failed to write scrollback to {:?}", path))?; + println!("Scrollback saved to {:?} ({} bytes)", path, text.len()); + } else { + print!("{text}"); + } + Ok(()) +} +``` + +- [ ] **Step 4: Update MCP tool list (if it enumerates commands)** + +Check whether `run_mcp_server` lists tools statically. Run: + +Run: `grep -n "scrollback\|screenshot\|start_sandbox\|tools" crates/cli-box-cli/src/main.rs | head` + +If there is a static JSON tool list that documents the `screenshot` tool, add `up`/`top` to its schema description and add a `scrollback` tool entry mirroring the existing tool shape (see the `start_sandbox`/`screenshot` entries around line 1228). If the MCP tools are generated dynamically, skip this step. + +- [ ] **Step 5: Build + clippy + fmt** + +Run: `cargo build -p cli-box-cli && cargo clippy -p cli-box-cli --all-targets -- -D warnings && cargo fmt -p cli-box-cli -- --check` +Expected: clean. + +- [ ] **Step 6: Smoke-test CLI parsing (no daemon needed)** + +Run: `cargo run -p cli-box-cli -- screenshot --help && cargo run -p cli-box-cli -- scrollback --help` +Expected: both print help showing `--up`/`--top` and the `scrollback` options; exit 0. + +- [ ] **Step 7: Commit (covers Tasks 8 + 9)** + +```bash +git add crates/cli-box-cli/src/client.rs crates/cli-box-cli/src/main.rs +git commit -m "feat(cli): screenshot --up/--top and scrollback subcommand" +``` + +--- + +## Task 10: E2E + empirical claude rendering check + +**Files:** +- Create: `tests/e2e-compound-start-screenshot.sh` +- Modify: `test.sh` (register the new E2E) + +- [ ] **Step 1: Write the E2E script** + +Create `tests/e2e-compound-start-screenshot.sh`: + +```bash +#!/usr/bin/env bash +# E2E: compound start command, viewport screenshot, scroll, and scrollback. +# Requires a built release on PATH (cli-box, cli-box-daemon) and Screen Recording +# permission. Run from repo root via `sh test.sh`. +set -euo pipefail + +require_cli() { command -v "$1" >/dev/null 2>&1 || { echo "missing $1" >&2; exit 1; }; } +require_cli cli-box + +MARKER_DIR="$(mktemp -d)" +MARKER_FILE="$MARKER_DIR/done" +trap 'cli-box close "$SID" >/dev/null 2>&1 || true; rm -rf "$MARKER_DIR"' EXIT + +# 1) Compound command: cd into a temp dir, run a shell compound that writes a marker. +SID=$(cli-box start "cd $MARKER_DIR && printf 'compound-ok\\n' > marker.txt && cat marker.txt" | sed -n 's/.*id=\([^,]*\).*/\1/p') +test -n "$SID" || { echo "FAIL: no sandbox id" >&2; exit 1; } +echo "started sandbox: $SID" + +# Give the PTY time to run the compound command. +sleep 3 + +# 2) scrollback contains the marker text produced by the compound command. +TEXT=$(cli-box scrollback --id "$SID") +echo "$TEXT" | grep -q "compound-ok" || { echo "FAIL: compound marker not in scrollback" >&2; exit 1; } +echo "compound command ran (marker found in scrollback)" + +# 3) default screenshot is a PNG (viewport = latest). --top produces a different +# byte count only when scrollback is larger than the viewport; assert both are PNGs. +cli-box screenshot --id "$SID" -o "$MARKER_DIR/bottom.png" >/dev/null +cli-box screenshot --id "$SID" --top -o "$MARKER_DIR/top.png" >/dev/null +file "$MARKER_DIR/bottom.png" | grep -q "PNG" || { echo "FAIL: bottom.png not PNG" >&2; exit 1; } +file "$MARKER_DIR/top.png" | grep -q "PNG" || { echo "FAIL: top.png not PNG" >&2; exit 1; } +echo "viewport + top screenshots captured as PNG" + +echo "E2E PASS" +``` + +- [ ] **Step 2: Register it in `test.sh`** + +Open `test.sh`, find where other `tests/e2e-*.sh` scripts are invoked, and add: + +```bash +echo "→ E2E: compound start + screenshot + scrollback" +bash tests/e2e-compound-start-screenshot.sh +``` + +Match the surrounding invocation style (some scripts may be guarded by a permission/env check; mirror that). + +- [ ] **Step 3: Empirical claude rendering check (manual, documented)** + +After a release build, run manually and record findings in the PR body: + +```bash +cli-box start claude # start a real claude session +# in another terminal, drive a multi-screen task, then: +cli-box scrollback --id | wc -l +cli-box screenshot --id -o /tmp/cl-latest.png +cli-box screenshot --id --up 200 -o /tmp/cl-up.png +``` + +Record: (a) does `scrollback` return the full conversation or only the current screen? (b) does `--up` reveal older content? If the buffer is incomplete (claude uses the alternate screen), note it in the PR as a known limitation and a follow-up for "drive claude's own scroll". + +- [ ] **Step 4: Commit** + +```bash +chmod +x tests/e2e-compound-start-screenshot.sh +git add tests/e2e-compound-start-screenshot.sh test.sh +git commit -m "test(e2e): compound start, viewport/top screenshot, scrollback" +``` + +--- + +## Task 11: Version bump, full quality gate, PR + +**Files:** +- Modify: `Cargo.toml`, `electron-app/package.json`, `packages/cli-box-skill/package.json` + +- [ ] **Step 1: Bump versions** + +- `Cargo.toml`: `version = "0.2.7"` → `version = "0.2.8"` (under `[workspace.package]`). +- `electron-app/package.json`: `"version": "0.2.7"` → `"version": "0.2.8"`. +- `packages/cli-box-skill/package.json`: `"version": "0.2.1"` → `"version": "0.2.2"` (the openclaw note is a published-skill change). + +- [ ] **Step 2: Run the full local quality gate** + +Run: `sh test.sh` +Expected: all green (cargo test, clippy, fmt, typecheck, vitest, e2e skill install, new compound e2e, sandbox residue check). + +If anything fails, use `superpowers:systematic-debugging` — root-cause before fixing. + +- [ ] **Step 3: Push and open the PR (do NOT merge)** + +```bash +git push -u origin feat/start-shell-screenshot-openclaw +gh pr create --title "feat: compound start, viewport screenshot, openclaw skill (0.2.8)" --body "$(cat <<'EOF' +## Problem +1. OpenClaw cannot read screenshots saved to CWD (sandboxed to /tmp/openclaw) — the installed SKILL.md never told the agent to save there. +2. `cli-box start "cd /x && claude -r"` fails — spawn treats the whole string as an executable name. +3. `cli-box screenshot` on long claude tasks returns the TOP of the scrollback (oldest lines), not the visible viewport; no scroll; no whole-session text. + +## Solution +- **Skill (1)**: per-target SKILL.md; OpenClaw body appends a `/tmp/openclaw/` screenshot-path note. (`packages/cli-box-skill/installer/shared.mjs`) +- **Compound start (2)**: `needs_shell` + `wrap_shell_command` in `process/mod.rs`; compound commands run via `zsh -lc ""`. `.app` paths unaffected. Instance record keeps the original command for display. (commits: process helpers + spawn wiring) +- **Viewport/scroll/scrollback (3)**: renderer buffer-fallback anchored at `buffer.baseY` (root-cause fix); extracted to pure `terminalBuffer.ts`; `screenshot --up N`/`--top` slide the window over the buffer; new `cli-box scrollback` dumps the whole session as clean text over the screenshot WS. (commits: renderer extract, daemon scroll/scrollback, renderer WS, cli) + +## Test Plan +- [x] UT (TS): `terminalBuffer` viewport(`baseY`)+offset+clamp; `scrollback` trim/range; `captureToPng` background/dimensions migrated to extracted module +- [x] UT (JS): installer openclaw body contains `/tmp/openclaw/`; claude/opencode do not +- [x] UT (Rust): `needs_shell`/`wrap_shell_command`/`prepare_spawn` truth table +- [x] IT (Rust): `screenshot?scroll=`/`?top=` parse; `/box/{id}/scrollback` route exists +- [x] E2E: compound `start` marker appears in `scrollback`; default + `--top` screenshots are PNG +- [x] `sh test.sh` green (cargo test/clippy/fmt, typecheck, vitest, skill install, residue check) +- [ ] Release manual + empirical claude-rendering check (recorded below after `sh release.sh`) + +### Empirical claude rendering (to fill in) +- scrollback completeness: +- `--up` reveals older content: +EOF +)" +``` + +- [ ] **Step 4: Hand off for release testing** + +Do not merge. Stop and report the PR URL. Release build (`sh release.sh`) and the manual scenarios in `tests/release_test.md` happen in a later step per the project workflow. + +--- + +## Self-Review (completed during authoring) + +**Spec coverage:** §3.1 → Task 1. §3.2 (`needs_shell`, `zsh -lc`, `.app` non-collision) → Tasks 2–3. §3.3.1 (baseY fix) → Tasks 4–5. §3.3.2 (`--up`/`--top`, daemon scroll/top, renderer offset, empirical check) → Tasks 4–7, 9, 10. §3.3.3 (`scrollback` CLI/route/renderer, xterm buffer source) → Tasks 6–9. §6 testing → embedded in each task + Task 10/11. §7 non-goals respected (no stitching, no claude-app-scroll driving unless empirical shows gap). + +**Placeholder scan:** none — every code/JSON step shows the literal content; the only open item (empirical claude rendering) is explicitly a manual observation recorded into the PR, with a defined procedure. + +**Type consistency:** `needs_shell`/`wrap_shell_command`/`prepare_spawn` defined in Task 2/3 and used consistently. WS contract field names (`scroll`, `from_line`, `to_line`, `raw`, `text`) match across daemon (Task 6) and renderer (Task 7). `daemon_screenshot(sandbox_id, with_frame, scroll, top)` / `daemon_scrollback(...)` signatures match between client.rs (Task 8) and main.rs (Task 9). `SandboxTerminalHandle` (captureToPng + terminal getter) consistent between Tasks 5 and 7. `RenderableTerminal`/`ScrollbackTerminal` structural types align with the extended `MockTerminal`. diff --git a/docs/superpowers/specs/2026-06-17-start-shell-screenshot-openclaw-design.md b/docs/superpowers/specs/2026-06-17-start-shell-screenshot-openclaw-design.md new file mode 100644 index 0000000..3f8a8ac --- /dev/null +++ b/docs/superpowers/specs/2026-06-17-start-shell-screenshot-openclaw-design.md @@ -0,0 +1,239 @@ +# Compound `start`, Viewport Screenshot & OpenClaw Skill — Design + +**Date:** 2026-06-17 +**Target version:** 0.2.8 +**Status:** Draft (awaiting user review) + +--- + +## 1. Problem + +Three issues surfaced during release testing of v0.2.7: + +### 1.1 OpenClaw cannot access screenshots + +When the `cli-box` skill is installed for **OpenClaw**, the installed `SKILL.md` +gives the agent no hint that OpenClaw is sandboxed to `/tmp/openclaw/`. The agent +writes screenshots to the current working directory (the default +`-o screenshot.png`), OpenClaw cannot read them, and the image is never sent. + +### 1.2 `cli-box start` rejects compound commands + +`cli-box start "cd /path && claude -r"` fails. `spawn_cli_with_size` builds the +PTY child with `CommandBuilder::new(command)`, treating the entire string +(`cd /path && claude -r`) as the executable name. There is no shell in the path, +so `&&`, `;`, `|`, `cd` (a builtin), and redirects are never interpreted. The +same applies to `cli-box start "claude -p hi"` (a command-with-args passed as a +single quoted token). + +### 1.3 Screenshots capture the top of the scrollback, not the visible viewport + +For a complex/long-running `claude` task, `cli-box screenshot` returns the +**oldest** lines of the session, not what is currently visible. Root cause: +the default screenshot path runs through the Electron renderer's +`captureToPng()` (`Terminal.tsx`). Its buffer-fallback renderer loops +`for (let y = 0; y < rows; y++) buffer.getLine(y)`, which reads lines +`0..rows` — the **top** of the scrollback — instead of the visible viewport +(`buffer.baseY .. baseY+rows`). + +Two follow-on needs ride on this: + +- **Scroll**: be able to move the capture window up/down through history, like a + human scrolling — default pinned to the bottom (latest). +- **Full session**: capture the entire `claude` session (research item). + +--- + +## 2. Research: can a screenshot capture the whole session? + +- **Pixel screenshot**: no, not in one frame. A window pixel capture only + contains the visible viewport. Capturing the whole session would require + scrolling and stitching many frames — slow, lossy, and for a TUI like `claude` + it produces dirty/overlapping tiles because of dynamic redraws. **Out of scope.** +- **Text**: yes, and clean. The Electron renderer's `xterm.js` buffer has already + interpreted every escape sequence; reading all lines yields clean text. This is + far more useful than the raw PTY stream in `PtyStore` (SQLite), which for a TUI + is a sequence of redraws/escape codes, not clean prose. **Decision: expose the + whole session as text, sourced from the xterm buffer.** + +> Known limitation: `xterm` is configured with `scrollback: 10000` and a TUI may +> use the alternate screen buffer, so the buffer may not hold the *complete* +> history. The implementation must **empirically verify `claude`'s rendering +> mode** (alt-screen vs. normal-buffer scrollback) and record the finding. If the +> buffer is incomplete, that becomes input for future "drive the app's own +> scroll" work — not part of this version. + +--- + +## 3. Solution + +### 3.1 OpenClaw screenshot path (per-target SKILL.md) + +**Files:** `packages/cli-box-skill/installer/shared.mjs` + +`installSkillToTargets` currently writes the identical bundled body into every +harness directory. Add per-target customization: + +- New helper `targetSpecificNote(id) -> string`. For `openclaw` it returns a + section to append under `## Screenshots`; for `claude` / `opencode` it returns + `""` (unchanged). +- `installSkillToTargets` writes `readBundledSkill() + targetSpecificNote(id)` + per target (compose the body inline; no separate "body override" parameter). +- The appended note for OpenClaw: + + ```markdown + ### OpenClaw note + + OpenClaw can only read files under `/tmp/openclaw/`. When you take a + screenshot, **write the output there** or OpenClaw cannot send the image: + + ```bash + cli-box screenshot --id -o /tmp/openclaw/screenshot.png + ``` + ``` + +No Rust change is needed: `cli-box screenshot -o /file.png` already +`mkdir -p`s the parent directory. + +### 3.2 Shell wrapping for compound commands + +**Files:** `crates/cli-box-core/src/process/mod.rs` + +Add the heuristic **in the daemon spawn layer** so every caller (CLI, MCP, +Electron new-sandbox dialog) benefits: + +- New function `needs_shell(command: &str) -> bool`: returns `true` if `command` + contains a space or any shell metacharacter from + `` { '&', ';', '|', '<', '>', '$', '`', '(', ')', '*', '?', '\n', '!' } ``. +- In `spawn_cli_with_size` (the macOS implementation), at the top: if + `needs_shell(command)`, reconstruct the full line as + `command + " " + args.join(" ")` and re-spawn as `zsh -lc ""` — i.e. + `command = "zsh"`, `args = ["-lc", full_line]`. Then proceed through the + existing PTY spawn path unchanged. +- If `needs_shell` is false, behavior is identical to today (direct `exec` of + `command` + `args`). +- `zsh -lc` (login shell) is used so PATH / nvm / asdf-installed CLIs resolve, + matching the "typed into a terminal" expectation. + +**Non-collision:** `.app` detection (`ends_with(".app")` → `mode = "app"`) happens +upstream in `cmd_start_daemon` before the cli spawn path is reached, so an +`.app` path containing spaces is never passed through `needs_shell`. + +### 3.3 Screenshot viewport fix + scroll + session text + +#### 3.3.1 Fix the default viewport (root-cause bug) + +**Files:** `electron-app/src/renderer/components/Terminal.tsx` + +In `captureToPng`, anchor the fallback renderer at the visible viewport: + +- `const baseY = term.buffer.active.baseY;` +- loop `for (let y = 0; y < rows; y++) term.buffer.active.getLine(baseY + y)`. + +The canvas-first path is retained; the fallback now reflects what a human sees +(the latest state). Extend `captureToPng` with an optional `scrollOffset = 0` +(relative to the viewport top, upward in lines) for 3.3.2. + +#### 3.3.2 Scroll the capture window over the buffer + +**CLI** — new flags on `screenshot`: + +| Flag | Effect | +|------|--------| +| *(default)* | bottom — visible viewport (latest) | +| `--up N` | move the window `N` lines up from the bottom | +| `--top` | jump to the top of the scrollback | + +**Daemon** — extend `/box/{id}/screenshot` query params: `scroll=` (lines up +from bottom, `0` = bottom) and `top=true`. The handler forwards the resolved +offset in the existing `capture_request` over `/screenshot/ws`. + +**Renderer** — `main.tsx` passes the offset from `capture_request` into +`captureToPng(offset)`; `Terminal.tsx` renders `baseY - offset .. baseY - offset + rows` +(clamped to `[0, buffer.length - rows]`). + +Empirical step: verify `claude` rendering mode. If the buffer does not contain +full history, record it; driving `claude`'s own scroll is explicitly out of scope +for this version. + +#### 3.3.3 Full session text + +**New CLI command:** `cli-box scrollback --id ` + +- Prints the entire `xterm` terminal buffer as **clean text** (ANSI stripped by + default) to stdout. +- Options: `-o ` (write to file), `--raw` (keep ANSI), + `--from-line --to-line ` (1-based line range). +- Source: renderer xterm buffer, transported over the existing `/screenshot/ws` + channel via a new `scrollback_request` / `scrollback_response` (text) message + pair (same connection, new message types). +- CLI surfaces it via the daemon route `/box/{id}/scrollback` and + `client::daemon_scrollback`. + +--- + +## 4. Components & data flow + +``` +cli-box start "cd /x && claude -r" + └─ daemon_create_sandbox(mode=cli, command, args) + └─ spawn_cli_with_size: needs_shell("cd /x && claude -r")=true + └─ re-spawn as zsh -lc "cd /x && claude -r" (PTY) + +cli-box screenshot --id X # bottom (latest) +cli-box screenshot --id X --up 100 # 100 lines up +cli-box screenshot --id X --top # scrollback top + └─ /box/X/screenshot?scroll=100|top=true + └─ /screenshot/ws → capture_request{offset} + └─ captureToPng(offset) → capture_response{image_base64} + +cli-box scrollback --id X # whole session, clean text + └─ /box/X/scrollback + └─ /screenshot/ws → scrollback_request + └─ (read all buffer lines) → scrollback_response{text} +``` + +--- + +## 5. Error handling + +- **`needs_shell` off by one**: if a legit single-binary name ever contained a + space (none on macOS), it would be wrapped in `zsh -lc` — still correct, just + via shell. Safe degradation. +- **Scroll clamping**: offset clamped so the window never starts below 0 or ends + past `buffer.length`. `--top` on a buffer shorter than the viewport returns the + whole buffer. +- **`scrollback` with no renderer**: if the renderer is not connected, return a + clear error (same as the existing screenshot-no-renderer path) with a hint to + ensure the Electron window is up. +- **Shell spawn failure** (`zsh` missing): propagated as the existing + `AppError::Process("Failed to spawn command: ...")`. + +--- + +## 6. Testing strategy + +| Layer | Scope | +|-------|-------| +| UT (TS) | Extend `captureToPng.test.ts` to assert the fallback reads from `baseY` (not line 0) and honors `scrollOffset`; assert clamp behavior. New `scrollback` text extraction (all lines, ANSI-strip, range). | +| UT (TS) | Installer: `installSkillToTargets(['openclaw'])` body contains `/tmp/openclaw/`; `['claude']` and `['opencode']` bodies do not. Extend `shared.test.mjs`. | +| UT (Rust) | `needs_shell` truth table (plain token vs. each metacharacter vs. spaced command). `spawn_cli` rewrites to `zsh -lc ""` argv (mock/spy on the spawn builder). | +| IT (Rust) | `daemon_integration.rs`: `/box/{id}/screenshot?scroll=N` and `?top=true` query parsing; `/box/{id}/scrollback` route returns text (renderer-mocked). | +| E2E | `cli-box start "cd && "` actually executes (assert marker appears in output/screenshot). `screenshot --up/--top` differs from default. `scrollback` emits session text. | +| Regression | Existing `e2e-skill-install.sh` still passes; existing screenshot tests still pass. | + +--- + +## 7. Scope & non-goals + +**In scope:** per-target OpenClaw SKILL.md note; shell wrapping of compound +`start` commands; viewport-correct default screenshot; scroll-over-buffer flags; +whole-session text dump. + +**Out of scope (this version):** + +- Image stitching / long-image capture (rejected by design — text + window only). +- Driving `claude`'s own app-level scroll (方案 2); revisit only if 3.3.2's + empirical check shows the buffer lacks history. +- Changing existing `ui-inspect` behavior. +- Changing `xterm` `scrollback` cap beyond the empirical finding. diff --git a/electron-app/package.json b/electron-app/package.json index 9446223..acff5ee 100644 --- a/electron-app/package.json +++ b/electron-app/package.json @@ -1,6 +1,6 @@ { "name": "cli-box-electron", - "version": "0.2.7", + "version": "0.2.8", "private": true, "main": "./out/main/index.js", "scripts": { diff --git a/electron-app/src/__tests__/captureToPng.test.ts b/electron-app/src/__tests__/captureToPng.test.ts index a6662f6..81c32ff 100644 --- a/electron-app/src/__tests__/captureToPng.test.ts +++ b/electron-app/src/__tests__/captureToPng.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { MockBufferLine, MockTerminal } from "./mocks/xterm"; +import { renderBufferToPng } from "../renderer/terminalBuffer"; /** * captureToPng buffer-fallback rendering logic (extracted from Terminal.tsx). @@ -63,54 +64,12 @@ function installCanvasMock() { } // --------------------------------------------------------------------------- -// The rendering logic under test (mirrors Terminal.tsx captureToPng fallback) +// The rendering logic under test lives in renderer/terminalBuffer.ts +// (renderBufferToPng). Tests below use the real extracted module — no local +// duplication. MockTerminal instances are cast to the module's structural +// RenderableTerminal type via `as any`. // --------------------------------------------------------------------------- -function renderBufferToDataUrl( - term: MockTerminal, - cols: number, - rows: number, -): string | null { - const el = term.element; - if (el) return null; // primary renderer path, not the fallback - - const fontSize = 13; - const lineHeight = Math.ceil(fontSize * 1.4); - const charWidth = Math.ceil(fontSize * 0.6); - - const canvas = document.createElement("canvas"); - canvas.width = cols * charWidth; - canvas.height = rows * lineHeight; - - const ctx = canvas.getContext("2d"); - if (!ctx) return null; - - // MUST use a dark background — not white - ctx.fillStyle = "#1a1a1a"; - ctx.fillRect(0, 0, canvas.width, canvas.height); - - ctx.font = `${fontSize}px "SF Mono", "Menlo", "Monaco", monospace`; - ctx.textBaseline = "top"; - - const buffer = term.buffer.active; - for (let y = 0; y < rows; y++) { - const line = buffer.getLine(y); - if (!line) continue; - for (let x = 0; x < line.length; x++) { - const char = line.getCell(x)?.getChars() || " "; - const fg = line.getCell(x)?.getFgColor(); - if (fg && fg !== 0) { - ctx.fillStyle = `rgb(${(fg >> 16) & 0xff},${(fg >> 8) & 0xff},${fg & 0xff})`; - } else { - ctx.fillStyle = "#cccccc"; - } - ctx.fillText(char, x * charWidth, y * lineHeight); - } - } - - return canvas.toDataURL("image/png"); -} - // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- @@ -125,9 +84,7 @@ describe("captureToPng buffer fallback", () => { new MockBufferLine("hello"), ]); - renderBufferToDataUrl(term, 80, 24); - - // The first fillRect call should be preceded by fillStyle = "#1a1a1a" + renderBufferToPng(term as any, 80, 24, 0); const fillRectCalls = drawCalls.filter(c => c.method === "fillRect"); expect(fillRectCalls.length).toBeGreaterThan(0); @@ -141,19 +98,21 @@ describe("captureToPng buffer fallback", () => { expect(recordedFillStyles).not.toContain("#fff"); }); - it("should produce a valid PNG data URL", () => { + it("should produce a valid PNG base64 string", () => { const term = new MockTerminal([ new MockBufferLine("test"), ]); - const result = renderBufferToDataUrl(term, 80, 24); + const result = renderBufferToPng(term as any, 80, 24, 0); expect(result).toBeTruthy(); - expect(result).toMatch(/^data:image\/png;base64,/); + // renderBufferToPng returns base64 only (no data: prefix) + expect(result).toMatch(/^[A-Za-z0-9+/=]+$/); }); - it("should return null when canvas getContext returns null", () => { - // Simulate getContext failure + it("should throw when canvas getContext returns null", () => { + // Simulate getContext failure — the extracted module throws rather than + // returning null, surfacing the failure loudly. vi.restoreAllMocks(); vi.spyOn(document, "createElement").mockImplementation((tag: string): any => { if (tag === "canvas") { @@ -168,17 +127,15 @@ describe("captureToPng buffer fallback", () => { }); const term = new MockTerminal([new MockBufferLine("test")]); - const result = renderBufferToDataUrl(term, 80, 24); - expect(result).toBeNull(); + expect(() => renderBufferToPng(term as any, 80, 24, 0)).toThrow(); }); it("should handle empty buffer without errors", () => { const term = new MockTerminal(); // no lines - const result = renderBufferToDataUrl(term, 80, 24); + const result = renderBufferToPng(term as any, 80, 24, 0); expect(result).toBeTruthy(); - expect(result).toMatch(/^data:image\/png;base64,/); // Should still have the background fillRect const fillRectCalls = drawCalls.filter(c => c.method === "fillRect"); @@ -195,7 +152,7 @@ describe("captureToPng buffer fallback", () => { new MockBufferLine(chineseText), ]); - const result = renderBufferToDataUrl(term, 80, 24); + const result = renderBufferToPng(term as any, 80, 24, 0); expect(result).toBeTruthy(); @@ -215,7 +172,7 @@ describe("captureToPng buffer fallback", () => { new MockBufferLine("X", greenFg), ]); - renderBufferToDataUrl(term, 80, 24); + renderBufferToPng(term as any, 80, 24, 0); // The fillText for the colored character should use the decoded RGB expect(recordedFillStyles).toContain("rgb(0,255,0)"); @@ -226,7 +183,7 @@ describe("captureToPng buffer fallback", () => { new MockBufferLine("A", 0), // fg=0 means default ]); - renderBufferToDataUrl(term, 80, 24); + renderBufferToPng(term as any, 80, 24, 0); // Should use #cccccc for default foreground expect(recordedFillStyles).toContain("#cccccc"); @@ -239,7 +196,7 @@ describe("captureToPng buffer fallback", () => { new MockBufferLine("O", orangeFg), ]); - renderBufferToDataUrl(term, 80, 24); + renderBufferToPng(term as any, 80, 24, 0); expect(recordedFillStyles).toContain("rgb(255,136,0)"); }); @@ -250,7 +207,7 @@ describe("captureToPng buffer fallback", () => { new MockBufferLine("line2"), ]); - const result = renderBufferToDataUrl(term, 80, 24); + const result = renderBufferToPng(term as any, 80, 24, 0); expect(result).toBeTruthy(); const fillTextCalls = drawCalls.filter(c => c.method === "fillText"); @@ -284,7 +241,7 @@ describe("captureToPng buffer fallback", () => { return origCreate(tag); }); - renderBufferToDataUrl(term, 40, 12); + renderBufferToPng(term as any, 40, 12, 0); const fontSize = 13; const lineHeight = Math.ceil(fontSize * 1.4); // 19 diff --git a/electron-app/src/__tests__/mocks/xterm.ts b/electron-app/src/__tests__/mocks/xterm.ts index 934028f..bfe555c 100644 --- a/electron-app/src/__tests__/mocks/xterm.ts +++ b/electron-app/src/__tests__/mocks/xterm.ts @@ -1,7 +1,12 @@ export class MockCell { - constructor(private chars: string = " ", private fg: number = 0) {} + constructor( + private chars: string = " ", + private fg: number = 0, + private width: number = 1, + ) {} getChars() { return this.chars; } getFgColor() { return this.fg; } + getWidth() { return this.width; } } export class MockBufferLine { @@ -13,12 +18,25 @@ export class MockBufferLine { this.length = this.cells.length; } + /** Build a line from explicit cells (e.g. wide char + 0-width continuation). */ + static fromCells(cells: MockCell[]): MockBufferLine { + const line = Object.create(MockBufferLine.prototype) as MockBufferLine; + line.cells = cells; + line.length = cells.length; + return line; + } + getCell(x: number) { return this.cells[x] ?? null; } } export class MockBuffer { + baseY: number; private lines: MockBufferLine[]; - constructor(lines: MockBufferLine[]) { this.lines = lines; } + constructor(lines: MockBufferLine[], baseY: number = 0) { + this.lines = lines; + this.baseY = baseY; + } + get length() { return this.lines.length; } getLine(y: number) { return this.lines[y] ?? null; } } diff --git a/electron-app/src/__tests__/scrollback.test.ts b/electron-app/src/__tests__/scrollback.test.ts new file mode 100644 index 0000000..9da00de --- /dev/null +++ b/electron-app/src/__tests__/scrollback.test.ts @@ -0,0 +1,43 @@ +import { describe, it, expect } from "vitest"; +import { readScrollback, type ScrollbackTerminal } from "../renderer/scrollback"; +import { MockBufferLine, MockCell, MockTerminal } from "./mocks/xterm"; + +function term(lines: string[]): ScrollbackTerminal { + return new MockTerminal(lines.map((l) => new MockBufferLine(l))) as unknown as ScrollbackTerminal; +} + +describe("readScrollback", () => { + it("joins all lines, trailing whitespace trimmed by default", () => { + const t = term(["hello ", "world"]); + expect(readScrollback(t, { raw: false })).toBe("hello\nworld"); + }); + + it("raw preserves trailing whitespace", () => { + const t = term(["hi ", "yo"]); + expect(readScrollback(t, { raw: true })).toBe("hi \nyo"); + }); + + it("from_line / to_line are 1-based inclusive", () => { + const t = term(["a", "b", "c", "d"]); + expect(readScrollback(t, { raw: false, fromLine: 2, toLine: 3 })).toBe("b\nc"); + }); + + it("clamps range to buffer length", () => { + const t = term(["a", "b"]); + expect(readScrollback(t, { raw: false, fromLine: 1, toLine: 99 })).toBe("a\nb"); + }); + + it("does not pad wide (CJK/emoji) chars with spaces from continuation cells", () => { + // A double-width char occupies 2 columns: cell N holds the char (width 2), + // cell N+1 is a 0-width continuation with no glyph. The continuation must + // be skipped so output is "执行" not "执 行". + const line = MockBufferLine.fromCells([ + new MockCell("执", 0, 2), + new MockCell("", 0, 0), + new MockCell("行", 0, 2), + new MockCell("", 0, 0), + ]); + const t = new MockTerminal([line]) as unknown as ScrollbackTerminal; + expect(readScrollback(t, { raw: false })).toBe("执行"); + }); +}); diff --git a/electron-app/src/__tests__/terminalBuffer.test.ts b/electron-app/src/__tests__/terminalBuffer.test.ts new file mode 100644 index 0000000..751cfb3 --- /dev/null +++ b/electron-app/src/__tests__/terminalBuffer.test.ts @@ -0,0 +1,85 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { MockBufferLine, MockCell, MockTerminal } from "./mocks/xterm"; +import { renderBufferToPng, type RenderableTerminal } from "../renderer/terminalBuffer"; + +let drawCalls: { method: string; args: unknown[] }[]; + +beforeEach(() => { + drawCalls = []; + const ctx = { + fillStyle: "#000", + font: "", + textBaseline: "", + fillRect(...args: unknown[]) { drawCalls.push({ method: "fillRect", args }); }, + fillText(...args: unknown[]) { drawCalls.push({ method: "fillText", args }); }, + }; + const origCreate = document.createElement.bind(document); + vi.spyOn(document, "createElement").mockImplementation((tag: string): any => { + if (tag === "canvas") { + return { + width: 0, height: 0, + getContext: () => ctx, + toDataURL: () => "data:image/png;base64,AAAA", + }; + } + return origCreate(tag); + }); +}); + +// Build a terminal where line N is the single character chr('a'+N) (so each +// line has a unique, identifiable glyph). baseY selects the viewport top. +function termWith(lineCount: number, baseY: number) { + const lines: string[] = []; + for (let i = 0; i < lineCount; i++) lines.push(String.fromCharCode("a".charCodeAt(0) + i)); + const t = new MockTerminal(lines.map((s) => new MockBufferLine(s))); + (t.buffer.active as any).baseY = baseY; + return t as unknown as RenderableTerminal; +} + +const char = (n: number) => String.fromCharCode("a".charCodeAt(0) + n); + +describe("renderBufferToPng viewport", () => { + it("renders the VISIBLE viewport (baseY..baseY+rows), not the top", () => { + // 6 lines above a 2-row viewport: lines 0..5 hidden, 6..7 visible. + const t = termWith(8, 6); + renderBufferToPng(t, 1, 2, 0); // cols=1, rows=2, offset=0 + // Viewport starts at baseY=6 → chars 'g' (line 6) and 'h' (line 7) drawn. + expect(drawCalls.some((d) => d.method === "fillText" && d.args[0] === char(6))).toBe(true); + expect(drawCalls.some((d) => d.method === "fillText" && d.args[0] === char(0))).toBe(false); + }); + + it("scrolls the window UP by offset lines", () => { + const t = termWith(8, 6); + renderBufferToPng(t, 1, 2, 3); // offset 3 → start = baseY-3 = 3 → chars 'd','e' + expect(drawCalls.some((d) => d.method === "fillText" && d.args[0] === char(3))).toBe(true); + expect(drawCalls.some((d) => d.method === "fillText" && d.args[0] === char(7))).toBe(false); + }); + + it("clamps offset so start line never goes below 0 (--top)", () => { + const t = termWith(4, 2); + renderBufferToPng(t, 1, 2, 9999); // huge offset → start clamped to 0 + expect(drawCalls.some((d) => d.method === "fillText" && d.args[0] === char(0))).toBe(true); + }); + + it("treats a negative scroll offset as 0 (no-op)", () => { + const t = termWith(4, 2); + renderBufferToPng(t, 1, 2, -5); // negative → clamp to 0 → start = baseY = 2 + // baseY=2, rows=2 → visible lines 2,3 → chars 'c' (line 2), 'd' (line 3). + expect(drawCalls.some((d) => d.method === "fillText" && d.args[0] === char(2))).toBe(true); + }); + + it("skips wide-char continuation cells (no spurious glyph)", () => { + // Double-width char at col 0 (width 2) + 0-width continuation at col 1. + // Only "执" should be drawn; the continuation must not emit a space glyph. + const line = MockBufferLine.fromCells([ + new MockCell("执", 0, 2), + new MockCell("", 0, 0), + ]); + const t = new MockTerminal([line]) as unknown as RenderableTerminal; + renderBufferToPng(t, 2, 1, 0); + const text = drawCalls + .filter((d) => d.method === "fillText") + .map((d) => d.args[0]); + expect(text).toEqual(["执"]); + }); +}); diff --git a/electron-app/src/renderer/components/Terminal.tsx b/electron-app/src/renderer/components/Terminal.tsx index c58dcdf..bc353d8 100644 --- a/electron-app/src/renderer/components/Terminal.tsx +++ b/electron-app/src/renderer/components/Terminal.tsx @@ -1,7 +1,9 @@ import { useEffect, useRef, useCallback, forwardRef, useImperativeHandle } from "react"; import { Terminal } from "@xterm/xterm"; +import type { Terminal as TerminalType } from "@xterm/xterm"; import { FitAddon } from "@xterm/addon-fit"; import { connectPty } from "../api"; +import { renderBufferToPng } from "../terminalBuffer"; import "@xterm/xterm/css/xterm.css"; interface TerminalProps { @@ -11,7 +13,8 @@ interface TerminalProps { } export interface SandboxTerminalHandle { - captureToPng(): Promise; + captureToPng(scrollOffset?: number): Promise; + readonly terminal: TerminalType | null; } const SandboxTerminal = forwardRef(function SandboxTerminal( @@ -25,53 +28,26 @@ const SandboxTerminal = forwardRef(functio const connRef = useRef | null>(null); useImperativeHandle(ref, () => ({ - async captureToPng(): Promise { + get terminal() { + return xtermRef.current; + }, + async captureToPng(scrollOffset: number = 0): Promise { const term = xtermRef.current; if (!term) throw new Error("Terminal not initialized"); - // Try canvas first (works for active/visible tabs) - const canvasEl = term.element?.querySelector("canvas"); - if (canvasEl) { - const dataUrl = canvasEl.toDataURL("image/png"); - return dataUrl.split(",")[1]; + // The live xterm canvas only ever shows the current viewport (offset 0). + // For any scroll offset we must render from the text buffer instead. + if (scrollOffset === 0) { + const canvasEl = term.element?.querySelector("canvas"); + if (canvasEl) { + const dataUrl = canvasEl.toDataURL("image/png"); + return dataUrl.split(",")[1]; + } } - // Fallback: render xterm buffer to canvas (works for hidden/offscreen tabs) const fitAddon = fitAddonRef.current; - if (fitAddon) { - fitAddon.fit(); - } - const cols = term.cols; - const rows = term.rows; - const fontSize = 13; - const lineHeight = Math.ceil(fontSize * 1.4); - const charWidth = Math.ceil(fontSize * 0.6); - const canvas = document.createElement("canvas"); - canvas.width = cols * charWidth; - canvas.height = rows * lineHeight; - const ctx = canvas.getContext("2d")!; - ctx.fillStyle = "#1a1a1a"; - ctx.fillRect(0, 0, canvas.width, canvas.height); - ctx.font = `${fontSize}px "SF Mono", "Menlo", "Monaco", monospace`; - ctx.textBaseline = "top"; - - const buffer = term.buffer.active; - for (let y = 0; y < rows; y++) { - const line = buffer.getLine(y); - if (!line) continue; - for (let x = 0; x < line.length; x++) { - const char = line.getCell(x)?.getChars() || " "; - const fg = line.getCell(x)?.getFgColor(); - // Use white for default text, or specific colors - if (fg && fg !== 0) { - ctx.fillStyle = `rgb(${(fg >> 16) & 0xff},${(fg >> 8) & 0xff},${fg & 0xff})`; - } else { - ctx.fillStyle = "#cccccc"; - } - ctx.fillText(char, x * charWidth, y * lineHeight); - } - } - return canvas.toDataURL("image/png").split(",")[1]; + if (fitAddon) fitAddon.fit(); + return renderBufferToPng(term, term.cols, term.rows, scrollOffset); }, }), []); diff --git a/electron-app/src/renderer/main.tsx b/electron-app/src/renderer/main.tsx index ca2689b..cb56398 100644 --- a/electron-app/src/renderer/main.tsx +++ b/electron-app/src/renderer/main.tsx @@ -11,6 +11,7 @@ import { import { Tab, syncTabs, selectAfterClose } from "./tabState"; import AppPanel from "./components/AppPanel"; import { ErrorModal } from "./components/ErrorModal"; +import { readScrollback } from "./scrollback"; import "./styles.css"; declare global { @@ -213,11 +214,11 @@ function App() { sandbox_id, })); } else if (msg.type === "capture_request") { - const { sandbox_id, request_id } = msg; + const { sandbox_id, request_id, scroll } = msg; const tabRef = terminalRefs.current.get(sandbox_id); if (tabRef?.current) { try { - const base64 = await tabRef.current.captureToPng(); + const base64 = await tabRef.current.captureToPng(Number(scroll) || 0); ws?.send(JSON.stringify({ type: "capture_response", request_id, @@ -240,6 +241,40 @@ function App() { error: "Terminal not found or not mounted", })); } + } else if (msg.type === "scrollback_request") { + const { sandbox_id, request_id, raw, from_line, to_line } = msg; + const tabRef = terminalRefs.current.get(sandbox_id); + if (tabRef?.current) { + try { + const terminal = tabRef.current.terminal; + if (!terminal) throw new Error("Terminal not available"); + const text = readScrollback(terminal, { + raw: Boolean(raw), + fromLine: from_line ?? null, + toLine: to_line ?? null, + }); + ws?.send(JSON.stringify({ + type: "scrollback_response", + request_id, + sandbox_id, + text, + })); + } catch (err) { + ws?.send(JSON.stringify({ + type: "scrollback_error", + request_id, + sandbox_id, + error: String(err), + })); + } + } else { + ws?.send(JSON.stringify({ + type: "scrollback_error", + request_id, + sandbox_id, + error: "Terminal not found or not mounted", + })); + } } } catch (err) { console.error("[screenshot-ws] parse error:", err); diff --git a/electron-app/src/renderer/scrollback.ts b/electron-app/src/renderer/scrollback.ts new file mode 100644 index 0000000..af4857d --- /dev/null +++ b/electron-app/src/renderer/scrollback.ts @@ -0,0 +1,55 @@ +// Pure extraction of an xterm.js buffer into clean session text. +// The xterm buffer is already ANSI-free (escape sequences are interpreted into +// cells), so this returns readable text. `raw` preserves trailing whitespace; +// the default trims each line's trailing whitespace. + +export interface ScrollbackCell { + getChars(): string; + // Cell width in columns. 0 = wide-char continuation (the second column of a + // double-width CJK/emoji char); these hold no character and must be skipped + // so CJK text isn't padded with spaces ("执 行" → "执行"). + getWidth(): number; +} +export interface ScrollbackLine { + readonly length: number; + getCell(x: number): ScrollbackCell | null | undefined; +} +export interface ScrollbackBuffer { + readonly length: number; + getLine(y: number): ScrollbackLine | null | undefined; +} +export interface ScrollbackTerminal { + readonly buffer: { readonly active: ScrollbackBuffer }; +} + +export interface ScrollbackOptions { + raw: boolean; + fromLine?: number | null; // 1-based inclusive + toLine?: number | null; // 1-based inclusive +} + +export function readScrollback(term: ScrollbackTerminal, opts: ScrollbackOptions): string { + const buffer = term.buffer.active; + const total = buffer.length; + const start = opts.fromLine != null ? Math.max(0, opts.fromLine - 1) : 0; + const end = opts.toLine != null ? Math.min(total, opts.toLine) : total; + + const out: string[] = []; + for (let y = start; y < end; y++) { + const line = buffer.getLine(y); + if (!line) continue; + let s = ""; + for (let x = 0; x < line.length; x++) { + const cell = line.getCell(x); + if (!cell) { + s += " "; + continue; + } + // Skip wide-char continuation cells so CJK/emoji text is contiguous. + if (cell.getWidth() === 0) continue; + s += cell.getChars() || " "; + } + out.push(opts.raw ? s : s.replace(/\s+$/, "")); + } + return out.join("\n"); +} diff --git a/electron-app/src/renderer/terminalBuffer.ts b/electron-app/src/renderer/terminalBuffer.ts new file mode 100644 index 0000000..4149aae --- /dev/null +++ b/electron-app/src/renderer/terminalBuffer.ts @@ -0,0 +1,80 @@ +// Pure, testable extraction of the xterm.js buffer → PNG renderer used by +// Terminal.tsx's captureToPng fallback. Kept free of React so it can be unit +// tested with the xterm mock. + +export interface BufferCellLike { + getChars(): string; + getFgColor(): number; + // Cell width in columns. 0 = wide-char continuation (second column of a + // double-width CJK/emoji char); holds no glyph, so skip it. + getWidth(): number; +} + +export interface BufferLineLike { + readonly length: number; + getCell(x: number): BufferCellLike | null | undefined; +} + +export interface BufferLike { + readonly baseY: number; + getLine(y: number): BufferLineLike | null | undefined; +} + +export interface RenderableTerminal { + readonly cols: number; + readonly buffer: { readonly active: BufferLike }; +} + +const FONT_SIZE = 13; +const LINE_HEIGHT = Math.ceil(FONT_SIZE * 1.4); +const CHAR_WIDTH = Math.ceil(FONT_SIZE * 0.6); + +/** + * Render an xterm.js terminal buffer window to a base64 PNG string. + * + * `scrollOffset` is the number of lines to scroll UP from the current viewport + * top (`buffer.baseY`). 0 = the visible viewport (latest content). The start + * line is clamped to >= 0, so a very large offset jumps to the very top. + */ +export function renderBufferToPng( + term: RenderableTerminal, + cols: number, + rows: number, + scrollOffset: number = 0, +): string { + const canvas = document.createElement("canvas"); + canvas.width = cols * CHAR_WIDTH; + canvas.height = rows * LINE_HEIGHT; + const ctx = canvas.getContext("2d"); + if (!ctx) throw new Error("Failed to get 2d context for buffer render"); + + ctx.fillStyle = "#1a1a1a"; + ctx.fillRect(0, 0, canvas.width, canvas.height); + ctx.font = `${FONT_SIZE}px "SF Mono", "Menlo", "Monaco", monospace`; + ctx.textBaseline = "top"; + + const buffer = term.buffer.active; + const baseY = buffer.baseY ?? 0; + const startLine = Math.max(0, baseY - Math.max(0, scrollOffset)); + + for (let y = 0; y < rows; y++) { + const line = buffer.getLine(startLine + y); + if (!line) continue; + for (let x = 0; x < line.length; x++) { + const cell = line.getCell(x); + // Skip wide-char continuation cells (no glyph of their own). + if (cell && cell.getWidth() === 0) continue; + const char = cell?.getChars() || " "; + const fg = cell?.getFgColor(); + if (fg && fg !== 0) { + ctx.fillStyle = `rgb(${(fg >> 16) & 0xff},${(fg >> 8) & 0xff},${fg & 0xff})`; + } else { + ctx.fillStyle = "#cccccc"; + } + // NOTE: wide chars (getWidth()===2) are drawn at single CHAR_WIDTH here; + // making them span two columns is a separate rendering refinement. + ctx.fillText(char, x * CHAR_WIDTH, y * LINE_HEIGHT); + } + } + return canvas.toDataURL("image/png").split(",")[1]; +} diff --git a/packages/cli-box-skill/installer/shared.mjs b/packages/cli-box-skill/installer/shared.mjs index bfadad7..824d2e8 100644 --- a/packages/cli-box-skill/installer/shared.mjs +++ b/packages/cli-box-skill/installer/shared.mjs @@ -73,12 +73,36 @@ export function readBundledSkill() { return fs.readFileSync(new URL("../skill/SKILL.md", import.meta.url), "utf8"); } +// Per-harness additions appended to the bundled SKILL.md body. +// Returns "" when the target needs no customization. +export function targetSpecificNote(id) { + if (id === "openclaw") { + return [ + "", + "## Notes for OpenClaw", + "", + "OpenClaw can only read files under `/tmp/openclaw/`. When you take a", + "screenshot, **write the output there** or OpenClaw cannot read or send the", + "image:", + "", + "```bash", + "cli-box screenshot --id -o /tmp/openclaw/screenshot.png", + "```", + "", + "The directory is created automatically. Do not write screenshots to the", + "current working directory when driving an OpenClaw agent.", + "", + ].join("\n"); + } + return ""; +} + // Writes the skill body into each target dir. Returns [{id, dir, ok, error?}]. export function installSkillToTargets(ids, { home = os.homedir(), content } = {}) { - const body = content ?? readBundledSkill(); return ids.map((id) => { const dir = HARNESS_TARGETS[id].skillDir(home); try { + const body = (content ?? readBundledSkill()) + targetSpecificNote(id); fs.mkdirSync(dir, { recursive: true }); fs.writeFileSync(path.join(dir, "SKILL.md"), body); return { id, dir, ok: true }; diff --git a/packages/cli-box-skill/package.json b/packages/cli-box-skill/package.json index 05633b9..5d1b1da 100644 --- a/packages/cli-box-skill/package.json +++ b/packages/cli-box-skill/package.json @@ -1,6 +1,6 @@ { "name": "cli-box-skill", - "version": "0.2.1", + "version": "0.2.2", "description": "macOS desktop automation sandbox for AI agents", "main": "postinstall.mjs", "bin": { diff --git a/packages/cli-box-skill/skill/SKILL.md b/packages/cli-box-skill/skill/SKILL.md index 4ac7e44..6f7c367 100644 --- a/packages/cli-box-skill/skill/SKILL.md +++ b/packages/cli-box-skill/skill/SKILL.md @@ -61,6 +61,7 @@ cli-box close | `cli-box start [command]` | Start sandbox (default: zsh). Supports `claude`, `opencode`, `zsh`, `bash`, or any CLI | | `cli-box start /path/to/App.app` | Start sandbox with a macOS application | | `cli-box start claude -- -p "question"` | Start sandbox with arguments | +| `cli-box start "cd /path && claude -r"` | Compound commands run via `zsh -lc` (`&&`, `;`, `\|`, `cd`, redirects) | | `cli-box list` | List all active sandboxes with ID, title, status, port | | `cli-box close ` | Close a sandbox and clean up | | `cli-box inspect ` | Show sandbox details | @@ -77,10 +78,26 @@ cli-box close ### Screenshots +By default a screenshot captures the **visible viewport** — the latest content a +human sees — not the top of the scrollback. + | Command | Description | |---------|-------------| | `cli-box screenshot --id ` | Screenshot to stdout (base64) | -| `cli-box screenshot --id -o file.png` | Screenshot to file | +| `cli-box screenshot --id -o file.png` | Screenshot to file (visible viewport) | +| `cli-box screenshot --id --up 100 -o h.png` | Slide the capture window up 100 lines (see older output) | +| `cli-box screenshot --id --top -o top.png` | Jump to the top of the scrollback | + +### Session Text (scrollback) + +Dump the **entire session** as clean text (ANSI-free) — the full terminal buffer. + +| Command | Description | +|---------|-------------| +| `cli-box scrollback --id ` | Print whole session text to stdout | +| `cli-box scrollback --id -o session.txt` | Write to a file | +| `cli-box scrollback --id --raw` | Preserve trailing whitespace (default trims) | +| `cli-box scrollback --id --from-line 10 --to-line 50` | 1-based inclusive line range | ### UI Inspection @@ -112,7 +129,7 @@ Add to `.claude/settings.json` or `.opencode/config.json`: } ``` -Then use tools: `start_sandbox`, `screenshot`, `click`, `type_text`, `press_key`, `close_sandbox`, `list_sandboxes`. +Then use tools: `start_sandbox`, `screenshot_sandbox` (supports `up`/`top` scroll), `scrollback_sandbox`, `type_text`, `press_key`, `close_sandbox`, `list_sandboxes`. ## Typical Workflow diff --git a/packages/cli-box-skill/test/shared.test.mjs b/packages/cli-box-skill/test/shared.test.mjs index 2f5c680..3c6af43 100644 --- a/packages/cli-box-skill/test/shared.test.mjs +++ b/packages/cli-box-skill/test/shared.test.mjs @@ -1,6 +1,7 @@ -import { test } from "node:test"; +import { test, describe, beforeEach, afterEach } from "node:test"; import assert from "node:assert/strict"; import fs from "node:fs"; +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; import os from "node:os"; import path from "node:path"; import { @@ -80,3 +81,42 @@ test("installSkillToTargets: writes SKILL.md into each target dir", () => { fs.rmSync(home, { recursive: true, force: true }); } }); + +describe("per-target SKILL.md customization", () => { + let home; + beforeEach(() => { + home = mkdtempSync(path.join(os.tmpdir(), "cli-box-skill-")); + }); + afterEach(() => { + rmSync(home, { recursive: true, force: true }); + }); + + test("openclaw body documents /tmp/openclaw screenshot path", () => { + const results = installSkillToTargets(["openclaw"], { home }); + const body = readFileSync( + path.join(home, ".openclaw", "skills", "cli-box", "SKILL.md"), + "utf8" + ); + assert.ok(results[0].ok, "install should succeed"); + assert.ok(body.includes("/tmp/openclaw/"), "should mention /tmp/openclaw/"); + assert.ok(/screenshot.*\/tmp\/openclaw/s.test(body), "should tie screenshots to the path"); + }); + + test("claude body does NOT mention /tmp/openclaw", () => { + installSkillToTargets(["claude"], { home }); + const body = readFileSync( + path.join(home, ".claude", "skills", "cli-box", "SKILL.md"), + "utf8" + ); + assert.ok(!body.includes("/tmp/openclaw/"), "claude body must stay generic"); + }); + + test("opencode body does NOT mention /tmp/openclaw", () => { + installSkillToTargets(["opencode"], { home }); + const body = readFileSync( + path.join(home, ".config", "opencode", "skills", "cli-box", "SKILL.md"), + "utf8" + ); + assert.ok(!body.includes("/tmp/openclaw/"), "opencode body must stay generic"); + }); +}); diff --git a/release.sh b/release.sh index a67a943..8a4284a 100755 --- a/release.sh +++ b/release.sh @@ -17,7 +17,7 @@ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" cd "$SCRIPT_DIR" RELEASE_DIR="$SCRIPT_DIR/release" -VERSION="0.2.7" +VERSION="0.2.8" APP_NAME="CLI Box" # --- helpers --- diff --git a/test.sh b/test.sh index 520421a..5521e37 100755 --- a/test.sh +++ b/test.sh @@ -117,6 +117,15 @@ else FAILED=1 fi +# ==================== E2E: Compound Start + Screenshot + Scrollback ======== +info "E2E: compound start + screenshot + scrollback" +if bash tests/e2e-compound-start-screenshot.sh 2>&1; then + ok "E2E compound-start-screenshot passed" +else + err "E2E compound-start-screenshot FAILED" + FAILED=1 +fi + # ==================== Rename Remnant Check ==================== info "Checking for 'sandbox' remnants in user-facing strings..." # Check specific files that were renamed for leftover "sandbox" references diff --git a/tests/e2e-compound-start-screenshot.sh b/tests/e2e-compound-start-screenshot.sh new file mode 100755 index 0000000..0eedddd --- /dev/null +++ b/tests/e2e-compound-start-screenshot.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +# E2E: compound start command, viewport screenshot, scroll, and scrollback. +# Requires a built release on PATH (cli-box, cli-box-daemon) and Screen Recording +# permission. Skips gracefully when those are unavailable so `sh test.sh` stays green. +set -euo pipefail + +# Graceful skip when the toolchain / daemon can't be exercised here. +if ! command -v cli-box >/dev/null 2>&1; then + echo "SKIP: cli-box not on PATH (needs a release build); skipping compound-start E2E" + exit 0 +fi + +MARKER_DIR="$(mktemp -d)" +cleanup() { + if [ -n "${SID:-}" ]; then cli-box close "$SID" >/dev/null 2>&1 || true; fi + rm -rf "$MARKER_DIR" +} +trap cleanup EXIT + +# 1) Compound command: cd into a temp dir, write a marker file, cat it. +SID=$(cli-box start "cd $MARKER_DIR && printf 'compound-ok\n' > marker.txt && cat marker.txt" \ + | sed -n 's/.*id=\([^,]*\).*/\1/p') +test -n "$SID" || { echo "FAIL: no sandbox id from compound start" >&2; exit 1; } +echo "started sandbox: $SID" + +# Give the PTY time to run the compound command. +sleep 3 + +# 2) scrollback contains the marker text the compound command produced. +TEXT=$(cli-box scrollback --id "$SID" || true) +echo "$TEXT" | grep -q "compound-ok" || { echo "FAIL: compound marker not in scrollback" >&2; exit 1; } +echo "compound command ran (marker found in scrollback)" + +# 3) default screenshot + --top screenshot are both PNGs. +cli-box screenshot --id "$SID" -o "$MARKER_DIR/bottom.png" >/dev/null \ + || { echo "FAIL: default screenshot failed" >&2; exit 1; } +cli-box screenshot --id "$SID" --top -o "$MARKER_DIR/top.png" >/dev/null \ + || { echo "FAIL: --top screenshot failed" >&2; exit 1; } +file "$MARKER_DIR/bottom.png" | grep -q "PNG" || { echo "FAIL: bottom.png not PNG" >&2; exit 1; } +file "$MARKER_DIR/top.png" | grep -q "PNG" || { echo "FAIL: top.png not PNG" >&2; exit 1; } +echo "viewport + top screenshots captured as PNG" + +echo "E2E PASS"