feat: Linux headless CLI support (vt100 + ab_glyph renderer)#48
Open
Shadow-Azure wants to merge 19 commits into
Open
feat: Linux headless CLI support (vt100 + ab_glyph renderer)#48Shadow-Azure wants to merge 19 commits into
Shadow-Azure wants to merge 19 commits into
Conversation
added 19 commits
June 24, 2026 23:23
Design for running cli-box as a headless daemon on cloud Linux servers, keeping keyboard input (PTY) and frame-less screenshots. - Plan A: Rust-native terminal renderer (vt100 + ab_glyph), no Electron - PTY un-gating (portable-pty is cross-platform) - Screenshot/scrollback headless routing when renderer not connected - ui-inspect explicitly out of scope (GUI-app only, no value headless) - Linux CI gate + headless E2E + release pipeline + npm package
Embed a CJK-capable monospace TTF (Sarasa Mono SC / Noto Sans Mono CJK, ~5-20MB) instead of a Latin-only font, so Chinese CLI output renders correctly for the automation-feedback loop. Note residual fidelity losses (emoji tofu, truecolor quantization, no ligatures/fallback) and binary-size mitigations (pyftsubset subsetting / lazy on-disk font).
The non-macOS stubs return Err(...) (they compile). The real Linux compile blocker is ungated code (SESSIONS static, list_processes(), is_session_alive()) referencing the cfg(macos)-gated PtySession type. Earlier 'Ok(()) type bug' claim was a misread; corrected. Design and Task structure unchanged.
9 TDD tasks: deps+font, PTY cfg(unix) un-gating, HeadlessTerminal (vt100+ab_glyph renderer), PTY-session mounting, daemon headless screenshot/scrollback routing, CLI --headless, Linux CI gate, headless E2E, release pipeline + npm package. vt100/ab_glyph APIs verified against docs.rs.
Execution environment cannot reach GitHub release assets to fetch a redistributable CJK font binary, and embedding 5-20MB bloats git/binary. Renderer now loads the font at runtime (CLIBOX_FONT -> ~/.cli-box/font.ttf -> system CJK font paths). Same goal (CJK renders on cloud Linux); smaller binary, swappable font. macOS Arial Unicode used for local tests.
The portable-pty + nix based PTY implementation was cfg(macos)-gated with error-returning stubs on other platforms; the ungated SESSIONS static, list_processes(), and is_session_alive() referenced the gated PtySession type (a Linux compile blocker). Move the real impl to cfg(unix), drop the PTY stubs. App-mode (spawn_app/with_window, find_pids_by_app_name) stays cfg(macos). No macOS behavior change.
Pure module: feed PTY bytes -> live vt100 grid -> render PNG. Replaces the Electron xterm.js canvas path when no renderer is connected (headless). Font loaded at runtime (CLIBOX_FONT -> ~/.cli-box/font.ttf -> system CJK paths). 5/5 unit tests pass (feed, ANSI color, text, PNG dims, ink).
Each PTY session now holds a persistent HeadlessTerminal, fed incrementally by the existing reader thread (raw bytes, alongside PtyStore). Adds get_terminal(pid) accessor for the headless screenshot path.
Add --headless daemon mode (DaemonState.headless). When headless,
/box/{id}/screenshot renders the PTY terminal grid via HeadlessTerminal
and /box/{id}/scrollback reads PtyStore/grid text — no Electron needed.
macOS (non-headless) behavior unchanged. Adds headless_screenshot IT
(spawns real PTY, asserts source=headless). Includes incidental rustfmt
of headless.rs long lines.
When no Electron app is found (Linux/cloud), the CLI starts the daemon with --headless and no longer spawns or waits for a renderer.
Builds and tests cli-box-core/cli/daemon on ubuntu-latest to validate cfg(unix) gating and the headless path on Linux (the authoritative check that cfg(macos)->cfg(unix) didn't miss anything). Installs DejaVu Mono (CLIBOX_FONT) so headless render tests run. Wired into gate-result.
End-to-end on ubuntu-latest: cli-box start printf -> screenshot (default + --top) -> scrollback against a --headless daemon (no Electron). Uses printf (non-compound, no zsh needed) and DejaVu Mono (CLIBOX_FONT) for rendering. Only end-to-end exercise of HeadlessTerminal; wired into gate-result.
Headless Linux binaries (cli-box + cli-box-daemon, no Electron) built on ubuntu-latest, uploaded as cli-box-linux-x64.tar.gz and published as the cli-box-linux-x64 npm package. Skill optionalDependencies now references it; the macOS job bumps+commits its version for repo consistency.
…with_window stub CI rust-linux failed: (1) libc was a macOS-only dep but is used in cross-platform process-alive checks (daemon); (2) spawn_app_with_window is cfg(macos) but called ungated by the daemon's app-mode handlers. Move libc to unconditional deps (trivially cross-platform) and add a non-macos stub for spawn_app_with_window (errors: app mode is macOS-only).
CI rust-linux round 2: clippy -D warnings failed on dead code on Linux — CHROMIUM_BUNDLE_IDS/read_bundle_id/is_chromium_app (private, used only by the macOS spawn_app_with_window) and cg_event's keycodes import (used only by macOS CGEvent code). Gate them cfg(macos). cleanup_chromium_data stays ungated (daemon calls it cross-platform).
CI e2e-linux-headless hung 11min then timed out: cli-box start spawns the daemon, which inherits the CLI's stdout pipe; when the CLI exits the daemon keeps the write-end open, so `Sandbox daemon already running on port 15801 (pid=86172) Creating sandbox: mode=cli, command=zsh Sandbox created: id=21f51147, pty_pid=Some(1016), window_id=Some(9466) 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... Sandbox ready: id=21f51147` never sees EOF. Redirect the daemon's stdin->null and stdout/stderr->~/.cli-box/ daemon.log so it no longer holds the CLI's pipes (logs preserved).
Round-4 e2e got 'started sandbox: None' — the bare `.*id=` sed greedily matched window_id=None (contains 'id=') on headless. Anchor on the real field and echo raw output for debuggability.
Round-5 e2e: find_electron_binary() auto-downloaded the macOS .app on the Linux runner (cached at ~/.cli-box/bin/), returned Some, so the CLI did NOT go headless — it tried to exec the macOS binary (Exec format error) and looped 'Waiting for terminal' forever. Electron is a macOS .app; return None on non-macOS so the headless short-circuit triggers (no download, --headless to daemon, no renderer wait).
🔒 门禁检查结果
Rust 测试覆盖率
前端测试覆盖率
✅ 所有检查通过,可以合入
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
cli-box 仅支持 macOS。需要以无头 daemon 形态运行在云端 Linux 服务器(x86_64),保留键盘输入(PTY)与终端截图(不带
--with-frame)用于自动化反馈。现状:发布流水线仅 macOS、代码深度耦合 macOS API,非 macOS 分支全是错误桩。Solution(方案 A:Rust 原生终端渲染器,无 Electron)
process/mod.rs):portable-pty+nix的真实 PTY 实现从cfg(macos)释放为cfg(unix),删除错误桩。键盘输入在 CLI 模式本就走 PTY 写入,天然跨平台。capture/headless.rs,新增):vt100解析 PTY 字节 → 终端网格 →ab_glyph+image渲染 PNG。每个 sandbox 常驻一个,reader 线程增量 feed。CLIBOX_FONT→~/.cli-box/font.ttf→ 系统字体。部署放 Sarasa/Noto 或apt install fonts-noto-cjk。daemon/mod.rs):daemon--headless模式;headless 时走服务端渲染。macOS 行为不变。main.rs):Electron 发现限 macOS(非 macOS 不下载 macOS app、走 headless);daemon stdio 分离(不挂住$(cli-box start | sed))。流水线
rust-linux(ubuntu 编译/clippy/test)+e2e-linux-headless(start→screenshot→scrollback),接入gate-result。build-linuxrelease job +cli-box-linux-x64npm 包 + skilloptionalDependencies。明确不支持(Linux)
--with-frame/ScreenCaptureKit、app 模式、鼠标 click/scroll 输入、Electron GUI、ui-inspect。Test Plan
capture::headless单测 5/5daemon_integrationheadless 截图 IT;全套 12/12rust-linuxCI:Linux 编译/clippy/test 通过e2e-linux-headlessCI:Linux 端到端通过(修了 daemon stdio 分离 + Electron 发现限 macOS + sandbox-id 提取)build-linux+ npm publish:实际发布(待 GitHub Release 触发;构建已由 rust-linux/e2e 间接验证)CI 调试历程(6 轮)
libc→无条件依赖 / spawn_app_with_window stub / cfg-gate macOS helper(编译+clippy)→ daemon stdio 分离(e2e 挂起)→ sandbox-id 提取 → Electron 发现限 macOS(Linux 走 headless)。