use std::fs;
use std::io::{self, BufReader, Read, Write};
use std::path::{Path, PathBuf};
use std::time::Duration;
use anyhow::{Context, Result, bail};
use clap::{Args, Parser, Subcommand, ValueEnum};
use terminal_control::{driver, recording, render, session, shot as shot_engine};
const HELP: &str = "\
termctrl controls and captures terminal applications for agents and tests. Start a named live
application, read its visible screen with `show`, or retain selected artifacts with `save`.";
const ROOT_EXAMPLES: &str = "\
Examples:
termctrl show -- my-terminal-app
termctrl save --format png --out captures/app.png -- my-terminal-app
termctrl start demo --host opentui -- opencode
termctrl wait demo '/connect' && termctrl send demo text:/connect enter
termctrl show demo
termctrl save demo --format png --out captures/provider.png
termctrl logs demo
termctrl restart demo
termctrl stop demo";
const SHOW_HELP: &str = "\
Show prints a settled visible terminal screen to standard output, as text by default.
Sources:
termctrl show NAME Read a named live session.
termctrl show -- COMMAND... Run a disposable command in a PTY.
termctrl show --pipe -- COMMAND... Read piped stdout/stderr.
termctrl show --input FILE Read ANSI/VT bytes from FILE, or use - for stdin.
termctrl show --recording FILE Replay the final screen of a .termctrl recording.
Use --format json, --format ansi, or --format svg for another stdout-readable representation.
Use `--at-marker NAME` or `--at-ms MS` with --recording to inspect an exact moment. Use `save`
to write files.";
const SAVE_HELP: &str = "\
Save freezes a visible terminal screen and writes exactly the requested artifact formats.
Examples:
termctrl save demo --format png --out captures/current.png
termctrl save demo --format png --format txt --out captures/current
termctrl save --input debug.ansi --format png --out captures/replay.png
termctrl save --recording captures/demo.termctrl --at-marker done --format png --out captures/done.png
termctrl save --format png --out captures/startup.png -- my-terminal-app";
const START_HELP: &str = "\
Start creates one background PTY session and returns once its local control socket is available.
The application stays alive until `termctrl stop NAME`, so later commands interact with the
same screen and application state. Persistent sessions currently require macOS or Linux. Session
sockets are local control endpoints protected for the current user; recordings contain terminal
output plus client and automatic host input, so treat them as sensitive artifacts.
Example:
termctrl start demo --host opentui --cols 112 --rows 34 -- opencode
termctrl status demo
termctrl wait demo '/connect'
termctrl send demo text:/connect enter
termctrl resize demo --cols 132 --rows 38
termctrl show demo
termctrl save demo --format png --out captures/provider.png
termctrl stop demo";
const SEND_HELP: &str = "\
Send ordered input to a live session. Text uses `text:<value>`; named keys include `enter`,
`escape`, arrows, `tab`, `shift-tab`, `backspace`, `delete`, `home`, `end`, `page-up`, and
`page-down`. Use `ctrl-a` through `ctrl-z` for control input such as `ctrl-c` cancellation.
Add `--pace-ms 35` when producing a human-readable recording so typed text appears character by
character in the terminal instead of as one immediate paste. Use `--stdin` to send exact bytes
from standard input as one burst.
Examples:
termctrl send demo ctrl-p text:model enter
termctrl send demo ctrl-c
printf '%s' 'a multiline prompt' | termctrl send demo --stdin
termctrl send demo --pace-ms 35 'text:Write a terminal haiku.' enter";
const VIDEO_HELP: &str = "\
Replay a recording produced by `termctrl start --record` into a video artifact. Without `--edit`,
the video preserves observed timing. For a concise annotated demo, add named moments while recording
with `termctrl mark`, then pass an edit-plan JSON file with `--edit`. Each clip selects a marker range
and may set `speed`, optional visible `caption`, or optional `hold_ms`. Omit `hold_ms` for no artificial
pause between clips. Use `--tail-ms 0` if the final frame should not be held after the last clip.
`--fps` controls the maximum sampled frame rate; identical rendered screens are rasterized once and
reused. Pass `--include-startup` to retain blank startup or capability negotiation frames. The source
`.termctrl` file always retains the original timing, terminal bytes, client input, automatic host
input, and markers until the session is closed. Video export requires `ffmpeg` to be installed.
Pass `--footer` to add a bottom row with the clip caption, elapsed timecode, and TERMINAL CONTROL
branding; without it, edit-plan captions render as inline annotation rows.
Example:
termctrl start demo --record captures/demo.termctrl -- opencode
termctrl mark demo before-connect
termctrl send demo text:/connect enter
termctrl mark demo after-connect
termctrl stop demo
termctrl markers captures/demo.termctrl
termctrl video captures/demo.termctrl --edit captures/demo.json --tail-ms 0 --out captures/demo.mp4";
const MARK_HELP: &str = "\
Add a named marker to the active `.termctrl` recording at the current session time. Markers do not
change the raw recording; they give later `show --recording --at-marker` and `video --edit` commands
stable names for important moments.
Example:
termctrl start demo --record captures/demo.termctrl -- opencode
termctrl wait demo \"Ask anything\"
termctrl mark demo ready
termctrl send demo text:/connect enter
termctrl mark demo after-connect";
const MARKERS_HELP: &str = "\
List named markers from a .termctrl recording. Use the timestamps to audit an edit plan, or inspect
screens with `termctrl show --recording FILE --at-marker NAME` before exporting a demo video.";
const DRIVER_HELP: &str = "\
Driver mode serves isolated embedded sessions as newline-delimited JSON over standard input and
standard output. It is used by the `@kitlangton/terminal-control` package; standard output
contains protocol messages only. Driver sessions support isolated child environments, stable
captures, SVG evidence, recordings, resizing, and explicit exit waiting.
Example:
termctrl driver";
#[derive(Parser)]
#[command(
name = "termctrl",
version,
about = "Control and capture terminal applications",
long_about = HELP,
after_help = ROOT_EXAMPLES
)]
struct Cli {
#[command(subcommand)]
command: Command,
}
#[derive(Subcommand)]
enum Command {
#[command(after_help = SHOW_HELP)]
Show(ShowArgs),
#[command(after_help = SAVE_HELP)]
Save(SaveArgs),
#[command(after_help = START_HELP)]
Start(StartArgs),
Wait(WaitArgs),
#[command(after_help = SEND_HELP)]
Send(SendArgs),
Status(StatusArgs),
List(ListArgs),
Resize(ResizeArgs),
#[command(after_help = MARK_HELP)]
Mark(MarkArgs),
#[command(after_help = MARKERS_HELP)]
Markers(MarkersArgs),
Logs(LogsArgs),
Restart(RestartArgs),
Stop(SessionArgs),
#[command(after_help = VIDEO_HELP)]
Video(VideoArgs),
#[command(after_help = DRIVER_HELP)]
Driver,
#[command(name = "__serve", hide = true)]
Serve(ServeArgs),
}
#[derive(Args)]
struct RenderArgs {
#[arg(long, default_value_t = 9)]
cell_width: u16,
#[arg(long, default_value_t = 18)]
cell_height: u16,
#[arg(long, default_value_t = 18.0)]
padding: f32,
#[arg(
long,
default_value = "JetBrains Mono, SFMono-Regular, Menlo, monospace"
)]
font_family: String,
#[arg(long, default_value_t = 2.0)]
pixel_ratio: f32,
#[arg(long)]
hide_cursor: bool,
}
#[derive(Args)]
struct SourceArgs {
#[arg(value_name = "NAME")]
name: Option<String>,
#[arg(long)]
cols: Option<u16>,
#[arg(long)]
rows: Option<u16>,
#[arg(long)]
pipe: bool,
#[arg(long, value_name = "FILE")]
input: Option<PathBuf>,
#[arg(long, value_name = "FILE")]
recording: Option<PathBuf>,
#[arg(long, requires = "recording", conflicts_with = "at_ms")]
at_marker: Option<String>,
#[arg(long, requires = "recording")]
at_ms: Option<u64>,
#[arg(long, value_enum)]
color: Option<ColorMode>,
#[arg(long)]
settle_ms: Option<u64>,
#[arg(long)]
deadline_ms: Option<u64>,
#[arg(long)]
initial_delay_ms: Option<u64>,
#[arg(long)]
wait_for: Option<String>,
#[arg(long)]
max_bytes: Option<usize>,
#[arg(long)]
cwd: Option<PathBuf>,
#[arg(long, value_enum)]
host: Option<HostProfile>,
#[arg(short = 's', long, value_name = "INPUT", num_args = 1..)]
send: Vec<String>,
#[arg(last = true, required = false, num_args = 1.., allow_hyphen_values = true)]
command: Vec<String>,
}
#[derive(Args)]
struct ShowArgs {
#[command(flatten)]
render: RenderArgs,
#[command(flatten)]
source: SourceArgs,
#[arg(long, value_enum, default_value = "txt")]
format: ShotFormat,
}
#[derive(Args)]
struct SaveArgs {
#[command(flatten)]
render: RenderArgs,
#[command(flatten)]
source: SourceArgs,
#[arg(short, long)]
out: PathBuf,
#[arg(long = "format", value_enum, required = true)]
formats: Vec<ShotFormat>,
}
#[derive(Args)]
struct StartArgs {
name: String,
#[arg(long, default_value_t = 80)]
cols: u16,
#[arg(long, default_value_t = 24)]
rows: u16,
#[arg(long, default_value_t = 9)]
cell_width: u16,
#[arg(long, default_value_t = 18)]
cell_height: u16,
#[arg(long, default_value_t = 16 * 1024 * 1024)]
max_bytes: usize,
#[arg(long)]
cwd: Option<PathBuf>,
#[arg(long)]
record: Option<PathBuf>,
#[arg(long, value_enum, default_value = "auto")]
color: ColorMode,
#[arg(long, value_enum)]
host: Option<HostProfile>,
#[arg(required = true, trailing_var_arg = true, allow_hyphen_values = true)]
command: Vec<String>,
}
#[derive(Args)]
struct WaitArgs {
name: String,
text: String,
#[arg(long, default_value_t = 5000, value_name = "MS")]
timeout: u64,
}
#[derive(Args)]
struct SendArgs {
name: String,
#[arg(long, default_value_t = 0)]
pace_ms: u64,
#[arg(long, conflicts_with = "input")]
stdin: bool,
#[arg(value_name = "INPUT")]
input: Vec<String>,
}
#[derive(Args)]
struct StatusArgs {
name: String,
#[arg(long)]
json: bool,
}
#[derive(Args)]
struct ListArgs {
#[arg(long)]
json: bool,
}
#[derive(Args)]
struct ResizeArgs {
name: String,
#[arg(long)]
cols: u16,
#[arg(long)]
rows: u16,
#[arg(long)]
cell_width: Option<u16>,
#[arg(long)]
cell_height: Option<u16>,
}
#[derive(Args)]
struct MarkArgs {
name: String,
marker: String,
}
#[derive(Args)]
struct MarkersArgs {
input: PathBuf,
#[arg(long)]
json: bool,
}
#[derive(Args)]
struct LogsArgs {
name: String,
#[arg(long)]
ansi: bool,
}
#[derive(Args)]
struct RestartArgs {
name: String,
#[arg(long)]
cols: Option<u16>,
#[arg(long)]
rows: Option<u16>,
#[arg(long)]
cell_width: Option<u16>,
#[arg(long)]
cell_height: Option<u16>,
#[arg(long)]
max_bytes: Option<usize>,
#[arg(long)]
cwd: Option<PathBuf>,
#[arg(long)]
record: Option<PathBuf>,
#[arg(long, value_enum)]
color: Option<ColorMode>,
#[arg(long, value_enum)]
host: Option<HostProfile>,
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
command: Vec<String>,
}
#[derive(Args)]
struct SessionArgs {
name: String,
}
#[derive(Args)]
struct ServeArgs {
#[arg(long)]
socket: PathBuf,
#[arg(long)]
cwd: Option<PathBuf>,
#[arg(long)]
record: Option<PathBuf>,
#[arg(long)]
opentui_host: bool,
#[arg(long, value_enum, default_value = "auto")]
color: ColorMode,
#[arg(long)]
cols: u16,
#[arg(long)]
rows: u16,
#[arg(long)]
cell_width: u16,
#[arg(long)]
cell_height: u16,
#[arg(long)]
max_bytes: usize,
#[arg(required = true, trailing_var_arg = true, allow_hyphen_values = true)]
command: Vec<String>,
}
#[derive(Args)]
struct VideoArgs {
input: PathBuf,
#[arg(long)]
cell_width: Option<u16>,
#[arg(long)]
cell_height: Option<u16>,
#[arg(long, default_value_t = 18.0)]
padding: f32,
#[arg(
long,
default_value = "JetBrains Mono, SFMono-Regular, Menlo, monospace"
)]
font_family: String,
#[arg(long, default_value_t = 2.0)]
pixel_ratio: f32,
#[arg(short, long, default_value = "video.mp4")]
out: PathBuf,
#[arg(long)]
hide_cursor: bool,
#[arg(long)]
footer: bool,
#[arg(long, default_value_t = 20)]
fps: u32,
#[arg(long)]
edit: Option<PathBuf>,
#[arg(long, default_value_t = 1000)]
tail_ms: u64,
#[arg(long)]
include_startup: bool,
}
#[derive(Clone, Copy, ValueEnum)]
enum HostProfile {
Opentui,
}
#[derive(Clone, Copy, ValueEnum)]
enum ColorMode {
Auto,
Always,
Never,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)]
enum ShotFormat {
Png,
Svg,
Txt,
Json,
Ansi,
}
impl From<ColorMode> for shot_engine::ColorMode {
fn from(value: ColorMode) -> Self {
match value {
ColorMode::Auto => shot_engine::ColorMode::Auto,
ColorMode::Always => shot_engine::ColorMode::Always,
ColorMode::Never => shot_engine::ColorMode::Never,
}
}
}
fn main() -> Result<()> {
match Cli::parse().command {
Command::Show(args) => show(args)?,
Command::Save(args) => save(args)?,
Command::Start(args) => {
start_session(&args)?;
println!("{}", args.name);
}
Command::Wait(args) => {
session::wait(&args.name, args.text, Duration::from_millis(args.timeout))?;
}
Command::Send(args) => send(args)?,
Command::Status(args) => status(args)?,
Command::List(args) => list(args)?,
Command::Resize(args) => {
validate_terminal_size(args.cols, args.rows)?;
session::resize(
&args.name,
args.cols,
args.rows,
args.cell_width,
args.cell_height,
)?;
}
Command::Mark(args) => session::mark(&args.name, args.marker)?,
Command::Markers(args) => markers(args)?,
Command::Logs(args) => logs(args)?,
Command::Restart(args) => {
restart_session(&args)?;
println!("{}", args.name);
}
Command::Stop(args) => session::stop(&args.name)?,
Command::Video(args) => {
let out = args.out.clone();
recording::video(
&args.input,
&recording::VideoOptions {
out: args.out,
cell_width: args.cell_width,
cell_height: args.cell_height,
padding: args.padding,
font_family: args.font_family,
pixel_ratio: args.pixel_ratio,
hide_cursor: args.hide_cursor,
footer: args.footer,
fps: args.fps,
tail: Duration::from_millis(args.tail_ms),
include_startup: args.include_startup,
edit: args.edit,
},
)?;
println!("{}", out.display());
}
Command::Driver => {
driver::serve(BufReader::new(io::stdin().lock()), io::stdout().lock())?;
}
Command::Serve(args) => {
session::serve(
args.socket,
args.command,
args.cwd,
args.record,
shot_engine::Options {
cols: args.cols,
rows: args.rows,
cell_width: args.cell_width,
cell_height: args.cell_height,
settle: Duration::ZERO,
deadline: Duration::ZERO,
input: Vec::new(),
initial_delay: Duration::ZERO,
wait_for: None,
max_bytes: args.max_bytes,
opentui_host: args.opentui_host,
color: args.color.into(),
env: Default::default(),
inherit_env: true,
},
)?;
}
}
Ok(())
}
fn show(args: ShowArgs) -> Result<()> {
if args.format == ShotFormat::Png {
bail!("show does not support PNG output; use save --format png --out PATH");
}
let captured = read_source(&args.source, &args.render)?;
write_stdout(&captured, &args.render, args.format)
}
fn save(args: SaveArgs) -> Result<()> {
let captured = read_source(&args.source, &args.render)?;
write_outputs(&captured, &args.render, &args.out, &args.formats)
}
fn read_source(args: &SourceArgs, render: &RenderArgs) -> Result<shot_engine::Shot> {
let defaults = shot_engine::Options::default();
let settle =
Duration::from_millis(args.settle_ms.unwrap_or(defaults.settle.as_millis() as u64));
let deadline = Duration::from_millis(
args.deadline_ms
.unwrap_or(defaults.deadline.as_millis() as u64),
);
if let Some(path) = args.recording.as_ref() {
if args.name.is_some()
|| args.pipe
|| args.input.is_some()
|| !args.command.is_empty()
|| args.cols.is_some()
|| args.rows.is_some()
|| args.color.is_some()
|| args.settle_ms.is_some()
|| args.deadline_ms.is_some()
|| args.initial_delay_ms.is_some()
|| args.wait_for.is_some()
|| args.max_bytes.is_some()
|| args.cwd.is_some()
|| args.host.is_some()
|| !args.send.is_empty()
{
bail!(
"--recording can only be combined with rendering options, --at-marker, or --at-ms"
);
}
return recording::shot_at(path, args.at_ms, args.at_marker.as_deref());
}
if args.at_marker.is_some() || args.at_ms.is_some() {
bail!("--at-marker and --at-ms require --recording");
}
if args.input.is_some() && (args.pipe || args.name.is_some() || !args.command.is_empty()) {
bail!("--input cannot be combined with --pipe, NAME, or a command");
}
if args.name.is_some() && (args.pipe || !args.command.is_empty()) {
bail!("NAME cannot be combined with --pipe or a command");
}
if let Some(name) = args.name.as_deref() {
if args.cols.is_some()
|| args.rows.is_some()
|| args.color.is_some()
|| args.initial_delay_ms.is_some()
|| args.wait_for.is_some()
|| args.max_bytes.is_some()
|| args.cwd.is_some()
|| args.host.is_some()
|| !args.send.is_empty()
{
bail!("named-session reads support rendering, --settle-ms, and --deadline-ms only");
}
return session::show(name, settle, deadline);
}
let cols = args.cols.unwrap_or(defaults.cols);
let rows = args.rows.unwrap_or(defaults.rows);
validate_terminal_size(cols, rows)?;
let max_bytes = args.max_bytes.unwrap_or(defaults.max_bytes);
if let Some(path) = args.input.as_ref() {
if args.color.is_some()
|| args.settle_ms.is_some()
|| args.deadline_ms.is_some()
|| args.initial_delay_ms.is_some()
|| args.wait_for.is_some()
|| args.cwd.is_some()
|| args.host.is_some()
|| !args.send.is_empty()
{
bail!("--input reads support dimensions, rendering, and --max-bytes only");
}
let mut input = Vec::new();
let limit = max_bytes.saturating_add(1) as u64;
if path.as_os_str() == "-" {
io::stdin()
.take(limit)
.read_to_end(&mut input)
.context("read ANSI input")?;
} else {
fs::File::open(path)
.with_context(|| format!("open {}", path.display()))?
.take(limit)
.read_to_end(&mut input)
.with_context(|| format!("read {}", path.display()))?;
}
return shot_engine::from_ansi(input, rows, cols, max_bytes);
}
if args.command.is_empty() {
bail!("provide NAME, a command after --, or --input FILE");
}
if args.pipe
&& (!args.send.is_empty()
|| args.host.is_some()
|| args.initial_delay_ms.is_some()
|| args.settle_ms.is_some())
{
bail!("--pipe reads do not support --send, --host, --initial-delay-ms, or --settle-ms");
}
let color = args.color.unwrap_or(if args.pipe {
ColorMode::Always
} else {
ColorMode::Auto
});
let options = shot_engine::Options {
cols,
rows,
cell_width: render.cell_width,
cell_height: render.cell_height,
settle,
deadline,
input: input_bytes(&args.send)?,
initial_delay: Duration::from_millis(args.initial_delay_ms.unwrap_or(0)),
wait_for: args.wait_for.clone(),
max_bytes,
opentui_host: matches!(args.host, Some(HostProfile::Opentui)),
color: color.into(),
env: Default::default(),
inherit_env: true,
};
if args.pipe {
shot_engine::from_pipe_command(&args.command, args.cwd.as_deref(), &options)
} else {
shot_engine::from_command(&args.command, args.cwd.as_deref(), &options)
}
}
fn send(args: SendArgs) -> Result<()> {
if args.stdin && args.pace_ms > 0 {
bail!("--stdin cannot be combined with --pace-ms");
}
let input = if args.stdin {
let mut bytes = Vec::new();
io::stdin()
.take(1024 * 1024 + 1)
.read_to_end(&mut bytes)
.context("read session input")?;
if bytes.len() > 1024 * 1024 {
bail!("session input exceeds 1 MiB");
}
vec![bytes]
} else {
if args.input.is_empty() {
bail!("provide INPUT events or --stdin");
}
session_input(&args.input, args.pace_ms > 0)?
};
session::send(&args.name, input, Duration::from_millis(args.pace_ms))?;
Ok(())
}
fn status(args: StatusArgs) -> Result<()> {
let status = session::status(&args.name)?;
if args.json {
println!("{}", serde_json::to_string_pretty(&status)?);
} else {
println!("{} {}", args.name, session_state(status.state));
println!("cwd: {}", status.launch.cwd.display());
println!("command: {}", status.launch.command.join(" "));
println!("viewport: {}x{}", status.cols, status.rows);
println!(
"recording: {}",
status
.launch
.record
.as_ref()
.map_or_else(|| "none".to_owned(), |path| path.display().to_string())
);
}
Ok(())
}
fn list(args: ListArgs) -> Result<()> {
let sessions = session::list()?;
if args.json {
println!("{}", serde_json::to_string_pretty(&sessions)?);
} else {
for entry in sessions {
if let Some(status) = entry.status {
println!(
"{}\t{}\t{}x{}\t{}",
entry.name,
session_state(status.state),
status.cols,
status.rows,
if status.recording { "recording" } else { "-" }
);
} else {
let reason = match entry.unavailable {
Some(session::UnavailableReason::IncompatibleProtocol) => "incompatible",
_ => "stale",
};
println!("{}\t{}\t-\t-", entry.name, reason);
}
}
}
Ok(())
}
fn logs(args: LogsArgs) -> Result<()> {
let bytes = session::logs(&args.name, args.ansi)?;
io::stdout()
.write_all(&bytes)
.context("write session logs")?;
if !args.ansi && !bytes.ends_with(b"\n") {
io::stdout()
.write_all(b"\n")
.context("write session logs newline")?;
}
Ok(())
}
fn markers(args: MarkersArgs) -> Result<()> {
let markers = recording::markers(&recording::read(&args.input)?);
if args.json {
println!("{}", serde_json::to_string_pretty(&markers)?);
return Ok(());
}
for marker in markers {
println!("{}\t{}", marker.at_ms, marker.name);
}
Ok(())
}
fn start_session(args: &StartArgs) -> Result<()> {
validate_terminal_size(args.cols, args.rows)?;
let options = shot_engine::Options {
cols: args.cols,
rows: args.rows,
cell_width: args.cell_width,
cell_height: args.cell_height,
settle: Duration::ZERO,
deadline: Duration::ZERO,
input: Vec::new(),
initial_delay: Duration::ZERO,
wait_for: None,
max_bytes: args.max_bytes,
opentui_host: matches!(args.host, Some(HostProfile::Opentui)),
color: args.color.into(),
env: Default::default(),
inherit_env: true,
};
session::start(
&args.name,
&args.command,
args.cwd.as_deref(),
args.record.as_deref(),
&options,
)
}
fn restart_session(args: &RestartArgs) -> Result<()> {
let previous = session::status(&args.name)?.launch;
let cols = args.cols.unwrap_or(previous.cols);
let rows = args.rows.unwrap_or(previous.rows);
validate_terminal_size(cols, rows)?;
let command = if args.command.is_empty() {
previous.command
} else {
args.command.clone()
};
let cwd = args.cwd.clone().unwrap_or(previous.cwd);
let record = args.record.clone().or(previous.record);
session::restart(
&args.name,
&command,
Some(&cwd),
record.as_deref(),
&shot_engine::Options {
cols,
rows,
cell_width: args.cell_width.unwrap_or(previous.cell_width),
cell_height: args.cell_height.unwrap_or(previous.cell_height),
max_bytes: args.max_bytes.unwrap_or(previous.max_bytes),
opentui_host: args.host.map_or(previous.opentui_host, |host| {
matches!(host, HostProfile::Opentui)
}),
color: args.color.map_or(previous.color, Into::into),
..shot_engine::Options::default()
},
)
}
fn session_state(state: session::SessionState) -> &'static str {
match state {
session::SessionState::Running => "running",
session::SessionState::Exited => "exited",
}
}
fn input_bytes(events: &[String]) -> Result<Vec<u8>> {
let mut input = Vec::new();
for event in events {
input.extend(input_event(event)?);
}
Ok(input)
}
fn input_event(event: &str) -> Result<Vec<u8>> {
if let Some(text) = event.strip_prefix("text:") {
return Ok(text.as_bytes().to_vec());
}
if let Some(key) = event
.strip_prefix("ctrl-")
.or_else(|| event.strip_prefix("ctrl:"))
&& key.len() == 1
{
let key = key.as_bytes()[0].to_ascii_lowercase();
if key.is_ascii_lowercase() {
return Ok(vec![key - b'a' + 1]);
}
}
Ok(match event {
"enter" => b"\r".to_vec(),
"escape" | "esc" => b"\x1b".to_vec(),
"up" => b"\x1b[A".to_vec(),
"down" => b"\x1b[B".to_vec(),
"left" => b"\x1b[D".to_vec(),
"right" => b"\x1b[C".to_vec(),
"tab" => b"\t".to_vec(),
"shift-tab" => b"\x1b[Z".to_vec(),
"backspace" => b"\x7f".to_vec(),
"delete" => b"\x1b[3~".to_vec(),
"home" => b"\x1b[H".to_vec(),
"end" => b"\x1b[F".to_vec(),
"page-up" => b"\x1b[5~".to_vec(),
"page-down" => b"\x1b[6~".to_vec(),
_ => anyhow::bail!(
"unsupported input event {event:?}; use text:<value>, ctrl-a through ctrl-z, enter, escape, arrows, tab, shift-tab, backspace, delete, home, end, page-up, or page-down"
),
})
}
fn session_input(events: &[String], paced: bool) -> Result<Vec<Vec<u8>>> {
if !paced {
return Ok(vec![input_bytes(events)?]);
}
let mut input = Vec::new();
for event in events {
if let Some(text) = event.strip_prefix("text:") {
input.extend(text.chars().map(|char| char.to_string().into_bytes()));
continue;
}
input.push(input_event(event)?);
}
Ok(input)
}
fn validate_terminal_size(cols: u16, rows: u16) -> Result<()> {
if cols == 0 || rows == 0 {
bail!("terminal dimensions must be greater than zero");
}
Ok(())
}
fn write_outputs(
captured: &shot_engine::Shot,
args: &RenderArgs,
out: &Path,
formats: &[ShotFormat],
) -> Result<()> {
if let Some(parent) = out.parent().filter(|parent| !parent.as_os_str().is_empty()) {
fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
}
let enabled = |format| formats.contains(&format);
let svg = (enabled(ShotFormat::Svg) || enabled(ShotFormat::Png))
.then(|| rendered_svg(captured, args));
if let Some(svg) = svg.as_ref().filter(|_| enabled(ShotFormat::Svg)) {
let path = out.with_extension("svg");
fs::write(&path, svg).with_context(|| format!("write {}", path.display()))?;
println!("{}", path.display());
}
if let Some(svg) = svg.as_ref().filter(|_| enabled(ShotFormat::Png)) {
let path = out.with_extension("png");
render::png(svg, &path, args.pixel_ratio)?;
println!("{}", path.display());
}
if enabled(ShotFormat::Json) {
let path = out.with_extension("json");
fs::write(&path, serde_json::to_vec_pretty(&captured.frame)?)
.with_context(|| format!("write {}", path.display()))?;
println!("{}", path.display());
}
if enabled(ShotFormat::Txt) {
let path = out.with_extension("txt");
fs::write(&path, captured.frame.text())
.with_context(|| format!("write {}", path.display()))?;
println!("{}", path.display());
}
if enabled(ShotFormat::Ansi) {
let path = out.with_extension("ansi");
fs::write(&path, &captured.ansi).with_context(|| format!("write {}", path.display()))?;
println!("{}", path.display());
}
Ok(())
}
fn write_stdout(captured: &shot_engine::Shot, args: &RenderArgs, format: ShotFormat) -> Result<()> {
let bytes = match format {
ShotFormat::Txt => captured.frame.text().into_bytes(),
ShotFormat::Json => serde_json::to_vec_pretty(&captured.frame)?,
ShotFormat::Ansi => captured.ansi.clone(),
ShotFormat::Svg => rendered_svg(captured, args).into_bytes(),
ShotFormat::Png => unreachable!("show validates PNG before reading source"),
};
io::stdout()
.write_all(&bytes)
.context("write visible screen")?;
if format != ShotFormat::Ansi && !bytes.ends_with(b"\n") {
io::stdout()
.write_all(b"\n")
.context("write visible screen newline")?;
}
Ok(())
}
fn rendered_svg(captured: &shot_engine::Shot, args: &RenderArgs) -> String {
render::svg(
&captured.frame,
&render::Options {
cell_width: f32::from(args.cell_width),
cell_height: f32::from(args.cell_height),
font_size: f32::from(args.cell_height) * 0.78,
padding: args.padding,
font_family: args.font_family.clone(),
show_cursor: !args.hide_cursor,
},
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn preserves_ordered_input_events() {
assert_eq!(
input_bytes(&[
"ctrl-p".to_owned(),
"text:model".to_owned(),
"enter".to_owned()
])
.unwrap(),
b"\x10model\r"
);
}
#[test]
fn rejects_unsupported_input_events() {
assert!(input_bytes(&["space".to_owned()]).is_err());
}
#[test]
fn encodes_control_and_navigation_input_events() {
assert_eq!(
input_bytes(&[
"ctrl-c".to_owned(),
"shift-tab".to_owned(),
"delete".to_owned()
])
.unwrap(),
b"\x03\x1b[Z\x1b[3~"
);
}
#[test]
fn parses_one_off_show_input_sequence() {
let cli = Cli::try_parse_from([
"termctrl",
"show",
"--wait-for",
"ready",
"-s",
"ctrl-p",
"text:model",
"enter",
"--",
"app",
])
.unwrap();
let Command::Show(args) = cli.command else {
panic!("expected show command");
};
assert!(args.source.name.is_none());
assert_eq!(args.source.command, ["app"]);
assert_eq!(args.source.send, ["ctrl-p", "text:model", "enter"]);
}
#[test]
fn parses_explicit_saved_formats_and_named_source() {
let cli = Cli::try_parse_from([
"termctrl", "save", "demo", "--out", "capture", "--format", "png", "--format", "txt",
])
.unwrap();
let Command::Save(args) = cli.command else {
panic!("expected save command");
};
assert_eq!(args.source.name.as_deref(), Some("demo"));
assert_eq!(args.formats, [ShotFormat::Png, ShotFormat::Txt]);
}
#[test]
fn parses_flat_session_control_commands() {
assert!(Cli::try_parse_from(["termctrl", "status", "demo", "--json"]).is_ok());
assert!(
Cli::try_parse_from([
"termctrl", "resize", "demo", "--cols", "120", "--rows", "40"
])
.is_ok()
);
assert!(Cli::try_parse_from(["termctrl", "send", "demo", "--stdin"]).is_ok());
assert!(Cli::try_parse_from(["termctrl", "mark", "demo", "before-send"]).is_ok());
assert!(Cli::try_parse_from(["termctrl", "markers", "captures/demo.termctrl"]).is_ok());
assert!(Cli::try_parse_from(["termctrl", "logs", "demo", "--ansi"]).is_ok());
assert!(Cli::try_parse_from(["termctrl", "restart", "demo"]).is_ok());
assert!(
Cli::try_parse_from(["termctrl", "wait", "demo", "ready", "--timeout", "5"]).is_ok()
);
}
#[test]
fn show_rejects_png_before_starting_a_source() {
let cli =
Cli::try_parse_from(["termctrl", "show", "--format", "png", "--", "app"]).unwrap();
let Command::Show(args) = cli.command else {
panic!("expected show command");
};
assert_eq!(
show(args).unwrap_err().to_string(),
"show does not support PNG output; use save --format png --out PATH"
);
}
#[test]
fn parses_recording_source_seek_options() {
let cli = Cli::try_parse_from([
"termctrl",
"show",
"--recording",
"captures/demo.termctrl",
"--at-marker",
"done",
])
.unwrap();
let Command::Show(args) = cli.command else {
panic!("expected show command");
};
assert_eq!(
args.source.recording.as_deref(),
Some(Path::new("captures/demo.termctrl"))
);
assert_eq!(args.source.at_marker.as_deref(), Some("done"));
}
#[test]
fn rejects_settling_options_for_pipe_reads() {
let cli = Cli::try_parse_from([
"termctrl",
"show",
"--pipe",
"--settle-ms",
"100",
"--",
"true",
])
.unwrap();
let Command::Show(args) = cli.command else {
panic!("expected show command");
};
assert!(show(args).is_err());
}
#[test]
fn rejects_zero_terminal_dimensions() {
assert!(validate_terminal_size(0, 24).is_err());
assert!(validate_terminal_size(80, 0).is_err());
}
#[test]
fn paced_session_input_splits_text_without_splitting_keys() {
assert_eq!(
session_input(&["text:hi".to_owned(), "enter".to_owned()], true).unwrap(),
vec![b"h".to_vec(), b"i".to_vec(), b"\r".to_vec()]
);
}
}