diff --git a/core/Cargo.lock b/core/Cargo.lock index 525f14b..828f22b 100644 --- a/core/Cargo.lock +++ b/core/Cargo.lock @@ -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" diff --git a/core/Cargo.toml b/core/Cargo.toml index aff9a18..9f2a22e 100644 --- a/core/Cargo.toml +++ b/core/Cargo.toml @@ -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 diff --git a/core/src/lib.rs b/core/src/lib.rs index ec1672f..b230aa9 100644 --- a/core/src/lib.rs +++ b/core/src/lib.rs @@ -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; diff --git a/core/src/main.rs b/core/src/main.rs index 81cc9c5..109ef2a 100644 --- a/core/src/main.rs +++ b/core/src/main.rs @@ -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, - /// Set deadline urgency (e.g. "7pm", "19:00", "30min") + /// Set deadline urgency (e.g. "7pm", "30min") #[arg(long)] deadline: Option, - /// 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}"); } } diff --git a/core/src/tui.rs b/core/src/tui.rs new file mode 100644 index 0000000..3c4ab22 --- /dev/null +++ b/core/src/tui.rs @@ -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 { + let items: Vec = 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::>() + .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!(); +} diff --git a/package.json b/package.json index dec06d3..18cf44c 100644 --- a/package.json +++ b/package.json @@ -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",