use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::{SystemTime, UNIX_EPOCH};
use agentty::db::{DB_DIR, DB_FILE, Database};
use assert_cmd::cargo::cargo_bin;
use sqlx::sqlite::SqliteConnectOptions;
use sqlx::{ConnectOptions, Connection, Executor};
use crate::common::BuilderEnv;
type DemoResult = Result<(), Box<dyn std::error::Error>>;
type SeedSessionRow<'a> = (
&'a str,
&'a str,
&'a str,
&'a str,
&'a str,
i64,
i64,
&'a str,
&'a str,
i64,
);
const GIF_NAME: &str = "demo";
#[test]
#[ignore = "requires VHS and regenerates a marketing asset"]
fn generate_marketing_demo_gif() -> DemoResult {
if Command::new("vhs").arg("--version").output().is_err() {
return Ok(());
}
let demo_root = make_fresh_demo_root()?;
let env = make_demo_env(&demo_root)?;
install_scripted_claude_stub(&env)?;
symlink_agentty_into_stub_bin(&env)?;
let fake_project_paths = create_fake_project_dirs(&demo_root)?;
let canonical_cwd = env.workdir.canonicalize()?;
seed_database(&env, &fake_project_paths, &canonical_cwd)?;
let output_dir = repo_demo_dir();
std::fs::create_dir_all(&output_dir)?;
let gif_path = output_dir.join(format!("{GIF_NAME}.gif"));
let tape = build_demo_tape(&env, &gif_path);
let tape_path = demo_root.join("demo.tape");
std::fs::write(&tape_path, &tape)?;
let output = Command::new("vhs").arg(&tape_path).output()?;
if !output.status.success() {
return Err(format!(
"vhs failed: {}\nstdout: {}",
String::from_utf8_lossy(&output.stderr),
String::from_utf8_lossy(&output.stdout),
)
.into());
}
if !gif_path.exists() {
return Err(format!("demo gif not produced at {}", gif_path.display()).into());
}
let _ = std::fs::remove_dir_all(&demo_root);
Ok(())
}
fn make_fresh_demo_root() -> std::io::Result<PathBuf> {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |duration| duration.as_nanos());
let root = PathBuf::from(format!("/tmp/agentty-demo-{nanos}"));
if root.exists() {
std::fs::remove_dir_all(&root)?;
}
std::fs::create_dir_all(&root)?;
Ok(root)
}
fn make_demo_env(demo_root: &Path) -> std::io::Result<BuilderEnv> {
let agentty_root = demo_root.join("agentty_root");
let home_dir = demo_root.join("home");
let workdir = demo_root.join("my_project");
let stub_bin = demo_root.join("stub-bin");
std::fs::create_dir_all(&agentty_root)?;
std::fs::create_dir_all(&home_dir)?;
std::fs::create_dir_all(&workdir)?;
std::fs::create_dir_all(&stub_bin)?;
let env = BuilderEnv {
agentty_root,
home_dir,
stub_bin,
workdir,
};
env.init_git()?;
Ok(env)
}
fn create_fake_project_dirs(demo_root: &Path) -> std::io::Result<Vec<PathBuf>> {
let fake_parent = demo_root.join("projects");
std::fs::create_dir_all(&fake_parent)?;
let paths = ["notes", "api-service", "playground"]
.iter()
.map(|name| {
let path = fake_parent.join(name);
std::fs::create_dir_all(&path)?;
Ok(path)
})
.collect::<std::io::Result<Vec<_>>>()?;
Ok(paths)
}
fn install_scripted_claude_stub(env: &BuilderEnv) -> std::io::Result<()> {
let claude_path = env.stub_bin.join("claude");
let reply = "I added a token-bucket rate limiter middleware on the auth routes. Each client \
IP is capped at 10 requests per second with a 20-request burst, and overflow \
returns 429 with a Retry-After header.";
let script = format!(
r#"#!/bin/sh
# Consume stdin so the parent's writer does not block.
cat > /dev/null 2>&1
# Long "thinking" pause so the Sessions list shows InProgress long enough
# for the viewer to notice the timer ticking.
sleep 8
printf '%s\n' '{{"type":"system","subtype":"init"}}'
sleep 1
printf '%s\n' '{{"type":"assistant","message":{{"role":"assistant","content":[{{"type":"text","text":"{reply}"}}]}}}}'
sleep 1
printf '%s\n' '{{"type":"result","subtype":"success","result":"{{\"answer\":\"{reply}\",\"questions\":[],\"summary\":null}}","usage":{{"input_tokens":5,"output_tokens":42}}}}'
"#
);
std::fs::write(&claude_path, &script)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&claude_path, std::fs::Permissions::from_mode(0o755))?;
}
Ok(())
}
fn symlink_agentty_into_stub_bin(env: &BuilderEnv) -> std::io::Result<()> {
let real_binary = cargo_bin("agentty");
let link_path = env.stub_bin.join("agentty");
if link_path.exists() {
std::fs::remove_file(&link_path)?;
}
#[cfg(unix)]
{
std::os::unix::fs::symlink(&real_binary, &link_path)?;
}
Ok(())
}
fn seed_database(
env: &BuilderEnv,
fake_project_paths: &[PathBuf],
canonical_cwd: &Path,
) -> DemoResult {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()?;
runtime.block_on(async {
let db_path = env.agentty_root.join(DB_DIR).join(DB_FILE);
let database = Database::open(&db_path).await?;
for (path, branch) in fake_project_paths.iter().zip(["main", "develop", "main"]) {
let id = database
.projects()
.upsert_project(&path.display().to_string(), Some(branch.to_string()))
.await?;
database.projects().touch_project_last_opened(id).await?;
}
let cwd_project_id = database
.projects()
.upsert_project(
&canonical_cwd.display().to_string(),
Some("main".to_string()),
)
.await?;
drop(database);
let mut connection = SqliteConnectOptions::new()
.filename(&db_path)
.connect()
.await?;
for name in [
"DefaultSmartModel",
"DefaultFastModel",
"DefaultReviewModel",
] {
let query = sqlx::query(
r"
INSERT INTO project_setting (project_id, name, value)
VALUES (?, ?, ?)
ON CONFLICT(project_id, name) DO UPDATE SET value = excluded.value
",
)
.bind(cwd_project_id)
.bind(name)
.bind("claude-haiku-4-5-20251001");
connection.execute(query).await?;
}
seed_pre_existing_sessions(&mut connection, cwd_project_id, &env.agentty_root).await?;
connection.close().await?;
Result::<(), Box<dyn std::error::Error>>::Ok(())
})?;
Ok(())
}
async fn seed_pre_existing_sessions(
connection: &mut sqlx::SqliteConnection,
cwd_project_id: i64,
agentty_root: &Path,
) -> DemoResult {
let now_seconds = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |duration| {
i64::try_from(duration.as_secs()).unwrap_or(i64::MAX)
});
let rows: &[SeedSessionRow<'_>] = &[
(
"aaaa1111-aaaa-1111-aaaa-111111111111",
"Refactor request handlers",
"gemini-3.1-pro-preview",
"Review",
"main",
89,
45,
"Refactor the request handler pipeline to share middleware.",
"Ready for your review — see diff.",
600,
),
(
"bbbb2222-bbbb-2222-bbbb-222222222222",
"Add dark mode",
"gpt-5.4",
"Done",
"main",
127,
33,
"Add a dark theme with a toggle in settings.",
"Merged.",
3_600,
),
(
"cccc3333-cccc-3333-cccc-333333333333",
"Fix auth token refresh",
"claude-sonnet-4-6",
"Done",
"main",
42,
18,
"Fix the token refresh race that logs users out.",
"Merged.",
7_200,
),
];
let wt_base = agentty_root.join("wt");
for (id, title, model, status, base_branch, added, deleted, prompt, output, age) in rows {
let created = now_seconds - age;
if !matches!(*status, "Done" | "Canceled") {
let stub_worktree = wt_base.join(&id[..8]);
std::fs::create_dir_all(&stub_worktree)?;
}
let query = sqlx::query(
r"
INSERT INTO session (
id, base_branch, status, project_id, model, title, prompt, output,
added_lines, deleted_lines, created_at, updated_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
",
)
.bind(id)
.bind(base_branch)
.bind(status)
.bind(cwd_project_id)
.bind(model)
.bind(title)
.bind(prompt)
.bind(output)
.bind(added)
.bind(deleted)
.bind(created)
.bind(created);
connection.execute(query).await?;
}
Ok(())
}
fn repo_demo_dir() -> PathBuf {
let manifest_dir = env!("CARGO_MANIFEST_DIR");
Path::new(manifest_dir).join("../../docs/site/static/demo")
}
fn build_demo_tape(env: &BuilderEnv, gif_path: &Path) -> String {
let agentty_root = env.agentty_root.display().to_string();
let path_env = {
let system_path = std::env::var("PATH").unwrap_or_default();
let mut parts = vec![env.stub_bin.clone()];
parts.extend(std::env::split_paths(&system_path));
std::env::join_paths(parts).map_or_else(
|_| env.stub_bin.display().to_string(),
|value| value.to_string_lossy().into_owned(),
)
};
let workdir = env.workdir.display().to_string();
format!(
r#"Set Shell "bash"
Set FontSize 20
Set Width 1600
Set Height 900
Set Padding 28
Set TypingSpeed 55ms
Set Framerate 30
Set Theme "Dracula"
Output "{gif}"
Hide
Type "export AGENTTY_ROOT='{root}'"
Enter
Sleep 80ms
Type "export PATH='{path}'"
Enter
Sleep 80ms
Type "export HOME='{root}'"
Enter
Sleep 80ms
Type "cd '{cwd}'"
Enter
Sleep 80ms
Type "clear"
Enter
Sleep 200ms
Show
Sleep 600ms
Type "agentty"
Sleep 400ms
Enter
Sleep 1500ms
Tab
Sleep 2200ms
Type "a"
Sleep 900ms
Type "/model"
Sleep 500ms
Enter
Sleep 700ms
Down
Sleep 400ms
Enter
Sleep 700ms
Enter
Sleep 700ms
Type "Add rate limiting to the auth API"
Sleep 900ms
Enter
Sleep 2500ms
Type "q"
Sleep 2500ms
Type "a"
Sleep 900ms
Type "Add pagination to the users list endpoint"
Sleep 900ms
Enter
Sleep 2500ms
Type "q"
Sleep 1500ms
Up
Sleep 400ms
Enter
Sleep 3500ms
Hide
Type "q"
Sleep 400ms
"#,
gif = gif_path.display(),
root = agentty_root,
path = path_env,
cwd = workdir,
)
}