From c1b563e80cd44ed0e63610a8f50b880f4b4573a8 Mon Sep 17 00:00:00 2001 From: Duyet Le Date: Wed, 28 Oct 2026 01:00:22 +0700 Subject: [PATCH 1/3] fix(tui): plain top-left screens, raw-mode lines back to column 0, rows cut to width Co-authored-by: Duyet Le Co-authored-by: duyetbot Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 17 + Cargo.lock | 47 ++ Cargo.toml | 4 + src/cmd/menu.rs | 25 +- src/cmd/status.rs | 141 ++++- src/http.rs | 82 ++- src/relay/live.rs | 6 +- src/term.rs | 265 +++++++- src/tui/theme.rs | 52 +- src/tui/view.rs | 1291 ++++++++++++++++---------------------- tests/cli.rs | 6 +- tests/tty_render.rs | 503 +++++++++++++++ 12 files changed, 1613 insertions(+), 826 deletions(-) create mode 100644 tests/tty_render.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0d81aa0..ab66be3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,7 +33,13 @@ jobs: with: tool: cargo-llvm-cov + # tests/tty_render.rs drives anyr in a pseudo-terminal under each shell. + - name: install shells for the terminal render tests + run: sudo apt-get update -q && sudo apt-get install -y -q zsh fish + - name: cargo llvm-cov (full suite) + env: + ANYR_PTY_REQUIRE_SHELLS: "1" run: cargo llvm-cov --locked --all-targets --lcov --output-path lcov.info # Repo secret CODECOV_TOKEN from https://app.codecov.io/gh/anyrouter-dev/cli @@ -109,8 +115,19 @@ jobs: with: targets: ${{ matrix.target }} + # tests/tty_render.rs drives anyr in a pseudo-terminal under each shell. + - name: install shells for the terminal render tests (linux) + if: runner.os == 'Linux' + run: sudo apt-get update -q && sudo apt-get install -y -q zsh fish + + - name: install shells for the terminal render tests (macos) + if: runner.os == 'macOS' + run: brew install fish + - name: cargo test timeout-minutes: 20 + env: + ANYR_PTY_REQUIRE_SHELLS: ${{ runner.os == 'Windows' && '0' || '1' }} run: cargo test --locked --all-targets - name: stamp beta version (main) diff --git a/Cargo.lock b/Cargo.lock index ffaced3..e4ee8e9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -24,10 +24,18 @@ dependencies = [ "serde_json", "sha2", "tungstenite", + "unicode-width 0.2.0", "ureq", + "vt100", "wasm-bindgen", ] +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + [[package]] name = "base64" version = "0.22.1" @@ -1098,12 +1106,51 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + [[package]] name = "version_check" version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "vt100" +version = "0.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84cd863bf0db7e392ba3bd04994be3473491b31e66340672af5d11943c6274de" +dependencies = [ + "itoa", + "log", + "unicode-width 0.1.14", + "vte", +] + +[[package]] +name = "vte" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5022b5fbf9407086c180e9557be968742d839e68346af7792b8592489732197" +dependencies = [ + "arrayvec", + "utf8parse", + "vte_generate_state_changes", +] + +[[package]] +name = "vte_generate_state_changes" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e369bee1b05d510a7b4ed645f5faa90619e05437111783ea5848f28d97d3c2e" +dependencies = [ + "proc-macro2", + "quote", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" diff --git a/Cargo.toml b/Cargo.toml index 17931c6..45e132d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,6 +26,7 @@ native = ["dep:ureq", "dep:ratatui", "dep:crossterm", "dep:tungstenite"] serde_json = "1" libc = "0.2" sha2 = "0.10" +unicode-width = "0.2" ureq = { version = "2.12", optional = true } ratatui = { version = "=0.29.0", default-features = false, features = ["crossterm"], optional = true } crossterm = { version = "=0.28.1", optional = true } @@ -42,3 +43,6 @@ codegen-units = 1 strip = true panic = "abort" opt-level = "z" + +[dev-dependencies] +vt100 = "0.15" diff --git a/src/cmd/menu.rs b/src/cmd/menu.rs index e1b3ce6..2aa9e75 100644 --- a/src/cmd/menu.rs +++ b/src/cmd/menu.rs @@ -32,6 +32,14 @@ use crate::cmd::models::{ }; use crate::cmd::usage::run_usage; +/// `…6789` from a masked key: enough to tell keys apart in a one-line row. +fn key_last4(masked: &str) -> String { + match masked.rsplit_once('…') { + Some((_, tail)) if !tail.is_empty() => format!("…{tail}"), + _ => masked.to_string(), + } +} + pub(crate) fn agent_binding_detail( cfg: &crate::config::Config, id: &str, @@ -57,19 +65,22 @@ pub(crate) fn agent_binding_detail( .filter(|s| !s.is_empty()) .unwrap_or(cfg.active_profile.as_str()); let key = if signed_in { - if let Some(k) = binding + let k = binding .and_then(|b| b.api_key.as_deref()) .map(str::trim) .filter(|s| !s.is_empty()) - { - mask_api_key(Some(k)) - } else { - mask_api_key(profile.and_then(|p| p.api_key.as_deref())) - } + .or_else(|| profile.and_then(|p| p.api_key.as_deref())); + key_last4(&mask_api_key(k)) } else { "(not signed in)".into() }; - let mut parts = vec![model, account.to_string(), key]; + // The account shows only when this agent uses another one than the + // active profile; the account row above already names that one. + let mut parts = vec![model]; + if account != cfg.active_profile { + parts.push(account.to_string()); + } + parts.push(key); if let Some(r) = binding.map(|b| &b.routing) { if r.wants_exacto() { parts.push("exacto".into()); diff --git a/src/cmd/status.rs b/src/cmd/status.rs index 77d9bef..0e09508 100644 --- a/src/cmd/status.rs +++ b/src/cmd/status.rs @@ -20,8 +20,11 @@ use crate::term; const DASHBOARD_URL: &str = "https://anyrouter.dev/dashboard"; -/// Account lookups are optional garnish: an offline `status` must not wait on them. -const IDENTITY_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(3); +/// Account lookups must not hang an offline `status`: it answers within 5 s +/// even when the gateway never replies. Measured on prod, `/v1/key` answers in +/// about 0.25 s warm and 1 s cold, so 4 s leaves room for a slow first +/// DNS + TLS handshake that overran the old 3 s. +const IDENTITY_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(4); /// What the status view shows. Model tier mappings stay in `config`; they are /// not part of this summary. @@ -32,6 +35,8 @@ pub(crate) struct StatusView { pub email: Option, pub username: Option, pub balance: Option, + /// Plan name from `/me`, when the server sends one. + pub plan_name: Option, /// Why name and email are missing, when they are. pub identity_note: Option, /// Set only when the account lookup itself failed. @@ -59,12 +64,15 @@ impl StatusView { } } - /// `free` or `paid`. The API reports only whether the plan is free. - pub(crate) fn plan(&self) -> Option<&'static str> { - self.key - .as_ref() - .and_then(|k| k.is_free_tier) - .map(|free| if free { "free" } else { "paid" }) + /// The plan name from `/me` when the server sends it, else `free` or + /// `paid` from the key's `is_free_tier`. `None` when neither answered. + pub(crate) fn plan(&self) -> Option { + self.plan_name.clone().or_else(|| { + self.key + .as_ref() + .and_then(|k| k.is_free_tier) + .map(|free| if free { "free" } else { "paid" }.to_string()) + }) } pub(crate) fn json(&self) -> serde_json::Value { @@ -95,7 +103,8 @@ impl StatusView { v } - /// Plain label/value lines from column 0. First byte is visible text. + /// Plain label/value lines from column 0, cut to the terminal width. + /// First byte is visible text. pub(crate) fn render(&self) -> String { let dash = || "—".to_string(); let identity = if self.name.is_some() || self.email.is_some() { @@ -103,11 +112,14 @@ impl StatusView { } else { format!("{} ({})", self.account, self.identity_line()) }; - let account = [ + let mut account = vec![ ("account", identity), ("credits", self.balance.map(format_usd).unwrap_or_else(dash)), - ("plan", self.plan().map(str::to_string).unwrap_or_else(dash)), ]; + // An unknown plan is left out: a bare dash read as "no plan". + if let Some(plan) = self.plan() { + account.push(("plan", plan)); + } let mut key_rows = vec![( "key", match self.key.as_ref().and_then(|k| k.name.as_deref()) { @@ -134,7 +146,7 @@ impl StatusView { .unwrap_or_else(|| "never".into()), )); } else if let Some(err) = &self.key_error { - key_rows.push(("scopes", format!("unavailable: {err}"))); + key_rows.push(("scopes", format!("unavailable ({})", short_reason(err)))); } key_rows.push(("profile", self.account.clone())); key_rows.push(("model", session_model_label(&self.default_model))); @@ -147,6 +159,37 @@ impl StatusView { } } +/// A failed lookup in a few words for a status row: `timed out`, `offline`, +/// `HTTP 500`. The full error stays in `--json` and `--verbose`. +pub(crate) fn short_reason(err: &str) -> String { + let lower = err.to_ascii_lowercase(); + if lower.contains("timed out") || lower.contains("timeout") { + return "timed out".into(); + } + if let Some(at) = err.find("HTTP ") { + let code: String = err[at + 5..] + .chars() + .take_while(char::is_ascii_digit) + .collect(); + if !code.is_empty() { + return format!("HTTP {code}"); + } + } + if lower.contains("restricted key") { + return "key lacks this scope".into(); + } + if lower.starts_with("could not reach") { + return "offline".into(); + } + // Anything else: its first clause, which is the subject of the error. + let first = err.split([':', '.']).next().unwrap_or(err).trim(); + if first.is_empty() { + "error".into() + } else { + first.to_string() + } +} + /// `2026-10-27T15:31:41Z` → `2026-10-27 15:31 UTC`; anything else unchanged. fn short_time(ts: &str) -> String { match (ts.get(..10), ts.get(11..16)) { @@ -186,7 +229,10 @@ pub(crate) fn identity_from_me(me: Result) -> Identity { Err(err) => ( None, None, - Some(format!("name and email unavailable: {err}")), + Some(format!( + "name and email unavailable ({})", + short_reason(&err) + )), ), } } @@ -229,6 +275,7 @@ fn build_view( let identity_error = me.as_ref().err().cloned(); let username = me.as_ref().ok().and_then(|m| m.username.clone()); let balance = me.as_ref().ok().and_then(|m| m.balance); + let plan_name = me.as_ref().ok().and_then(|m| m.plan.clone()); let (name, email, identity_note) = identity_from_me(me); Ok(StatusView { account, @@ -236,6 +283,7 @@ fn build_view( email, username, balance, + plan_name, identity_note, identity_error, key_error: key_info.as_ref().err().cloned(), @@ -272,6 +320,16 @@ pub(crate) fn run_whoami( return Ok(0); } print!("{}", view.render()); + if parsed.flag_true("verbose") { + for (what, err) in [ + ("account lookup", &view.identity_error), + ("key lookup", &view.key_error), + ] { + if let Some(err) = err { + eprintln!("{}", term::dim(&format!("{what}: {err}"))); + } + } + } if term::machine_output(parsed, env) || !term::is_interactive() { return Ok(0); } @@ -476,12 +534,65 @@ mod tests { let mut v = view(Some("a"), None); v.key_error = Some("HTTP 500".into()); let out = v.render(); - assert!(out.contains("plan —"), "{out}"); - assert!(out.contains("unavailable: HTTP 500"), "{out}"); + // WHY: a bare dash read as "no plan" on a paid account; an unknown + // plan is left out instead. + assert!(!out.contains("plan"), "{out}"); + assert!(out.contains("unavailable (HTTP 500)"), "{out}"); assert_eq!(v.json()["key_error"], "HTTP 500"); assert!(v.json()["plan"].is_null()); } + #[test] + fn a_timed_out_lookup_is_one_short_row() { + // The exact error 0.1.34 printed into the scopes row on a paid account. + let err = "Could not reach AnyRouter: https://anyrouter.dev/api/v1/key: Network Error: Network Error: Error encountered in the status line: timed out reading response"; + let mut v = view(Some("duyet"), Some("me@duyet.net")); + v.balance = Some(133.83); + v.key_error = Some(err.into()); + let out = v.render(); + assert!(out.contains("scopes unavailable (timed out)"), "{out}"); + assert!(!out.contains("Network Error"), "{out}"); + // WHY: a bare dash read as "no plan" on a paid account. + assert!(!out.contains("plan"), "{out}"); + assert!(!out.contains('—'), "{out}"); + // The full error stays available to scripts. + assert_eq!(v.json()["key_error"], err); + } + + #[test] + fn short_reason_names_the_cause_in_a_few_words() { + for (err, want) in [ + ( + "Could not reach AnyRouter: https://x/api/v1/key: timed out", + "timed out", + ), + ( + "Could not reach AnyRouter: https://x/api/v1/key: could not connect", + "offline", + ), + ("Could not fetch key info (HTTP 500).", "HTTP 500"), + ( + "Invalid /key response: expected value", + "Invalid /key response", + ), + ] { + assert_eq!(short_reason(err), want, "{err}"); + } + } + + #[test] + fn plan_prefers_the_server_plan_name_over_the_free_flag() { + let mut v = view(Some("a"), None); + v.key = Some(KeyInfo { + is_free_tier: Some(false), + ..Default::default() + }); + assert_eq!(v.plan().as_deref(), Some("paid")); + v.plan_name = Some("payg".into()); + assert_eq!(v.plan().as_deref(), Some("payg")); + assert!(v.render().contains("plan payg"), "{}", v.render()); + } + #[test] fn missing_identity_says_why() { let (n, e, note) = identity_from_me(Err("HTTP 401".into())); diff --git a/src/http.rs b/src/http.rs index dbb490a..b45c442 100644 --- a/src/http.rs +++ b/src/http.rs @@ -36,6 +36,41 @@ fn with_auth(mut req: ureq::Request, api_key: Option<&str>) -> ureq::Request { req } +/// `Could not reach : : ` for a request that got no +/// HTTP answer. ureq nests an I/O error inside its own kind, so its text +/// repeats itself ("Network Error: Network Error: …"); name the cause once. +#[cfg(feature = "native")] +fn unreachable(service: &str, err: &ureq::Error) -> String { + let ureq::Error::Transport(t) = err else { + return format!("Could not reach {service}: {err}"); + }; + let full = err.to_string(); + let reason = if full.to_ascii_lowercase().contains("timed out") { + "timed out".to_string() + } else { + match t.kind() { + ureq::ErrorKind::Dns => "DNS lookup failed".to_string(), + ureq::ErrorKind::ConnectionFailed => "could not connect".to_string(), + _ => { + let deepest = std::error::Error::source(err) + .map(|e| e.to_string()) + .or_else(|| t.message().map(str::to_string)) + .unwrap_or_else(|| t.kind().to_string()); + let kind = format!("{}: ", t.kind()); + let mut d = deepest.as_str(); + while let Some(rest) = d.strip_prefix(&kind) { + d = rest; + } + d.to_string() + } + } + }; + match t.url() { + Some(url) => format!("Could not reach {service}: {url}: {reason}"), + None => format!("Could not reach {service}: {reason}"), + } +} + #[cfg(feature = "native")] fn into_status_body(result: Result) -> Result<(u16, String), String> { match result { @@ -48,7 +83,7 @@ fn into_status_body(result: Result) -> Result<(u16, let body = resp.into_string().unwrap_or_default(); Ok((code, body)) } - Err(err) => Err(format!("Could not reach AnyRouter: {err}")), + Err(err) => Err(unreachable("AnyRouter", &err)), } } @@ -82,7 +117,7 @@ fn into_status_body_labeled( let body = resp.into_string().unwrap_or_default(); Ok((code, body)) } - Err(err) => Err(format!("Could not reach {service}: {err}")), + Err(err) => Err(unreachable(service, &err)), } } @@ -144,7 +179,7 @@ pub fn http_post_stream_headers( let resp = match req.send_string(json_body) { Ok(resp) => resp, Err(ureq::Error::Status(_, resp)) => resp, - Err(err) => return Err(format!("Could not reach AnyRouter: {err}")), + Err(err) => return Err(unreachable("AnyRouter", &err)), }; let status = resp.status(); let headers = resp @@ -209,7 +244,7 @@ pub fn http_post_json_headers( let resp = match req.send_string(json_body) { Ok(r) => r, Err(ureq::Error::Status(_, r)) => r, - Err(err) => return Err(format!("Could not reach AnyRouter: {err}")), + Err(err) => return Err(unreachable("AnyRouter", &err)), }; let status = resp.status(); let headers = resp @@ -658,6 +693,8 @@ pub struct MeInfo { pub name: Option, pub username: Option, pub balance: Option, + /// Plan name (`free`, `payg`, `enterprise`), once the server sends it. + pub plan: Option, } impl MeInfo { @@ -694,6 +731,12 @@ pub fn parse_me(body: &str) -> Result { .and_then(|v| v.as_str()) .map(str::to_string), balance: value.get("balance").and_then(|v| v.as_f64()), + plan: value + .get("plan") + .and_then(|v| v.as_str()) + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_string), }) } @@ -997,6 +1040,37 @@ mod tests { use super::*; use crate::config::DEFAULT_BASE_URL; + #[cfg(feature = "native")] + #[test] + fn a_server_that_never_answers_reads_timed_out_once() { + // WHY: ureq nests its I/O error in its own kind, and the status row + // printed "Network Error: Network Error: Error encountered in the + // status line: timed out reading response". + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let url = format!("http://{}/api/v1/key", listener.local_addr().unwrap()); + let hold = std::thread::spawn(move || listener.accept().map(|(s, _)| s)); + let err = http_get_timeout(&url, None, std::time::Duration::from_millis(300)).unwrap_err(); + assert_eq!(err, format!("Could not reach AnyRouter: {url}: timed out")); + drop(hold.join()); + } + + #[cfg(feature = "native")] + #[test] + fn a_refused_connection_says_so_without_repeating_itself() { + let err = http_get("http://127.0.0.1:9/api/v1/key", None).unwrap_err(); + assert!(err.starts_with("Could not reach AnyRouter: "), "{err}"); + assert!(!err.contains("Network Error: Network Error"), "{err}"); + assert!(err.ends_with("could not connect"), "{err}"); + } + + #[test] + fn me_reads_the_plan_when_the_server_sends_it() { + let me = parse_me(r#"{"email":"a@b.c","balance":1,"plan":"payg"}"#).unwrap(); + assert_eq!(me.plan.as_deref(), Some("payg")); + let me = parse_me(r#"{"email":"a@b.c","plan":""}"#).unwrap(); + assert_eq!(me.plan, None); + } + #[test] fn join_api_appends_v1_like_js_create_client() { assert_eq!( diff --git a/src/relay/live.rs b/src/relay/live.rs index cc705ac..e273260 100644 --- a/src/relay/live.rs +++ b/src/relay/live.rs @@ -437,8 +437,10 @@ fn draw(s: &Snapshot) { let p = Paint(crate::term::color_enabled()); let body = view::render_running(s, state::now_secs(), p) + &view::render_live_footer(&events(), p); - // Raw mode: every line needs an explicit carriage return. - let frame = format!("\x1b[H\x1b[J{}", body.replace('\n', "\r\n")); + // Raw mode: the shared writer ends each line in `\r\n` and cuts it to + // the width, so nothing wraps. + let width = crossterm::terminal::size().ok().map(|(w, _)| w as usize); + let frame = format!("\x1b[H\x1b[J{}", crate::term::raw_frame_bytes(&body, width)); let mut out = std::io::stdout(); let _ = out.write_all(frame.as_bytes()); let _ = out.flush(); diff --git a/src/term.rs b/src/term.rs index 0f48322..16af713 100644 --- a/src/term.rs +++ b/src/term.rs @@ -21,12 +21,121 @@ pub const MUTED: &str = "\x1b[38;2;108;108;108m"; pub const LABEL: &str = "\x1b[38;2;200;200;200m"; pub fn color_enabled() -> bool { - if std::env::var_os("NO_COLOR").is_some() { + if std::env::var_os("NO_COLOR").is_some() || dumb_terminal() { return false; } io::stdout().is_terminal() } +/// `TERM=dumb`: the terminal cannot take colors or cursor moves. +pub fn dumb_terminal() -> bool { + std::env::var("TERM").is_ok_and(|t| t.trim() == "dumb") +} + +/// Columns of the terminal on stdout, else `COLUMNS`. `None` for a pipe, +/// where nothing wraps. +pub fn width() -> Option { + #[cfg(feature = "native")] + if io::stdout().is_terminal() { + if let Ok((w, _)) = crossterm::terminal::size() { + if w > 0 { + return Some(w as usize); + } + } + } + std::env::var("COLUMNS") + .ok() + .and_then(|s| s.trim().parse().ok()) + .filter(|n: &usize| *n > 0) +} + +/// Display columns of plain text (wide characters count two). +pub fn text_width(s: &str) -> usize { + unicode_width::UnicodeWidthStr::width(s) +} + +/// Cut plain text to `max` display columns, ending in `…` when cut. +pub fn truncate(s: &str, max: usize) -> String { + use unicode_width::UnicodeWidthChar; + if text_width(s) <= max { + return s.to_string(); + } + if max == 0 { + return String::new(); + } + let mut out = String::new(); + let mut used = 0; + for c in s.chars() { + let w = c.width().unwrap_or(0); + if used + w > max - 1 { + break; + } + out.push(c); + used += w; + } + out.push('…'); + out +} + +/// Cut a line that may hold color codes to `max` visible columns. Escape +/// sequences pass through untouched and do not count; a cut line ends in +/// `…` plus a reset so the color does not bleed into the next line. +pub fn truncate_ansi(s: &str, max: usize) -> String { + use unicode_width::UnicodeWidthChar; + if text_width(&strip_ansi(s)) <= max { + return s.to_string(); + } + let mut out = String::new(); + let mut used = 0; + let mut chars = s.chars().peekable(); + while let Some(c) = chars.next() { + if c == '\x1b' { + out.push(c); + if chars.peek() == Some(&'[') { + for n in chars.by_ref() { + out.push(n); + if n.is_ascii_alphabetic() { + break; + } + } + } + continue; + } + let w = c.width().unwrap_or(0); + if used + w > max.saturating_sub(1) { + break; + } + out.push(c); + used += w; + } + if max > 0 { + out.push('…'); + } + if out.contains('\x1b') { + out.push_str(RESET); + } + out +} + +/// Drop `ESC [ … letter` sequences. +pub fn strip_ansi(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + let mut chars = s.chars().peekable(); + while let Some(c) = chars.next() { + if c == '\x1b' && chars.peek() == Some(&'[') { + chars.next(); + for n in chars.by_ref() { + if n.is_ascii_alphabetic() { + break; + } + } + continue; + } + out.push(c); + } + out +} + pub fn paint(ansi: &str, text: &str) -> String { if !color_enabled() { return text.to_string(); @@ -344,34 +453,70 @@ pub fn tool_color(tool: &str) -> &'static str { } } -/// Plain `label value` lines from column 0: labels dimmed and padded to one -/// column, groups split by one thin line. No frame. NO_COLOR drops the dim. -pub fn label_rows(groups: &[&[(&str, String)]]) -> String { +/// One line of a label/value block: a row, or the thin line between groups. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum LabelLine { + /// `label` is padded to the block's label column; `value` is already cut + /// to the width. + Row { + label: String, + value: String, + }, + Divider(usize), +} + +/// Lay out `label value` rows from column 0: labels padded to one column, +/// groups split by one thin line, every value cut with `…` so no row is wider +/// than `width`. Empty groups add nothing. +pub fn label_lines(groups: &[&[(&str, String)]], width: Option) -> Vec { let rows = || groups.iter().flat_map(|g| g.iter()); - let label_w = rows().map(|(l, _)| l.chars().count()).max().unwrap_or(0); + let label_w = rows().map(|(l, _)| text_width(l)).max().unwrap_or(0); + let value_max = width.map(|w| w.saturating_sub(label_w + 2)); let line_w = rows() - .map(|(_, v)| label_w + 2 + v.chars().count()) + .map(|(_, v)| label_w + 2 + text_width(v)) .max() .unwrap_or(0) - .min(60); - let mut out = String::new(); + .min(60) + .min(width.unwrap_or(usize::MAX)); + let mut out = Vec::new(); for (i, group) in groups.iter().filter(|g| !g.is_empty()).enumerate() { if i > 0 { - out.push_str(÷r(line_w)); - out.push('\n'); + out.push(LabelLine::Divider(line_w)); } for (label, value) in group.iter() { - out.push_str(&dim(&format!("{label: truncate(value, max), + None => value.clone(), + }, + }); } } out } +/// `label_lines` for the current terminal, painted: labels dimmed, a thin +/// line between groups. No frame. NO_COLOR drops the dim. +pub fn label_rows(groups: &[&[(&str, String)]]) -> String { + let mut out = String::new(); + for line in label_lines(groups, width()) { + match line { + LabelLine::Row { label, value } => { + out.push_str(&dim(&label)); + out.push_str(" "); + out.push_str(&value); + } + LabelLine::Divider(w) => out.push_str(÷r(w)), + } + out.push('\n'); + } + out +} + pub fn divider(width: usize) -> String { - dim(&"─".repeat(width.max(8))) + dim(&"─".repeat(width)) } pub fn is_interactive() -> bool { @@ -507,8 +652,9 @@ pub fn render_inline_menu( } for (i, item) in items.iter().enumerate() { let n = i + 1; + // The `›` marks the row without color, so NO_COLOR still shows it. if i == cursor { - lines.push(format!(" {} {}", accent(&format!("{n}.")), item)); + lines.push(format!("{} {}", accent(&format!("› {n}.")), item)); } else { lines.push(format!(" {n}. {item}")); } @@ -532,12 +678,44 @@ pub fn pick_inline( return Err("Nothing to pick.".into()); } #[cfg(feature = "native")] - if is_interactive() { + if is_interactive() && !dumb_terminal() { return pick_inline_raw(header, question, items, footer, current); } pick_numbered("anyr", items, current) } +/// Write a frame while raw mode is on. Raw mode turns off the terminal's +/// newline translation, so a bare `\n` moves down without going back to +/// column 0 and every line starts where the last one ended. Each line ends in +/// `\r\n` and is cut to the terminal width: a wrapped line would also throw +/// off the line count the next redraw moves up by. Returns that count. +#[cfg(feature = "native")] +fn write_raw_frame(out: &mut impl Write, frame: &str) -> Result { + let width = crossterm::terminal::size() + .ok() + .map(|(w, _)| w as usize) + .filter(|w| *w > 0); + let bytes = raw_frame_bytes(frame, width); + out.write_all(bytes.as_bytes()).map_err(|e| e.to_string())?; + out.flush().map_err(|e| e.to_string())?; + Ok(frame.lines().count().max(1) as u16) +} + +/// The bytes `write_raw_frame` sends: each line cut to `width`, then `\r\n`. +pub fn raw_frame_bytes(frame: &str, width: Option) -> String { + let mut out = String::with_capacity(frame.len() + 16); + for line in frame.lines() { + match width { + // One column spare: writing the last column parks the cursor in + // a pending-wrap state some terminals resolve as a new line. + Some(w) => out.push_str(&truncate_ansi(line, w.saturating_sub(1))), + None => out.push_str(line), + } + out.push_str("\r\n"); + } + out +} + #[cfg(feature = "native")] fn pick_inline_raw( header: &[String], @@ -546,7 +724,7 @@ fn pick_inline_raw( footer: &str, current: Option, ) -> Result { - use std::io::{stderr, Write as _}; + use std::io::stderr; use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers}; use crossterm::terminal::{self, Clear, ClearType}; @@ -569,7 +747,6 @@ fn pick_inline_raw( let mut last_lines: u16 = 0; loop { let frame = render_inline_menu(header, question, items, cursor_i, footer); - let n = frame.lines().count().max(1) as u16; if last_lines > 0 { queue!( out, @@ -579,9 +756,7 @@ fn pick_inline_raw( ) .map_err(|e| e.to_string())?; } - writeln!(out, "{frame}").map_err(|e| e.to_string())?; - out.flush().map_err(|e| e.to_string())?; - last_lines = n; + last_lines = write_raw_frame(&mut out, &frame)?; match event::read().map_err(|e| e.to_string())? { Event::Key(ev) if ev.kind == KeyEventKind::Press => match ev.code { @@ -646,7 +821,7 @@ pub fn pick_checkboxes( .map(|i| checked.get(i).copied().unwrap_or(false)) .collect(); #[cfg(feature = "native")] - if is_interactive() && !items.is_empty() { + if is_interactive() && !dumb_terminal() && !items.is_empty() { return pick_checkboxes_raw(question, items, state); } Ok(state) @@ -658,7 +833,7 @@ fn pick_checkboxes_raw( items: &[String], mut state: Vec, ) -> Result, String> { - use std::io::{stderr, Write as _}; + use std::io::stderr; use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers}; use crossterm::terminal::{self, Clear, ClearType}; @@ -690,10 +865,7 @@ fn pick_checkboxes_raw( ) .map_err(|e| e.to_string())?; } - // Raw mode: lines need an explicit carriage return. - write!(out, "{}\r\n", frame.replace('\n', "\r\n")).map_err(|e| e.to_string())?; - out.flush().map_err(|e| e.to_string())?; - last_lines = frame.lines().count().max(1) as u16; + last_lines = write_raw_frame(&mut out, &frame)?; if let Event::Key(ev) = event::read().map_err(|e| e.to_string())? { if ev.kind != KeyEventKind::Press { @@ -945,6 +1117,43 @@ pub fn rank_ids(query: &str, ids: &[String]) -> Vec { #[cfg(test)] mod tests { + #[test] + fn raw_frames_return_to_column_zero_on_every_line() { + // WHY: raw mode turns off the terminal's newline translation; a bare + // `\n` left each menu row starting where the last one ended. + let out = super::raw_frame_bytes(" 1. Switch account\n 2. Log out", Some(80)); + assert_eq!(out, " 1. Switch account\r\n 2. Log out\r\n"); + assert!(!out.replace("\r\n", "").contains('\n'), "{out:?}"); + } + + #[test] + fn raw_frames_cut_lines_to_the_width_and_keep_color_codes_whole() { + let line = format!("{}{}", "\x1b[2m", "x".repeat(100)); + let out = super::raw_frame_bytes(&line, Some(40)); + let row = out.trim_end_matches("\r\n"); + assert_eq!(super::text_width(&super::strip_ansi(row)), 39, "{row:?}"); + assert!( + row.starts_with("\x1b[2m") && row.ends_with(super::RESET), + "{row:?}" + ); + } + + #[test] + fn label_lines_cut_values_to_the_width_with_an_ellipsis() { + let long = "y".repeat(300); + let rows = [("scopes", long), ("key", "sk-ar-v1-ab…wxyz".to_string())]; + for width in [40, 80, 200] { + for line in super::label_lines(&[&rows], Some(width)) { + if let super::LabelLine::Row { label, value } = line { + let w = super::text_width(&label) + 2 + super::text_width(&value); + assert!(w <= width, "{width}: {label}{value}"); + } + } + } + let lines = super::label_lines(&[&rows], Some(40)); + assert!(matches!(&lines[0], super::LabelLine::Row { value, .. } if value.ends_with('…'))); + } + #[test] fn label_rows_align_one_column_and_split_groups_with_one_line() { let a = [("account", "duyet".to_string()), ("plan", "free".into())]; diff --git a/src/tui/theme.rs b/src/tui/theme.rs index 66fd450..abc87bd 100644 --- a/src/tui/theme.rs +++ b/src/tui/theme.rs @@ -6,55 +6,57 @@ pub fn rgb(r: u8, g: u8, b: u8) -> Color { Color::Rgb(r, g, b) } -/// Dim backdrop behind the centered launcher dialog. -pub fn backdrop_rgb() -> Color { - rgb(12, 12, 14) +/// `NO_COLOR` or `TERM=dumb`: styles keep bold and the selection, drop color. +fn plain() -> bool { + static PLAIN: std::sync::OnceLock = std::sync::OnceLock::new(); + *PLAIN.get_or_init(|| std::env::var_os("NO_COLOR").is_some() || crate::term::dumb_terminal()) } -/// Dialog card surface (slightly lifted from backdrop). -pub fn surface_rgb() -> Color { - rgb(22, 22, 26) +fn fg(c: Color) -> Style { + if plain() { + Style::default() + } else { + Style::default().fg(c) + } } pub fn brand() -> Style { - Style::default() - .fg(rgb(246, 130, 31)) - .add_modifier(Modifier::BOLD) + fg(rgb(246, 130, 31)).add_modifier(Modifier::BOLD) } pub fn accent() -> Style { - Style::default() - .fg(rgb(230, 160, 60)) - .add_modifier(Modifier::BOLD) + fg(rgb(230, 160, 60)).add_modifier(Modifier::BOLD) } pub fn muted() -> Style { - Style::default().fg(rgb(108, 108, 108)) + fg(rgb(108, 108, 108)) } pub fn success() -> Style { - Style::default() - .fg(rgb(158, 206, 106)) - .add_modifier(Modifier::BOLD) + fg(rgb(158, 206, 106)).add_modifier(Modifier::BOLD) } pub fn model() -> Style { - Style::default().fg(rgb(58, 149, 171)) + fg(rgb(58, 149, 171)) } pub fn white() -> Style { - Style::default().fg(rgb(225, 225, 225)) + fg(rgb(225, 225, 225)) } pub fn selected() -> Style { - Style::default() - .fg(rgb(225, 225, 225)) - .bg(rgb(46, 46, 51)) - .add_modifier(Modifier::BOLD) + let style = if plain() { + Style::default() + } else { + fg(rgb(225, 225, 225)).bg(rgb(46, 46, 51)) + }; + style.add_modifier(Modifier::BOLD) +} + +pub fn warn() -> Style { + fg(rgb(230, 160, 60)) } pub fn title() -> Style { - Style::default() - .fg(rgb(225, 225, 225)) - .add_modifier(Modifier::BOLD) + fg(rgb(225, 225, 225)).add_modifier(Modifier::BOLD) } diff --git a/src/tui/view.rs b/src/tui/view.rs index 2fd7bc4..6e3f6f3 100644 --- a/src/tui/view.rs +++ b/src/tui/view.rs @@ -1,226 +1,297 @@ //! Ratatui widgets + ANSI-free plain frames for dump / tests. //! -//! The launcher / config menu renders as a **centered dialog card** (title, -//! status, actions) — not a full-bleed two-pane sprawl. Pickers stay -//! single-pane and fill the terminal. All plain_* dumps remain ANSI-free for CI. +//! Every screen draws from the top-left as plain text: `label value` status +//! rows, one thin line, the list, and a one-line key hint. No frame, no +//! backdrop, no logo, no centering. All plain_* dumps stay ANSI-free for CI +//! and show the same lines the live screen draws. use ratatui::layout::{Constraint, Direction, Layout, Rect}; -use ratatui::style::{Color, Style}; use ratatui::text::{Line, Span}; -use ratatui::widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph}; +use ratatui::widgets::{List, ListItem, ListState, Paragraph}; use ratatui::Frame; use super::state::{ MenuState, PaletteEntry, PaletteState, PickerState, SettingRow, SettingsState, Tone, }; use super::theme; +use crate::term::{label_lines, text_width, truncate, LabelLine}; -/// Preferred dialog width; shrinks on narrow terminals. -const DIALOG_PREF_WIDTH: u16 = 56; -/// Minimum usable dialog width before we fill almost the whole terminal. -const DIALOG_MIN_WIDTH: u16 = 28; -/// Settings screen is wider — model ids need room next to their labels. -const SETTINGS_PREF_WIDTH: u16 = 68; -/// Inner inset (cols / rows) so content isn't flush against the border. -const INSET_X: u16 = 1; -const INSET_Y: u16 = 1; +/// The thin line under the status rows is never wider than this. +const RULE_MAX: usize = 60; + +/// Plain dumps never lay out narrower than this. +const DUMP_MIN_COLS: usize = 20; + +/// `label value` header strings as rows. A line with no double space has no +/// label and prints whole. +fn status_pairs(header: &[String]) -> Vec<(&str, String)> { + header + .iter() + .map(|h| match h.split_once(" ") { + Some((k, v)) => (k.trim(), v.trim_start().to_string()), + None => ("", h.clone()), + }) + .collect() +} + +/// Status rows as (label, value) text cut to `width`, via the shared +/// `label_lines` layout so every screen aligns the same way. +fn status_text(header: &[String], width: usize) -> Vec<(String, String)> { + if header.is_empty() { + return vec![(String::new(), "—".into())]; + } + let pairs = status_pairs(header); + let labeled: Vec<(&str, String)> = pairs + .iter() + .filter(|(k, _)| !k.is_empty()) + .cloned() + .collect(); + let mut laid = label_lines(&[&labeled], Some(width)) + .into_iter() + .filter_map(|l| match l { + LabelLine::Row { label, value } => Some((label, value)), + LabelLine::Divider(_) => None, + }); + pairs + .iter() + .map(|(k, v)| { + if k.is_empty() { + (String::new(), truncate(v, width)) + } else { + laid.next().unwrap_or_default() + } + }) + .collect() +} + +fn status_lines(header: &[String], width: usize) -> Vec> { + status_text(header, width) + .into_iter() + .map(|(label, value)| { + if label.is_empty() { + Line::from(Span::styled(value, theme::white())) + } else { + Line::from(vec![ + Span::styled(label, theme::muted()), + Span::raw(" "), + Span::styled(value, theme::white()), + ]) + } + }) + .collect() +} + +fn status_plain(header: &[String], width: usize) -> Vec { + status_text(header, width) + .into_iter() + .map(|(label, value)| { + if label.is_empty() { + value + } else { + format!("{label} {value}") + } + }) + .collect() +} + +fn rule_text(width: usize) -> String { + "─".repeat(width.min(RULE_MAX)) +} + +fn rule(width: u16) -> Paragraph<'static> { + Paragraph::new(Span::styled(rule_text(width as usize), theme::muted())) +} + +fn hint(text: &str, width: u16) -> Paragraph<'static> { + Paragraph::new(Span::styled(truncate(text, width as usize), theme::muted())) +} + +fn title_line(title: &str) -> Line<'static> { + Line::from(Span::styled(title.to_string(), theme::title())) +} + +/// The top-left block a screen draws in: full width, as tall as its content. +fn top_left(area: Rect, height: u16) -> Rect { + Rect::new(area.x, area.y, area.width, height.min(area.height).max(1)) +} + +fn split(area: Rect, rows: &[u16], grow: usize) -> Vec { + let constraints: Vec = rows + .iter() + .enumerate() + .map(|(i, h)| { + if i == grow { + Constraint::Min(*h) + } else { + Constraint::Length(*h) + } + }) + .collect(); + Layout::default() + .direction(Direction::Vertical) + .constraints(constraints) + .split(area) + .to_vec() +} + +/// Column where values start: two spaces past the widest label. +fn value_column(labels: impl Iterator) -> usize { + labels.max().unwrap_or(0) + 2 +} + +// Picker: title, header, search, line, list, gap, hint. +const PK_HEAD: usize = 0; +const PK_SEARCH: usize = 1; +const PK_RULE: usize = 2; +const PK_LIST: usize = 3; +const PK_HINT: usize = 5; + +fn picker_chunks(area: Rect, state: &PickerState) -> Vec { + split( + area, + &[1 + state.header.len() as u16, 1, 1, 1, 1, 1], + PK_LIST, + ) +} pub fn render_picker(frame: &mut Frame, state: &PickerState) { let area = frame.area(); - let area = inset(area, 1, 0); - let chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Length(1 + state.header.len() as u16), - Constraint::Length(1), - Constraint::Length(3), - Constraint::Length(1), - Constraint::Min(3), - Constraint::Length(1), - Constraint::Length(1), - ]) - .split(area); - - render_header(frame, chunks[0], &state.title, &state.header); + let chunks = picker_chunks(area, state); + let width = area.width as usize; + + let mut head = vec![title_line(&state.title)]; + for h in &state.header { + head.push(Line::from(Span::styled(truncate(h, width), theme::muted()))); + } + frame.render_widget(Paragraph::new(head), chunks[PK_HEAD]); let search = Paragraph::new(Line::from(vec![ Span::styled("⌕ ", theme::accent()), - Span::styled(state.query.as_str(), theme::white()), + Span::styled(state.query.clone(), theme::white()), Span::styled("█", theme::accent()), - ])) - .block( - Block::default() - .borders(Borders::ALL) - .border_style(theme::muted()) - .title(Span::styled(" search ", theme::muted())), - ); - frame.render_widget(search, chunks[2]); + ])); + frame.render_widget(search, chunks[PK_SEARCH]); + frame.render_widget(rule(area.width), chunks[PK_RULE]); let filtered = state.filtered(); let items: Vec = filtered .iter() .enumerate() .map(|(i, (_, label))| { - let style = if i == state.cursor { - theme::selected() - } else { - theme::white() - }; + let selected = i == state.cursor; + let icon = item_icon(label); + let room = width.saturating_sub(2 + text_width(icon)); ListItem::new(Line::from(vec![ - Span::styled(if i == state.cursor { "❯ " } else { " " }, theme::accent()), - Span::styled(item_icon(label), item_icon_style(label)), - Span::styled(label.to_string(), style), + Span::styled(if selected { "❯ " } else { " " }, theme::accent()), + Span::styled(icon, item_icon_style(label)), + Span::styled( + truncate(label, room), + if selected { + theme::selected() + } else { + theme::white() + }, + ), ])) }) .collect(); - - let list = List::new(items).block( - Block::default() - .borders(Borders::ALL) - .border_style(theme::muted()) - .title(Span::styled(format!(" {} ", state.title), theme::title())), - ); let mut list_state = ListState::default() .with_selected(Some(state.cursor.min(filtered.len().saturating_sub(1)))); - frame.render_stateful_widget(list, chunks[4], &mut list_state); + frame.render_stateful_widget(List::new(items), chunks[PK_LIST], &mut list_state); + frame.render_widget(hint(state.hint(), area.width), chunks[PK_HINT]); +} + +// Menu: title, status, line, items, gap, hint. +const MN_TITLE: usize = 0; +const MN_STATUS: usize = 1; +const MN_RULE: usize = 2; +const MN_LIST: usize = 3; +const MN_HINT: usize = 5; - let footer = Paragraph::new(Span::styled(state.hint(), theme::muted())); - frame.render_widget(footer, chunks[6]); +fn menu_chunks(area: Rect, state: &MenuState) -> Vec { + let status = state.header.len().max(1) as u16; + let items = state.items.len().max(1) as u16; + let rows = [1, status, 1, items, 1, 1]; + let height = rows.iter().sum(); + split(top_left(area, height), &rows, MN_LIST) } pub fn render_menu(frame: &mut Frame, state: &MenuState) { let area = frame.area(); - - // Dim full-screen backdrop so the card reads as a modal. + let chunks = menu_chunks(area, state); + let width = area.width as usize; + frame.render_widget(Paragraph::new(title_line(&state.title)), chunks[MN_TITLE]); frame.render_widget( - Block::default().style(Style::default().bg(theme::backdrop_rgb()).fg(Color::Reset)), - area, + Paragraph::new(status_lines(&state.header, width)), + chunks[MN_STATUS], ); - - let dialog = centered_dialog(area, dialog_height(state), DIALOG_PREF_WIDTH); - frame.render_widget(Clear, dialog); - - let block = Block::default() - .borders(Borders::ALL) - .border_style(theme::brand()) - .title(Span::styled(format!(" ▲ {} ", state.title), theme::brand())) - .style(Style::default().bg(theme::surface_rgb())); - let inner = inset(block.inner(dialog), INSET_X, INSET_Y); - frame.render_widget(block, dialog); - - // status | pad | rule | pad | actions | pad | hint - let status_h = state.header.len().max(1) as u16; - let chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Length(status_h), - Constraint::Length(1), - Constraint::Length(1), - Constraint::Length(1), - Constraint::Min(state.items.len().max(1) as u16), - Constraint::Length(1), - Constraint::Length(1), - ]) - .split(inner); - - render_status_lines(frame, chunks[0], &state.header); - - let rule = Paragraph::new(Span::styled( - "─".repeat(chunks[2].width as usize), - theme::muted(), - )); - frame.render_widget(rule, chunks[2]); + frame.render_widget(rule(area.width), chunks[MN_RULE]); let items: Vec = state .items .iter() .enumerate() .map(|(i, label)| { - let style = if i == state.cursor { - theme::selected() - } else { - theme::white() - }; + let selected = i == state.cursor; + let icon = item_icon(label); + let room = width.saturating_sub(2 + text_width(icon)); ListItem::new(Line::from(vec![ - Span::styled(if i == state.cursor { "❯ " } else { " " }, theme::accent()), - Span::styled(item_icon(label), item_icon_style(label)), - Span::styled(label.as_str(), style), + Span::styled(if selected { "❯ " } else { " " }, theme::accent()), + Span::styled(icon, item_icon_style(label)), + Span::styled( + truncate(label, room), + if selected { + theme::selected() + } else { + theme::white() + }, + ), ])) }) .collect(); - - let list = List::new(items); let mut list_state = ListState::default().with_selected(Some(state.cursor)); - frame.render_stateful_widget(list, chunks[4], &mut list_state); - - let footer = Paragraph::new(Span::styled(state.hint(), theme::muted())); - frame.render_widget(footer, chunks[6]); -} - -fn dialog_height(state: &MenuState) -> u16 { - // borders(2) + inset(2) + status + pads(3) + rule(1) + items + hint(1) - let status = state.header.len().max(1) as u16; - let items = state.items.len().max(1) as u16; - 2 + 2 + status + 3 + 1 + items + 1 + frame.render_stateful_widget(List::new(items), chunks[MN_LIST], &mut list_state); + frame.render_widget(hint(state.hint(), area.width), chunks[MN_HINT]); } -/// Palette preferred width — AR mark + status captions need room. -const PALETTE_PREF_WIDTH: u16 = 72; +// Palette: status, line, input, results, gap, hint. +const PL_STATUS: usize = 0; +const PL_RULE: usize = 1; +const PL_INPUT: usize = 2; +const PL_RESULTS: usize = 3; +const PL_HINT: usize = 5; -/// Command palette: floating input on top, fuzzy results below, groups -/// rendered only where they change. Same centered-card language as the menu. +/// Command palette (the launcher): account rows, a line, the filter input, +/// then agents and actions grouped under plain headers. pub fn render_palette(frame: &mut Frame, state: &PaletteState) { let area = frame.area(); - frame.render_widget( - Block::default().style(Style::default().bg(theme::backdrop_rgb()).fg(Color::Reset)), - area, - ); - - let (dialog, chunks, visible) = palette_card(area, state); - frame.render_widget(Clear, dialog); - - let block = Block::default() - .borders(Borders::ALL) - .border_style(theme::brand()) - .title(Span::styled(" anyr ", theme::brand())) - .style(Style::default().bg(theme::surface_rgb())); - frame.render_widget(block, dialog); + let (chunks, visible) = palette_chunks(area, state); + let width = area.width as usize; frame.render_widget( - Paragraph::new(palette_header_lines( - &state.header, - chunks[0].width as usize, - )), - chunks[0], - ); - frame.render_widget( - Paragraph::new(Span::styled( - "─".repeat(chunks[1].width as usize), - theme::muted(), - )), - chunks[1], + Paragraph::new(status_lines(&state.header, width)), + chunks[PL_STATUS], ); + frame.render_widget(rule(area.width), chunks[PL_RULE]); let input = Paragraph::new(Line::from(vec![ Span::styled("❯ ", theme::accent()), Span::styled(state.query.clone(), theme::white()), Span::styled("█", theme::accent()), ])); - frame.render_widget(input, chunks[2]); + frame.render_widget(input, chunks[PL_INPUT]); let filtered = state.filtered(); if filtered.is_empty() { frame.render_widget( Paragraph::new(Span::styled("no matches", theme::muted())), - chunks[4], + chunks[PL_RESULTS], ); } else { - let rows = palette_rows(state, &filtered, visible, chunks[4].width as usize); - frame.render_widget(Paragraph::new(rows), chunks[4]); + let rows = palette_rows(state, &filtered, visible, width); + frame.render_widget(Paragraph::new(rows), chunks[PL_RESULTS]); } - - let footer = Paragraph::new(Span::styled(state.hint(), theme::muted())); - frame.render_widget(footer, chunks[6]); + frame.render_widget(hint(state.hint(), area.width), chunks[PL_HINT]); } /// Number of distinct groups among the first `visible` filtered entries — @@ -240,15 +311,47 @@ fn palette_groups(state: &PaletteState, filtered: &[usize], visible: usize) -> u groups.len() } +/// One palette row as text parts: marker, icon, label padded to the value +/// column, detail cut to the width. +fn palette_row_parts( + entry: &PaletteEntry, + selected: bool, + label_col: usize, + width: usize, +) -> (&'static str, &'static str, String, String) { + let marker = if selected { "❯ " } else { " " }; + let icon = item_icon(&entry.label); + let lead = 2 + text_width(icon); + let label = truncate(&entry.label, width.saturating_sub(lead)); + let pad = label_col.saturating_sub(text_width(&label)).max(2); + let padded = format!("{label}{}", " ".repeat(pad)); + let room = width.saturating_sub(lead + text_width(&padded)); + let detail = if entry.detail.is_empty() || room == 0 { + String::new() + } else { + truncate(&entry.detail, room) + }; + (marker, icon, padded, detail) +} + +fn palette_label_col(state: &PaletteState, filtered: &[usize]) -> usize { + value_column( + filtered + .iter() + .map(|&i| text_width(&state.entries[i].label)), + ) +} + /// Build the result lines, inserting a group header whenever it changes and -/// right-aligning each row's detail column. +/// lining every detail up in one column. fn palette_rows( state: &PaletteState, filtered: &[usize], visible: usize, - inner_w: usize, + width: usize, ) -> Vec> { let (start, vis) = palette_window(state.cursor, filtered.len(), visible.max(1)); + let label_col = palette_label_col(state, filtered); let mut rows: Vec = Vec::new(); let mut last_group: Option<&str> = None; for (row_i, &entry_i) in filtered.iter().skip(start).take(vis).enumerate() { @@ -259,99 +362,79 @@ fn palette_rows( } if !entry.group.is_empty() { rows.push(Line::from(Span::styled( - format!(" {}", entry.group.to_ascii_uppercase()), + truncate(&format!(" {}", entry.group), width), theme::muted(), ))); } last_group = Some(&entry.group); } let selected = start + row_i == state.cursor; - let marker_style = if selected { - theme::accent() - } else { - theme::muted() - }; - let label = entry.label.clone(); - let icon = item_icon(&label); - // Right-align the detail: pad between label and detail like the - // settings screen pads its value column. - let used = 2 + icon.chars().count() + label.chars().count() + entry.detail.chars().count(); - let gap = inner_w.saturating_sub(used).max(1); - let marker: String = if selected { "❯ ".into() } else { " ".into() }; + let (marker, icon, label, detail) = palette_row_parts(entry, selected, label_col, width); rows.push(Line::from(vec![ - Span::styled(marker, marker_style), - Span::styled(icon.to_string(), item_icon_style(&label)), Span::styled( - format!("{label}{}", " ".repeat(gap)), + marker, + if selected { + theme::accent() + } else { + theme::muted() + }, + ), + Span::styled(icon, item_icon_style(&entry.label)), + Span::styled( + label, if selected { theme::selected() } else { theme::white() }, ), - Span::styled(entry.detail.clone(), theme::muted()), + Span::styled(detail, theme::muted()), ])); } rows } -/// Settings screen: same centered-dialog shape, grouped rows with -/// right-aligned colored values. +// Settings: title, status, tabs, line, rows, gap, hint. +const ST_TITLE: usize = 0; +const ST_STATUS: usize = 1; +const ST_TABS: usize = 2; +const ST_RULE: usize = 3; +const ST_ROWS: usize = 4; +const ST_HINT: usize = 6; + +fn settings_chunks(area: Rect, state: &SettingsState) -> Vec { + let status = state.header.len().max(1) as u16; + let tabs = if state.tabs.len() > 1 { 1 } else { 0 }; + let rows_h = state.rows.len().max(1) as u16; + let rows = [1, status, tabs, 1, rows_h, 1, 1]; + let height = rows.iter().sum(); + split(top_left(area, height), &rows, ST_ROWS) +} + +/// Settings screen: grouped rows, each value in one column after the labels. pub fn render_settings(frame: &mut Frame, state: &SettingsState) { let area = frame.area(); + let chunks = settings_chunks(area, state); + let width = area.width as usize; + frame.render_widget(Paragraph::new(title_line(&state.title)), chunks[ST_TITLE]); frame.render_widget( - Block::default().style(Style::default().bg(theme::backdrop_rgb()).fg(Color::Reset)), - area, + Paragraph::new(status_lines(&state.header, width)), + chunks[ST_STATUS], ); - - let dialog = centered_dialog(area, settings_dialog_height(state), SETTINGS_PREF_WIDTH); - frame.render_widget(Clear, dialog); - - let block = Block::default() - .borders(Borders::ALL) - .border_style(theme::brand()) - .title(Span::styled(format!(" ▲ {} ", state.title), theme::brand())) - .style(Style::default().bg(theme::surface_rgb())); - let inner = inset(block.inner(dialog), INSET_X, INSET_Y); - frame.render_widget(block, dialog); - - let status_h = state.header.len().max(1) as u16; - let tab_h = if state.tabs.len() > 1 { 2 } else { 0 }; - let chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Length(status_h), - Constraint::Length(tab_h), - Constraint::Length(1), - Constraint::Length(1), - Constraint::Min(state.rows.len().max(1) as u16), - Constraint::Length(1), - Constraint::Length(1), - ]) - .split(inner); - - render_status_lines(frame, chunks[0], &state.header); - if tab_h > 0 { - frame.render_widget(Paragraph::new(tab_bar_line(state)), chunks[1]); + if state.tabs.len() > 1 { + frame.render_widget(Paragraph::new(tab_bar_line(state)), chunks[ST_TABS]); } + frame.render_widget(rule(area.width), chunks[ST_RULE]); - let rule = Paragraph::new(Span::styled( - "─".repeat(chunks[2].width as usize), - theme::muted(), - )); - frame.render_widget(rule, chunks[2]); - - let inner_w = chunks[4].width as usize; + let label_col = settings_label_col(state); let lines: Vec = state .rows .iter() .enumerate() - .map(|(i, row)| settings_row_line(row, i == state.cursor, inner_w)) + .map(|(i, row)| settings_row_line(row, i == state.cursor, label_col, width)) .collect(); - frame.render_widget(Paragraph::new(lines), chunks[4]); - - let footer = Paragraph::new(Span::styled(state.hint(), theme::muted())); - frame.render_widget(footer, chunks[6]); + frame.render_widget(Paragraph::new(lines), chunks[ST_ROWS]); + frame.render_widget(hint(state.hint(), area.width), chunks[ST_HINT]); } fn tab_bar_line(state: &SettingsState) -> Line<'static> { @@ -369,90 +452,78 @@ fn tab_bar_line(state: &SettingsState) -> Line<'static> { Line::from(spans) } -fn settings_dialog_height(state: &SettingsState) -> u16 { - // borders(2) + inset(2) + status + pads(3) + rule(1) + rows + hint(1) + tabs - let status = state.header.len().max(1) as u16; - let rows = state.rows.len().max(1) as u16; - let tabs = if state.tabs.len() > 1 { 2 } else { 0 }; - 2 + 2 + status + tabs + 3 + 1 + rows + 1 +fn settings_label_col(state: &SettingsState) -> usize { + value_column(state.rows.iter().filter_map(|r| match r { + SettingRow::Entry { label, .. } => Some(text_width(label)), + _ => None, + })) } -fn tone_style(tone: Tone) -> Style { +fn tone_style(tone: Tone) -> ratatui::style::Style { match tone { Tone::Normal => theme::white(), Tone::Good => theme::success(), Tone::Model => theme::model(), - Tone::Warn => Style::default().fg(theme::rgb(230, 160, 60)), + Tone::Warn => theme::warn(), Tone::Muted => theme::muted(), } } -/// One settings row as styled spans: `❯ label value` (value right-aligned). -fn settings_row_line(row: &SettingRow, selected: bool, inner_w: usize) -> Line<'static> { +/// One settings row as text: marker, label padded to the value column, value +/// cut to the width. +fn settings_row_text( + label: &str, + value: &str, + selected: bool, + label_col: usize, + width: usize, +) -> (&'static str, String, String) { + let marker = if selected { "❯ " } else { " " }; + let label = truncate(label, width.saturating_sub(2)); + let pad = label_col.saturating_sub(text_width(&label)).max(2); + let padded = format!("{label}{}", " ".repeat(pad)); + let room = width.saturating_sub(2 + text_width(&padded)); + (marker, padded, truncate(value, room)) +} + +fn settings_row_line( + row: &SettingRow, + selected: bool, + label_col: usize, + width: usize, +) -> Line<'static> { match row { SettingRow::Section(name) => Line::from(Span::styled( - format!(" {}", name.to_ascii_uppercase()), + truncate(&format!(" {}", name.to_ascii_uppercase()), width), theme::muted(), )), SettingRow::Gap => Line::from(""), SettingRow::Entry { label, value, tone } => { - let marker = if selected { "❯ " } else { " " }; - let marker_style = if selected { - theme::accent() - } else { - theme::muted() - }; - let label_style = if selected { - theme::selected() - } else { - theme::white() - }; - // Right-align the value: pad the label column to fill the gap. - let used = 2 + label.chars().count() + value.chars().count(); - let gap = inner_w.saturating_sub(used).max(1); - let padded_label = format!("{label}{}", " ".repeat(gap)); + let (marker, label, value) = + settings_row_text(label, value, selected, label_col, width); Line::from(vec![ - Span::styled(marker.to_string(), marker_style), - Span::styled(padded_label, label_style), - Span::styled(value.clone(), tone_style(*tone)), + Span::styled( + marker, + if selected { + theme::accent() + } else { + theme::muted() + }, + ), + Span::styled( + label, + if selected { + theme::selected() + } else { + theme::white() + }, + ), + Span::styled(value, tone_style(*tone)), ]) } } } -/// Shrink a rect by `x` columns and `y` rows on each side. -fn inset(area: Rect, x: u16, y: u16) -> Rect { - if area.width == 0 || area.height == 0 { - return area; - } - let x = x.min(area.width.saturating_sub(1) / 2); - let y = y.min(area.height.saturating_sub(1) / 2); - Rect::new( - area.x + x, - area.y + y, - area.width.saturating_sub(x.saturating_mul(2)).max(1), - area.height.saturating_sub(y.saturating_mul(2)).max(1), - ) -} - -/// Center a fixed-size dialog; clamp to terminal so narrow TTYs never clip badly. -fn centered_dialog(area: Rect, content_height: u16, pref_width: u16) -> Rect { - if area.width == 0 || area.height == 0 { - return area; - } - let max_w = area.width.saturating_sub(2).max(1); - let width = pref_width.min(max_w).max(DIALOG_MIN_WIDTH.min(max_w)); - let max_h = area.height.saturating_sub(0).max(1); - let height = content_height.min(max_h).max(5.min(max_h)); - let x = area.x + (area.width.saturating_sub(width)) / 2; - let y = area.y + (area.height.saturating_sub(height)) / 2; - Rect::new(x, y, width, height) -} - -fn card_inner(dialog: Rect) -> Rect { - inset(inset(dialog, 1, 1), INSET_X, INSET_Y) -} - fn in_rect(r: Rect, col: u16, row: u16) -> bool { col >= r.x && row >= r.y @@ -478,44 +549,22 @@ fn palette_window(cursor: usize, n: usize, max: usize) -> (usize, usize) { (start, vis) } -fn palette_card(area: Rect, state: &PaletteState) -> (Rect, Vec, usize) { +fn palette_chunks(area: Rect, state: &PaletteState) -> (Vec, usize) { let filtered = state.filtered(); - let header_h = palette_header_height(&state.header); + let status = state.header.len().max(1) as u16; let (_, visible) = palette_window(state.cursor, filtered.len(), PALETTE_LIVE_ROWS); let groups = palette_groups(state, &filtered, visible); let between = groups.saturating_sub(1); - let height = 2 - + 2 - + header_h - + 1 - + 1 - + 1 - + visible.max(1) as u16 - + groups as u16 - + between as u16 - + 1 - + 1; - let dialog = centered_dialog(area, height, PALETTE_PREF_WIDTH); - let inner = card_inner(dialog); - let chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Length(header_h), - Constraint::Length(1), - Constraint::Length(1), - Constraint::Length(1), - Constraint::Min(visible.max(1) as u16), - Constraint::Length(1), - Constraint::Length(1), - ]) - .split(inner); - (dialog, chunks.to_vec(), visible) + let results = (visible.max(1) + groups + between) as u16; + let rows = [status, 1, 1, results, 1, 1]; + let height = rows.iter().sum(); + (split(top_left(area, height), &rows, PL_RESULTS), visible) } /// Filtered-row index under the cell, skipping group headers and gaps. pub fn hit_palette(area: Rect, state: &PaletteState, col: u16, row: u16) -> Option { - let (_, chunks, visible) = palette_card(area, state); - let results = chunks[4]; + let (chunks, visible) = palette_chunks(area, state); + let results = chunks[PL_RESULTS]; if !in_rect(results, col, row) { return None; } @@ -545,58 +594,21 @@ fn palette_hit_map(state: &PaletteState, visible: usize) -> Vec> { } pub fn hit_menu(area: Rect, state: &MenuState, col: u16, row: u16) -> Option { - let dialog = centered_dialog(area, dialog_height(state), DIALOG_PREF_WIDTH); - let inner = card_inner(dialog); - let status_h = state.header.len().max(1) as u16; - let chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Length(status_h), - Constraint::Length(1), - Constraint::Length(1), - Constraint::Length(1), - Constraint::Min(state.items.len().max(1) as u16), - Constraint::Length(1), - Constraint::Length(1), - ]) - .split(inner); - let list = chunks[4]; + let list = menu_chunks(area, state)[MN_LIST]; if !in_rect(list, col, row) { return None; } let i = (row - list.y) as usize; - if i < state.items.len() { - Some(i) - } else { - None - } + (i < state.items.len()).then_some(i) } pub fn hit_picker(area: Rect, state: &PickerState, col: u16, row: u16) -> Option { - let area = inset(area, 1, 0); - let chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Length(1 + state.header.len() as u16), - Constraint::Length(1), - Constraint::Length(3), - Constraint::Length(1), - Constraint::Min(3), - Constraint::Length(1), - Constraint::Length(1), - ]) - .split(area); - let inner = inset(chunks[4], 1, 1); - if !in_rect(inner, col, row) { + let list = picker_chunks(area, state)[PK_LIST]; + if !in_rect(list, col, row) { return None; } - let i = (row - inner.y) as usize; - let n = state.filtered().len(); - if i < n { - Some(i) - } else { - None - } + let i = (row - list.y) as usize; + (i < state.filtered().len()).then_some(i) } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -606,28 +618,13 @@ pub enum SettingsHit { } pub fn hit_settings(area: Rect, state: &SettingsState, col: u16, row: u16) -> Option { - let dialog = centered_dialog(area, settings_dialog_height(state), SETTINGS_PREF_WIDTH); - let inner = card_inner(dialog); - let status_h = state.header.len().max(1) as u16; - let tab_h = if state.tabs.len() > 1 { 2 } else { 0 }; - let chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Length(status_h), - Constraint::Length(tab_h), - Constraint::Length(1), - Constraint::Length(1), - Constraint::Min(state.rows.len().max(1) as u16), - Constraint::Length(1), - Constraint::Length(1), - ]) - .split(inner); - if tab_h > 0 { - if let Some(tab) = settings_tab_at(state, chunks[1], col, row) { + let chunks = settings_chunks(area, state); + if state.tabs.len() > 1 { + if let Some(tab) = settings_tab_at(state, chunks[ST_TABS], col, row) { return Some(SettingsHit::Tab(tab)); } } - let list = chunks[4]; + let list = chunks[ST_ROWS]; if !in_rect(list, col, row) { return None; } @@ -660,36 +657,6 @@ fn settings_tab_at(state: &SettingsState, bar: Rect, col: u16, row: u16) -> Opti None } -fn render_status_lines(frame: &mut Frame, area: Rect, header: &[String]) { - let mut lines: Vec = Vec::new(); - if header.is_empty() { - lines.push(Line::from(Span::styled("—", theme::muted()))); - } else { - for h in header { - if let Some((key, rest)) = h.split_once(" ") { - lines.push(Line::from(vec![ - Span::styled(format!("{key:<9}"), theme::muted()), - Span::styled(rest.to_string(), theme::white()), - ])); - } else { - lines.push(Line::from(Span::styled(h.clone(), theme::white()))); - } - } - } - frame.render_widget(Paragraph::new(lines), area); -} - -fn render_header(frame: &mut Frame, area: Rect, title: &str, header: &[String]) { - let mut lines: Vec = vec![Line::from(Span::styled( - format!("▲ {title}"), - theme::brand(), - ))]; - for h in header { - lines.push(Line::from(Span::styled(h.clone(), theme::muted()))); - } - frame.render_widget(Paragraph::new(lines), area); -} - /// Geometric row prefix (› for launch rows). No emoji. pub fn item_icon(label: &str) -> &'static str { let l = label.to_ascii_lowercase(); @@ -706,69 +673,7 @@ pub fn item_icon(label: &str) -> &'static str { } } -fn mark_line_width() -> usize { - crate::term::TUI_MARK_LINES - .iter() - .map(|s| s.chars().count()) - .max() - .unwrap_or(0) -} - -fn palette_shows_mark(inner_w: usize) -> bool { - inner_w >= mark_line_width() + 2 + 12 -} - -fn padded_mark_line(i: usize) -> String { - let raw = crate::term::TUI_MARK_LINES.get(i).copied().unwrap_or(""); - let w = mark_line_width(); - format!("{raw}{}", " ".repeat(w.saturating_sub(raw.chars().count()))) -} - -fn palette_header_height(header: &[String]) -> u16 { - crate::term::TUI_MARK_LINES.len().max(header.len()).max(1) as u16 -} - -fn status_caption_spans(line: &str) -> Vec> { - if let Some((key, rest)) = line.split_once(" ") { - vec![ - Span::styled(format!("{key:<9}"), theme::muted()), - Span::styled(rest.to_string(), theme::white()), - ] - } else { - vec![Span::styled(line.to_string(), theme::white())] - } -} - -fn palette_header_lines(header: &[String], inner_w: usize) -> Vec> { - let with_mark = palette_shows_mark(inner_w); - let n = if with_mark { - crate::term::TUI_MARK_LINES.len().max(header.len()).max(1) - } else { - header.len().max(1) - }; - let mut lines = Vec::with_capacity(n); - for i in 0..n { - let cap = header.get(i).map(String::as_str).unwrap_or(""); - let mut spans = Vec::new(); - if with_mark { - spans.push(Span::styled(padded_mark_line(i), theme::white())); - if !cap.is_empty() { - spans.push(Span::raw(" ")); - } - } - if cap.is_empty() { - if spans.is_empty() { - spans.push(Span::styled("—", theme::muted())); - } - } else { - spans.extend(status_caption_spans(cap)); - } - lines.push(Line::from(spans)); - } - lines -} - -fn item_icon_style(label: &str) -> Style { +fn item_icon_style(label: &str) -> ratatui::style::Style { let l = label.to_ascii_lowercase(); if label.starts_with("Launch") { theme::success() @@ -781,79 +686,47 @@ fn item_icon_style(label: &str) -> Style { } } +fn dump_width(cols: usize) -> usize { + cols.max(DUMP_MIN_COLS) +} + /// ANSI-free plain frame for `--dump-tui` and unit tests. pub fn plain_picker_lines(state: &PickerState, cols: usize) -> Vec { - let width = cols.max(40); - let mut lines = Vec::new(); - lines.push(truncate(&format!("▲ {}", state.title), width)); + let width = dump_width(cols); + let mut lines = vec![truncate(&state.title, width)]; for h in &state.header { lines.push(truncate(h, width)); } lines.push(truncate(&format!("search: {}", state.query), width)); - lines.push(truncate(&"─".repeat(width.min(48)), width)); + lines.push(rule_text(width)); let filtered = state.filtered(); if filtered.is_empty() { lines.push(truncate(" (no matches)", width)); } else { for (i, (_, label)) in filtered.iter().enumerate() { - let marker = if i == state.cursor { "◆" } else { " " }; + let marker = if i == state.cursor { "❯" } else { " " }; lines.push(truncate(&format!("{marker} {label}"), width)); } } + lines.push(String::new()); lines.push(truncate(state.hint(), width)); lines } pub fn plain_menu_lines(state: &MenuState, cols: usize) -> Vec { - // Dialog-shaped dump: fixed inner width, centered with padding when wide. - let term_w = cols.max(DIALOG_MIN_WIDTH as usize); - let inner = (DIALOG_PREF_WIDTH as usize) - .min(term_w.saturating_sub(2)) - .max(DIALOG_MIN_WIDTH as usize) - .min(term_w); - let pad = term_w.saturating_sub(inner) / 2; - let pad_s = " ".repeat(pad); - let content_w = inner.saturating_sub(2); // inside │…│ - - let mut lines = Vec::new(); - let title_raw = format!(" ▲ {} ", state.title); - let title = truncate(&title_raw, content_w); - let dash_n = content_w.saturating_sub(title.chars().count()); - lines.push(format!("{pad_s}╭{title}{}╮", "─".repeat(dash_n))); - - if state.header.is_empty() { - lines.push(format!("{pad_s}│{}│", pad_content("—", content_w))); - } else { - for h in &state.header { - lines.push(format!("{pad_s}│{}│", pad_content(h, content_w))); - } - } - lines.push(format!("{pad_s}│{}│", pad_content("", content_w))); - lines.push(format!("{pad_s}├{}┤", "─".repeat(content_w))); - lines.push(format!("{pad_s}│{}│", pad_content("", content_w))); - + let width = dump_width(cols); + let mut lines = vec![truncate(&state.title, width)]; + lines.extend(status_plain(&state.header, width)); + lines.push(rule_text(width)); for (i, label) in state.items.iter().enumerate() { - let marker = if i == state.cursor { "◆" } else { " " }; - let row = format!("{marker} {label}"); - lines.push(format!("{pad_s}│{}│", pad_content(&row, content_w))); + let marker = if i == state.cursor { "❯" } else { " " }; + lines.push(truncate(&format!("{marker} {label}"), width)); } - - lines.push(format!("{pad_s}│{}│", pad_content("", content_w))); - lines.push(format!("{pad_s}├{}┤", "─".repeat(content_w))); - lines.push(format!("{pad_s}│{}│", pad_content(state.hint(), content_w))); - lines.push(format!("{pad_s}╰{}╯", "─".repeat(content_w))); + lines.push(String::new()); + lines.push(truncate(state.hint(), width)); lines } -fn pad_content(s: &str, width: usize) -> String { - let truncated = truncate(s, width); - let chars: Vec = truncated.chars().collect(); - if chars.len() >= width { - return truncated; - } - format!("{truncated}{}", " ".repeat(width - chars.len())) -} - pub fn plain_picker_frame(state: &PickerState, cols: usize) -> String { let mut lines = plain_picker_lines(state, cols); lines.push(String::new()); @@ -866,69 +739,43 @@ pub fn plain_menu_frame(state: &MenuState, cols: usize) -> String { lines.join("\n") } -fn header_val<'a>(header: &'a [String], key: &str) -> &'a str { - let prefix = format!("{key} "); - header - .iter() - .find_map(|h| h.strip_prefix(&prefix)) - .unwrap_or("—") -} - -/// Compact HUD dump: two status lines, then actions. No dialog card. +/// The launcher as plain lines: the same rows the live palette draws, with +/// every filtered entry (a dump is a snapshot, not a viewport). pub fn plain_palette_lines(state: &PaletteState, cols: usize) -> Vec { - let width = cols.max(40); - let mut lines = Vec::new(); - let mark = crate::term::TUI_MARK_LINES[0].trim(); - let account = header_val(&state.header, "account"); - let credits = header_val(&state.header, "credits"); - lines.push(truncate( - &format!("▲ anyr {mark} · account {account} · credits {credits}"), - width, - )); - let rest: Vec<&str> = state - .header - .iter() - .map(String::as_str) - .filter(|h| !h.starts_with("account ") && !h.starts_with("credits ")) - .collect(); - if !rest.is_empty() { - lines.push(truncate(&rest.join(" · "), width)); - } - lines.push(String::new()); + let width = dump_width(cols); + let mut lines = status_plain(&state.header, width); + lines.push(rule_text(width)); + lines.push(truncate(&format!("❯ {}", state.query), width)); let filtered = state.filtered(); - if filtered.is_empty() { lines.push(truncate(" (no matches)", width)); } else { - // Dump is a full snapshot, not a viewport — CI must see every row. let cursor_row = state.cursor.min(filtered.len().saturating_sub(1)); + let label_col = palette_label_col(state, &filtered); let mut last_group: Option<&str> = None; for (row_i, &entry_i) in filtered.iter().enumerate() { let entry = &state.entries[entry_i]; if last_group != Some(entry.group.as_str()) { + if last_group.is_some() { + lines.push(String::new()); + } if !entry.group.is_empty() { - lines.push(truncate(&entry.group.to_ascii_uppercase(), width)); + lines.push(truncate(&format!(" {}", entry.group), width)); } last_group = Some(&entry.group); } - let selected = row_i == cursor_row; - let marker = if selected { "◆" } else { " " }; - let icon = item_icon(&entry.label); - let row = if entry.detail.is_empty() { - format!("{marker} {icon}{label}", label = entry.label) - } else { - format!( - "{marker} {icon}{label} {detail}", - label = entry.label, - detail = entry.detail - ) - }; - lines.push(truncate(&row, width)); + let (marker, icon, label, detail) = + palette_row_parts(entry, row_i == cursor_row, label_col, width); + lines.push( + format!("{marker}{icon}{label}{detail}") + .trim_end() + .to_string(), + ); } } - - lines.push(truncate("❯ ↵ launch · esc quit", width)); + lines.push(String::new()); + lines.push(truncate(state.hint(), width)); lines } @@ -940,29 +787,9 @@ pub fn plain_palette_frame(state: &PaletteState, cols: usize) -> String { /// ANSI-free settings frame for `--dump-tui` and unit tests. pub fn plain_settings_lines(state: &SettingsState, cols: usize) -> Vec { - let term_w = cols.max(DIALOG_MIN_WIDTH as usize); - let inner = (SETTINGS_PREF_WIDTH as usize) - .min(term_w.saturating_sub(2)) - .max(DIALOG_MIN_WIDTH as usize) - .min(term_w); - let pad = term_w.saturating_sub(inner) / 2; - let pad_s = " ".repeat(pad); - let content_w = inner.saturating_sub(2); - - let mut lines = Vec::new(); - let title_raw = format!(" ▲ {} ", state.title); - let title = truncate(&title_raw, content_w); - let dash_n = content_w.saturating_sub(title.chars().count()); - lines.push(format!("{pad_s}╭{title}{}╮", "─".repeat(dash_n))); - - if state.header.is_empty() { - lines.push(format!("{pad_s}│{}│", pad_content("—", content_w))); - } else { - for h in &state.header { - lines.push(format!("{pad_s}│{}│", pad_content(h, content_w))); - } - } - lines.push(format!("{pad_s}│{}│", pad_content("", content_w))); + let width = dump_width(cols); + let mut lines = vec![truncate(&state.title, width)]; + lines.extend(status_plain(&state.header, width)); if state.tabs.len() > 1 { let bar = state .tabs @@ -977,29 +804,25 @@ pub fn plain_settings_lines(state: &SettingsState, cols: usize) -> Vec { }) .collect::>() .join(" "); - lines.push(format!("{pad_s}│{}│", pad_content(&bar, content_w))); - lines.push(format!("{pad_s}│{}│", pad_content("", content_w))); + lines.push(truncate(&bar, width)); } - lines.push(format!("{pad_s}├{}┤", "─".repeat(content_w))); - lines.push(format!("{pad_s}│{}│", pad_content("", content_w))); - + lines.push(rule_text(width)); + let label_col = settings_label_col(state); for (i, row) in state.rows.iter().enumerate() { - let line = match row { - SettingRow::Section(name) => format!(" {}", name.to_ascii_uppercase()), + lines.push(match row { + SettingRow::Section(name) => { + truncate(&format!(" {}", name.to_ascii_uppercase()), width) + } SettingRow::Gap => String::new(), SettingRow::Entry { label, value, .. } => { - let marker = if i == state.cursor { "◆" } else { " " }; - let used = 4 + label.chars().count() + value.chars().count(); - let gap = content_w.saturating_sub(used).max(1); - format!("{marker} {label}{}{value}", " ".repeat(gap)) + let (marker, label, value) = + settings_row_text(label, value, i == state.cursor, label_col, width); + format!("{marker}{label}{value}").trim_end().to_string() } - }; - lines.push(format!("{pad_s}│{}│", pad_content(&line, content_w))); + }); } - - lines.push(format!("{pad_s}├{}┤", "─".repeat(content_w))); - lines.push(format!("{pad_s}│{}│", pad_content(state.hint(), content_w))); - lines.push(format!("{pad_s}╰{}╯", "─".repeat(content_w))); + lines.push(String::new()); + lines.push(truncate(state.hint(), width)); lines } @@ -1009,23 +832,27 @@ pub fn plain_settings_frame(state: &SettingsState, cols: usize) -> String { lines.join("\n") } -fn truncate(s: &str, max: usize) -> String { - if max == 0 { - return String::new(); - } - let chars: Vec = s.chars().collect(); - if chars.len() <= max { - return s.to_string(); - } - let mut out: String = chars.into_iter().take(max.saturating_sub(1)).collect(); - out.push('…'); - out -} - #[cfg(test)] mod tests { use super::*; + /// Every dumped line starts near column 0 and fits the width: the + /// owner's plain top-left rule, with no frame characters. + fn assert_plain(frame: &str, cols: usize) { + assert!(!frame.contains('\u{1b}'), "must be ANSI-free: {frame}"); + for line in frame.lines() { + assert!( + text_width(line) <= cols, + "line wider than {cols}: {line:?}\n{frame}" + ); + let indent = line.chars().take_while(|c| *c == ' ').count(); + assert!(indent <= 4, "indented {indent}: {line:?}\n{frame}"); + for c in ['╭', '╮', '╰', '╯', '│', '├', '┤'] { + assert!(!line.contains(c), "frame char {c} in {line:?}"); + } + } + } + #[test] fn palette_window_keeps_cursor_visible() { // WHY: Enter must select the highlighted row, not a row below the fold. @@ -1037,35 +864,23 @@ mod tests { } #[test] - fn dump_menu_is_ansi_free() { + fn dump_menu_is_plain_text_from_the_top_left() { let state = MenuState::new( "AnyRouter", vec!["account default".into()], vec!["Launch claude".into(), "Config".into(), "Quit".into()], ); - let frame = plain_menu_frame(&state, 80); - assert!(!frame.contains('\u{1b}')); - assert!(frame.contains("▲ AnyRouter"), "{frame}"); - assert!(frame.contains("◆ Launch claude"), "{frame}"); - assert!(frame.contains("Config"), "{frame}"); - assert!(frame.contains("Quit"), "{frame}"); - assert!( - frame.contains('╭') && frame.contains('╯'), - "dialog box: {frame}" - ); - } - - #[test] - fn dump_menu_narrow_terminal() { - let state = MenuState::new( - "AnyRouter", - vec!["model auto".into()], - vec!["Login / sign in".into(), "Quit".into()], - ); - let frame = plain_menu_frame(&state, 32); - assert!(!frame.contains('\u{1b}')); - assert!(frame.contains("▲ AnyRouter"), "{frame}"); - assert!(frame.contains("Quit"), "{frame}"); + for cols in [32, 80] { + let frame = plain_menu_frame(&state, cols); + assert_plain(&frame, cols); + assert!(frame.starts_with("AnyRouter\n"), "{frame}"); + assert!(frame.contains("account default"), "{frame}"); + assert!(frame.contains("❯ Launch claude"), "{frame}"); + assert!( + frame.contains(" Config") && frame.contains(" Quit"), + "{frame}" + ); + } } #[test] @@ -1074,7 +889,7 @@ mod tests { state.query = "a".into(); let frame = plain_picker_frame(&state, 80); assert!(frame.contains("search: a")); - assert!(!frame.contains('\u{1b}')); + assert_plain(&frame, 80); } #[test] @@ -1088,71 +903,68 @@ mod tests { assert!(!item_icon("claude").contains("⚡")); } - #[test] - fn dump_palette_shows_mark_status_and_icons() { - let state = PaletteState::new( + fn launcher_state() -> PaletteState { + PaletteState::new( vec![ "account duyet · me@example.co".into(), - "key sk-ar-v1-ab…wxyz".into(), - "model stealth/ox-alpha".into(), - "agent claude".into(), "credits $12.50".into(), ], vec![ - PaletteEntry::new("claude", "stealth/ox-alpha", "launch", "Launch claude"), PaletteEntry::new( - "model…", - "switch session default", - "configure", - "Switch model", + "claude", + "stealth/ox-alpha · …wxyz", + "launch", + "Launch claude", ), - PaletteEntry::new("quit", "esc works too", "configure", "Quit"), + PaletteEntry::new("codex", "auto · …wxyz", "launch", "Launch codex"), + PaletteEntry::new("model…", "for claude", "configure · claude", "Switch model"), + PaletteEntry::new("quit", "esc works too", "more", "Quit"), ], - ); + ) + } + + #[test] + fn dump_palette_is_status_rows_a_line_and_the_agent_list() { + let state = launcher_state(); let frame = plain_palette_frame(&state, 80); - assert!(!frame.contains('\u{1b}'), "must be ANSI-free: {frame}"); - assert!(frame.contains(" anyr "), "{frame}"); + assert_plain(&frame, 80); + let lines: Vec<&str> = frame.lines().collect(); + // WHY: account and credits first, from row 0, then one thin line. + assert_eq!(lines[0], "account duyet · me@example.co", "{frame}"); + assert_eq!(lines[1], "credits $12.50", "{frame}"); + assert!(lines[2].chars().all(|c| c == '─'), "{frame}"); assert!( - frame.contains(crate::term::TUI_MARK_LINES[0].trim()), - "AR mark missing:\n{frame}" + !frame.contains(crate::term::TUI_MARK_LINES[1].trim()), + "no logo:\n{frame}" ); - for line in [ - "account duyet · me@example.co", - "key sk-ar-v1-ab…wxyz", - "model stealth/ox-alpha", - "agent claude", - "credits $12.50", - ] { - assert!(frame.contains(line), "missing {line} in:\n{frame}"); - } - assert!(!frame.contains("⚡"), "emoji icons removed:\n{frame}"); - assert!(frame.contains("claude"), "{frame}"); - assert!(frame.contains("LAUNCH"), "{frame}"); - assert!(frame.contains("CONFIGURE"), "{frame}"); - assert!(frame.contains('❯'), "{frame}"); + // Details line up in one column after the longest label. + let col = |needle: &str| { + let line = lines.iter().find(|l| l.contains(needle)).unwrap(); + line[..line.find(needle).unwrap()].chars().count() + }; + assert_eq!(col("stealth/ox-alpha"), col("auto · …wxyz"), "{frame}"); + assert!(frame.contains("❯ › claude"), "{frame}"); + assert!(frame.contains(" configure · claude"), "{frame}"); + assert_eq!(*lines.last().unwrap(), hint_text(), "{frame}"); } - #[test] - fn inset_shrinks_and_clamps() { - let r = Rect::new(0, 0, 20, 10); - let i = inset(r, 1, 1); - assert_eq!(i, Rect::new(1, 1, 18, 8)); - let tiny = Rect::new(0, 0, 2, 2); - let i = inset(tiny, 4, 4); - assert!(i.width >= 1 && i.height >= 1); + fn hint_text() -> &'static str { + super::super::keys::hint_line(super::super::keys::Surface::Palette) } #[test] - fn centered_dialog_clamps_to_area() { - let tiny = Rect::new(0, 0, 20, 8); - let d = centered_dialog(tiny, 20, 52); - assert!(d.width <= tiny.width); - assert!(d.height <= tiny.height); - assert!(d.width >= 1); + fn dump_palette_cuts_long_details_at_narrow_widths() { + let mut state = launcher_state(); + state.entries[0].detail = "x".repeat(300); + for cols in [40, 80, 200] { + let frame = plain_palette_frame(&state, cols); + assert_plain(&frame, cols); + assert!(frame.contains('…'), "{cols}: long detail not cut:\n{frame}"); + } } #[test] - fn dump_settings_is_ansi_free_and_grouped() { + fn dump_settings_is_plain_and_grouped() { use super::super::state::{SettingRow, SettingsState, Tone}; let state = SettingsState::new( "Config", @@ -1175,56 +987,31 @@ mod tests { ) .with_tabs(vec!["general".into(), "claude".into(), "codex".into()], 0); let frame = plain_settings_frame(&state, 80); - assert!(!frame.contains('\u{1b}'), "must be ANSI-free: {frame}"); - assert!(frame.contains("▲ Config"), "{frame}"); + assert_plain(&frame, 80); + assert!(frame.starts_with("Config\n"), "{frame}"); assert!(frame.contains("[general]"), "{frame}"); - assert!(frame.contains("ACCOUNT"), "{frame}"); - assert!(frame.contains("MODEL"), "{frame}"); - assert!(frame.contains("◆ account"), "{frame}"); - assert!(frame.contains('╭') && frame.contains('╯'), "{frame}"); + assert!( + frame.contains("ACCOUNT") && frame.contains("MODEL"), + "{frame}" + ); + assert!(frame.contains("❯ account duyet"), "{frame}"); // Blank line between ACCOUNT and MODEL sections. let dumped: Vec<&str> = frame.lines().collect(); - let acct = dumped - .iter() - .position(|l| l.contains("ACCOUNT")) - .expect("ACCOUNT"); - let model = dumped - .iter() - .position(|l| l.contains("MODEL")) - .expect("MODEL"); + let acct = dumped.iter().position(|l| l.contains("ACCOUNT")).unwrap(); + let model = dumped.iter().position(|l| l.contains("MODEL")).unwrap(); assert!( model > acct + 1, "expected padding between sections:\n{frame}" ); - // Values right-aligned inside the card (before the right border). - let row_line = frame - .lines() - .find(|l| l.contains("default") && l.contains("auto")) - .expect("default row"); - assert!( - row_line - .trim_end() - .trim_end_matches('│') - .trim_end() - .ends_with("auto"), - "{row_line}" - ); - // Narrow terminals still render. - let narrow = plain_settings_frame(&state, 30); - assert!(!narrow.contains('\u{1b}')); - assert!(narrow.contains("▲ Config"), "{narrow}"); + // Values share one column after the labels, not the far right edge. + assert!(frame.contains(" default auto"), "{frame}"); + assert_plain(&plain_settings_frame(&state, 30), 30); } #[test] fn palette_click_skips_group_headers_and_hits_rows() { let state = PaletteState::new( - vec![ - "account default".into(), - "key sk-ar-…".into(), - "model auto".into(), - "agent claude".into(), - "credits -".into(), - ], + vec!["account default".into(), "credits -".into()], vec![ PaletteEntry::new("claude", "x", "launch", "Launch claude"), PaletteEntry::new("codex", "y", "launch", "Launch codex"), @@ -1232,8 +1019,10 @@ mod tests { ], ); let area = Rect::new(0, 0, 80, 40); - let (_, chunks, _) = palette_card(area, &state); - let results = chunks[4]; + let (chunks, _) = palette_chunks(area, &state); + let results = chunks[PL_RESULTS]; + // Status rows, the line and the input sit above the results. + assert_eq!(results.y, 4); let x = results.x + 2; assert_eq!(hit_palette(area, &state, x, results.y), None); assert_eq!(hit_palette(area, &state, x, results.y + 1), Some(0)); @@ -1252,26 +1041,42 @@ mod tests { vec!["Launch claude".into(), "Config".into(), "Quit".into()], ); let area = Rect::new(0, 0, 80, 24); - let dialog = centered_dialog(area, dialog_height(&state), DIALOG_PREF_WIDTH); - let inner = card_inner(dialog); - let status_h = 1u16; - let chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Length(status_h), - Constraint::Length(1), - Constraint::Length(1), - Constraint::Length(1), - Constraint::Min(3), - Constraint::Length(1), - Constraint::Length(1), - ]) - .split(inner); - let list = chunks[4]; - let x = list.x + 2; - assert_eq!(hit_menu(area, &state, x, list.y), Some(0)); - assert_eq!(hit_menu(area, &state, x, list.y + 1), Some(1)); - assert_eq!(hit_menu(area, &state, x, list.y + 2), Some(2)); + // Title, one status row and the line: the list starts on row 3. + let y = 3; + assert_eq!(hit_menu(area, &state, 2, y), Some(0)); + assert_eq!(hit_menu(area, &state, 2, y + 1), Some(1)); + assert_eq!(hit_menu(area, &state, 2, y + 2), Some(2)); + assert_eq!(hit_menu(area, &state, 2, y + 3), None); assert_eq!(hit_menu(area, &state, 0, 0), None); } + + #[test] + fn live_palette_draws_from_the_top_left_without_a_frame() { + use ratatui::backend::TestBackend; + use ratatui::Terminal; + for cols in [40u16, 80, 200] { + let mut term = Terminal::new(TestBackend::new(cols, 30)).unwrap(); + let state = launcher_state(); + term.draw(|f| render_palette(f, &state)).unwrap(); + let buf = term.backend().buffer().clone(); + let row = |y: u16| -> String { + (0..cols) + .map(|x| buf[(x, y)].symbol().to_string()) + .collect() + }; + assert!(row(0).starts_with("account duyet"), "{cols}: {:?}", row(0)); + assert!( + row(1).starts_with("credits $12.50"), + "{cols}: {:?}", + row(1) + ); + for y in 0..30 { + let r = row(y); + assert!( + !r.contains('│') && !r.contains('╭'), + "{cols}: frame on row {y}: {r:?}" + ); + } + } + } } diff --git a/tests/cli.rs b/tests/cli.rs index 31737ac..cdc0f04 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -2040,10 +2040,12 @@ profiles: !stdout.contains('\u{1b}'), "dump must be ANSI-free: {stdout}" ); + // WHY: plain text from the top-left, no card frame (owner style rule). assert!( - stdout.contains('╭') && stdout.contains('╯'), - "dialog card: {stdout}" + !stdout.contains('╭') && !stdout.contains('│'), + "no frame: {stdout}" ); + assert!(stdout.starts_with("Config"), "starts top-left: {stdout}"); // Grouped sections with current values. for section in ["ACCOUNT", "MODEL", "AGENT", "GENERAL"] { assert!(stdout.contains(section), "missing {section} in:\n{stdout}"); diff --git a/tests/tty_render.rs b/tests/tty_render.rs new file mode 100644 index 0000000..faab597 --- /dev/null +++ b/tests/tty_render.rs @@ -0,0 +1,503 @@ +//! Drive the real `anyr` binary inside a pseudo-terminal, feed what it writes +//! into a VT100 emulator, and assert on the rendered screen. Raw bytes can look +//! fine while the screen is broken: a bare `\n` in raw mode moves the cursor +//! down without returning it to column 0, so a menu turns into a staircase. +//! +//! Each case runs under every shell found on the host (bash, zsh, fish, sh). +//! A missing shell is skipped with a message on stderr; set +//! `ANYR_PTY_REQUIRE_SHELLS=1` (CI does) to make a missing shell fail instead. +#![cfg(all(unix, feature = "native"))] + +mod common; + +use std::io::{Read, Write}; +use std::os::fd::{FromRawFd, OwnedFd}; +use std::os::unix::process::CommandExt; +use std::path::{Path, PathBuf}; +use std::process::{Child, Command, Stdio}; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; + +use common::{serve_routes, unique_temp_dir, KEY}; + +const SHELLS: [&str; 4] = ["bash", "zsh", "fish", "sh"]; +const WIDTHS: [u16; 3] = [40, 80, 200]; +const ROWS: u16 = 50; +const DEADLINE: Duration = Duration::from_secs(20); + +const STATUS_ACTIONS: [&str; 5] = [ + "Switch account", + "Change default model", + "Log out", + "Open dashboard", + "Quit", +]; + +const ME: &str = r#"{"id":"user_1","email":"me@example.com","name":"Duyet","username":"duyet","balance":133.83}"#; + +/// A key whose scope list is far wider than any terminal, so the row must be +/// cut to the width instead of wrapping onto the next line. +fn key_info() -> String { + let endpoints: Vec = (0..40) + .map(|i| format!("\"/api/v1/endpoint-number-{i}\"")) + .collect(); + format!( + r#"{{"data":{{"name":"laptop","is_free_tier":false,"last_used_at":"2026-10-27T15:31:41Z","allowed_endpoints":[{}],"allowed_models":null}}}}"#, + endpoints.join(",") + ) +} + +fn server() -> String { + serve_routes(vec![ + ("/api/v1/me", 200, ME.into()), + ("/api/v1/key", 200, key_info()), + ("/api/v1/credits", 200, r#"{"balance":133.83}"#.into()), + ]) +} + +fn find_shell(name: &str) -> Option { + ["/bin", "/usr/bin", "/usr/local/bin", "/opt/homebrew/bin"] + .iter() + .map(|d| Path::new(d).join(name)) + .find(|p| p.is_file()) +} + +/// Shells present on this host. A missing one is reported, never dropped +/// silently, and fails the run when CI asks for every shell. +fn shells() -> Vec<(&'static str, PathBuf)> { + let require = std::env::var("ANYR_PTY_REQUIRE_SHELLS").is_ok_and(|v| v == "1"); + let mut found = Vec::new(); + for name in SHELLS { + match find_shell(name) { + Some(path) => found.push((name, path)), + None if require => panic!("{name} is not installed; ANYR_PTY_REQUIRE_SHELLS=1"), + None => eprintln!("SKIP {name}: not installed on this host"), + } + } + assert!(!found.is_empty(), "no shell found at all"); + found +} + +/// A throwaway HOME with fake agents on PATH, so the launcher lists the same +/// agents on every machine and no rc file of the developer leaks in. +struct Sandbox { + home: PathBuf, + bin: PathBuf, +} + +impl Sandbox { + /// Signed in against `base` through a saved profile, as after a login. + fn new(base: &str) -> Self { + let home = unique_temp_dir("anyr-tty"); + let bin = home.join("bin"); + std::fs::create_dir_all(&bin).unwrap(); + let anyrouter = home.join(".anyrouter"); + std::fs::create_dir_all(&anyrouter).unwrap(); + std::fs::write( + anyrouter.join("config.yaml"), + format!("active_profile: default\nprofiles:\n default:\n api_key: {KEY}\n base_url: {base}\n"), + ) + .unwrap(); + for agent in ["claude", "codex"] { + let p = bin.join(agent); + std::fs::write(&p, "#!/bin/sh\nexit 0\n").unwrap(); + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o755)).unwrap(); + } + Sandbox { home, bin } + } + + fn command(&self, shell: &Path, script: &str, env: &[(&str, &str)]) -> Command { + let mut cmd = Command::new(shell); + cmd.arg("-c").arg(script).env_clear(); + cmd.env("PATH", format!("{}:/usr/bin:/bin", self.bin.display())) + .env("HOME", &self.home) + .env("XDG_CONFIG_HOME", self.home.join(".config")) + .env("XDG_DATA_HOME", self.home.join(".local/share")) + .env("ANYROUTER_HOME", self.home.join(".anyrouter")) + .env("ANYROUTER_API_KEY", KEY) + .env("ANYR_NO_UPDATE", "1") + .env("LANG", "C.UTF-8") + .env("TERM", "xterm-256color"); + for (k, v) in env { + cmd.env(k, v); + } + cmd + } +} + +impl Drop for Sandbox { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.home); + } +} + +/// `exec '' `: single quotes parse the same in all four shells. +/// `ANYR_TTY_BIN` points the cases at another build, such as a release. +fn script(args: &str) -> String { + let bin = std::env::var("ANYR_TTY_BIN").unwrap_or_else(|_| env!("CARGO_BIN_EXE_anyr").into()); + format!("exec '{bin}' {args}") +} + +struct Pty { + child: Child, + writer: std::fs::File, + parser: Arc>, + raw: Arc>>, +} + +impl Pty { + fn spawn(mut cmd: Command, cols: u16, rows: u16) -> Pty { + let mut master: libc::c_int = -1; + let mut slave: libc::c_int = -1; + let mut ws = libc::winsize { + ws_row: rows, + ws_col: cols, + ws_xpixel: 0, + ws_ypixel: 0, + }; + // SAFETY: out-pointers are valid locals; name and termios may be null. + let rc = unsafe { + libc::openpty( + &mut master, + &mut slave, + std::ptr::null_mut(), + std::ptr::null_mut::(), + // A raw pointer: Linux takes `*const`, macOS `*mut`. + std::ptr::addr_of_mut!(ws), + ) + }; + assert_eq!(rc, 0, "openpty failed"); + // SAFETY: openpty returned two fresh descriptors we now own. + let (master, slave) = + unsafe { (OwnedFd::from_raw_fd(master), OwnedFd::from_raw_fd(slave)) }; + cmd.stdin(Stdio::from(slave.try_clone().unwrap())) + .stdout(Stdio::from(slave.try_clone().unwrap())) + .stderr(Stdio::from(slave.try_clone().unwrap())); + // SAFETY: setsid and ioctl are async-signal-safe; this makes the pty + // the child's controlling terminal, as a real terminal emulator does. + unsafe { + cmd.pre_exec(|| { + if libc::setsid() == -1 { + return Err(std::io::Error::last_os_error()); + } + if libc::ioctl(0, libc::TIOCSCTTY as _, 0) == -1 { + return Err(std::io::Error::last_os_error()); + } + Ok(()) + }); + } + let child = cmd.spawn().expect("spawn in pty"); + drop(slave); + let reader = std::fs::File::from(master); + let writer = reader.try_clone().unwrap(); + let parser = Arc::new(Mutex::new(vt100::Parser::new(rows, cols, 0))); + let raw = Arc::new(Mutex::new(Vec::new())); + let (p, r) = (Arc::clone(&parser), Arc::clone(&raw)); + std::thread::spawn(move || { + let mut reader = reader; + let mut buf = [0u8; 4096]; + // Linux answers EIO once the child side closes; that ends the read. + while let Ok(n) = reader.read(&mut buf) { + if n == 0 { + break; + } + p.lock().unwrap().process(&buf[..n]); + r.lock().unwrap().extend_from_slice(&buf[..n]); + } + }); + Pty { + child, + writer, + parser, + raw, + } + } + + fn screen(&self) -> vt100::Screen { + self.parser.lock().unwrap().screen().clone() + } + + fn raw(&self) -> Vec { + self.raw.lock().unwrap().clone() + } + + /// Wait until every `needle` is on screen; panic with the screen if not. + fn wait_for(&mut self, needles: &[&str]) -> vt100::Screen { + let start = Instant::now(); + loop { + let screen = self.screen(); + let text = screen.contents(); + if needles.iter().all(|n| text.contains(n)) { + return screen; + } + if start.elapsed() > DEADLINE { + let _ = self.child.kill(); + panic!( + "timed out waiting for {needles:?}; screen:\n{}", + dump(&screen) + ); + } + std::thread::sleep(Duration::from_millis(20)); + } + } + + fn send(&mut self, bytes: &[u8]) { + self.writer.write_all(bytes).unwrap(); + self.writer.flush().unwrap(); + } + + fn finish(mut self) { + let start = Instant::now(); + loop { + if let Ok(Some(_)) = self.child.try_wait() { + return; + } + if start.elapsed() > DEADLINE { + let _ = self.child.kill(); + let _ = self.child.wait(); + panic!("anyr did not exit; screen:\n{}", dump(&self.screen())); + } + std::thread::sleep(Duration::from_millis(20)); + } + } +} + +/// The screen as text, one numbered row per line, for failure messages and +/// the before/after captures. +fn dump(screen: &vt100::Screen) -> String { + let (_, cols) = screen.size(); + let rows: Vec = screen.rows(0, cols).collect(); + let last = rows.iter().rposition(|r| !r.trim().is_empty()).unwrap_or(0); + rows[..=last] + .iter() + .map(|r| format!("|{}", r.trim_end())) + .collect::>() + .join("\n") +} + +fn indent(row: &str) -> usize { + row.chars().take_while(|c| *c == ' ').count() +} + +/// The checks every screen must pass, at every width. +fn assert_plain_top_left(screen: &vt100::Screen, what: &str) { + let (rows, cols) = screen.size(); + let text: Vec = screen.rows(0, cols).collect(); + let shown = dump(screen); + for (i, row) in text.iter().enumerate() { + if row.trim().is_empty() { + continue; + } + // WHY: the owner rule is plain text from the top-left. A staircase or + // a centered card both push content right. + assert!( + indent(row) <= 4, + "{what}: row {i} starts at column {} (centered or staircase):\n{shown}", + indent(row) + ); + // WHY: a wrapped row breaks label alignment and the picker's redraw. + assert!( + !screen.row_wrapped(i as u16), + "{what}: row {i} wrapped past {cols} columns:\n{shown}" + ); + for frame in ['╭', '╮', '╰', '╯', '│', '┌', '┐', '└', '┘'] { + assert!( + !row.contains(frame), + "{what}: row {i} draws a frame ({frame}):\n{shown}" + ); + } + } + let first = text.iter().position(|r| !r.trim().is_empty()); + assert!(first.is_some(), "{what}: blank screen"); + assert!(rows > 0); +} + +/// `anyr auth status` on a TTY: summary rows, then the inline action picker. +fn status_in_pty(shell: &Path, cols: u16, env: &[(&str, &str)]) -> (vt100::Screen, Vec) { + let sandbox = Sandbox::new(&server()); + let cmd = sandbox.command(shell, &script("auth status"), env); + let mut pty = Pty::spawn(cmd, cols, ROWS); + // The hint is the last line of the picker frame. + let screen = pty.wait_for(&["Open dashboard", "Quit", "esc quit"]); + pty.send(b"q"); + let raw = pty.raw(); + pty.finish(); + (screen, raw) +} + +#[test] +fn status_menu_rows_start_at_one_column_in_every_shell_and_width() { + for (name, shell) in shells() { + for cols in WIDTHS { + let (screen, _) = status_in_pty(&shell, cols, &[]); + let what = format!("auth status, {name}, {cols} cols"); + assert_plain_top_left(&screen, &what); + let text: Vec = screen.rows(0, cols).collect(); + // WHY: the staircase bug. Every action must begin in the same + // column; a bare LF in raw mode shifts each row right. + let starts: Vec = STATUS_ACTIONS + .iter() + .map(|label| { + let row = text + .iter() + .find(|r| r.contains(label)) + .unwrap_or_else(|| panic!("{what}: {label} missing:\n{}", dump(&screen))); + row[..row.find(label).unwrap()].chars().count() + }) + .collect(); + assert!( + starts.windows(2).all(|w| w[0] == w[1]), + "{what}: menu rows start at columns {starts:?}:\n{}", + dump(&screen) + ); + // WHY: a value wider than the terminal is cut with an ellipsis on + // its own row, not wrapped into the next label. + let scopes = text + .iter() + .find(|r| r.starts_with("scopes")) + .unwrap_or_else(|| panic!("{what}: scopes row missing:\n{}", dump(&screen))); + assert!( + scopes.contains('…'), + "{what}: scopes not truncated:\n{}", + dump(&screen) + ); + // WHY: a paid key must never show a bare dash for its plan. + let plan = text.iter().find(|r| r.starts_with("plan")); + assert!( + plan.is_some_and(|r| r.contains("paid")), + "{what}: plan row should say paid:\n{}", + dump(&screen) + ); + if cols == 80 && name == "bash" { + eprintln!("--- auth status, 80 cols ---\n{}", dump(&screen)); + } + } + } +} + +/// Escape sequences that set colors or attributes (SGR, `ESC [ … m`). +fn has_sgr(bytes: &[u8]) -> bool { + let mut i = 0; + while i + 1 < bytes.len() { + if bytes[i] == 0x1b && bytes[i + 1] == b'[' { + let mut j = i + 2; + while j < bytes.len() && (bytes[j].is_ascii_digit() || bytes[j] == b';') { + j += 1; + } + if j < bytes.len() && bytes[j] == b'm' { + return true; + } + } + i += 1; + } + false +} + +/// Output from the first byte anyr itself wrote, so a shell's own startup +/// bytes cannot pass or fail the check. +fn from_first<'a>(raw: &'a [u8], marker: &str) -> &'a [u8] { + let m = marker.as_bytes(); + let at = raw + .windows(m.len()) + .position(|w| w == m) + .unwrap_or_else(|| panic!("{marker:?} not in output: {}", String::from_utf8_lossy(raw))); + &raw[at..] +} + +#[test] +fn no_color_drops_every_color_code_in_every_shell() { + for (name, shell) in shells() { + let (screen, raw) = status_in_pty(&shell, 80, &[("NO_COLOR", "1")]); + assert_plain_top_left(&screen, &format!("NO_COLOR, {name}")); + let out = from_first(&raw, "account"); + assert!( + !has_sgr(out), + "{name}: NO_COLOR output still sets colors:\n{:?}", + String::from_utf8_lossy(out) + ); + } +} + +#[test] +fn dumb_terminal_gets_plain_text_and_a_typed_prompt_in_every_shell() { + for (name, shell) in shells() { + let sandbox = Sandbox::new(&server()); + let cmd = sandbox.command(&shell, &script("auth status"), &[("TERM", "dumb")]); + let mut pty = Pty::spawn(cmd, 80, ROWS); + let screen = pty.wait_for(&["Quit", "q quit"]); + assert_plain_top_left(&screen, &format!("TERM=dumb, {name}")); + pty.send(b"q\n"); + let raw = pty.raw(); + pty.finish(); + // WHY: a dumb terminal cannot move the cursor or paint; any escape + // byte shows up as garbage. + let out = from_first(&raw, "account"); + assert!( + !out.contains(&0x1b), + "{name}: TERM=dumb output has escape codes:\n{:?}", + String::from_utf8_lossy(out) + ); + } +} + +#[test] +fn piped_output_has_no_escape_codes_in_every_shell() { + for (name, shell) in shells() { + let sandbox = Sandbox::new(&server()); + for args in ["auth status", "menu"] { + let out = sandbox + .command(&shell, &script(args), &[]) + .stdin(Stdio::null()) + .stderr(Stdio::piped()) + .stdout(Stdio::piped()) + .output() + .unwrap(); + let text = String::from_utf8_lossy(&out.stdout); + assert!(!text.trim().is_empty(), "{name} {args}: no output"); + assert!( + !out.stdout.contains(&0x1b) && !out.stderr.contains(&0x1b), + "{name} {args}: piped output has escape codes:\n{text:?}" + ); + } + } +} + +#[test] +fn launcher_renders_plain_from_the_top_left_in_every_shell_and_width() { + for (name, shell) in shells() { + for cols in WIDTHS { + let sandbox = Sandbox::new(&server()); + let cmd = sandbox.command(&shell, &script(""), &[]); + let mut pty = Pty::spawn(cmd, cols, ROWS); + // Snapshot while the launcher is still up: leaving the alternate + // screen restores the shell's screen. + // Credits fill in from a background lookup after the first frame. + let screen = pty.wait_for(&["claude", "quit", "esc", "$133.83"]); + let what = format!("launcher, {name}, {cols} cols"); + assert_plain_top_left(&screen, &what); + let text: Vec = screen.rows(0, cols).collect(); + // WHY: plain text starts on the first row, not in a vertically + // centered card. + let first = text.iter().position(|r| !r.trim().is_empty()).unwrap(); + assert!( + first <= 1, + "{what}: content starts on row {first}:\n{}", + dump(&screen) + ); + // The capabilities stay: account, credits, agents, configure, quit. + for want in ["account", "credits", "claude", "codex", "model…", "quit"] { + assert!( + screen.contents().contains(want), + "{what}: {want} missing:\n{}", + dump(&screen) + ); + } + if cols == 80 && name == "bash" { + eprintln!("--- launcher, 80 cols ---\n{}", dump(&screen)); + } + pty.send(b"\x1b"); + pty.finish(); + } + } +} From 6d8f886d6457f704c1d8e060326f612c5dd0165d Mon Sep 17 00:00:00 2001 From: Duyet Le Date: Wed, 28 Oct 2026 01:11:59 +0700 Subject: [PATCH 2/3] fix(status): --verbose prints the full lookup error on stderr Co-authored-by: Duyet Le Co-authored-by: duyetbot Co-Authored-By: Claude Opus 5.5 --- src/cmd/dispatch.rs | 3 ++- src/help.rs | 1 + tests/status.rs | 23 +++++++++++++++++++++++ 3 files changed, 26 insertions(+), 1 deletion(-) diff --git a/src/cmd/dispatch.rs b/src/cmd/dispatch.rs index 58231c6..a95d605 100644 --- a/src/cmd/dispatch.rs +++ b/src/cmd/dispatch.rs @@ -463,7 +463,7 @@ pub(crate) fn allowed_flags(command: &str) -> Option<&'static [&'static str]> { "base-url", "config", ], - "whoami" => &["profile", "config", "json", "key", "base-url"], + "whoami" => &["profile", "config", "json", "key", "base-url", "verbose"], "config" => &[ "config", "json", "key", "base-url", "profile", "dump-tui", "pick", ], @@ -616,6 +616,7 @@ pub(crate) fn allowed_flags(command: &str) -> Option<&'static [&'static str]> { "yes", "json", "masked", + "verbose", ], "prompt" => &["base-url", "json"], "menu" => &["dump-tui", "config", "profile", "key", "base-url"], diff --git a/src/help.rs b/src/help.rs index 6a7f779..7baeb5c 100644 --- a/src/help.rs +++ b/src/help.rs @@ -429,6 +429,7 @@ FLAGS --json Print as JSON: account, user, key_masked, default_model, config_path, base_url (identity_error if the lookup failed) --profile Use a named account + --verbose Print the full error on stderr when a lookup failed Also available as `{bin} whoami`. "; diff --git a/tests/status.rs b/tests/status.rs index 45412cb..35d3c7d 100644 --- a/tests/status.rs +++ b/tests/status.rs @@ -90,3 +90,26 @@ fn signed_out_prints_the_login_hint_and_exits_4() { assert!(stdout.contains("anyr auth login"), "{stdout}"); let _ = std::fs::remove_dir_all(&home); } + +#[test] +fn failed_key_lookup_is_short_on_stdout_and_full_with_verbose() { + let base = serve_routes(vec![ + ("/api/v1/me", 200, ME.into()), + ("/api/v1/key", 500, r#"{"error":{"message":"boom"}}"#.into()), + ]); + let (code, out, err) = run(&base, &["auth", "status"], None); + assert_eq!(code, 0, "{out}{err}"); + // WHY: a long error wrapped across the terminal and broke the rows. + assert!(out.contains("scopes unavailable (HTTP 500)"), "{out}"); + assert!(!out.contains("plan"), "unknown plan must be hidden:\n{out}"); + assert!( + !err.contains("key lookup"), + "quiet without --verbose: {err}" + ); + let (code, out, err) = run(&base, &["auth", "status", "--verbose"], None); + assert_eq!(code, 0, "{out}{err}"); + assert!( + err.contains("key lookup: Could not fetch key info (HTTP 500)."), + "{err}" + ); +} From 14b97a9e876aac90cc7237f1d78c27990b3321b0 Mon Sep 17 00:00:00 2001 From: Duyet Le Date: Wed, 28 Oct 2026 01:32:31 +0700 Subject: [PATCH 3/3] fix(http): treat Windows os error 10060 as a timeout Co-authored-by: Duyet Le Co-authored-by: duyetbot --- src/http.rs | 38 ++++++++++++++++++++------------------ 1 file changed, 20 insertions(+), 18 deletions(-) diff --git a/src/http.rs b/src/http.rs index b45c442..a3f8056 100644 --- a/src/http.rs +++ b/src/http.rs @@ -45,26 +45,28 @@ fn unreachable(service: &str, err: &ureq::Error) -> String { return format!("Could not reach {service}: {err}"); }; let full = err.to_string(); - let reason = if full.to_ascii_lowercase().contains("timed out") { - "timed out".to_string() - } else { - match t.kind() { - ureq::ErrorKind::Dns => "DNS lookup failed".to_string(), - ureq::ErrorKind::ConnectionFailed => "could not connect".to_string(), - _ => { - let deepest = std::error::Error::source(err) - .map(|e| e.to_string()) - .or_else(|| t.message().map(str::to_string)) - .unwrap_or_else(|| t.kind().to_string()); - let kind = format!("{}: ", t.kind()); - let mut d = deepest.as_str(); - while let Some(rest) = d.strip_prefix(&kind) { - d = rest; + // Windows reports a read timeout as WSAETIMEDOUT (10060) with its own wording. + let reason = + if full.to_ascii_lowercase().contains("timed out") || full.contains("os error 10060") { + "timed out".to_string() + } else { + match t.kind() { + ureq::ErrorKind::Dns => "DNS lookup failed".to_string(), + ureq::ErrorKind::ConnectionFailed => "could not connect".to_string(), + _ => { + let deepest = std::error::Error::source(err) + .map(|e| e.to_string()) + .or_else(|| t.message().map(str::to_string)) + .unwrap_or_else(|| t.kind().to_string()); + let kind = format!("{}: ", t.kind()); + let mut d = deepest.as_str(); + while let Some(rest) = d.strip_prefix(&kind) { + d = rest; + } + d.to_string() } - d.to_string() } - } - }; + }; match t.url() { Some(url) => format!("Could not reach {service}: {url}: {reason}"), None => format!("Could not reach {service}: {reason}"),