From 27770e519a67209b4f27e3cee187127bc644f1fc Mon Sep 17 00:00:00 2001 From: ZN-Ice Date: Wed, 17 Jun 2026 22:33:50 +0800 Subject: [PATCH 01/17] docs(spec): compound start, viewport screenshot, openclaw skill design Spec for v0.2.8 covering three test-found issues: - per-target OpenClaw SKILL.md note (screenshots to /tmp/openclaw) - shell wrapping of compound Sandbox daemon already running on port 15801 Creating sandbox: mode=cli, command=zsh Sandbox created: id=9e684f01, pty_pid=Some(1004), window_id=Some(170387) Daemon port: 15801 Waiting for renderer done Waiting for terminal Waiting for terminal. Waiting for terminal.. Waiting for terminal... Waiting for terminal. Waiting for terminal.. Waiting for terminal... Waiting for terminal. Waiting for terminal.. Waiting for terminal... Waiting for terminal. Waiting for terminal.. Waiting for terminal... Waiting for terminal. Waiting for terminal.. Waiting for terminal... Waiting for terminal. Waiting for terminal.. Waiting for terminal... Waiting for terminal. Waiting for terminal.. Waiting for terminal... Waiting for terminal. Waiting for terminal.. Waiting for terminal... Waiting for terminal. Waiting for terminal.. Waiting for terminal... Waiting for terminal. Waiting for terminal.. Waiting for terminal... Waiting for terminal. Waiting for terminal.. Waiting for terminal... Waiting for terminal. Waiting for terminal.. Waiting for terminal... Waiting for terminal. Waiting for terminal.. Waiting for terminal... Waiting for terminal. Waiting for terminal.. Waiting for terminal... Waiting for terminal. Waiting for terminal.. Waiting for terminal... Waiting for terminal. Waiting for terminal.. Waiting for terminal... Waiting for terminal. Waiting for terminal.. Waiting for terminal... Waiting for terminal. Waiting for terminal.. Waiting for terminal... Waiting for terminal. Waiting for terminal.. Waiting for terminal... Waiting for terminal. Waiting for terminal.. Waiting for terminal... Waiting for terminal. Waiting for terminal.. Waiting for terminal... Waiting for terminal. Waiting for terminal.. Waiting for terminal... Waiting for terminal. Waiting for terminal.. Waiting for terminal... Waiting for terminal. Waiting for terminal.. Waiting for terminal... Waiting for terminal. Waiting for terminal.. Waiting for terminal... Waiting for terminal. Waiting for terminal.. Waiting for terminal... Waiting for terminal. Waiting for terminal.. Waiting for terminal... Waiting for terminal. Waiting for terminal.. Waiting for terminal... Waiting for terminal. Waiting for terminal.. Waiting for terminal... Waiting for terminal. Waiting for terminal.. Waiting for terminal... Waiting for terminal. Waiting for terminal.. Waiting for terminal... Waiting for terminal. Waiting for terminal.. Waiting for terminal... Waiting for terminal. Waiting for terminal.. Waiting for terminal... Waiting for terminal. Waiting for terminal.. Waiting for terminal... Waiting for terminal. Waiting for terminal.. Waiting for terminal... Waiting for terminal. Waiting for terminal.. Waiting for terminal... Waiting for terminal. Waiting for terminal.. Waiting for terminal... Waiting for terminal. Waiting for terminal.. Waiting for terminal... Waiting for terminal. Waiting for terminal.. Waiting for terminal... Waiting for terminal. Waiting for terminal.. Waiting for terminal... commands (zsh -lc) - fix screenshot viewport (baseY), scroll-over-buffer, full-session text Co-Authored-By: Claude --- ...-start-shell-screenshot-openclaw-design.md | 239 ++++++++++++++++++ 1 file changed, 239 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-17-start-shell-screenshot-openclaw-design.md 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. From adf159d2ac271fd6160b102c3cd3c26e9ccdc652 Mon Sep 17 00:00:00 2001 From: ZN-Ice Date: Wed, 17 Jun 2026 22:43:57 +0800 Subject: [PATCH 02/17] docs(plan): implementation plan for compound start, viewport screenshot, openclaw skill Co-Authored-By: Claude --- ...6-06-17-start-shell-screenshot-openclaw.md | 1573 +++++++++++++++++ 1 file changed, 1573 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-17-start-shell-screenshot-openclaw.md 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`. From 329d99240e14d514d2e49fd2f891e5f087f79e18 Mon Sep 17 00:00:00 2001 From: ZN-Ice Date: Wed, 17 Jun 2026 22:48:27 +0800 Subject: [PATCH 03/17] feat(skill): openclaw SKILL.md note to save screenshots under /tmp/openclaw Co-Authored-By: Claude --- packages/cli-box-skill/installer/shared.mjs | 26 ++++++++++++- packages/cli-box-skill/test/shared.test.mjs | 42 ++++++++++++++++++++- 2 files changed, 66 insertions(+), 2 deletions(-) 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/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"); + }); +}); From e669c5a9ac67bb9daeb3941c25fdb670caf6af0a Mon Sep 17 00:00:00 2001 From: ZN-Ice Date: Wed, 17 Jun 2026 22:52:36 +0800 Subject: [PATCH 04/17] feat(process): needs_shell + wrap_shell_command helpers for compound commands Co-Authored-By: Claude --- crates/cli-box-core/src/process/mod.rs | 103 +++++++++++++++++++++++++ 1 file changed, 103 insertions(+) diff --git a/crates/cli-box-core/src/process/mod.rs b/crates/cli-box-core/src/process/mod.rs index ee04126..fef9c40 100644 --- a/crates/cli-box-core/src/process/mod.rs +++ b/crates/cli-box-core/src/process/mod.rs @@ -117,6 +117,41 @@ 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]) +} + /// Process manager for launching and managing apps/CLIs in the sandbox pub struct ProcessManager; @@ -731,3 +766,71 @@ impl ProcessManager { )) } } + +#[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 Date: Wed, 17 Jun 2026 22:59:38 +0800 Subject: [PATCH 05/17] feat(process): run compound start commands through zsh -lc Co-Authored-By: Claude --- crates/cli-box-core/src/process/mod.rs | 34 +++++++++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/crates/cli-box-core/src/process/mod.rs b/crates/cli-box-core/src/process/mod.rs index fef9c40..a6d973e 100644 --- a/crates/cli-box-core/src/process/mod.rs +++ b/crates/cli-box-core/src/process/mod.rs @@ -152,6 +152,17 @@ pub fn wrap_shell_command(command: &str, args: &[String]) -> (String, Vec"`; 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; @@ -359,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 { @@ -769,7 +783,7 @@ impl ProcessManager { #[cfg(test)] mod shell_wrap_tests { - use super::{needs_shell, wrap_shell_command}; + use super::{needs_shell, prepare_spawn, wrap_shell_command}; #[test] fn plain_token_needs_no_shell() { @@ -833,4 +847,22 @@ mod shell_wrap_tests { let (_, out_args) = wrap_shell_command("echo", &args); assert_eq!(out_args[1], "echo a b"); } + + #[test] + fn prepare_spawn_wraps_when_needed() { + let (cmd, args) = 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) = prepare_spawn("claude", &args); + assert_eq!(cmd, "claude"); + assert_eq!(args2, args); + } } From a8cfe451797100b4fa5e5f9d03715cbe2180886d Mon Sep 17 00:00:00 2001 From: ZN-Ice Date: Wed, 17 Jun 2026 23:08:09 +0800 Subject: [PATCH 06/17] refactor(renderer): extract buffer renderer anchored at baseY + scroll offset Co-Authored-By: Claude --- .../src/__tests__/captureToPng.test.ts | 85 +++++-------------- electron-app/src/__tests__/mocks/xterm.ts | 7 +- .../src/__tests__/terminalBuffer.test.ts | 62 ++++++++++++++ electron-app/src/renderer/terminalBuffer.ts | 73 ++++++++++++++++ 4 files changed, 162 insertions(+), 65 deletions(-) create mode 100644 electron-app/src/__tests__/terminalBuffer.test.ts create mode 100644 electron-app/src/renderer/terminalBuffer.ts 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..d291d76 100644 --- a/electron-app/src/__tests__/mocks/xterm.ts +++ b/electron-app/src/__tests__/mocks/xterm.ts @@ -17,8 +17,13 @@ export class MockBufferLine { } 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__/terminalBuffer.test.ts b/electron-app/src/__tests__/terminalBuffer.test.ts new file mode 100644 index 0000000..1233449 --- /dev/null +++ b/electron-app/src/__tests__/terminalBuffer.test.ts @@ -0,0 +1,62 @@ +import { describe, it, expect, beforeEach, vi } 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 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); + }); +}); diff --git a/electron-app/src/renderer/terminalBuffer.ts b/electron-app/src/renderer/terminalBuffer.ts new file mode 100644 index 0000000..05a802c --- /dev/null +++ b/electron-app/src/renderer/terminalBuffer.ts @@ -0,0 +1,73 @@ +// 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]; +} From dca44cea36828d099894b3d5232613f8a11358c7 Mon Sep 17 00:00:00 2001 From: ZN-Ice Date: Wed, 17 Jun 2026 23:14:35 +0800 Subject: [PATCH 07/17] feat(renderer): captureToPng honors scroll offset; canvas only at offset 0 Co-Authored-By: Claude --- .../src/renderer/components/Terminal.tsx | 55 +++++-------------- electron-app/src/renderer/terminalBuffer.ts | 2 +- 2 files changed, 14 insertions(+), 43 deletions(-) diff --git a/electron-app/src/renderer/components/Terminal.tsx b/electron-app/src/renderer/components/Terminal.tsx index c58dcdf..3998eb9 100644 --- a/electron-app/src/renderer/components/Terminal.tsx +++ b/electron-app/src/renderer/components/Terminal.tsx @@ -2,6 +2,7 @@ import { useEffect, useRef, useCallback, forwardRef, useImperativeHandle } from import { Terminal } 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 +12,7 @@ interface TerminalProps { } export interface SandboxTerminalHandle { - captureToPng(): Promise; + captureToPng(scrollOffset?: number): Promise; } const SandboxTerminal = forwardRef(function SandboxTerminal( @@ -25,53 +26,23 @@ const SandboxTerminal = forwardRef(functio const connRef = useRef | null>(null); useImperativeHandle(ref, () => ({ - async captureToPng(): Promise { + 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/terminalBuffer.ts b/electron-app/src/renderer/terminalBuffer.ts index 05a802c..1bcfff5 100644 --- a/electron-app/src/renderer/terminalBuffer.ts +++ b/electron-app/src/renderer/terminalBuffer.ts @@ -14,7 +14,7 @@ export interface BufferLineLike { export interface BufferLike { readonly baseY: number; - getLine(y: number): BufferLineLike | null; + getLine(y: number): BufferLineLike | null | undefined; } export interface RenderableTerminal { From 5bc769c207887fdb5b22fb3361e88b9ab38383b6 Mon Sep 17 00:00:00 2001 From: ZN-Ice Date: Wed, 17 Jun 2026 23:24:33 +0800 Subject: [PATCH 08/17] feat(daemon): screenshot scroll/top + /box/{id}/scrollback session text - Extend ScreenshotQuery with optional scroll (u32) and top (bool); top maps to u32::MAX so renderer clamps to scrollback top. - Add scroll field to capture_request WS message and pass through to request_renderer_screenshot. - Add new /box/{id}/scrollback route backed by request_renderer_scrollback (ScrollbackQuery: raw/from_line/to_line). Response is text/plain. - Add pending_scrollback HashMap to DaemonState; resolve scrollback_response and scrollback_error WS arms from it. - IT: screenshot_query_parses_scroll_and_top and scrollback_route_exists. Co-Authored-By: Claude --- crates/cli-box-core/src/daemon/mod.rs | 144 +++++++++++++++++- .../cli-box-core/tests/daemon_integration.rs | 53 +++++++ 2 files changed, 195 insertions(+), 2 deletions(-) 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/tests/daemon_integration.rs b/crates/cli-box-core/tests/daemon_integration.rs index 1de36c3..647f7b1 100644 --- a/crates/cli-box-core/tests/daemon_integration.rs +++ b/crates/cli-box-core/tests/daemon_integration.rs @@ -19,6 +19,7 @@ fn empty_state() -> 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" + ); +} From dc8e1b65ed73559dbb66d1d00609b113793ea8a7 Mon Sep 17 00:00:00 2001 From: ZN-Ice Date: Wed, 17 Jun 2026 23:30:17 +0800 Subject: [PATCH 09/17] feat(renderer): handle scroll offset + scrollback_request over screenshot WS Co-Authored-By: Claude --- electron-app/src/__tests__/scrollback.test.ts | 29 +++++++++++++ .../src/renderer/components/Terminal.tsx | 5 +++ electron-app/src/renderer/main.tsx | 39 ++++++++++++++++- electron-app/src/renderer/scrollback.ts | 42 +++++++++++++++++++ 4 files changed, 113 insertions(+), 2 deletions(-) create mode 100644 electron-app/src/__tests__/scrollback.test.ts create mode 100644 electron-app/src/renderer/scrollback.ts diff --git a/electron-app/src/__tests__/scrollback.test.ts b/electron-app/src/__tests__/scrollback.test.ts new file mode 100644 index 0000000..fe4090e --- /dev/null +++ b/electron-app/src/__tests__/scrollback.test.ts @@ -0,0 +1,29 @@ +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"); + }); +}); diff --git a/electron-app/src/renderer/components/Terminal.tsx b/electron-app/src/renderer/components/Terminal.tsx index 3998eb9..bc353d8 100644 --- a/electron-app/src/renderer/components/Terminal.tsx +++ b/electron-app/src/renderer/components/Terminal.tsx @@ -1,5 +1,6 @@ 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"; @@ -13,6 +14,7 @@ interface TerminalProps { export interface SandboxTerminalHandle { captureToPng(scrollOffset?: number): Promise; + readonly terminal: TerminalType | null; } const SandboxTerminal = forwardRef(function SandboxTerminal( @@ -26,6 +28,9 @@ const SandboxTerminal = forwardRef(functio const connRef = useRef | null>(null); useImperativeHandle(ref, () => ({ + get terminal() { + return xtermRef.current; + }, async captureToPng(scrollOffset: number = 0): Promise { const term = xtermRef.current; if (!term) throw new Error("Terminal not initialized"); 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..a1d1036 --- /dev/null +++ b/electron-app/src/renderer/scrollback.ts @@ -0,0 +1,42 @@ +// 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 { + 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++) s += line.getCell(x)?.getChars() || " "; + out.push(opts.raw ? s : s.replace(/\s+$/, "")); + } + return out.join("\n"); +} From 77eb8ca6935e41cb05636c29b281ff7a099baa9b Mon Sep 17 00:00:00 2001 From: ZN-Ice Date: Wed, 17 Jun 2026 23:43:35 +0800 Subject: [PATCH 10/17] feat(cli): screenshot --up/--top and scrollback subcommand Extend daemon_screenshot with scroll/top query params and add daemon_scrollback client helper. Wire them into the CLI surface: - screenshot --up N / --top thread into the daemon URL - new \`scrollback\` subcommand dumps the clean ANSI-free session text to stdout or -o , with optional --raw / --from-line / --to-line - MCP tool list: extend screenshot_sandbox schema with up/top and add scrollback_sandbox tool Co-Authored-By: Claude --- crates/cli-box-cli/src/client.rs | 73 ++++++++++++++++++-- crates/cli-box-cli/src/main.rs | 110 +++++++++++++++++++++++++++++-- 2 files changed, 172 insertions(+), 11 deletions(-) diff --git a/crates/cli-box-cli/src/client.rs b/crates/cli-box-cli/src/client.rs index 7885d94..96d3e53 100644 --- a/crates/cli-box-cli/src/client.rs +++ b/crates/cli-box-cli/src/client.rs @@ -155,14 +155,34 @@ 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"); + sep = '&'; + } else if let Some(n) = scroll { + url.push(sep); + url.push_str(&format!("scroll={n}")); + sep = '&'; + } + let _ = sep; let resp = client .get(url) .send() @@ -191,6 +211,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(""); From b9fb9813af17180313bce5b870d1898142ba875a Mon Sep 17 00:00:00 2001 From: ZN-Ice Date: Wed, 17 Jun 2026 23:51:31 +0800 Subject: [PATCH 11/17] test(e2e): compound start, viewport/top screenshot, scrollback Co-Authored-By: Claude --- test.sh | 9 ++++++ tests/e2e-compound-start-screenshot.sh | 43 ++++++++++++++++++++++++++ 2 files changed, 52 insertions(+) create mode 100755 tests/e2e-compound-start-screenshot.sh 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" From dd80f59c1cdf9472e0c8efa72bc669f5b035b887 Mon Sep 17 00:00:00 2001 From: ZN-Ice Date: Wed, 17 Jun 2026 23:56:06 +0800 Subject: [PATCH 12/17] chore: remove dead code + add negative-offset guard test Co-Authored-By: Claude --- crates/cli-box-cli/src/client.rs | 3 --- electron-app/src/__tests__/terminalBuffer.test.ts | 10 +++++++++- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/crates/cli-box-cli/src/client.rs b/crates/cli-box-cli/src/client.rs index 96d3e53..5cea4b7 100644 --- a/crates/cli-box-cli/src/client.rs +++ b/crates/cli-box-cli/src/client.rs @@ -176,13 +176,10 @@ pub async fn daemon_screenshot( 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 _ = sep; let resp = client .get(url) .send() diff --git a/electron-app/src/__tests__/terminalBuffer.test.ts b/electron-app/src/__tests__/terminalBuffer.test.ts index 1233449..7461bdc 100644 --- a/electron-app/src/__tests__/terminalBuffer.test.ts +++ b/electron-app/src/__tests__/terminalBuffer.test.ts @@ -13,6 +13,7 @@ beforeEach(() => { 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 { @@ -21,7 +22,7 @@ beforeEach(() => { toDataURL: () => "data:image/png;base64,AAAA", }; } - return (document as any).__origCreate?.(tag); + return origCreate(tag); }); }); @@ -59,4 +60,11 @@ describe("renderBufferToPng viewport", () => { 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); + }); }); From 5cd9b2495e1f01ab0472ce6aac0d84726f2944ed Mon Sep 17 00:00:00 2001 From: ZN-Ice Date: Thu, 18 Jun 2026 00:06:13 +0800 Subject: [PATCH 13/17] chore: bump version to 0.2.8 (cli-box-skill 0.2.2) Co-Authored-By: Claude --- Cargo.lock | 6 +++--- Cargo.toml | 2 +- electron-app/package.json | 2 +- packages/cli-box-skill/package.json | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) 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/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/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": { From 452b6b11a8a777ca60809bdef63719f694344f96 Mon Sep 17 00:00:00 2001 From: ZN-Ice Date: Thu, 18 Jun 2026 07:26:53 +0800 Subject: [PATCH 14/17] docs: document compound start, screenshot --up/--top, and scrollback Co-Authored-By: Claude --- README.md | 4 ++++ README.zh-cn.md | 4 ++++ packages/cli-box-skill/skill/SKILL.md | 21 +++++++++++++++++++-- 3 files changed, 27 insertions(+), 2 deletions(-) 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/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 From b88e26e914152a6a8a32e25559f8c7b87be67986 Mon Sep 17 00:00:00 2001 From: ZN-Ice Date: Thu, 18 Jun 2026 07:36:19 +0800 Subject: [PATCH 15/17] chore(release): bump release.sh display version to 0.2.8 Co-Authored-By: Claude --- release.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 --- From 06f6c0fc7707fb6fa9c05b9a31b5fda195fa9693 Mon Sep 17 00:00:00 2001 From: ZN-Ice Date: Thu, 18 Jun 2026 13:00:56 +0800 Subject: [PATCH 16/17] fix(daemon): honor --version/-V/-h instead of starting the daemon Previously cli-box-daemon ignored all args and always started serving, so 'cli-box-daemon --version' would silently launch a daemon. Handle the introspection flags before initializing the runtime. Co-Authored-By: Claude --- crates/cli-box-daemon/src/main.rs | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) 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) From bb239bd908612f66125d0fc415484840f090cc4c Mon Sep 17 00:00:00 2001 From: ZN-Ice Date: Thu, 18 Jun 2026 13:00:56 +0800 Subject: [PATCH 17/17] fix(renderer): skip wide-char continuation cells for clean CJK output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit xterm renders CJK/emoji as double-width: the char sits in a width-2 cell followed by a width-0 continuation cell with no glyph. The buffer readers treated that empty continuation cell as a space, padding CJK text ('执 行 pwd 命 令'). Skip cells where getWidth()===0 in both readScrollback and renderBufferToPng so CJK is contiguous. Co-Authored-By: Claude --- electron-app/src/__tests__/mocks/xterm.ts | 15 ++++++++++++++- electron-app/src/__tests__/scrollback.test.ts | 16 +++++++++++++++- .../src/__tests__/terminalBuffer.test.ts | 17 ++++++++++++++++- electron-app/src/renderer/scrollback.ts | 15 ++++++++++++++- electron-app/src/renderer/terminalBuffer.ts | 7 +++++++ 5 files changed, 66 insertions(+), 4 deletions(-) diff --git a/electron-app/src/__tests__/mocks/xterm.ts b/electron-app/src/__tests__/mocks/xterm.ts index d291d76..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,6 +18,14 @@ 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; } } diff --git a/electron-app/src/__tests__/scrollback.test.ts b/electron-app/src/__tests__/scrollback.test.ts index fe4090e..9da00de 100644 --- a/electron-app/src/__tests__/scrollback.test.ts +++ b/electron-app/src/__tests__/scrollback.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from "vitest"; import { readScrollback, type ScrollbackTerminal } from "../renderer/scrollback"; -import { MockBufferLine, MockTerminal } from "./mocks/xterm"; +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; @@ -26,4 +26,18 @@ describe("readScrollback", () => { 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 index 7461bdc..751cfb3 100644 --- a/electron-app/src/__tests__/terminalBuffer.test.ts +++ b/electron-app/src/__tests__/terminalBuffer.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, beforeEach, vi } from "vitest"; -import { MockBufferLine, MockTerminal } from "./mocks/xterm"; +import { MockBufferLine, MockCell, MockTerminal } from "./mocks/xterm"; import { renderBufferToPng, type RenderableTerminal } from "../renderer/terminalBuffer"; let drawCalls: { method: string; args: unknown[] }[]; @@ -67,4 +67,19 @@ describe("renderBufferToPng viewport", () => { // 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/scrollback.ts b/electron-app/src/renderer/scrollback.ts index a1d1036..af4857d 100644 --- a/electron-app/src/renderer/scrollback.ts +++ b/electron-app/src/renderer/scrollback.ts @@ -5,6 +5,10 @@ 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; @@ -35,7 +39,16 @@ export function readScrollback(term: ScrollbackTerminal, opts: ScrollbackOptions const line = buffer.getLine(y); if (!line) continue; let s = ""; - for (let x = 0; x < line.length; x++) s += line.getCell(x)?.getChars() || " "; + 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 index 1bcfff5..4149aae 100644 --- a/electron-app/src/renderer/terminalBuffer.ts +++ b/electron-app/src/renderer/terminalBuffer.ts @@ -5,6 +5,9 @@ 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 { @@ -59,6 +62,8 @@ export function renderBufferToPng( 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) { @@ -66,6 +71,8 @@ export function renderBufferToPng( } 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); } }