Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <id> # Close sandbox

# Screenshot + input
cli-box screenshot --id <id> -o shot.png
cli-box screenshot --id <id> --up 100 -o history.png # Slide window up 100 lines
cli-box screenshot --id <id> --top -o top.png # Jump to scrollback top
cli-box scrollback --id <id> # Dump full session text (clean)
cli-box type --id <id> "hello world"
cli-box key --id <id> Return
cli-box click --id <id> 100 200
Expand Down
4 changes: 4 additions & 0 deletions README.zh-cn.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <id> # 关闭沙箱

# 截图 + 输入
cli-box screenshot --id <id> -o shot.png
cli-box screenshot --id <id> --up 100 -o history.png # 截图窗口上移 100 行
cli-box screenshot --id <id> --top -o top.png # 跳到 scrollback 最顶部
cli-box scrollback --id <id> # 导出整段会话文本(干净文本)
cli-box type --id <id> "你好世界"
cli-box key --id <id> Return
cli-box click --id <id> 100 200
Expand Down
70 changes: 64 additions & 6 deletions crates/cli-box-cli/src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -155,14 +155,31 @@ pub async fn daemon_list_sandboxes() -> Result<Vec<DaemonSandbox>> {

/// 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<ScreenshotResult> {
///
/// `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<u32>,
top: bool,
) -> Result<ScreenshotResult> {
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()
Expand Down Expand Up @@ -191,6 +208,47 @@ pub async fn daemon_screenshot(sandbox_id: &str, with_frame: bool) -> Result<Scr
})
}

/// Fetch the full session text for a sandbox via the daemon HTTP API.
///
/// When `raw` is true, the server preserves trailing whitespace (no per-line
/// trim). `from_line`/`to_line` are 1-based inclusive line bounds.
pub async fn daemon_scrollback(
sandbox_id: &str,
raw: bool,
from_line: Option<u32>,
to_line: Option<u32>,
) -> Result<String> {
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()?;
Expand Down
110 changes: 105 additions & 5 deletions crates/cli-box-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<u32>,

/// 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<PathBuf>,

/// Preserve trailing whitespace (no per-line trim)
#[arg(long)]
raw: bool,

/// Start line (1-based, inclusive)
#[arg(long)]
from_line: Option<u32>,

/// End line (1-based, inclusive)
#[arg(long)]
to_line: Option<u32>,
},

/// List all visible windows on the system
Expand Down Expand Up @@ -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()?;
Expand Down Expand Up @@ -670,14 +712,16 @@ async fn cmd_screenshot_daemon(
output: &std::path::Path,
id: Option<&str>,
with_frame: bool,
up: Option<u32>,
top: bool,
) -> anyhow::Result<()> {
let sandbox_id = id.ok_or_else(|| {
anyhow::anyhow!(
"--id is required for screenshots. Use: cli-box screenshot --id <sandbox-id>"
)
})?;

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}");
Expand Down Expand Up @@ -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<u32>,
to_line: Option<u32>,
) -> 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?;
Expand Down Expand Up @@ -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"]
}
Expand Down Expand Up @@ -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 {
Expand All @@ -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("");
Expand Down
Loading
Loading