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:
parent
db79f7b857
commit
45b0785c12
6 changed files with 829 additions and 231 deletions
384
core/Cargo.lock
generated
384
core/Cargo.lock
generated
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
344
core/src/main.rs
344
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<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
324
core/src/tui.rs
Normal 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!();
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue