v0.5.0 — Beautiful TUI with spinners, boxes, interactive agent picker

- Animated spinners for each step (capture → build → launch)
- Box-drawn sections with emoji headers
- Interactive FuzzySelect agent picker when no --to specified
- Color-coded conversation turns (cyan AI, dimmed tools)
- Progress bar steps [1/3] [2/3] [3/3]
- 8 agents with install instructions shown inline
- UTF-8 safe truncation throughout

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Manavarya09 2026-04-05 14:47:01 +04:00
parent db79f7b857
commit 45b0785c12
6 changed files with 829 additions and 231 deletions

384
core/Cargo.lock generated
View file

@ -106,6 +106,12 @@ version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "bitflags"
version = "2.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af"
[[package]]
name = "blake3"
version = "1.8.4"
@ -212,6 +218,19 @@ dependencies = [
"windows-sys 0.59.0",
]
[[package]]
name = "console"
version = "0.15.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "054ccb5b10f9f2cbf51eb355ca1d05c2d279ce1804688d0db74b4733a5aeafd8"
dependencies = [
"encode_unicode",
"libc",
"once_cell",
"unicode-width",
"windows-sys 0.59.0",
]
[[package]]
name = "constant_time_eq"
version = "0.4.2"
@ -242,6 +261,20 @@ dependencies = [
"cfg-if",
]
[[package]]
name = "dialoguer"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "658bce805d770f407bc62102fca7c2c64ceef2fbcb2b8bd19d2765ce093980de"
dependencies = [
"console",
"fuzzy-matcher",
"shell-words",
"tempfile",
"thiserror 1.0.69",
"zeroize",
]
[[package]]
name = "displaydoc"
version = "0.2.5"
@ -253,12 +286,34 @@ dependencies = [
"syn",
]
[[package]]
name = "encode_unicode"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0"
[[package]]
name = "equivalent"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
[[package]]
name = "errno"
version = "0.3.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.61.2",
]
[[package]]
name = "fastrand"
version = "2.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a043dc74da1e37d6afe657061213aa6f425f855399a11d3463c6ecccc4dfda1f"
[[package]]
name = "find-msvc-tools"
version = "0.1.9"
@ -275,6 +330,12 @@ dependencies = [
"miniz_oxide",
]
[[package]]
name = "foldhash"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
[[package]]
name = "form_urlencoded"
version = "1.2.2"
@ -284,6 +345,15 @@ dependencies = [
"percent-encoding",
]
[[package]]
name = "fuzzy-matcher"
version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "54614a3312934d066701a80f20f15fa3b56d67ac7722b39eea5b4c9dd1d66c94"
dependencies = [
"thread_local",
]
[[package]]
name = "getrandom"
version = "0.2.17"
@ -295,6 +365,28 @@ dependencies = [
"wasi",
]
[[package]]
name = "getrandom"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555"
dependencies = [
"cfg-if",
"libc",
"r-efi",
"wasip2",
"wasip3",
]
[[package]]
name = "hashbrown"
version = "0.15.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
dependencies = [
"foldhash",
]
[[package]]
name = "hashbrown"
version = "0.16.1"
@ -413,6 +505,12 @@ dependencies = [
"zerovec",
]
[[package]]
name = "id-arena"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
[[package]]
name = "idna"
version = "1.1.0"
@ -441,7 +539,22 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "45a8a2b9cb3e0b0c1803dbb0758ffac5de2f425b23c28f518faabd9d805342ff"
dependencies = [
"equivalent",
"hashbrown",
"hashbrown 0.16.1",
"serde",
"serde_core",
]
[[package]]
name = "indicatif"
version = "0.17.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "183b3088984b400f4cfac3620d5e076c84da5364016b4f49473de574b2586235"
dependencies = [
"console",
"number_prefix",
"portable-atomic",
"unicode-width",
"web-time",
]
[[package]]
@ -472,12 +585,24 @@ version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
[[package]]
name = "leb128fmt"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
[[package]]
name = "libc"
version = "0.2.184"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af"
[[package]]
name = "linux-raw-sys"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
[[package]]
name = "litemap"
version = "0.8.2"
@ -533,6 +658,12 @@ dependencies = [
"autocfg",
]
[[package]]
name = "number_prefix"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "830b246a0e5f20af87141b25c173cd1b609bd7779a4617d6ec582abaf90870f3"
[[package]]
name = "once_cell"
version = "1.21.4"
@ -557,6 +688,12 @@ version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "portable-atomic"
version = "1.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49"
[[package]]
name = "potential_utf"
version = "0.1.5"
@ -566,6 +703,16 @@ dependencies = [
"zerovec",
]
[[package]]
name = "prettyplease"
version = "0.2.37"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
dependencies = [
"proc-macro2",
"syn",
]
[[package]]
name = "proc-macro2"
version = "1.0.106"
@ -584,6 +731,12 @@ dependencies = [
"proc-macro2",
]
[[package]]
name = "r-efi"
version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "regex"
version = "1.12.3"
@ -622,10 +775,13 @@ dependencies = [
"chrono",
"clap",
"colored",
"console",
"dialoguer",
"indicatif",
"regex",
"serde",
"serde_json",
"thiserror",
"thiserror 2.0.18",
"toml",
"tracing",
"tracing-subscriber",
@ -640,12 +796,25 @@ checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
dependencies = [
"cc",
"cfg-if",
"getrandom",
"getrandom 0.2.17",
"libc",
"untrusted",
"windows-sys 0.52.0",
]
[[package]]
name = "rustix"
version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
dependencies = [
"bitflags",
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.61.2",
]
[[package]]
name = "rustls"
version = "0.23.37"
@ -687,6 +856,12 @@ version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
[[package]]
name = "semver"
version = "1.0.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
[[package]]
name = "serde"
version = "1.0.228"
@ -748,6 +923,12 @@ dependencies = [
"lazy_static",
]
[[package]]
name = "shell-words"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77"
[[package]]
name = "shlex"
version = "1.3.0"
@ -806,13 +987,46 @@ dependencies = [
"syn",
]
[[package]]
name = "tempfile"
version = "3.27.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
"getrandom 0.4.2",
"once_cell",
"rustix",
"windows-sys 0.61.2",
]
[[package]]
name = "thiserror"
version = "1.0.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
dependencies = [
"thiserror-impl 1.0.69",
]
[[package]]
name = "thiserror"
version = "2.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
dependencies = [
"thiserror-impl",
"thiserror-impl 2.0.18",
]
[[package]]
name = "thiserror-impl"
version = "1.0.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
@ -953,6 +1167,18 @@ version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "unicode-width"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
[[package]]
name = "unicode-xid"
version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
[[package]]
name = "untrusted"
version = "0.9.0"
@ -1013,6 +1239,24 @@ version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]]
name = "wasip2"
version = "1.0.2+wasi-0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5"
dependencies = [
"wit-bindgen",
]
[[package]]
name = "wasip3"
version = "0.4.0+wasi-0.3.0-rc-2026-01-06"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5"
dependencies = [
"wit-bindgen",
]
[[package]]
name = "wasm-bindgen"
version = "0.2.117"
@ -1058,6 +1302,50 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "wasm-encoder"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319"
dependencies = [
"leb128fmt",
"wasmparser",
]
[[package]]
name = "wasm-metadata"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909"
dependencies = [
"anyhow",
"indexmap",
"wasm-encoder",
"wasmparser",
]
[[package]]
name = "wasmparser"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
dependencies = [
"bitflags",
"hashbrown 0.15.5",
"indexmap",
"semver",
]
[[package]]
name = "web-time"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
dependencies = [
"js-sys",
"wasm-bindgen",
]
[[package]]
name = "webpki-roots"
version = "0.26.11"
@ -1235,6 +1523,94 @@ dependencies = [
"memchr",
]
[[package]]
name = "wit-bindgen"
version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5"
dependencies = [
"wit-bindgen-rust-macro",
]
[[package]]
name = "wit-bindgen-core"
version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc"
dependencies = [
"anyhow",
"heck",
"wit-parser",
]
[[package]]
name = "wit-bindgen-rust"
version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21"
dependencies = [
"anyhow",
"heck",
"indexmap",
"prettyplease",
"syn",
"wasm-metadata",
"wit-bindgen-core",
"wit-component",
]
[[package]]
name = "wit-bindgen-rust-macro"
version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a"
dependencies = [
"anyhow",
"prettyplease",
"proc-macro2",
"quote",
"syn",
"wit-bindgen-core",
"wit-bindgen-rust",
]
[[package]]
name = "wit-component"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
dependencies = [
"anyhow",
"bitflags",
"indexmap",
"log",
"serde",
"serde_derive",
"serde_json",
"wasm-encoder",
"wasm-metadata",
"wasmparser",
"wit-parser",
]
[[package]]
name = "wit-parser"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736"
dependencies = [
"anyhow",
"id-arena",
"indexmap",
"log",
"semver",
"serde",
"serde_derive",
"serde_json",
"unicode-xid",
"wasmparser",
]
[[package]]
name = "writeable"
version = "0.6.3"

View file

@ -42,8 +42,11 @@ blake3 = "1"
# HTTP client (for Ollama, OpenAI, Gemini APIs)
ureq = { version = "2", features = ["json"] }
# Terminal
# Terminal UI
colored = "2"
indicatif = "0.17"
dialoguer = { version = "0.11", features = ["fuzzy-select"] }
console = "0.15"
[profile.release]
opt-level = 3

View file

@ -2,6 +2,7 @@ pub mod agents;
pub mod capture;
pub mod detect;
pub mod handoff;
pub mod tui;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;

View file

@ -1,22 +1,20 @@
use anyhow::Result;
use clap::{Parser, Subcommand};
use colored::Colorize;
use std::path::PathBuf;
use relay::{agents, capture, handoff, Config};
use relay::{agents, capture, handoff, tui, Config};
#[derive(Parser)]
#[command(
name = "relay",
about = "Relay — When Claude's rate limit hits, another agent picks up where you left off.",
long_about = "Captures your Claude Code session state (task, todos, git diff, decisions,\nerrors) and hands it off to Codex, Gemini, Ollama, or GPT-4 — so your\nwork never stops.",
version
)]
struct Cli {
#[command(subcommand)]
command: Commands,
/// Output as JSON
/// Output as JSON (no TUI)
#[arg(long, global = true)]
json: bool,
@ -31,17 +29,17 @@ struct Cli {
#[derive(Subcommand)]
enum Commands {
/// Hand off current session to a fallback agent right now
/// Hand off current session to a fallback agent
Handoff {
/// Force a specific agent (codex, gemini, ollama, openai)
/// Target agent (codex, claude, aider, gemini, copilot, opencode, ollama, openai)
#[arg(long)]
to: Option<String>,
/// Set deadline urgency (e.g. "7pm", "19:00", "30min")
/// Set deadline urgency (e.g. "7pm", "30min")
#[arg(long)]
deadline: Option<String>,
/// Don't execute — just print the handoff package
/// Just print the handoff — don't launch agent
#[arg(long)]
dry_run: bool,
@ -54,18 +52,17 @@ enum Commands {
include: String,
},
/// Show current session snapshot (what would be handed off)
/// Show current session snapshot
Status,
/// List configured agents and their availability
/// List configured agents and availability
Agents,
/// Generate default config file at ~/.relay/config.toml
/// Generate default config at ~/.relay/config.toml
Init,
/// PostToolUse hook mode (auto-detect rate limits from stdin)
/// PostToolUse hook (auto-detect rate limits)
Hook {
/// Session ID
#[arg(long, default_value = "unknown")]
session: String,
},
@ -92,286 +89,183 @@ fn main() -> Result<()> {
});
match cli.command {
// ═══════════════════════════════════════════════════════════════
// HANDOFF
// ═══════════════════════════════════════════════════════════════
Commands::Handoff { to, deadline, dry_run, turns, include } => {
eprintln!("{}", "⚡ Relay — capturing session state...".yellow().bold());
if !cli.json {
tui::print_banner();
}
// Step 1: Capture
let sp = if !cli.json { Some(tui::step(1, 3, "Capturing session state...")) } else { None };
// Set conversation turn limit before capture
relay::capture::session::MAX_CONVERSATION_TURNS
.store(turns, std::sync::atomic::Ordering::Relaxed);
let mut snapshot = capture::capture_snapshot(
&project_dir,
deadline.as_deref(),
)?;
let mut snapshot = capture::capture_snapshot(&project_dir, deadline.as_deref())?;
// Filter sections based on --include flag
// Apply include filter
let includes: Vec<&str> = include.split(',').map(|s| s.trim()).collect();
if !includes.contains(&"all") {
if !includes.contains(&"conversation") {
snapshot.conversation.clear();
}
if !includes.contains(&"git") {
snapshot.git_state = None;
snapshot.recent_files.clear();
}
if !includes.contains(&"todos") {
snapshot.todos.clear();
}
if !includes.contains(&"conversation") { snapshot.conversation.clear(); }
if !includes.contains(&"git") { snapshot.git_state = None; snapshot.recent_files.clear(); }
if !includes.contains(&"todos") { snapshot.todos.clear(); }
}
let target = to.as_deref().unwrap_or("auto");
let handoff_text = handoff::build_handoff(
&snapshot,
target,
config.general.max_context_tokens,
)?;
if let Some(sp) = sp { sp.finish_with_message("Session captured"); }
// Save handoff file for reference
// Step 2: Build handoff
let sp = if !cli.json { Some(tui::step(2, 3, "Building handoff package...")) } else { None };
// Resolve target agent
let target_name = if let Some(ref name) = to {
name.clone()
} else if !cli.json && !dry_run {
// Interactive agent selection
if let Some(sp) = sp.as_ref() { sp.finish_with_message("Handoff built"); }
let statuses = agents::check_all_agents(&config);
let agent_list: Vec<(String, bool, String)> = statuses
.iter()
.map(|s| (s.name.clone(), s.available, s.reason.clone()))
.collect();
match tui::select_agent(&agent_list) {
Some(name) => name,
None => {
eprintln!(" No agent selected.");
return Ok(());
}
}
} else {
"auto".into()
};
let handoff_text = handoff::build_handoff(
&snapshot, &target_name, config.general.max_context_tokens,
)?;
let handoff_path = handoff::save_handoff(&handoff_text, &project_dir)?;
if dry_run || cli.json {
if cli.json {
let result = serde_json::json!({
"snapshot": snapshot,
"handoff_text": handoff_text,
"handoff_file": handoff_path.to_string_lossy(),
"target_agent": target,
});
println!("{}", serde_json::to_string_pretty(&result)?);
} else {
println!("{handoff_text}");
eprintln!();
eprintln!("{}", format!("📄 Saved to: {}", handoff_path.display()).dimmed());
}
if let Some(sp) = sp { sp.finish_with_message("Handoff built"); }
// JSON / dry-run output
if cli.json {
println!("{}", serde_json::to_string_pretty(&serde_json::json!({
"snapshot": snapshot,
"handoff_text": handoff_text,
"handoff_file": handoff_path.to_string_lossy(),
"target_agent": target_name,
}))?);
return Ok(());
}
if dry_run {
println!("{handoff_text}");
eprintln!();
eprintln!(" 📄 Saved: {}", handoff_path.display());
return Ok(());
}
eprintln!("{}", format!("📄 Handoff saved: {}", handoff_path.display()).dimmed());
eprintln!();
// Step 3: Launch agent
let sp = tui::step(3, 3, &format!("Launching {}...", target_name));
// Execute handoff
let result = if let Some(ref agent_name) = to {
agents::handoff_to_named(&config, agent_name, &handoff_text, &project_dir.to_string_lossy())
let result = if to.is_some() {
agents::handoff_to_named(&config, &target_name, &handoff_text, &project_dir.to_string_lossy())
} else {
agents::handoff_to_first_available(&config, &handoff_text, &project_dir.to_string_lossy())
}?;
if result.success {
eprintln!("{}", format!("✅ Handed off to {}", result.agent).green().bold());
eprintln!(" {}", result.message);
sp.finish_with_message(if result.success {
format!("{} launched", target_name)
} else {
eprintln!("{}", format!("❌ Handoff failed: {}", result.message).red());
eprintln!();
eprintln!("💡 The handoff context was saved to:");
eprintln!(" {}", handoff_path.display());
eprintln!(" You can copy-paste it into any AI assistant manually.");
"Failed".into()
});
if result.success {
tui::print_handoff_success(&result.agent, &handoff_path.to_string_lossy());
} else {
tui::print_handoff_fail(&result.message, &handoff_path.to_string_lossy());
}
}
// ═══════════════════════════════════════════════════════════════
// STATUS
// ═══════════════════════════════════════════════════════════════
Commands::Status => {
let sp = if !cli.json { Some(tui::spinner("Reading session state...")) } else { None };
let snapshot = capture::capture_snapshot(&project_dir, None)?;
if let Some(sp) = sp { sp.finish_and_clear(); }
if cli.json {
println!("{}", serde_json::to_string_pretty(&snapshot)?);
return Ok(());
}
println!("{}", "═══ Relay Session Snapshot ═══".bold());
println!();
println!("{}: {}", "Project".bold(), snapshot.project_dir);
println!("{}: {}", "Captured".bold(), snapshot.timestamp);
println!();
println!("{}", "── Current Task ──".cyan());
println!(" {}", snapshot.current_task);
println!();
if !snapshot.todos.is_empty() {
println!("{}", "── Todos ──".cyan());
for t in &snapshot.todos {
let icon = match t.status.as_str() {
"completed" => "",
"in_progress" => "🔄",
_ => "",
};
println!(" {icon} [{}] {}", t.status, t.content);
}
println!();
}
if let Some(ref err) = snapshot.last_error {
println!("{}", "── Last Error ──".red());
println!(" {err}");
println!();
}
if !snapshot.decisions.is_empty() {
println!("{}", "── Decisions ──".cyan());
for d in &snapshot.decisions {
println!("{d}");
}
println!();
}
if let Some(ref git) = snapshot.git_state {
println!("{}", "── Git ──".cyan());
println!(" Branch: {}", git.branch);
println!(" {}", git.status_summary);
if !git.recent_commits.is_empty() {
println!(" Recent:");
for c in git.recent_commits.iter().take(3) {
println!(" {c}");
}
}
println!();
}
if !snapshot.recent_files.is_empty() {
println!("{}", "── Changed Files ──".cyan());
for f in snapshot.recent_files.iter().take(10) {
println!(" {f}");
}
println!();
}
if !snapshot.conversation.is_empty() {
println!("{}", format!("── Conversation ({} turns) ──", snapshot.conversation.len()).cyan());
// Show last 15 turns
let start = snapshot.conversation.len().saturating_sub(15);
for turn in &snapshot.conversation[start..] {
let prefix = match turn.role.as_str() {
"user" => "👤 USER".to_string(),
"assistant" => "🤖 CLAUDE".to_string(),
"assistant_tool" => "🔧 TOOL".to_string(),
"tool_result" => "📤 RESULT".to_string(),
_ => turn.role.clone(),
};
let content = if turn.content.len() > 120 {
format!("{}...", &turn.content[..117])
} else {
turn.content.clone()
};
println!(" {}: {}", prefix, content);
}
println!();
} else {
tui::print_snapshot(&snapshot);
}
}
// ═══════════════════════════════════════════════════════════════
// AGENTS
// ═══════════════════════════════════════════════════════════════
Commands::Agents => {
let sp = if !cli.json { Some(tui::spinner("Checking agents...")) } else { None };
let statuses = agents::check_all_agents(&config);
if let Some(sp) = sp { sp.finish_and_clear(); }
if cli.json {
println!("{}", serde_json::to_string_pretty(&statuses)?);
return Ok(());
}
println!("{}", "═══ Relay Agents ═══".bold());
println!();
println!("Priority order: {}", config.general.priority.join(""));
println!();
for s in &statuses {
let icon = if s.available { "" } else { "" };
let name = if s.available {
s.name.green().bold().to_string()
} else {
s.name.dimmed().to_string()
};
println!(
" {icon} {:<10} {}",
name,
s.reason
);
if let Some(ref v) = s.version {
println!(" Version: {v}");
}
}
println!();
let available = statuses.iter().filter(|s| s.available).count();
if available == 0 {
eprintln!("{}", "⚠️ No agents available. Run 'relay init' to configure.".yellow());
} else {
println!(
" {} agent{} ready for handoff.",
available,
if available == 1 { "" } else { "s" }
);
tui::print_agents(&config.general.priority, &statuses);
}
}
// ═══════════════════════════════════════════════════════════════
// INIT
// ═══════════════════════════════════════════════════════════════
Commands::Init => {
let path = relay::config_path();
if path.exists() {
println!("Config already exists at: {}", path.display());
println!("Edit it to add API keys and customize agent priority.");
eprintln!(" Config exists: {}", path.display());
eprintln!(" Edit to add API keys and customize priority.");
} else {
Config::save_default(&path)?;
println!("{}", "✅ Config created at:".green());
println!(" {}", path.display());
println!();
println!("Edit it to add API keys:");
println!(" [agents.gemini]");
println!(" api_key = \"your-gemini-key\"");
println!();
println!(" [agents.openai]");
println!(" api_key = \"your-openai-key\"");
eprintln!(" ✅ Config created: {}", path.display());
eprintln!();
eprintln!(" Add API keys:");
eprintln!(" [agents.gemini]");
eprintln!(" api_key = \"your-key\"");
eprintln!();
eprintln!(" [agents.openai]");
eprintln!(" api_key = \"your-key\"");
}
}
// ═══════════════════════════════════════════════════════════════
// HOOK
// ═══════════════════════════════════════════════════════════════
Commands::Hook { session: _ } => {
use std::io::Read;
let mut raw = String::new();
std::io::stdin().read_to_string(&mut raw)?;
// Check for rate limit signals
if let Some(detection) = relay::detect::check_hook_output(&raw) {
eprintln!(
"{}",
format!(
"🚨 [relay] Rate limit detected in {} output (signal: {})",
detection.tool_name, detection.signal
).red().bold()
" 🚨 Rate limit detected in {} (signal: {})",
detection.tool_name, detection.signal
);
if config.general.auto_handoff {
// Auto-handoff
let snapshot = capture::capture_snapshot(&project_dir, None)?;
let handoff_text = handoff::build_handoff(
&snapshot,
"auto",
config.general.max_context_tokens,
)?;
let handoff_text = handoff::build_handoff(&snapshot, "auto", config.general.max_context_tokens)?;
let handoff_path = handoff::save_handoff(&handoff_text, &project_dir)?;
eprintln!(
"📄 Handoff saved: {}",
handoff_path.display()
);
let result = agents::handoff_to_first_available(
&config,
&handoff_text,
&project_dir.to_string_lossy(),
&config, &handoff_text, &project_dir.to_string_lossy(),
)?;
if result.success {
eprintln!(
"{}",
format!("✅ Auto-handed off to {}", result.agent).green()
);
eprintln!(" ✅ Auto-handed off to {}", result.agent);
} else {
eprintln!(
"{}",
format!("⚠️ No agents available. Handoff saved to: {}",
handoff_path.display()
).yellow()
);
eprintln!(" 📄 Saved: {}", handoff_path.display());
}
}
}
// Always pass through the original output
print!("{raw}");
}
}

324
core/src/tui.rs Normal file
View file

@ -0,0 +1,324 @@
//! Beautiful terminal UI for Relay — spinners, boxes, interactive prompts.
use colored::Colorize;
use console::Term;
use indicatif::{ProgressBar, ProgressStyle};
use std::time::Duration;
// ─── Banner ─────────────────────────────────────────────────────────────────
pub fn print_banner() {
let banner = r#"
R E L A Y
Cross-agent context handoff
"#;
eprintln!("{}", banner.cyan());
}
// ─── Spinners ───────────────────────────────────────────────────────────────
pub fn spinner(msg: &str) -> ProgressBar {
let pb = ProgressBar::new_spinner();
pb.set_style(
ProgressStyle::with_template(" {spinner:.cyan} {msg}")
.unwrap()
.tick_strings(&["", "", "", "", "", "", "", "", "", "", ""]),
);
pb.set_message(msg.to_string());
pb.enable_steady_tick(Duration::from_millis(80));
pb
}
pub fn step(num: usize, total: usize, msg: &str) -> ProgressBar {
let pb = ProgressBar::new_spinner();
pb.set_style(
ProgressStyle::with_template(&format!(
" {{spinner:.cyan}} [{}/{}] {{msg}}",
num, total
))
.unwrap()
.tick_strings(&["", "", "", "", "", "", "", "", "", "", ""]),
);
pb.set_message(msg.to_string());
pb.enable_steady_tick(Duration::from_millis(80));
pb
}
// ─── Boxes ──────────────────────────────────────────────────────────────────
pub fn print_box(title: &str, content: &str) {
let term_width = Term::stdout().size().1 as usize;
let width = term_width.min(72).max(40);
let inner = width - 4;
// Top border
eprintln!("{}", "".repeat(inner + 2));
// Title
let title_padded = format!(" {} ", title);
let pad = inner.saturating_sub(title_padded.len()) + 1;
eprintln!("{}{}", title_padded.bold().cyan(), " ".repeat(pad));
// Separator
eprintln!("{}", "".repeat(inner + 2));
// Content lines
for line in content.lines() {
let display_line = if line.len() > inner {
let mut end = inner.saturating_sub(1);
while end > 0 && !line.is_char_boundary(end) { end -= 1; }
format!("{}", &line[..end])
} else {
line.to_string()
};
let pad = inner.saturating_sub(display_line.len()) + 1;
eprintln!("{}{}", display_line, " ".repeat(pad.saturating_sub(1)));
}
// Bottom border
eprintln!("{}", "".repeat(inner + 2));
}
pub fn print_section(icon: &str, title: &str) {
eprintln!();
eprintln!(" {} {}", icon, title.bold());
eprintln!(" {}", "".repeat(50).dimmed());
}
// ─── Agent Select ───────────────────────────────────────────────────────────
pub fn select_agent(agents: &[(String, bool, String)]) -> Option<String> {
let items: Vec<String> = agents
.iter()
.map(|(name, available, reason)| {
if *available {
format!("{}{}", name, reason)
} else {
format!("{}{}", name, reason)
}
})
.collect();
eprintln!();
let selection = dialoguer::FuzzySelect::with_theme(
&dialoguer::theme::ColorfulTheme::default(),
)
.with_prompt(" Select target agent")
.items(&items)
.default(0)
.interact_opt()
.ok()
.flatten()?;
let (name, available, _) = &agents[selection];
if !*available {
eprintln!(
"\n {} {} is not available.",
"⚠️ ".yellow(),
name.bold()
);
return None;
}
Some(name.clone())
}
// ─── Status Display ─────────────────────────────────────────────────────────
pub fn print_snapshot(snapshot: &crate::SessionSnapshot) {
eprintln!();
let term_width = Term::stdout().size().1 as usize;
let width = term_width.min(72).max(40);
eprintln!(" {}", "".repeat(width).cyan());
eprintln!(
" {} {}",
"📋".to_string(),
"Session Snapshot".bold().cyan()
);
eprintln!(" {}", "".repeat(width).cyan());
// Project + time
eprintln!();
eprintln!(" {} {}", "📁", snapshot.project_dir.dimmed());
eprintln!(" {} {}", "🕐", snapshot.timestamp.dimmed());
// Current task
print_section("🎯", "Current Task");
eprintln!(" {}", snapshot.current_task);
// Todos
if !snapshot.todos.is_empty() {
print_section("📝", "Progress");
for t in &snapshot.todos {
let (icon, style) = match t.status.as_str() {
"completed" => ("", t.content.dimmed().to_string()),
"in_progress" => ("🔄", t.content.yellow().bold().to_string()),
_ => ("", t.content.normal().to_string()),
};
eprintln!(" {icon} {style}");
}
}
// Last error
if let Some(ref err) = snapshot.last_error {
print_section("🚨", "Last Error");
for line in err.lines().take(5) {
eprintln!(" {}", line.red());
}
}
// Decisions
if !snapshot.decisions.is_empty() {
print_section("💡", "Key Decisions");
for d in &snapshot.decisions {
eprintln!("{}", d.dimmed());
}
}
// Git
if let Some(ref git) = snapshot.git_state {
print_section("🔀", "Git State");
eprintln!(" Branch: {}", git.branch.green());
eprintln!(" {}", git.status_summary);
if !git.recent_commits.is_empty() {
eprintln!();
for c in git.recent_commits.iter().take(3) {
eprintln!(" {}", c.dimmed());
}
}
}
// Changed files
if !snapshot.recent_files.is_empty() {
print_section("📄", &format!("Changed Files ({})", snapshot.recent_files.len()));
for f in snapshot.recent_files.iter().take(10) {
eprintln!(" {f}");
}
}
// Conversation
if !snapshot.conversation.is_empty() {
print_section(
"💬",
&format!("Conversation ({} turns)", snapshot.conversation.len()),
);
let start = snapshot.conversation.len().saturating_sub(10);
for turn in &snapshot.conversation[start..] {
let (prefix, color) = match turn.role.as_str() {
"user" => ("👤 YOU ", turn.content.normal().to_string()),
"assistant" => ("🤖 AI ", turn.content.cyan().to_string()),
"assistant_tool" => ("🔧 TOOL", turn.content.dimmed().to_string()),
"tool_result" => ("📤 OUT ", turn.content.dimmed().to_string()),
_ => (" ", turn.content.normal().to_string()),
};
let short = if turn.content.len() > 90 {
let mut end = 85;
while end > 0 && !turn.content.is_char_boundary(end) { end -= 1; }
format!("{}", &turn.content[..end])
} else {
turn.content.clone()
};
let styled = match turn.role.as_str() {
"user" => short.normal().to_string(),
"assistant" => short.cyan().to_string(),
"assistant_tool" => short.dimmed().to_string(),
"tool_result" => short.dimmed().to_string(),
_ => short,
};
eprintln!(" {} {}", prefix.dimmed(), styled);
}
}
eprintln!();
eprintln!(" {}", "".repeat(width).cyan());
}
// ─── Agents Display ─────────────────────────────────────────────────────────
pub fn print_agents(
priority: &[String],
statuses: &[crate::AgentStatus],
) {
eprintln!();
let term_width = Term::stdout().size().1 as usize;
let width = term_width.min(72).max(40);
eprintln!(" {}", "".repeat(width).cyan());
eprintln!(" {} {}", "🤖", "Available Agents".bold().cyan());
eprintln!(" {}", "".repeat(width).cyan());
eprintln!();
eprintln!(
" Priority: {}",
priority
.iter()
.map(|s| s.as_str())
.collect::<Vec<_>>()
.join("")
.dimmed()
);
eprintln!();
for s in statuses {
if s.available {
eprintln!(
" {} {:<12} {}",
"",
s.name.green().bold(),
s.reason.dimmed()
);
if let Some(ref v) = s.version {
eprintln!(" {} {:<12} {}", " ", "", format!("v{v}").dimmed());
}
} else {
eprintln!(
" {} {:<12} {}",
"",
s.name.dimmed(),
s.reason.dimmed()
);
}
}
let available = statuses.iter().filter(|s| s.available).count();
eprintln!();
if available == 0 {
eprintln!(
" {} {}",
"⚠️ ",
"No agents available. Run 'relay init' to configure.".yellow()
);
} else {
eprintln!(
" {} {} agent{} ready for handoff",
"🚀",
available.to_string().green().bold(),
if available == 1 { "" } else { "s" }
);
}
eprintln!();
}
// ─── Handoff Result ─────────────────────────────────────────────────────────
pub fn print_handoff_success(agent: &str, file: &str) {
eprintln!();
eprintln!(
" {} {}",
"",
format!("Handed off to {agent}").green().bold()
);
eprintln!(" 📄 {}", file.dimmed());
eprintln!();
}
pub fn print_handoff_fail(message: &str, file: &str) {
eprintln!();
eprintln!(" {} {}", "", message.red());
eprintln!();
eprintln!(" 💡 Context saved — copy-paste into any AI:");
eprintln!(" {}", file.cyan());
eprintln!();
}

View file

@ -1,6 +1,6 @@
{
"name": "@masyv/relay",
"version": "0.3.0",
"version": "0.4.0",
"description": "Relay — When Claude's rate limit hits, another agent picks up exactly where you left off. Captures session state and hands off to Codex, Gemini, Ollama, or GPT-4.",
"scripts": {
"build": "./scripts/build.sh",