diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f3ffa9b..3c6e742e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,16 @@ UTC release dates from [PyPI](https://pypi.org/project/hcom/#history). +## Unreleased + +### Added + +- Added a conversation tab to the TUI's vertical view (`c`): the selected agent's recent turns with the request, a tool summary and the reply, for local and remote agents. The latest turn is open and earlier ones are one line each; `e` shows every turn in full, `R` refreshes, and Enter messages the agent. Markdown headings, lists, bold, code and tables are rendered. Set `HCOM_TUI_TAB=chat` to open vertical view on it. +- Showed which account a Claude or Codex agent runs under (tool and logged-in e-mail, read from `CLAUDE_CONFIG_DIR` / `CODEX_HOME` when set) in the conversation tab header. Remote agents' e-mail is shared only when their device sets `hcom config relay_share_account 1`, and only for agents started after upgrading; a stopped agent's account is not shown. +- Showed a spinner with the current tool and elapsed time under the conversation while the agent is working. +- Answered a Claude agent's choice prompt from the conversation tab: while a local agent waits on a question (`AskUserQuestion`) or a permission prompt, its options are read off the agent's screen and listed under the conversation; a digit picks one and Enter moves on from a multi-select question. Keys are sent only while the same prompt is still on screen. Options that need typing are left to the agent's terminal. +- Marked a blocked agent in the conversation tab and in the compose footer: messages to it are held until its prompt is answered. + ## 0.7.31 — 2026-10-21 ### Breaking changes diff --git a/README.md b/README.md index 530ffa55..8057536e 100644 --- a/README.md +++ b/README.md @@ -87,6 +87,8 @@ hcom codex hcom ``` +In the TUI, `\` switches to the side-by-side view and `c` there shows the selected agent's recent conversation: each request, the tools it used, and the reply. Set `HCOM_TUI_TAB=chat` to open on it. + ## What agents can do - **Message** each other in real time. Messages arrive mid-turn (after tool calls) or wake idle agents immediately. @@ -300,6 +302,7 @@ hcom config -i # per-agent override at runtime | `terminal` | Where new agent windows open (`hcom config terminal --info`) | | `timeout` | Idle timeout for headless Claude (seconds) | | `subagent_timeout` | Keep-alive for Claude subagents (seconds) | +| `relay_share_account` | Send each agent's logged-in account e-mail to relay peers so their TUI can show it (default: off) | | `claude_args` / `gemini_args` / `codex_args` / `opencode_args` / `kilo_args` / `pi_args` / `omp_args` / `cursor_args` / `kimi_args` / `copilot_args` / `qoder_args` / `grok_args` / `hermes_args` | Default args passed to the tool | #### Scope diff --git a/src/accounts.rs b/src/accounts.rs new file mode 100644 index 00000000..c0ece200 --- /dev/null +++ b/src/accounts.rs @@ -0,0 +1,183 @@ +//! Logged-in account e-mail for a tool's config directory. Only the e-mail +//! leaves this module; tokens are parsed in place and dropped. +use std::path::{Path, PathBuf}; +use std::time::SystemTime; + +use crate::tool::Tool; + +/// Every `config_dir_env_key`. +pub const CONFIG_DIR_ENV_KEYS: [&str; 2] = ["CLAUDE_CONFIG_DIR", "CODEX_HOME"]; + +/// Env var that points a tool at a non-default config directory. +pub fn config_dir_env_key(tool: Tool) -> Option<&'static str> { + match tool { + Tool::Claude => Some("CLAUDE_CONFIG_DIR"), + Tool::Codex => Some("CODEX_HOME"), + _ => None, + } +} + +pub fn account_file(tool: Tool, config_dir: Option<&Path>) -> Option { + let home = crate::runtime_env::user_home; + match tool { + Tool::Claude => Some(match config_dir { + Some(d) => d.join(".claude.json"), + None => home()?.join(".claude.json"), + }), + Tool::Codex => Some(match config_dir { + Some(d) => d.join("auth.json"), + None => home()?.join(".codex").join("auth.json"), + }), + _ => None, + } +} + +pub type Stamp = Option<(u64, Option)>; + +pub fn file_stamp(path: &Path) -> Stamp { + std::fs::metadata(path) + .ok() + .map(|m| (m.len(), m.modified().ok())) +} + +pub fn read_email(tool: Tool, config_dir: Option<&Path>) -> Option { + let path = account_file(tool, config_dir)?; + let text = std::fs::read_to_string(path).ok()?; + match tool { + Tool::Claude => parse_claude_email(&text), + Tool::Codex => parse_codex_email(&text), + _ => None, + } +} + +fn parse_claude_email(json: &str) -> Option { + let v: serde_json::Value = serde_json::from_str(json).ok()?; + v.get("oauthAccount")? + .get("emailAddress")? + .as_str() + .filter(|s| !s.is_empty()) + .map(str::to_owned) +} + +/// ChatGPT logins carry the e-mail in the id_token; API-key logins have none. +fn parse_codex_email(json: &str) -> Option { + let v: serde_json::Value = serde_json::from_str(json).ok()?; + if v.get("auth_mode").and_then(|m| m.as_str()) != Some("chatgpt") { + return None; + } + jwt_payload_email(v.get("tokens")?.get("id_token")?.as_str()?) +} + +fn jwt_payload_email(id_token: &str) -> Option { + use base64::Engine; + let parts: Vec<&str> = id_token.split('.').collect(); + if parts.len() != 3 { + return None; + } + let raw = base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(parts[1].trim_end_matches('=')) + .ok()?; + let v: serde_json::Value = serde_json::from_slice(&raw).ok()?; + v.get("email")? + .as_str() + .filter(|s| !s.is_empty()) + .map(str::to_owned) +} + +/// Re-reads an account file only when its size or mtime changes; +/// `.claude.json` can be large. +#[derive(Default)] +pub struct EmailCache { + entries: Vec<(PathBuf, Stamp, Option)>, +} + +impl EmailCache { + pub fn email(&mut self, tool: Tool, config_dir: Option<&Path>) -> Option { + let path = account_file(tool, config_dir)?; + let stamp = file_stamp(&path); + if let Some((_, s, e)) = self.entries.iter().find(|(p, ..)| *p == path) + && *s == stamp + { + return e.clone(); + } + let email = read_email(tool, config_dir); + self.entries.retain(|(p, ..)| *p != path); + if self.entries.len() >= 32 { + self.entries.remove(0); + } + self.entries.push((path, stamp, email.clone())); + email + } +} + +#[cfg(test)] +mod tests { + use super::*; + use base64::Engine; + + fn fake_jwt(payload: &str) -> String { + let p = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(payload); + format!("eyJhbGciOiJub25lIn0.{p}.sig") + } + + #[test] + fn claude_email() { + assert_eq!( + parse_claude_email(r#"{"oauthAccount":{"emailAddress":"a@b.com"}}"#).as_deref(), + Some("a@b.com") + ); + assert_eq!(parse_claude_email(r#"{"other":1}"#), None); + assert_eq!(parse_claude_email("{broken"), None); + } + + #[test] + fn codex_email() { + let auth = |mode: &str, tok: &str| { + format!(r#"{{"auth_mode":"{mode}","tokens":{{"id_token":"{tok}"}}}}"#) + }; + let tok = fake_jwt(r#"{"email":"c@d.com"}"#); + assert_eq!( + parse_codex_email(&auth("chatgpt", &tok)).as_deref(), + Some("c@d.com") + ); + assert_eq!(parse_codex_email(&auth("apikey", &tok)), None); + assert_eq!(parse_codex_email(&auth("chatgpt", "a.b")), None); + assert_eq!(parse_codex_email(&auth("chatgpt", "a.b.c.d")), None); + // Trailing garbage after the JSON is rejected. + let padded = format!( + "h.{}.s", + base64::engine::general_purpose::URL_SAFE.encode(r#"{"email":"p@q.com"}x"#) + ); + assert_eq!(jwt_payload_email(&padded), None); + // Padded payloads decode too. + let padded = format!( + "h.{}.s", + base64::engine::general_purpose::URL_SAFE.encode(r#"{"email":"p@q.com"}"#) + ); + assert_eq!(jwt_payload_email(&padded).as_deref(), Some("p@q.com")); + } + + #[test] + fn files_and_cache() { + let dir = tempfile::tempdir().unwrap(); + assert_eq!( + account_file(Tool::Claude, Some(dir.path())), + Some(dir.path().join(".claude.json")) + ); + assert_eq!(account_file(Tool::Gemini, Some(dir.path())), None); + let path = dir.path().join(".claude.json"); + std::fs::write(&path, r#"{"oauthAccount":{"emailAddress":"a@b.com"}}"#).unwrap(); + let mut cache = EmailCache::default(); + assert_eq!( + cache.email(Tool::Claude, Some(dir.path())).as_deref(), + Some("a@b.com") + ); + std::fs::write(&path, r#"{"oauthAccount":{"emailAddress":"longer@b.com"}}"#).unwrap(); + assert_eq!( + cache.email(Tool::Claude, Some(dir.path())).as_deref(), + Some("longer@b.com") + ); + std::fs::remove_file(&path).unwrap(); + assert_eq!(cache.email(Tool::Claude, Some(dir.path())), None); + } +} diff --git a/src/commands/config.rs b/src/commands/config.rs index 3df79d2e..678dc24c 100644 --- a/src/commands/config.rs +++ b/src/commands/config.rs @@ -770,6 +770,16 @@ fn config_bootstrap(db: &HcomDb, args: &[String]) -> i32 { } /// Main entry point for `hcom config` command. +/// Keys only `hcom relay` sets. `HCOM_RELAY_SHARE_ACCOUNT` is not one: it +/// changes what the worker sends, not whether or where it runs. +const RELAY_MANAGED_KEYS: &[&str] = &[ + "HCOM_RELAY", + "HCOM_RELAY_ID", + "HCOM_RELAY_TOKEN", + "HCOM_RELAY_ENABLED", + "HCOM_RELAY_PSK", +]; + pub fn cmd_config(db: &HcomDb, args: &ConfigArgs, ctx: Option<&CommandContext>) -> i32 { if args.key.as_deref() != Some("bootstrap") && !args.extra.is_empty() { eprintln!("Error: quote multi-word config values"); @@ -999,7 +1009,7 @@ pub fn cmd_config(db: &HcomDb, args: &ConfigArgs, ctx: Option<&CommandContext>) } // Relay settings change what the relay worker does and must go through // `hcom relay`, which validates them and starts/stops the worker to match. - if key.starts_with("HCOM_RELAY") { + if RELAY_MANAGED_KEYS.contains(&key.as_str()) { eprintln!( "Error: {key} is managed by 'hcom relay' (new, connect, on, off).\nRun: hcom relay --help" ); @@ -2327,6 +2337,29 @@ mod tests { ); } + #[test] + fn test_relay_share_account_is_set_through_config() { + assert!(!RELAY_MANAGED_KEYS.contains(&normalize_key("relay_share_account").as_str())); + for key in [ + "relay", + "relay_id", + "relay_token", + "relay_enabled", + "relay_psk", + ] { + assert!( + RELAY_MANAGED_KEYS.contains(&normalize_key(key).as_str()), + "{key}" + ); + } + + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("config.toml"); + config_set_at_path(&path, "HCOM_RELAY_SHARE_ACCOUNT", Some("1")).unwrap(); + let parsed: toml::Table = std::fs::read_to_string(&path).unwrap().parse().unwrap(); + assert_eq!(parsed["relay"]["share_account"].as_bool(), Some(true)); + } + #[test] fn test_config_set_saves_valid_args() { let dir = tempfile::tempdir().unwrap(); diff --git a/src/commands/help.rs b/src/commands/help.rs index e286ad64..68a68279 100644 --- a/src/commands/help.rs +++ b/src/commands/help.rs @@ -662,9 +662,14 @@ const CONFIG_KEYS_HELP: &[HelpEntry] = &[ " title_mode", "Terminal/tab title: combined, label, or off", ), + ( + " relay_share_account", + "Send agents' login e-mail to relay peers (default off)", + ), ]; -/// Registry keys left out of help: relay keys are managed by `hcom relay`; +/// Registry keys left out of help: the relay worker's keys are managed by +/// `hcom relay` (`relay_share_account` is set with `hcom config`, so listed); /// `timeout` only affects headless/vanilla Claude (see `config timeout --info`). #[cfg(test)] const CONFIG_KEYS_NOT_IN_HELP: &[&str] = &[ diff --git a/src/commands/term.rs b/src/commands/term.rs index 1b57ff13..750f8a9b 100644 --- a/src/commands/term.rs +++ b/src/commands/term.rs @@ -117,6 +117,89 @@ pub fn inject_text_remote_result( Ok(label) } +/// What a step answers. Keys go out only while the screen shows exactly +/// it: the caller reads the screen the way it read the prompt it showed, +/// so a different prompt that merely contains the same text does not pass. +pub trait ScreenCheck { + fn shown_on(&self, lines: &[String]) -> bool; +} + +/// One step of answering a prompt in an agent's terminal: `keys` are sent, +/// one chunk at a time, once `expect` is what the screen shows. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ScreenStep { + pub expect: C, + pub keys: Vec>, +} + +const STEP_SCREEN_TIMEOUT: Duration = Duration::from_secs(2); +/// Gap between key chunks, so the agent's TUI reads each as its own key +/// rather than one pasted string. +const KEY_GAP: Duration = Duration::from_millis(120); + +fn screen_lines(port: i32) -> Option> { + let screen = query_screen(port)?; + Some( + screen + .get("lines")? + .as_array()? + .iter() + .filter_map(|l| l.as_str().map(String::from)) + .collect(), + ) +} + +/// The agent's terminal screen, one string per row; `None` without a PTY. +pub(crate) fn screen_of(db: &HcomDb, name: &str) -> Option> { + screen_lines(get_inject_port(db, name)?) +} + +/// Send each step's keys once the screen shows what it answers. Stops at +/// the first step whose prompt does not appear. The screen is read just +/// before the keys go out, so a prompt that changes in between can still +/// receive them. +pub fn answer_on_screen( + db: &HcomDb, + name: &str, + steps: &[ScreenStep], +) -> Result { + let port = get_inject_port(db, name).ok_or_else(|| no_terminal_error(db, name))?; + for (i, step) in steps.iter().enumerate() { + let deadline = Instant::now() + STEP_SCREEN_TIMEOUT; + loop { + let lines = screen_lines(port); + if lines + .as_ref() + .is_some_and(|lines| step.expect.shown_on(lines)) + { + break; + } + if Instant::now() >= deadline { + return Err(match (i, lines.is_some()) { + (0, false) => format!("no response from {name}'s terminal; nothing sent"), + (0, true) => { + format!("the prompt is no longer on {name}'s screen; nothing sent") + } + (_, false) => { + format!( + "no response from {name}'s terminal after step {i}; answer the rest there" + ) + } + (_, true) => { + format!("{name}'s screen changed after step {i}; answer the rest there") + } + }); + } + std::thread::sleep(Duration::from_millis(50)); + } + for chunk in &step.keys { + inject_raw(port, chunk)?; + std::thread::sleep(KEY_GAP); + } + } + Ok(format!("Sent keys to {name}")) +} + /// Poll the screen until the injected text exclusively fills the input box /// before returning, so Enter is sent once the TUI has actually rendered it /// rather than after a guessed delay. Tools with no input-box parser (e.g. @@ -558,6 +641,128 @@ mod tests { assert_eq!(received, "status"); } + /// A fake PTY: answers screen queries with `lines`, records the rest. + fn fake_terminal( + db: &HcomDb, + lines: &[&str], + ) -> std::sync::Arc>>> { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port() as i32; + db.conn() + .execute( + "INSERT INTO notify_endpoints (instance, kind, port, updated_at) VALUES (?1, 'inject', ?2, 0)", + rusqlite::params!["luna", port], + ) + .unwrap(); + let screen = serde_json::json!({ "lines": lines }).to_string(); + let keys = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); + let recorded = keys.clone(); + thread::spawn(move || { + for stream in listener.incoming() { + let Ok(mut stream) = stream else { return }; + let mut request = Vec::new(); + stream.read_to_end(&mut request).unwrap(); + if request == b"\x00SCREEN\n" { + stream.write_all(screen.as_bytes()).unwrap(); + } else { + recorded.lock().unwrap().push(request); + } + } + }); + keys + } + + use crate::tui::transcript_view::prompt::{ScreenPrompt, parse_screen}; + + /// The prompt the TUI read off the screen and answers with `2`. + const PROMPT: [&str; 11] = [ + "────────────────────────────────", + " Bash command", + " Create probe-perm.txt", + "╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌", + " touch probe-perm.txt", + "╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌", + " Do you want to proceed?", + " ❯ 1. Yes", + " 2. Yes, and don't ask again", + " 3. No", + " Esc to cancel · Tab to amend", + ]; + + fn lines(screen: &[&str]) -> Vec { + screen.iter().map(|l| l.to_string()).collect() + } + + fn step(screen: &[&str]) -> ScreenStep { + ScreenStep { + expect: parse_screen(&lines(screen)).unwrap(), + keys: vec![b"2".to_vec()], + } + } + + fn sent(keys: &std::sync::Mutex>>) -> Vec> { + for _ in 0..100 { + if !keys.lock().unwrap().is_empty() { + break; + } + thread::sleep(Duration::from_millis(10)); + } + keys.lock().unwrap().clone() + } + + #[test] + fn answer_on_screen_sends_only_to_the_matching_prompt() { + let db = test_db(); + let keys = fake_terminal(&db, &PROMPT); + + let mut other = PROMPT; + other[4] = " rm -rf build"; + let err = answer_on_screen(&db, "luna", &[step(&other)]).unwrap_err(); + assert_eq!( + err, + "the prompt is no longer on luna's screen; nothing sent" + ); + assert!(keys.lock().unwrap().is_empty()); + + let ok = answer_on_screen(&db, "luna", &[step(&PROMPT)]); + assert_eq!(ok, Ok("Sent keys to luna".to_string())); + assert_eq!(sent(&keys), vec![b"2".to_vec()]); + } + + /// SEC-003: a key meant for prompt A must not reach a later prompt B + /// whose command contains A's. Every line of A is somewhere on B's + /// screen, so a text-contains match would send it. + #[test] + fn answer_on_screen_blocks_a_prompt_that_contains_the_answered_one() { + let db = test_db(); + let mut b = PROMPT; + b[4] = " touch probe-perm.txt; touch other.txt"; + assert!(parse_screen(&lines(&b)).is_some(), "B is a prompt"); + let keys = fake_terminal(&db, &b); + let err = answer_on_screen(&db, "luna", &[step(&PROMPT)]).unwrap_err(); + assert!(err.contains("nothing sent"), "{err}"); + assert!(keys.lock().unwrap().is_empty()); + } + + #[test] + fn answer_on_screen_reports_a_silent_terminal() { + let db = test_db(); + // Bound and dropped: nothing listens on the port. + let port = TcpListener::bind("127.0.0.1:0") + .unwrap() + .local_addr() + .unwrap() + .port() as i32; + db.conn() + .execute( + "INSERT INTO notify_endpoints (instance, kind, port, updated_at) VALUES (?1, 'inject', ?2, 0)", + rusqlite::params!["luna", port], + ) + .unwrap(); + let err = answer_on_screen(&db, "luna", &[step(&PROMPT)]).unwrap_err(); + assert_eq!(err, "no response from luna's terminal; nothing sent"); + } + #[test] fn test_read_instance_screen_formats_contract_output() { let db = test_db(); diff --git a/src/config.rs b/src/config.rs index 5207ddc6..64ceab26 100644 --- a/src/config.rs +++ b/src/config.rs @@ -242,6 +242,7 @@ pub(crate) static FIELDS: LazyLock> = LazyLock::new(|| { f("relay_id", "HCOM_RELAY_ID", "relay.id", Str, "Relay group identifier (managed by hcom relay)"), f("relay_token", "HCOM_RELAY_TOKEN", "relay.token", Str, "Relay authentication token (managed by hcom relay)"), f("relay_enabled", "HCOM_RELAY_ENABLED", "relay.enabled", Bool, "Enable relay sync (managed by hcom relay)"), + f("relay_share_account", "HCOM_RELAY_SHARE_ACCOUNT", "relay.share_account", Bool, "Share agents' logged-in account e-mail with relay peers"), // Forge/decrypt authority for the whole relay group: never shown by // `hcom config`, never settable through it, never exported to env. f("relay_psk", "HCOM_RELAY_PSK", "relay.psk", Str, "Relay pre-shared key"), @@ -316,6 +317,7 @@ pub struct HcomConfig { pub relay_token: String, pub relay_psk: String, pub relay_enabled: bool, + pub relay_share_account: bool, pub auto_approve: bool, pub auto_subscribe: String, pub name_export: String, @@ -350,6 +352,7 @@ impl Default for HcomConfig { relay_token: String::new(), relay_psk: String::new(), relay_enabled: true, + relay_share_account: false, auto_approve: true, auto_subscribe: "collision".to_string(), name_export: String::new(), @@ -530,6 +533,7 @@ impl HcomConfig { "relay_token" => Some(self.relay_token.clone()), "relay_psk" => Some(self.relay_psk.clone()), "relay_enabled" => Some(if self.relay_enabled { "1" } else { "0" }.into()), + "relay_share_account" => Some(if self.relay_share_account { "1" } else { "0" }.into()), "auto_approve" => Some(if self.auto_approve { "1" } else { "0" }.into()), "auto_subscribe" => Some(self.auto_subscribe.clone()), "name_export" => Some(self.name_export.clone()), @@ -576,6 +580,7 @@ impl HcomConfig { "relay_token" => self.relay_token = value.to_string(), "relay_psk" => self.relay_psk = value.to_string(), "relay_enabled" => self.relay_enabled = !is_falsy(value), + "relay_share_account" => self.relay_share_account = !is_falsy(value), "auto_approve" => self.auto_approve = !is_falsy(value), "auto_subscribe" => self.auto_subscribe = value.to_string(), "name_export" => self.name_export = value.to_string(), @@ -2265,6 +2270,7 @@ auto_approve = false fn sample_value(field: &ConfigField) -> &'static str { match (field.kind, field.name) { (FieldKind::Int, _) => "123", + (FieldKind::Bool, "relay_share_account") => "1", (FieldKind::Bool, _) => "0", (_, "terminal") => "print", (_, "title_mode") => "label", diff --git a/src/instance_binding.rs b/src/instance_binding.rs index b4446b32..3603fe0a 100644 --- a/src/instance_binding.rs +++ b/src/instance_binding.rs @@ -226,6 +226,18 @@ fn capture_context() -> serde_json::Map { env_map.insert((*key).to_string(), serde_json::json!(val)); } } + // Where the tool keeps its login, read later from other processes: so + // absolute, and "" for the tool's default, which tells a reader this + // context was captured with the key rather than before it existed. + for key in crate::accounts::CONFIG_DIR_ENV_KEYS { + let dir = std::env::var_os(key) + .filter(|v| !v.is_empty()) + .map(std::path::PathBuf::from) + .map(|p| std::path::absolute(&p).unwrap_or(p)) + .map(|p| p.to_string_lossy().into_owned()) + .unwrap_or_default(); + env_map.insert(key.to_string(), serde_json::json!(dir)); + } ctx.insert("env".into(), serde_json::Value::Object(env_map)); // The launcher already resolved the effective preset; record it so @@ -1401,6 +1413,25 @@ mod tests { ); } + #[test] + #[serial] + fn test_capture_context_records_config_dirs() { + crate::config::Config::init(); + let _claude = EnvVarGuard::set("CLAUDE_CONFIG_DIR", "rel/claude"); + let _codex = EnvVarGuard::set("CODEX_HOME", ""); + + let ctx = capture_context(); + let env = ctx.get("env").unwrap(); + + let claude = env["CLAUDE_CONFIG_DIR"].as_str().unwrap(); + assert_eq!( + std::path::Path::new(claude), + std::env::current_dir().unwrap().join("rel/claude") + ); + // Unset is recorded as the default, not left out. + assert_eq!(env["CODEX_HOME"].as_str(), Some("")); + } + #[test] #[serial] fn test_capture_and_store_launch_context_preserves_terminal_metadata() { diff --git a/src/main.rs b/src/main.rs index 9a01e560..bcb3c834 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,6 +4,7 @@ //! each tool's own UI. Agents use `hcom` CLI commands (learnt through bootstrap.rs) //! as a side-channel for messaging and coordination with other agents. +mod accounts; mod bootstrap; mod claude_actor; mod cli_context; diff --git a/src/relay/client.rs b/src/relay/client.rs index e65133ab..ea6de4bd 100644 --- a/src/relay/client.rs +++ b/src/relay/client.rs @@ -638,9 +638,8 @@ impl MqttRelay { /// Re-read the active PSK from disk. This is a best-effort escape hatch for /// same-namespace config changes; full relay resets still restart the worker. - fn reload_psk_if_changed(&self) { - let cfg = HcomConfig::load(None); - if let Ok(new) = load_psk(&cfg) { + fn reload_psk_if_changed(&self, cfg: &HcomConfig) { + if let Ok(new) = load_psk(cfg) { let mut psk = match self.psk.lock() { Ok(psk) => psk, Err(e) => { @@ -672,7 +671,8 @@ impl MqttRelay { } }; - self.reload_psk_if_changed(); + let cfg = HcomConfig::load(None); + self.reload_psk_if_changed(&cfg); let psk = match self.psk.lock() { Ok(psk) => *psk, Err(e) => { @@ -690,6 +690,7 @@ impl MqttRelay { &self.relay_id, &self.device_uuid, &psk, + cfg.relay_share_account, true, ) { Ok((true, has_more)) => { diff --git a/src/relay/control.rs b/src/relay/control.rs index c7f22cdf..60a6f6b4 100644 --- a/src/relay/control.rs +++ b/src/relay/control.rs @@ -706,10 +706,13 @@ fn string_list_param(params: &Value, key: &str) -> Vec { } fn normalize_config_field(field: &str) -> String { - if field.starts_with("HCOM_") { - field.to_string() + // Config keys match case-insensitively, so the remote guards must compare + // the uppercased form or `hcom_relay_psk` would slip past them. + let upper = field.to_uppercase(); + if upper.starts_with("HCOM_") { + upper } else { - format!("HCOM_{}", field.to_uppercase()) + format!("HCOM_{upper}") } } @@ -937,6 +940,10 @@ fn handle_remote_config_set( // Flipping the flag alone leaves the worker running; relay_off stops it. return Err("relay_enabled is not remotely settable; use relay off".to_string()); } + if normalized == "HCOM_RELAY_SHARE_ACCOUNT" { + // Sharing this device's e-mails is its own user's opt-in. + return Err("relay_share_account is not remotely settable".to_string()); + } crate::commands::config::config_set_and_apply(&normalized, value)?; Ok(json!({"field": field, "value": value})) } @@ -1061,9 +1068,9 @@ const REMOTE_EVENTS_MIN_BYTES: usize = 1024; /// Largest events answer this device can publish right now. The answer rides /// in this device's next push next to its state snapshot, so it gets the push /// budget (sized for every live receiver) minus that snapshot and the wrapper. -fn remote_events_byte_cap(db: &HcomDb) -> usize { +fn remote_events_byte_cap(db: &HcomDb, share_account: bool) -> usize { let state_bytes = super::existing_device_uuid() - .map(|uuid| super::push::build_state(db, &uuid)) + .map(|uuid| super::push::build_state(db, &uuid, share_account)) .and_then(|state| serde_json::to_vec(&state).ok()) .map_or(0, |v| v.len()); super::push::sealed_payload_budget(db) @@ -1077,7 +1084,7 @@ fn handle_remote_events( db: &HcomDb, params: &Value, _initiated_by: &str, - _config: &HcomConfig, + config: &HcomConfig, ) -> Result { let filters: crate::core::filters::FilterMap = match params.get("filters") { Some(v) if !v.is_null() => { @@ -1088,7 +1095,7 @@ fn handle_remote_events( let sql = optional_param(params, "sql").map(|s| s.to_string()); // Callers whose answer must share a snapshot with other data (catch-up // backfill) ask for a smaller byte budget; the widest is what fits a publish. - let widest = remote_events_byte_cap(db); + let widest = remote_events_byte_cap(db, config.relay_share_account); let byte_cap = usize_param(params, "max_bytes", widest).clamp(REMOTE_EVENTS_MIN_BYTES, widest); let mut last_n = usize_param(params, "last", 20); if last_n == 0 { @@ -1975,14 +1982,16 @@ mod tests { #[test] fn test_handle_remote_config_get_blocks_relay_psk() { let db = test_db(); - let err = handle_remote_config_get( - &db, - &json!({"fields": ["relay_psk"]}), - "initiator", - &HcomConfig::default(), - ) - .unwrap_err(); - assert_eq!(err, "relay_psk is not remotely queryable"); + for field in ["relay_psk", "HCOM_relay_psk", "hcom_RELAY_PSK"] { + let err = handle_remote_config_get( + &db, + &json!({"fields": [field]}), + "initiator", + &HcomConfig::default(), + ) + .unwrap_err(); + assert_eq!(err, "relay_psk is not remotely queryable"); + } } #[test] @@ -2024,6 +2033,26 @@ mod tests { assert_eq!(err, "relay_psk is not remotely queryable"); } + #[test] + fn test_handle_remote_config_set_blocks_relay_share_account() { + let db = test_db(); + for field in [ + "relay_share_account", + "HCOM_RELAY_SHARE_ACCOUNT", + "HCOM_relay_share_account", + "hcom_Relay_Share_Account", + ] { + let err = handle_remote_config_set( + &db, + &json!({"field": field, "value": "1"}), + "initiator", + &HcomConfig::default(), + ) + .unwrap_err(); + assert_eq!(err, "relay_share_account is not remotely settable"); + } + } + fn seed_events(db: &HcomDb, count: usize) { for i in 0..count { let etype = if i % 2 == 0 { "message" } else { "status" }; @@ -2122,7 +2151,7 @@ mod tests { ); let envelope_len = serde_json::to_string(&out).unwrap().len(); assert!( - envelope_len <= remote_events_byte_cap(&db), + envelope_len <= remote_events_byte_cap(&db, false), "envelope {envelope_len} bytes exceeds cap" ); } @@ -2163,7 +2192,7 @@ mod tests { &HcomConfig::default(), ) .unwrap(); - assert!(serde_json::to_string(&out).unwrap().len() <= remote_events_byte_cap(&db)); + assert!(serde_json::to_string(&out).unwrap().len() <= remote_events_byte_cap(&db, false)); } #[test] @@ -2179,7 +2208,7 @@ mod tests { .unwrap(); let out = handle_remote_events( &db, - &json!({"last": 1, "max_bytes": remote_events_byte_cap(&db)}), + &json!({"last": 1, "max_bytes": remote_events_byte_cap(&db, false)}), "initiator", &HcomConfig::default(), ) @@ -2189,7 +2218,7 @@ mod tests { assert_eq!(out["truncated"].as_bool(), Some(true)); assert_eq!(events[0]["data"]["from"], "luna"); assert_eq!(events[0]["data"]["_relay_truncated"]["level"], 1); - assert!(serde_json::to_string(&out).unwrap().len() <= remote_events_byte_cap(&db)); + assert!(serde_json::to_string(&out).unwrap().len() <= remote_events_byte_cap(&db, false)); } #[test] @@ -2216,7 +2245,7 @@ mod tests { assert_eq!(narrow["truncated"].as_bool(), Some(true)); let wide = handle_remote_events( &db, - &json!({"last": 1, "max_bytes": remote_events_byte_cap(&db)}), + &json!({"last": 1, "max_bytes": remote_events_byte_cap(&db, false)}), "initiator", &HcomConfig::default(), ) diff --git a/src/relay/mod.rs b/src/relay/mod.rs index 138a530d..a2e6eedb 100644 --- a/src/relay/mod.rs +++ b/src/relay/mod.rs @@ -367,6 +367,7 @@ pub fn clear_relay_device_state(db: &HcomDb) { "relay_sync_time_", "relay_state_ts_", "relay_ctrl_", + "relay_accounts_", PEER_MAX_PACKET_PREFIX, ]; for prefix in &prefixes { @@ -1298,6 +1299,16 @@ mod tests { db } + #[test] + fn clear_relay_device_state_drops_peer_accounts() { + let db = test_db(); + safe_kv_set(&db, "relay_accounts_ABCD", Some(r#"{"luna":"a@b.c"}"#)); + safe_kv_set(&db, "relay_caps_ABCD", Some("[]")); + clear_relay_device_state(&db); + assert!(safe_kv_get(&db, "relay_accounts_ABCD").is_none()); + assert!(safe_kv_get(&db, "relay_caps_ABCD").is_none()); + } + #[test] fn clear_runtime_relay_kv_nukes_runtime_health_fields() { let db = test_db(); diff --git a/src/relay/pull.rs b/src/relay/pull.rs index 01130e13..a249247f 100644 --- a/src/relay/pull.rs +++ b/src/relay/pull.rs @@ -118,6 +118,7 @@ pub fn handle_device_gone(db: &HcomDb, device_id: &str) { safe_kv_set(db, &format!("relay_sync_time_{}", device_id), None); safe_kv_set(db, &format!("relay_caps_{}", device_id), None); safe_kv_set(db, &format!("relay_ctrl_{}", device_id), None); + safe_kv_set(db, &format!("relay_accounts_{}", device_id), None); safe_kv_set( db, &format!("{}{}", super::PEER_MAX_PACKET_PREFIX, device_id), @@ -416,6 +417,28 @@ pub fn handle_state_message( .cloned() .unwrap_or_default(); + // Account e-mails the peer chose to share, keyed by the namespaced row name. + let accounts: serde_json::Map = instances + .iter() + .filter_map(|(name, inst)| { + let email = inst.get("account_email")?.as_str()?; + Some(( + super::add_device_suffix(name, &short_id), + Value::from(email), + )) + }) + .collect(); + let accounts_key = format!("relay_accounts_{}", device_id); + if accounts.is_empty() { + safe_kv_set(db, &accounts_key, None); + } else { + safe_kv_set( + db, + &accounts_key, + Some(&Value::Object(accounts).to_string()), + ); + } + // Instance upserts and removals commit together (one sync, not one per row). let instances_applied = db.with_write_scope(|| { let mut seen_instances = std::collections::HashSet::new(); diff --git a/src/relay/push.rs b/src/relay/push.rs index 00a15aa2..df70596a 100644 --- a/src/relay/push.rs +++ b/src/relay/push.rs @@ -283,15 +283,35 @@ fn shrink_strings(value: &mut Value) { } } +/// Account e-mail for a local agent, read from the tool config dir it was +/// launched with. Only sent when `relay_share_account` is on. A context +/// captured without the dir key (bound by an older hcom) leaves the account +/// unknown: the default account may not be the agent's. +fn shared_account_email(tool: Option<&str>, launch_context: Option<&str>) -> Option { + use std::sync::{LazyLock, Mutex}; + static EMAILS: LazyLock> = LazyLock::new(Default::default); + let tool: crate::tool::Tool = tool.unwrap_or("claude").parse().ok()?; + let key = crate::accounts::config_dir_env_key(tool)?; + let context = serde_json::from_str::(launch_context?).ok()?; + // "" is the tool's default dir. + let dir = context.get("env")?.get(key)?.as_str()?.to_string(); + let dir = Some(dir).filter(|d| !d.is_empty()); + EMAILS + .lock() + .ok()? + .email(tool, dir.as_deref().map(std::path::Path::new)) +} + /// Build current instance state snapshot for publishing. /// Only includes local instances (no origin_device_id). -pub fn build_state(db: &HcomDb, device_uuid: &str) -> Value { +/// `share_account` is the `relay_share_account` setting. +pub fn build_state(db: &HcomDb, device_uuid: &str, share_account: bool) -> Value { let short_id = device_short_id_for_db(db, device_uuid); let instances = match db.conn().prepare( "SELECT name, status, status_context, status_detail, status_time, parent_name, directory, transcript_path, - wait_timeout, last_stop, tcp_mode, tag, tool, background + wait_timeout, last_stop, tcp_mode, tag, tool, background, launch_context FROM instances WHERE COALESCE(origin_device_id, '') = ''", ) { Ok(mut stmt) => { @@ -312,6 +332,7 @@ pub fn build_state(db: &HcomDb, device_uuid: &str) -> Value { row.get::<_, Option>(11)?, // tag row.get::<_, Option>(12)?, // tool row.get::<_, Option>(13)?, // background + row.get::<_, Option>(14)?, // launch_context )) }) .ok() @@ -325,10 +346,12 @@ pub fn build_state(db: &HcomDb, device_uuid: &str) -> Value { if name.starts_with('_') || name.starts_with("sys_") { continue; } - entries.push(( - name.clone(), - row.3.clone().unwrap_or_default(), - json!({ + let account = if share_account { + shared_account_email(row.12.as_deref(), row.14.as_deref()) + } else { + None + }; + let mut entry = json!({ "enabled": true, "status": row.1.as_deref().unwrap_or("unknown"), "context": row.2.as_deref().unwrap_or(""), @@ -342,8 +365,11 @@ pub fn build_state(db: &HcomDb, device_uuid: &str) -> Value { "tag": row.11, "tool": row.12.as_deref().unwrap_or("claude"), "background": row.13.unwrap_or(false), - }), - )); + }); + if let Some(email) = account { + entry["account_email"] = json!(email); + } + entries.push((name.clone(), row.3.clone().unwrap_or_default(), entry)); } Value::Object(with_detail_previews(db, &short_id, entries)) } @@ -436,8 +462,12 @@ fn log_preview_level(max_chars: usize) { /// Build push payload: state + events, returning (state, events, max_event_id, has_more). /// Fetches 101 rows, sends first 100 — has_more=true if 101st exists. -pub fn build_push_payload(db: &HcomDb, device_uuid: &str) -> (Value, Vec, i64, bool) { - let state = build_state(db, device_uuid); +pub fn build_push_payload( + db: &HcomDb, + device_uuid: &str, + share_account: bool, +) -> (Value, Vec, i64, bool) { + let state = build_state(db, device_uuid, share_account); let last_push_id: i64 = safe_kv_get(db, "relay_last_push_id") .and_then(|s| s.parse().ok()) @@ -524,13 +554,14 @@ pub fn push( relay_id: &str, device_uuid: &str, psk: &[u8; 32], + share_account: bool, is_worker: bool, ) -> Result<(bool, bool), String> { let last_push_id = safe_kv_get(db, "relay_last_push_id") .and_then(|value| value.parse().ok()) .unwrap_or(0); let (state, mut events, _unbounded_max_id, source_has_more) = - build_push_payload(db, device_uuid); + build_push_payload(db, device_uuid, share_account); let source_new_events = new_event_count(&events, last_push_id); let topic = state_topic(relay_id, device_uuid); @@ -628,7 +659,7 @@ mod tests { .unwrap(); safe_kv_set(&db, "relay_last_push_id", Some(&recent_id.to_string())); - let (_state, events, max_id, has_more) = build_push_payload(&db, "device-a"); + let (_state, events, max_id, has_more) = build_push_payload(&db, "device-a", false); assert!(!has_more); assert_eq!(max_id, recent_id); @@ -950,7 +981,7 @@ mod tests { ) .unwrap(); - let state = build_state(&db, "device-test"); + let state = build_state(&db, "device-test", false); let row_detail = state["instances"]["nova"]["detail"].as_str().unwrap(); let cmd = crate::relay::preview_fetch_command(row_detail).unwrap(); assert!( @@ -960,7 +991,7 @@ mod tests { assert!(row_detail.len() < 1000); // Relayed status events keep the whole text for subscriptions and filters. - let (_, events, _, _) = build_push_payload(&db, "device-test"); + let (_, events, _, _) = build_push_payload(&db, "device-test", false); let event = events.iter().find(|e| e["id"] == event_id).unwrap(); assert_eq!(event["data"]["detail"].as_str().unwrap().len(), 5000); @@ -997,7 +1028,7 @@ mod tests { ) .unwrap(); } - let state = build_state(&db, "device-test"); + let state = build_state(&db, "device-test", false); let rows = state["instances"].as_object().unwrap(); assert_eq!(rows.len(), 150, "no row is dropped"); assert!( @@ -1041,13 +1072,49 @@ mod tests { fn state_announces_this_devices_packet_limit() { let dir = tempfile::tempdir().unwrap(); let db = HcomDb::open_at(&dir.path().join("hcom.db")).unwrap(); - let state = build_state(&db, "device-test"); + let state = build_state(&db, "device-test", false); assert_eq!( state["max_packet"].as_u64(), Some(crate::relay::MQTT_MAX_PACKET_BYTES as u64) ); } + #[test] + fn state_shares_account_emails_only_when_on() { + let dir = tempfile::tempdir().unwrap(); + let db = HcomDb::open_at(&dir.path().join("hcom.db")).unwrap(); + let claude_dir = dir.path().join("claude"); + std::fs::create_dir(&claude_dir).unwrap(); + std::fs::write( + claude_dir.join(".claude.json"), + r#"{"oauthAccount":{"emailAddress":"luna@example.com"}}"#, + ) + .unwrap(); + let with_dir = json!({"env": {"CLAUDE_CONFIG_DIR": claude_dir}}).to_string(); + // Bound before the dir key was captured: the account is unknown. + let old = json!({"env": {"TERM_PROGRAM": "x"}}).to_string(); + for (name, ctx) in [("luna", with_dir), ("nova", old)] { + db.conn() + .execute( + "INSERT INTO instances (name, session_id, tool, status, status_time, + created_at, launch_context) + VALUES (?, ?, 'claude', 'active', 1, 0, ?)", + rusqlite::params![name, format!("s-{name}"), ctx], + ) + .unwrap(); + } + + let on = build_state(&db, "device-test", true); + assert_eq!( + on["instances"]["luna"]["account_email"].as_str(), + Some("luna@example.com") + ); + assert!(on["instances"]["nova"].get("account_email").is_none()); + + let off = build_state(&db, "device-test", false); + assert!(off["instances"]["luna"].get("account_email").is_none()); + } + #[test] fn state_includes_quiet_children() { // A stale child may still be alive; peers need its row to reach it. @@ -1063,7 +1130,7 @@ mod tests { 'subagent:stale', 1, 0);", ) .unwrap(); - let state = build_state(&db, "device-test"); + let state = build_state(&db, "device-test", false); let instances = state["instances"].as_object().unwrap(); assert!(instances.contains_key("nova")); assert!(instances.contains_key("nova_task_1")); @@ -1081,7 +1148,7 @@ mod tests { .unwrap(); let start = Instant::now(); - let error = push(&db, &client, "relay", "device", &[0x42; 32], false).unwrap_err(); + let error = push(&db, &client, "relay", "device", &[0x42; 32], false, false).unwrap_err(); assert!(error.starts_with("publish:"), "{error}"); // A blocking publish would wait forever; the slack only absorbs a // loaded debug build's db and seal work. diff --git a/src/tui/actions.rs b/src/tui/actions.rs index 03198e6f..c8d3ddfd 100644 --- a/src/tui/actions.rs +++ b/src/tui/actions.rs @@ -275,6 +275,19 @@ impl App { RpcOp::Command { cmd } => { self.show_command_output(&cmd, rpc_output_lines(result.result)); } + RpcOp::AnswerPrompt { name, .. } => { + // Done either way: the next key may go out. + self.transcript.answering.remove(&name); + // Read the screen again either way: the next question or tab, + // or the prompt that was dropped when a failed answer left it. + if let Some(agent) = self.data.agents.iter().find(|a| a.name == name) { + self.transcript.local.force_refresh(agent); + } + match result.result { + Ok(resp) if resp.ok() => self.reload_data_force(), + other => self.ui.flash = Some(rpc_error_flash("Answer failed", other)), + } + } } } diff --git a/src/tui/app.rs b/src/tui/app.rs index 41ded6af..672de29a 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -22,6 +22,7 @@ pub struct App { pub(crate) source: Box, pub(crate) rpc_client: Option, pub(crate) usage: crate::tui::usage::UsageWorker, + pub(crate) transcript: crate::tui::transcript_view::TranscriptView, } impl Default for App { @@ -48,6 +49,7 @@ impl App { source, rpc_client, usage: Default::default(), + transcript: Default::default(), ejector: Ejector::new(), ui: UiState { cursor: 0, @@ -229,6 +231,7 @@ impl App { }; dirty |= self.usage.poll(agent); } + dirty |= self.transcript_poll(std::time::Instant::now()); if self.drain_rpc_results() { dirty = true; } @@ -299,11 +302,13 @@ impl App { .any(|a| a.status == AgentStatus::Launching) || self.ejector.is_replaying() || rpc_pending; - let poll_ms = if animating || resize_cooldown > 0 { - 80 - } else { - 500 - }; + let pace = LoopPace::new( + animating, + self.transcript_spinning(), + self.transcript.has_pending_timer(), + resize_cooldown > 0, + ); + let poll_ms = pace.poll_ms; if event::poll(Duration::from_millis(poll_ms))? { loop { @@ -345,12 +350,12 @@ impl App { self.ui.tick += 1; // Animations need redraws for spinner frames etc. - if animating { + if pace.redraw { dirty = true; } // Data reload — refresh faster while launching/pending RPC, slower when idle. - let reload_ms = if animating { 120 } else { 350 }; + let reload_ms = pace.reload_ms; if last_reload.elapsed() >= Duration::from_millis(reload_ms) { self.reload_data(); last_reload = std::time::Instant::now(); @@ -383,6 +388,32 @@ impl App { } } +/// How fast the event loop turns, redraws and reloads data. +#[derive(Debug, PartialEq, Eq)] +struct LoopPace { + poll_ms: u64, + /// Redraw every turn. + redraw: bool, + reload_ms: u64, +} + +impl LoopPace { + /// The conversation tab's spinner redraws but leaves data reload at its + /// idle pace. Its timers (cursor settle, RPCs, reads) only need the loop + /// to turn fast: what they change marks the frame dirty itself. + fn new(animating: bool, spinning: bool, transcript_timer: bool, resizing: bool) -> Self { + Self { + poll_ms: if animating || spinning || transcript_timer || resizing { + 80 + } else { + 500 + }, + redraw: animating || spinning, + reload_ms: if animating { 120 } else { 350 }, + } + } +} + #[cfg(test)] mod tests { use super::*; @@ -491,6 +522,7 @@ mod tests { source: Box::new(NullSource), rpc_client: None, usage: Default::default(), + transcript: Default::default(), } } @@ -669,4 +701,37 @@ mod tests { // 2 agents + 1 remote header + 1 stopped header + 1 orphan header assert_eq!(app.total_visible_rows(), 5); } + + #[test] + fn transcript_timers_turn_the_loop_without_redraw_or_reload() { + let idle = LoopPace { + poll_ms: 500, + redraw: false, + reload_ms: 350, + }; + assert_eq!(LoopPace::new(false, false, false, false), idle); + assert_eq!( + LoopPace::new(false, false, true, false), + LoopPace { + poll_ms: 80, + ..idle + } + ); + assert_eq!( + LoopPace::new(false, true, false, false), + LoopPace { + poll_ms: 80, + redraw: true, + reload_ms: 350 + } + ); + assert_eq!( + LoopPace::new(true, false, false, false), + LoopPace { + poll_ms: 80, + redraw: true, + reload_ms: 120 + } + ); + } } diff --git a/src/tui/db.rs b/src/tui/db.rs index e4fbca27..5b5dd427 100644 --- a/src/tui/db.rs +++ b/src/tui/db.rs @@ -4,6 +4,7 @@ //! Used when HCOM_MOCK_TUI is not set. use rusqlite::{Connection, params}; +use std::collections::HashMap; use std::path::PathBuf; use crate::log::log_warn; @@ -311,6 +312,7 @@ fn load_all(conn: &Connection, default_limit: usize) -> DataState { remote_agents.sort_by(|a, b| b.created_at.total_cmp(&a.created_at)); let relay_health = load_relay_state(conn, configured, enabled); + let (tool_config_dirs, remote_accounts) = load_account_maps(conn, &device_uuid); DataState { agents, @@ -322,9 +324,73 @@ fn load_all(conn: &Connection, default_limit: usize) -> DataState { relay_enabled, relay_health, search_results: None, + remote_accounts, + tool_config_dirs, } } +/// For the conversation header: each local agent's launch-time tool config +/// dir (where its login lives), and e-mails peers shared for their agents. +/// A context from before the dir was recorded has no key: such an agent is +/// left out, as its account is not known. +fn load_account_maps( + conn: &Connection, + device_uuid: &str, +) -> (HashMap>, HashMap) { + let mut dirs = HashMap::new(); + let mut shared: HashMap> = HashMap::new(); + let Ok(mut stmt) = + conn.prepare("SELECT name, tool, origin_device_id, launch_context FROM instances") + else { + return (dirs, HashMap::new()); + }; + let Ok(rows) = stmt.query_map([], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, Option>(2)?, + row.get::<_, Option>(3)?, + )) + }) else { + return (dirs, HashMap::new()); + }; + let mut remote_rows = Vec::new(); + for (name, tool, origin, ctx) in rows.flatten() { + let origin = origin.unwrap_or_default(); + if !origin.is_empty() && origin != device_uuid { + remote_rows.push((name, origin)); + continue; + } + if let Some(tool) = AgentTool::parse(&tool).canonical() + && let Some(key) = crate::accounts::config_dir_env_key(tool) + && let Some(ctx) = ctx.filter(|c| !c.is_empty()) + && let Ok(v) = serde_json::from_str::(&ctx) + && let Some(dir) = v + .get("env") + .and_then(|e| e.get(key)) + .and_then(|d| d.as_str()) + { + // "" is the tool's default. + dirs.insert(name, (!dir.is_empty()).then(|| dir.to_string())); + } + } + let mut accounts = HashMap::new(); + for (name, origin) in remote_rows { + let map = shared.entry(origin.clone()).or_insert_with(|| { + kv_get(conn, &format!("relay_accounts_{origin}")) + .and_then(|s| serde_json::from_str(&s).ok()) + }); + if let Some(email) = map + .as_ref() + .and_then(|m| m.get(&name)) + .and_then(|e| e.as_str()) + { + accounts.insert(name, email.to_string()); + } + } + (dirs, accounts) +} + // ── Instance loading ──────────────────────────────────────────── /// Extract a string field from a JSON object, returning `default` if missing or non-string. @@ -1448,6 +1514,26 @@ mod tests { conn } + /// An agent launched before the config dir was recorded is not given the + /// default account: its row has no key, unlike a recorded default (""). + #[test] + fn account_maps_tell_a_missing_dir_from_the_default() { + let conn = Connection::open_in_memory().unwrap(); + conn.execute_batch( + "CREATE TABLE instances (name TEXT, tool TEXT, origin_device_id TEXT, + launch_context TEXT); + INSERT INTO instances VALUES + ('luna', 'claude', NULL, '{\"env\":{\"CLAUDE_CONFIG_DIR\":\"/work\"}}'), + ('nova', 'claude', NULL, '{\"env\":{\"CLAUDE_CONFIG_DIR\":\"\"}}'), + ('kira', 'claude', NULL, '{\"env\":{}}');", + ) + .unwrap(); + let (dirs, _) = super::load_account_maps(&conn, "this-device"); + assert_eq!(dirs.get("luna"), Some(&Some("/work".to_string()))); + assert_eq!(dirs.get("nova"), Some(&None)); + assert_eq!(dirs.get("kira"), None); + } + #[test] fn parse_tool_preserves_unknown_persisted_value() { assert_eq!( diff --git a/src/tui/input.rs b/src/tui/input.rs index 836bd80d..85da956f 100644 --- a/src/tui/input.rs +++ b/src/tui/input.rs @@ -335,6 +335,10 @@ impl App { if self.ui.overlay.is_some() && self.handle_overlay_key(code) { return; } + // A question the agent under the cursor waits on takes digits first. + if self.ui.overlay.is_none() && self.transcript_answer_key(code) { + return; + } match code { // MOVEMENT — always work, no conditions @@ -377,6 +381,14 @@ impl App { self.ui.msg_scroll = self.ui.msg_scroll.saturating_sub(5); } + // Conversation tab: Enter messages the agent being read, not the + // selection. Header and orphan rows keep the toggle below. + KeyCode::Enter + if self.conversation_visible() && self.cursor_display_name().is_some() => + { + let names: Vec = self.cursor_display_name().into_iter().collect(); + self.compose_to(&names); + } // SELECTION — in inline mode, auto-apply as scrollback filter KeyCode::Enter | KeyCode::Char(' ') => { self.toggle_select_at_cursor(); @@ -471,20 +483,7 @@ impl App { } // COMPOSE ENTRY - KeyCode::Char('m') => { - // Message: pre-fill @mentions for selected or cursor agent - self.ui.mode = InputMode::Compose; - self.ui.input.clear(); - self.ui.input_cursor = 0; - if !self.ui.selected.is_empty() { - for name in self.ui.selected.iter() { - self.ui.input.push_str(&format!("@{} ", name)); - } - } else if let Some(name) = self.cursor_display_name() { - self.ui.input = format!("@{} ", name); - } - self.ui.input_cursor = self.ui.input.len(); - } + KeyCode::Char('m') => self.compose_to_targets(), KeyCode::Char('b') => { // Broadcast: compose with no @mentions (sends to all) self.ui.mode = InputMode::Compose; @@ -516,6 +515,10 @@ impl App { KeyCode::Char('?') => { self.ui.help_open = true; } + // CONVERSATION TAB + KeyCode::Char('c') => self.transcript_toggle_tab(), + KeyCode::Char('R') => self.transcript_refresh(std::time::Instant::now()), + KeyCode::Char('e') => self.transcript_toggle_expand(), _ => {} } @@ -1299,6 +1302,42 @@ fn push_agent_command_suggestions(s: &mut Vec, agent: &Agent) } } +impl App { + /// Message: pre-fill @mentions for selected or cursor agent + fn compose_to_targets(&mut self) { + let names: Vec = if self.ui.selected.is_empty() { + self.cursor_display_name().into_iter().collect() + } else { + self.ui.selected.iter().cloned().collect() + }; + self.compose_to(&names); + } + + fn compose_to(&mut self, names: &[String]) { + self.ui.mode = InputMode::Compose; + self.ui.input.clear(); + for name in names { + self.ui.input.push_str(&format!("@{} ", name)); + } + self.ui.input_cursor = self.ui.input.len(); + } + + /// Recipients in the compose input that are waiting on a prompt in their + /// terminal; delivery holds messages to them until it is answered. + pub(crate) fn blocked_recipients(&self) -> Vec { + let (recipients, _) = parse_outbound_message(&self.ui.input); + recipients + .into_iter() + .filter(|r| { + self.all_agents().any(|a| { + a.status == AgentStatus::Blocked + && (a.display_name() == *r || a.action_name() == *r) + }) + }) + .collect() + } +} + #[cfg(test)] mod tests { use super::*; @@ -1501,6 +1540,22 @@ mod tests { assert_eq!(app.ui.input_cursor, app.ui.input.len()); } + #[test] + fn blocked_recipients_lists_mentioned_agents_waiting_on_a_prompt() { + let mut app = test_app(); + let luna = app + .data + .agents + .iter_mut() + .find(|a| a.name == "luna") + .expect("luna"); + luna.status = AgentStatus::Blocked; + app.ui.input = "@luna @nova hi".into(); + assert_eq!(app.blocked_recipients(), vec!["luna".to_string()]); + app.ui.input = "@nova hi".into(); + assert!(app.blocked_recipients().is_empty()); + } + #[test] fn broadcast_key_sets_all_target() { let mut app = test_app(); diff --git a/src/tui/mod.rs b/src/tui/mod.rs index 0c5aaf3d..cd2ad161 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -12,6 +12,7 @@ pub mod rpc_async; pub mod state; pub mod status; pub mod theme; +pub mod transcript_view; mod usage; use std::io::{Write, stdout}; @@ -72,6 +73,7 @@ fn prepare_vertical_viewport(app: &mut App) { app.source.set_timeline_limit(5000); app.ui.eject_filter = None; app.ui.msg_scroll = 0; + app.transcript.on_enter_vertical(); } fn prepare_inline_viewport(app: &mut App) { @@ -191,9 +193,12 @@ fn run_inner(viewport_height: u16) -> Result<()> { // HCOM_TUI_FULLSCREEN=1 starts directly in alternate screen (fullscreen) mode, // bypassing inline viewport which requires cursor position queries. + app.transcript.default_tab = + transcript_view::default_tab_from_env(std::env::var("HCOM_TUI_TAB").ok().as_deref()); if std::env::var("HCOM_TUI_FULLSCREEN").as_deref() == Ok("1") { app.ui.view_mode = self::model::ViewMode::Vertical; app.source.set_timeline_limit(5000); + app.transcript.tab = app.transcript.default_tab; } let mut in_alt_screen = false; diff --git a/src/tui/render/agents.rs b/src/tui/render/agents.rs index f919e22b..d3af6f09 100644 --- a/src/tui/render/agents.rs +++ b/src/tui/render/agents.rs @@ -50,7 +50,7 @@ fn selected_name_style(is_selected: bool) -> Style { } } -const SPINNER_FRAMES: &[&str] = &[ +pub(crate) const SPINNER_FRAMES: &[&str] = &[ "\u{280b}", "\u{2819}", "\u{2839}", "\u{2838}", "\u{283c}", "\u{2834}", "\u{2826}", "\u{2827}", "\u{2807}", "\u{280f}", ]; diff --git a/src/tui/render/messages.rs b/src/tui/render/messages.rs index d9f54f02..37366aca 100644 --- a/src/tui/render/messages.rs +++ b/src/tui/render/messages.rs @@ -618,7 +618,7 @@ fn render_command_output( /// Render lines with bottom-anchored scroll and an auto-hiding scrollbar. /// Return the total and adjusted offset for the next frame. -fn render_scrolled( +pub(crate) fn render_scrolled( frame: &mut Frame, area: Rect, lines: Vec>, diff --git a/src/tui/render/mod.rs b/src/tui/render/mod.rs index c28ef668..3cdf02b7 100644 --- a/src/tui/render/mod.rs +++ b/src/tui/render/mod.rs @@ -1,4 +1,5 @@ mod agents; +pub(crate) use agents::SPINNER_FRAMES; pub mod launch; pub(crate) mod messages; pub(crate) mod text; @@ -566,8 +567,16 @@ fn render_vertical(frame: &mut Frame, app: &mut App, area: Rect) { // Split right panel: 1-line heading + messages content let body_right = Layout::vertical([Constraint::Length(1), Constraint::Min(0)]).split(body[2]); - render_messages_heading(frame, body_right[0], app); - let scroll = messages::render_messages(frame, body_right[1], app); + use crate::tui::transcript_view::{RightTab, render as conversation}; + // A command's output shows on either tab until it is dismissed. + let scroll = if app.transcript.tab == RightTab::Conversation && app.ui.command_result.is_none() + { + conversation::render_conversation(frame, body_right[0], body_right[1], app) + } else { + render_messages_heading(frame, conversation::heading_area(body_right[0]), app); + messages::render_messages(frame, body_right[1], app) + }; + conversation::render_tabstrip(frame, body_right[0], app.transcript.tab); app.ui.scroll_max = scroll.max; app.ui.msg_scroll = scroll.offset; app.ui.last_msg_total = scroll.total; @@ -1392,14 +1401,26 @@ fn render_footer(frame: &mut Frame, area: Rect, app: &App) { ] } } - InputMode::Compose => vec![ - Span::raw(" "), - Span::styled("enter", key), - Span::styled(" send ", lbl), - Span::styled("\u{00b7} ", dot), - Span::styled("esc", key), - Span::styled(" cancel", lbl), - ], + InputMode::Compose => { + let mut hints = vec![ + Span::raw(" "), + Span::styled("enter", key), + Span::styled(" send ", lbl), + Span::styled("\u{00b7} ", dot), + Span::styled("esc", key), + Span::styled(" cancel", lbl), + ]; + let blocked = app.blocked_recipients(); + if !blocked.is_empty() { + let names: Vec = blocked.iter().map(|n| format!("@{n}")).collect(); + hints.push(Span::styled(" \u{00b7} ", dot)); + hints.push(Span::styled( + format!("{} blocked: delivered after it's answered", names.join(" ")), + Theme::blocked(), + )); + } + hints + } InputMode::Navigate => { if let Some(ref overlay) = app.ui.overlay { if overlay.kind == OverlayKind::Command && overlay.palette.is_some() { @@ -1434,14 +1455,21 @@ fn render_footer(frame: &mut Frame, area: Rect, app: &App) { } } else { // Hints are in the input bar - vec![ + let mut hints = vec![ Span::raw(" "), Span::styled("?", key), Span::styled(" help ", lbl), Span::styled("\u{00b7} ", dot), - Span::styled("\\", key), - Span::styled(" view", lbl), - ] + ]; + if app.ui.view_mode == ViewMode::Vertical { + hints.extend([ + Span::styled("c", key), + Span::styled(" chat ", lbl), + Span::styled("\u{00b7} ", dot), + ]); + } + hints.extend([Span::styled("\\", key), Span::styled(" view", lbl)]); + hints } } }; @@ -1756,6 +1784,14 @@ fn render_help(frame: &mut Frame, help_scroll: u16) { ]), Line::from(vec![key("enter/space"), desc("select + filter scrollback")]), Line::from(vec![key("a"), desc("select all")]), + Line::from(vec![key("c"), desc("conversation tab")]), + Line::from(vec![key("R"), desc("refresh conversation")]), + Line::from(vec![key("e"), desc("full conversation text")]), + Line::from(vec![key("enter"), desc("message agent (conversation tab)")]), + Line::from(vec![ + key("1-9"), + desc("answer a waiting question (conversation tab)"), + ]), Line::from(vec![key("b"), desc("broadcast to all")]), Line::from(vec![key("ctrl+r"), desc("relay settings")]), Line::from(vec![key("ctrl+s"), desc("all stopped agents")]), diff --git a/src/tui/rpc_async.rs b/src/tui/rpc_async.rs index ba9a8b7a..156f2ece 100644 --- a/src/tui/rpc_async.rs +++ b/src/tui/rpc_async.rs @@ -51,6 +51,13 @@ pub enum RpcOp { Command { cmd: String, }, + /// Answer a question waiting in an agent's terminal. + AnswerPrompt { + name: String, + steps: Vec< + crate::commands::term::ScreenStep, + >, + }, } pub struct RpcResult { @@ -69,7 +76,9 @@ impl RpcClient { Self::start_with(run_op) } - fn start_with(run: impl Fn(&RpcOp) -> Result + Send + 'static) -> Self { + pub(crate) fn start_with( + run: impl Fn(&RpcOp) -> Result + Send + 'static, + ) -> Self { let (tx_req, rx_req) = mpsc::channel::(); let (tx_res, rx_res) = mpsc::channel::(); @@ -159,6 +168,16 @@ fn run_op(op: &RpcOp) -> Result { commands::run_native(&argv) } + RpcOp::AnswerPrompt { name, steps } => { + let db = crate::db::HcomDb::open().map_err(|e| e.to_string())?; + let stdout = crate::commands::term::answer_on_screen(&db, name, steps)?; + Ok(Response { + exit_code: 0, + stdout, + stderr: String::new(), + }) + } + RpcOp::KillAgent { name } => commands::run_native(&["kill".into(), name.clone()]), RpcOp::ForkAgent { name, .. } => commands::run_native(&[ diff --git a/src/tui/state.rs b/src/tui/state.rs index 08d5c85f..2de9ed28 100644 --- a/src/tui/state.rs +++ b/src/tui/state.rs @@ -1,4 +1,4 @@ -use std::collections::BTreeSet; +use std::collections::{BTreeSet, HashMap}; use crate::tui::model::{ Agent, CommandResult, Event, Flash, InputMode, LaunchState, Message, OrphanProcess, Overlay, @@ -23,6 +23,11 @@ pub struct DataState { pub relay_health: crate::relay::RelayHealth, /// FTS search results. `Some` when a text search query is active (both inline and vertical modes). pub search_results: Option<(Vec, Vec)>, + /// Remote row name (`base:DEVICE`) → account e-mail its device shared. + pub remote_accounts: HashMap, + /// Local agent name → tool config dir it was launched with; `None` for + /// the tool's default. Absent when the launch did not record it. + pub tool_config_dirs: HashMap>, } impl DataState { @@ -37,6 +42,8 @@ impl DataState { relay_enabled: false, relay_health: crate::relay::RelayHealth::NotConfigured, search_results: None, + remote_accounts: HashMap::new(), + tool_config_dirs: HashMap::new(), } } diff --git a/src/tui/transcript_view/convert.rs b/src/tui/transcript_view/convert.rs new file mode 100644 index 00000000..fe332fc0 --- /dev/null +++ b/src/tui/transcript_view/convert.rs @@ -0,0 +1,525 @@ +//! Exchange (local) and transcript JSON rows (remote) into `TurnView`. +use std::collections::BTreeMap; + +use serde_json::Value; + +use super::model::{ToolSummary, TurnView, format_turn_time, parse_turn_epoch}; +use crate::transcript::Exchange; +use crate::tui::model::Message; + +pub const REQUEST_LINES: usize = 3; +pub const RESPONSE_TAIL_LINES: usize = 8; +pub const MAX_FILES: usize = 3; + +/// Non-blank lines, indentation kept (code and nested lists). +fn nonempty_lines(text: &str) -> Vec { + text.lines() + .filter(|l| !l.trim().is_empty()) + .map(|l| l.trim_end().to_string()) + .collect() +} + +/// Lines with trailing space removed, runs of blank lines collapsed to +/// one, and no blank lines at either end. +fn full_lines(text: &str) -> Vec { + let mut out: Vec = Vec::new(); + for line in text.lines().map(str::trim_end) { + if line.is_empty() && out.last().is_none_or(|l| l.is_empty()) { + continue; + } + out.push(line.to_string()); + } + if out.last().is_some_and(|l| l.is_empty()) { + out.pop(); + } + out +} + +fn tag_body<'a>(text: &'a str, tag: &str) -> Option<&'a str> { + let open = format!("<{tag}>"); + let start = text.find(&open)? + open.len(); + let end = start + text[start..].find(&format!(""))?; + Some(text[start..end].trim()) +} + +fn strip_block(text: &str, tag: &str) -> String { + let (open, close) = (format!("<{tag}>"), format!("")); + let mut out = text.to_string(); + while let Some(start) = out.find(&open) { + let Some(end) = out[start..].find(&close) else { + break; + }; + out.replace_range(start..start + end + close.len(), ""); + } + out +} + +/// Claude wraps some user-turn text in tags: slash commands, `!` shell +/// commands and their output, local command output, and background task +/// results. Show each the way a person would read it. Only text that opens +/// with such a tag is Claude's: a request quoting one stays as written. +fn clean_request(user: &str) -> String { + let opens = |text: &str, tags: &[&str]| { + let text = text.trim_start(); + tags.iter().any(|tag| text.starts_with(&format!("<{tag}>"))) + }; + if !opens( + user, + &[ + "local-command-caveat", + "command-message", + "command-name", + "task-notification", + "bash-input", + "bash-stdout", + "bash-stderr", + "local-command-stdout", + ], + ) { + return user.trim().to_string(); + } + let user = strip_block(user, "local-command-caveat"); + if opens(&user, &["command-message", "command-name"]) + && let Some(name) = tag_body(&user, "command-name") + { + let args = tag_body(&user, "command-args").unwrap_or(""); + return if args.is_empty() { + name.to_string() + } else { + format!("{name} {args}") + }; + } + if opens(&user, &["task-notification"]) { + let head = tag_body(&user, "summary") + .or_else(|| tag_body(&user, "status")) + .unwrap_or("task"); + let result = tag_body(&user, "result").unwrap_or(""); + return format!("[task] {head}\n{result}"); + } + if opens(&user, &["bash-input"]) + && let Some(command) = tag_body(&user, "bash-input") + { + return format!("! {command}"); + } + let output_tags = ["bash-stdout", "bash-stderr", "local-command-stdout"]; + if !opens(&user, &output_tags) { + return user.trim().to_string(); + } + let output: Vec<&str> = output_tags + .iter() + .filter_map(|tag| tag_body(&user, tag)) + .filter(|body| !body.is_empty()) + .collect(); + if !output.is_empty() { + return output.join("\n"); + } + user.trim().to_string() +} + +fn build_turn( + position: usize, + timestamp: &str, + user: &str, + action: &str, + tools: ToolSummary, +) -> TurnView { + let hcom_wake = user.trim() == crate::delivery::WAKE_TRIGGER; + let user = clean_request(user); + let request = nonempty_lines(&user); + let response = nonempty_lines(action); + let omitted = response.len().saturating_sub(RESPONSE_TAIL_LINES); + TurnView { + position, + time: format_turn_time(timestamp), + request_more: request.len() > REQUEST_LINES, + request_summary: request + .first() + .map(|l| l.trim().to_string()) + .unwrap_or_default(), + request_lines: request.into_iter().take(REQUEST_LINES).collect(), + response_summary: response + .last() + .map(|l| l.trim().to_string()) + .unwrap_or_default(), + response_tail: response[omitted..].to_vec(), + response_omitted: omitted, + tools, + request_full: full_lines(&user), + response_full: full_lines(action), + started_at: parse_turn_epoch(timestamp), + hcom_wake, + } +} + +/// How far before the oldest shown turn a wake message may have been sent. +const WAKE_LOOKBACK_SECS: f64 = 1800.0; +/// Clock slack between the hcom event and the transcript timestamp. +const WAKE_SLACK_SECS: f64 = 2.0; + +/// Replace each bare `` request with the messages `agent` received +/// since the previous turn started: mentions of `agent`, or broadcasts. +pub fn fill_wake_requests(turns: &mut [TurnView], agent: &str, messages: &[Message]) { + let mut previous: Option = None; + for turn in turns.iter_mut() { + let start = turn.started_at; + if turn.hcom_wake + && let Some(start) = start + { + let from = previous.unwrap_or(start - WAKE_LOOKBACK_SECS); + let text: Vec = messages + .iter() + .filter(|m| m.time > from && m.time <= start + WAKE_SLACK_SECS) + .filter(|m| m.sender != agent) + .filter(|m| { + m.recipients.iter().any(|r| r == agent) + || (m.recipients.is_empty() && !m.is_system()) + }) + .map(|m| format!("{}: {}", m.sender, m.body)) + .collect(); + if !text.is_empty() { + let request = nonempty_lines(&text.join("\n")); + turn.request_more = request.len() > REQUEST_LINES; + turn.request_summary = request.first().cloned().unwrap_or_default(); + turn.request_lines = request.into_iter().take(REQUEST_LINES).collect(); + turn.request_full = full_lines(&text.join("\n")); + } + } + if start.is_some() { + previous = start; + } + } +} + +pub fn turn_from_exchange(ex: &Exchange) -> TurnView { + let tools = if ex.tools.is_empty() { + ToolSummary::None + } else { + summarize_tools(ex.tools.iter().map(|t| t.name.as_str()), &ex.files) + }; + build_turn(ex.position, &ex.timestamp, &ex.user, &ex.action, tools) +} + +/// A row of `hcom transcript --json`. `None` when a required field has the +/// wrong type. +pub fn turn_from_remote_json(row: &Value) -> Option { + let position = row.get("position")?.as_u64()? as usize; + let user = row.get("user")?.as_str()?; + let action = row.get("action")?.as_str()?; + let timestamp = row.get("timestamp").and_then(Value::as_str).unwrap_or(""); + let files: Vec = row + .get("files") + .and_then(Value::as_array) + .map(|a| { + a.iter() + .filter_map(|f| f.as_str().map(String::from)) + .collect() + }) + .unwrap_or_default(); + let tools = match row.get("tools").and_then(Value::as_array) { + None => ToolSummary::Unknown, + Some(list) if list.is_empty() => ToolSummary::None, + Some(list) => summarize_tools( + list.iter() + .filter_map(|t| t.get("name").and_then(Value::as_str)), + &files, + ), + }; + Some(build_turn(position, timestamp, user, action, tools)) +} + +pub fn turns_from_remote_content(content: &str) -> Result, ()> { + let rows: Vec = serde_json::from_str(content).map_err(|_| ())?; + // One bad row fails the reply, so the last good conversation stays. + rows.iter() + .map(turn_from_remote_json) + .collect::>() + .ok_or(()) +} + +fn summarize_tools<'a>(names: impl Iterator, files: &[String]) -> ToolSummary { + let mut counts: BTreeMap = BTreeMap::new(); + for name in names { + *counts.entry(name.to_string()).or_default() += 1; + } + if counts.is_empty() { + return ToolSummary::None; + } + let mut counts: Vec<(String, usize)> = counts.into_iter().collect(); + counts.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0))); + + let mut unique: Vec = Vec::new(); + for f in files { + if !unique.contains(f) { + unique.push(f.clone()); + } + } + let mut short = disambiguate_files(&unique); + let extra_files = short.len().saturating_sub(MAX_FILES); + short.truncate(MAX_FILES); + ToolSummary::Some { + counts, + files: short, + extra_files, + } +} + +fn split_path(path: &str) -> (Option<&str>, &str) { + let trimmed = path.trim_end_matches(['/', '\\']); + match trimmed.rfind(['/', '\\']) { + Some(i) => { + let parent = &trimmed[..i]; + let parent_base = parent.rsplit(['/', '\\']).next().filter(|s| !s.is_empty()); + (parent_base, &trimmed[i + 1..]) + } + None => (None, trimmed), + } +} + +/// Basenames, with the parent directory kept where two basenames collide. +fn disambiguate_files(paths: &[String]) -> Vec { + let bases: Vec<&str> = paths.iter().map(|p| split_path(p).1).collect(); + paths + .iter() + .zip(&bases) + .map(|(p, base)| { + let clash = bases.iter().filter(|b| *b == base).count() > 1; + match split_path(p).0 { + Some(parent) if clash => format!("{parent}/{base}"), + _ => base.to_string(), + } + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn ex(user: &str, action: &str, tools: &[&str], files: &[&str]) -> Exchange { + Exchange { + usage: None, + position: 1, + user: user.into(), + action: action.into(), + final_text: String::new(), + files: files.iter().map(|s| s.to_string()).collect(), + timestamp: "2026-10-22T16:02:00Z".into(), + tools: tools + .iter() + .map(|n| crate::transcript::ToolUse { + name: n.to_string(), + is_error: false, + file: None, + command: None, + output: None, + }) + .collect(), + edits: vec![], + errors: vec![], + ended_on_error: false, + } + } + + #[test] + fn tools_counted_and_files_shortened() { + let t = turn_from_exchange(&ex( + "do it", + "done", + &["Read", "Edit", "Read", "Bash", "Read", "Edit"], + &["src/a.rs", "tests/a.rs", "b.rs", "c.rs", "d.rs", "b.rs"], + )); + assert_eq!( + t.tools, + ToolSummary::Some { + counts: vec![("Read".into(), 3), ("Edit".into(), 2), ("Bash".into(), 1)], + files: vec!["src/a.rs".into(), "tests/a.rs".into(), "b.rs".into()], + extra_files: 2, + } + ); + } + + #[test] + fn long_response_keeps_tail() { + let action: String = (1..=20).map(|i| format!("line {i}\n")).collect(); + let t = turn_from_exchange(&ex("q", &action, &[], &[])); + assert_eq!(t.response_omitted, 12); + assert_eq!(t.response_tail.len(), 8); + assert_eq!(t.response_tail[0], "line 13"); + assert_eq!(t.response_summary, "line 20"); + assert_eq!(t.tools, ToolSummary::None); + } + + #[test] + fn compact_lines_keep_indentation() { + let t = turn_from_exchange(&ex( + "q", + "code:\n\n let x = 1; \n - nested\n", + &[], + &[], + )); + assert_eq!( + t.response_tail, + vec!["code:", " let x = 1;", " - nested"] + ); + assert_eq!(t.response_summary, "- nested"); + } + + #[test] + fn request_lines_capped() { + let t = turn_from_exchange(&ex("a\n\nb\nc\nd", "", &[], &[])); + assert_eq!(t.request_lines, vec!["a", "b", "c"]); + assert!(t.request_more); + assert_eq!(t.request_summary, "a"); + assert!(t.response_tail.is_empty()); + assert_eq!(t.response_summary, ""); + } + + #[test] + fn slash_command_request_and_full_text() { + let t = turn_from_exchange(&ex( + "personal\n/personal\none question", + "a\n\n\nb \n\n", + &[], + &[], + )); + assert_eq!(t.request_summary, "/personal one question"); + assert_eq!(t.request_full, vec!["/personal one question"]); + assert_eq!(t.response_full, vec!["a", "", "b"]); + assert_eq!(clean_request("plain"), "plain"); + assert_eq!( + clean_request("/clear"), + "/clear" + ); + } + + fn msg(sender: &str, to: &[&str], body: &str, time: f64) -> Message { + Message { + event_id: 1, + sender: sender.into(), + recipients: to.iter().map(|s| s.to_string()).collect(), + body: body.into(), + time, + sender_kind: crate::tui::model::SenderKind::Instance, + intent: None, + reply_to: None, + } + } + + #[test] + fn wake_request_shows_received_messages() { + let mut first = turn_from_exchange(&ex("q", "a", &[], &[])); + first.started_at = Some(100.0); + let mut wake = turn_from_exchange(&ex("", "a", &[], &[])); + assert!(wake.hcom_wake); + wake.started_at = Some(200.0); + let mut turns = vec![first, wake]; + let messages = vec![ + msg("bigboss", &["lava:TUGA"], "too early", 90.0), + msg("bigboss", &["lava:TUGA"], "how long left?", 199.5), + msg("luna", &["nova"], "not for lava", 199.0), + msg("lava:TUGA", &[], "own broadcast", 199.0), + msg("nova", &[], "all hands\nline 2", 201.0), + msg("bigboss", &["lava:TUGA"], "too late", 210.0), + ]; + fill_wake_requests(&mut turns, "lava:TUGA", &messages); + assert_eq!(turns[0].request_summary, "q"); + assert_eq!( + turns[1].request_full, + vec!["bigboss: how long left?", "nova: all hands", "line 2"] + ); + assert_eq!(turns[1].request_summary, "bigboss: how long left?"); + + // Nothing found: the trigger stays. + let mut lone = vec![turns[1].clone()]; + lone[0].request_full = vec!["".into()]; + fill_wake_requests(&mut lone, "gigi", &messages[..1]); + assert_eq!(lone[0].request_full, vec![""]); + } + + #[test] + fn claude_wrapped_requests() { + let task = "\na1\ncompleted\n\ + Agent \"planner\" finished\ndone\nok\n\ + 9\n"; + assert_eq!( + clean_request(task), + "[task] Agent \"planner\" finished\ndone\nok" + ); + assert_eq!( + clean_request("failed"), + "[task] failed\n" + ); + assert_eq!( + clean_request("echo $HOME"), + "! echo $HOME" + ); + assert_eq!( + clean_request( + "(Bash completed with no output)" + ), + "(Bash completed with no output)" + ); + assert_eq!( + clean_request( + "Caveat text.\n\ + Login successful" + ), + "Login successful" + ); + assert_eq!( + clean_request("only"), + "" + ); + assert_eq!(strip_block("a1b2copen", "x"), "abcopen"); + // Tags quoted inside a request are the user's text. + let quoted = "why does /clear show up?"; + assert_eq!(clean_request(quoted), quoted); + assert_eq!( + clean_request("see x"), + "see x" + ); + assert_eq!(clean_request(" ls"), "! ls"); + } + + #[test] + fn remote_rows() { + let row = + json!({"position": 3, "user": "hi", "action": "yo", "files": [], "timestamp": ""}); + assert_eq!( + turn_from_remote_json(&row).unwrap().tools, + ToolSummary::Unknown + ); + let row = json!({"position": 3, "user": "hi", "action": "yo", "tools": []}); + assert_eq!( + turn_from_remote_json(&row).unwrap().tools, + ToolSummary::None + ); + let row = json!({"position": 3, "user": "hi", "action": "yo", "files": ["x/y.rs"], + "tools": [{"name": "Edit"}, {"bad": 1}]}); + assert_eq!( + turn_from_remote_json(&row).unwrap().tools, + ToolSummary::Some { + counts: vec![("Edit".into(), 1)], + files: vec!["y.rs".into()], + extra_files: 0 + } + ); + let bad = json!({"position": "3", "user": "hi", "action": "yo"}); + assert!(turn_from_remote_json(&bad).is_none()); + assert!(turns_from_remote_content("not json").is_err()); + assert!( + turns_from_remote_content( + r#"[{"position":1,"user":"a","action":"b"},{"position":"2","user":"a","action":"b"}]"# + ) + .is_err(), + "one bad row fails the whole reply" + ); + assert_eq!( + turns_from_remote_content(r#"[{"position":1,"user":"a","action":"b"}]"#) + .unwrap() + .len(), + 1 + ); + } +} diff --git a/src/tui/transcript_view/local.rs b/src/tui/transcript_view/local.rs new file mode 100644 index 00000000..e192d385 --- /dev/null +++ b/src/tui/transcript_view/local.rs @@ -0,0 +1,704 @@ +//! Local conversation reads happen off the UI thread, like `usage.rs`: +//! a transcript is re-parsed only when its file stamp changes. +use std::path::{Path, PathBuf}; +use std::sync::mpsc::{self, Receiver, Sender, TryRecvError}; +use std::time::{Duration, Instant, SystemTime}; + +use super::convert::turn_from_exchange; +use super::model::{ConversationSnapshot, LocalSource, TurnView}; +use super::prompt::{ScreenPrompt, parse_screen}; +use crate::accounts::EmailCache; +use crate::tool::Tool; +use crate::transcript::{self, ReadOptions}; +use crate::tui::model::{Agent, AgentStatus}; + +pub const LOCAL_LAST: usize = 10; +const CACHE_LIMIT: usize = 32; + +/// Per file: size and mtime, or why it could not be read. +type Stamp = Vec), std::io::ErrorKind>>; + +/// Where a local agent's login lives. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum AccountDir<'a> { + /// The launch-time config dir; `None` is the tool's default. + Known(Option<&'a str>), + /// A stopped agent: its launch dir went with its row, and the default + /// account may not be its own. + Unknown, +} + +struct Request { + source: LocalSource, + config_dir: Option, + tool: Option, + force: bool, + /// The agent waits on its terminal: read the prompt off its screen. + prompt_of: Option, + prompt_epoch: u64, +} + +struct Reply { + source: LocalSource, + turns: Result, String>, + email: Option, + prompt: Option, + prompt_epoch: u64, +} + +struct Entry { + source: LocalSource, + turns: Option>, + last_success: Option, + failure: Option, + email: Option, + prompt: Option, + /// Bumped when an answered prompt is dropped: a screen read sent + /// before the answer must not bring it back. + prompt_epoch: u64, +} + +impl Entry { + fn new(source: LocalSource) -> Self { + Entry { + source, + turns: None, + last_success: None, + failure: None, + email: None, + prompt: None, + prompt_epoch: 0, + } + } +} + +/// `source`'s entry, made (and the oldest dropped when full) if missing. +fn entry_in<'a>(entries: &'a mut Vec, source: &LocalSource) -> &'a mut Entry { + let idx = match entries.iter().position(|e| &e.source == source) { + Some(i) => i, + None => { + if entries.len() >= CACHE_LIMIT { + entries.remove(0); + } + entries.push(Entry::new(source.clone())); + entries.len() - 1 + } + }; + &mut entries[idx] +} + +struct Cached { + source: LocalSource, + stamp: Stamp, + turns: Vec, +} + +fn stamp_of(source: &LocalSource) -> Stamp { + let mut paths = vec![source.path.clone()]; + if source.backend.is_shared_database() { + let mut wal = source.path.as_os_str().to_os_string(); + wal.push("-wal"); + paths.push(PathBuf::from(wal)); + } + paths + .iter() + .map(|p| { + std::fs::metadata(p) + .map(|m| (m.len(), m.modified().ok())) + .map_err(|e| e.kind()) + }) + .collect() +} + +fn read_turns(req: &Request, cache: &mut Vec) -> Result, String> { + let stamp = stamp_of(&req.source); + if !req.force + && let Some(hit) = cache + .iter() + .find(|c| c.source == req.source && c.stamp == stamp) + { + return Ok(hit.turns.clone()); + } + let turns = match req.source.path.try_exists() { + // Not written yet: an empty conversation, not an error. + Ok(false) => Ok(Vec::new()), + // Unreadable (permissions, I/O): a failure, not an empty one. + Err(e) => Err(format!("cannot read transcript: {e}")), + Ok(true) => { + let source = req.source.clone(); + std::panic::catch_unwind(move || { + transcript::read( + &source.path, + source.backend, + &ReadOptions { + last: LOCAL_LAST, + detailed: false, + session_id: source.session.clone(), + allow_codex_retry: false, + }, + ) + }) + .unwrap_or_else(|_| Err("parse panicked".to_string())) + .map(|t| t.exchanges.iter().map(turn_from_exchange).collect()) + } + }; + if let Ok(t) = &turns { + cache.retain(|c| c.source != req.source); + if cache.len() >= CACHE_LIMIT { + cache.remove(0); + } + cache.push(Cached { + source: req.source.clone(), + stamp, + turns: t.clone(), + }); + } + turns.map_err(|e| { + e.lines() + .next() + .unwrap_or("read error") + .chars() + .take(40) + .collect() + }) +} + +fn collect( + req: Request, + cache: &mut Vec, + emails: &mut EmailCache, + db: &mut Option, +) -> Reply { + let turns = read_turns(&req, cache); + let prompt_epoch = req.prompt_epoch; + let email = req + .tool + .and_then(|tool| emails.email(tool, req.config_dir.as_deref().map(Path::new))); + // A failed open leaves `db` empty, so the next request tries again. + let prompt = req.prompt_of.as_deref().and_then(|name| { + if db.is_none() { + *db = crate::db::HcomDb::open().ok(); + } + parse_screen(&crate::commands::term::screen_of(db.as_ref()?, name)?) + }); + Reply { + source: req.source, + turns, + email, + prompt, + prompt_epoch, + } +} + +/// Transcripts only grow while the agent works; idle rechecks catch late writes. +fn recheck_interval(status: Option) -> Duration { + match status { + Some(AgentStatus::Active | AgentStatus::Blocked) => Duration::from_secs(2), + _ => Duration::from_secs(30), + } +} + +#[derive(Default)] +pub struct LocalTranscriptWorker { + channels: Option<(Sender, Receiver)>, + pending: bool, + checked: Option, + selected: Option, + /// Status and its time: a new prompt while still Blocked rechecks too. + status: Option<(AgentStatus, u64)>, + force: bool, + entries: Vec, +} + +impl LocalTranscriptWorker { + /// `agent` is the cursor's local agent while the tab is visible, with + /// the config dir it was launched with. + pub fn poll(&mut self, agent: Option<(&Agent, AccountDir<'_>)>, now: Instant) -> bool { + let selected = agent.and_then(|(a, _)| LocalSource::for_agent(a)); + let mut changed = false; + if self.selected != selected { + self.selected = selected; + self.checked = None; + self.force = false; + changed = true; + } + let status = agent.map(|(a, _)| (a.status, a.status_time.to_bits())); + if self.status != status { + self.status = status; + self.checked = None; + } + + let mut gone = false; + if let Some((_, rx)) = &self.channels { + loop { + let reply = match rx.try_recv() { + Ok(reply) => reply, + Err(TryRecvError::Empty) => break, + Err(TryRecvError::Disconnected) => { + gone = true; + break; + } + }; + self.pending = false; + changed |= self.selected.as_ref() == Some(&reply.source); + let entry = entry_in(&mut self.entries, &reply.source); + match reply.turns { + Ok(turns) => { + entry.turns = Some(turns); + entry.last_success = Some(now); + entry.failure = None; + } + Err(e) => entry.failure = Some(e), + } + entry.email = reply.email; + // A read sent before this agent's answer keeps the prompt + // already dropped; other agents' answers do not matter. + if reply.prompt_epoch == entry.prompt_epoch { + entry.prompt = reply.prompt; + } + } + } + if gone { + // The worker thread died: start a fresh one on the next request. + self.channels = None; + self.pending = false; + changed = true; + } + + let due = self.force + || self.checked.is_none_or(|t| { + now.saturating_duration_since(t) >= recheck_interval(self.status.map(|(s, _)| s)) + }); + if !self.pending + && due + && let Some((agent, account)) = agent + && let Some(source) = self.selected.clone() + { + let prompt_epoch = self + .entries + .iter() + .find(|e| e.source == source) + .map_or(0, |e| e.prompt_epoch); + let (tx, _) = self.channels.get_or_insert_with(|| { + let (tx, requests) = mpsc::channel::(); + let (results, rx) = mpsc::channel(); + std::thread::spawn(move || { + let mut cache = Vec::new(); + let mut emails = EmailCache::default(); + let mut db = None; + while let Ok(req) = requests.recv() { + let source = req.source.clone(); + let prompt_epoch = req.prompt_epoch; + let reply = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + collect(req, &mut cache, &mut emails, &mut db) + })) + .unwrap_or_else(|_| { + // State touched mid-panic is not trusted. + cache.clear(); + db = None; + Reply { + source, + turns: Err("read panicked".to_string()), + email: None, + prompt: None, + prompt_epoch, + } + }); + if results.send(reply).is_err() { + break; + } + } + }); + (tx, rx) + }); + self.pending = tx + .send(Request { + source, + config_dir: match account { + AccountDir::Known(dir) => dir.map(String::from), + AccountDir::Unknown => None, + }, + // No tool, no e-mail lookup. + tool: agent + .tool + .canonical() + .filter(|_| account != AccountDir::Unknown), + force: self.force, + prompt_of: (agent.status == AgentStatus::Blocked + && agent.tool.canonical() == Some(Tool::Claude)) + .then(|| agent.name.clone()), + prompt_epoch, + }) + .is_ok(); + self.checked = Some(now); + self.force = false; + changed = true; + } + changed + } + + pub fn snapshot(&self, agent: &Agent) -> ConversationSnapshot<'_> { + let source = LocalSource::for_agent(agent); + let loading = self.pending && source.is_some() && self.selected == source; + match self + .entries + .iter() + .find(|e| Some(&e.source) == source.as_ref()) + { + Some(e) => ConversationSnapshot { + turns: e.turns.as_deref(), + last_success: e.last_success, + failure: e.failure.as_deref(), + loading, + email: e.email.as_deref(), + prompt: e.prompt.as_ref(), + }, + None => ConversationSnapshot { + loading, + ..Default::default() + }, + } + } + + /// Re-read now, ignoring the stamp cache. + pub fn force_refresh(&mut self, agent: &Agent) { + if LocalSource::for_agent(agent) == self.selected { + self.force = true; + self.checked = None; + } + } + + pub fn has_pending_timer(&self) -> bool { + self.pending + } + + /// An answer to `agent`'s prompt went out: forget the prompt until the + /// screen is read again, so no second key is sent against it. + pub fn drop_prompt(&mut self, agent: &Agent) { + if let Some(source) = LocalSource::for_agent(agent) { + let entry = entry_in(&mut self.entries, &source); + entry.prompt_epoch += 1; + entry.prompt = None; + } + } + + #[cfg(test)] + pub fn take_refresh_request(&mut self) -> bool { + std::mem::take(&mut self.force) + } + + #[cfg(test)] + pub fn set_prompt_for_test(&mut self, agent: &Agent, prompt: ScreenPrompt) { + let source = LocalSource::for_agent(agent).expect("agent has a transcript"); + // A prompt is only read for the agent being viewed. + self.selected = Some(source.clone()); + let entry = entry_in(&mut self.entries, &source); + entry.turns.get_or_insert_with(Vec::new); + entry.prompt = Some(prompt); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::tui::test_helpers::make_test_agent; + + fn claude_line(role: &str, text: &str) -> String { + serde_json::json!({ + "type": role, + "timestamp": "2026-10-22T07:00:00Z", + "message": {"role": role, "content": text}, + }) + .to_string() + } + + fn write_turns(path: &Path, n: usize) { + let mut out = String::new(); + for i in 1..=n { + out.push_str(&claude_line("user", &format!("q{i}"))); + out.push('\n'); + out.push_str(&claude_line("assistant", &format!("a{i}"))); + out.push('\n'); + } + std::fs::write(path, out).unwrap(); + } + + fn agent_at(path: &Path) -> Agent { + let mut a = make_test_agent("luna", 0.0); + a.transcript_path = Some(path.to_string_lossy().into_owned()); + a + } + + fn wait(worker: &mut LocalTranscriptWorker, agent: &Agent, now: Instant) { + for _ in 0..200 { + worker.poll(Some((agent, AccountDir::Known(None))), now); + if !worker.pending { + return; + } + std::thread::sleep(Duration::from_millis(10)); + } + panic!("worker never answered"); + } + + #[test] + fn reads_last_turns_and_caches_by_stamp() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("s.jsonl"); + write_turns(&path, 12); + let agent = agent_at(&path); + let mut w = LocalTranscriptWorker::default(); + let t0 = Instant::now(); + wait(&mut w, &agent, t0); + let snap = w.snapshot(&agent); + let turns = snap.turns.unwrap(); + assert_eq!(turns.len(), LOCAL_LAST); + assert_eq!(turns.last().unwrap().request_summary, "q12"); + assert!(snap.last_success.is_some()); + + // Not due yet: no request. + w.poll( + Some((&agent, AccountDir::Known(None))), + t0 + Duration::from_secs(1), + ); + assert!(!w.pending); + // Active agent rechecks after 2s. + write_turns(&path, 13); + wait(&mut w, &agent, t0 + Duration::from_secs(3)); + assert_eq!( + w.snapshot(&agent) + .turns + .unwrap() + .last() + .unwrap() + .request_summary, + "q13" + ); + } + + #[test] + fn missing_file_is_empty_and_hidden_tab_sends_nothing() { + let dir = tempfile::tempdir().unwrap(); + let agent = agent_at(&dir.path().join("none.jsonl")); + let mut w = LocalTranscriptWorker::default(); + assert!(!w.poll(None, Instant::now())); + assert!(w.channels.is_none()); + assert!(!w.pending); + wait(&mut w, &agent, Instant::now()); + assert_eq!(w.snapshot(&agent).turns, Some(&[][..])); + } + + /// A path that cannot be checked is a failure, not an empty conversation. + #[cfg(unix)] + #[test] + fn unreadable_path_is_a_failure() { + use std::os::unix::fs::PermissionsExt; + let dir = tempfile::tempdir().unwrap(); + let locked = dir.path().join("locked"); + std::fs::create_dir(&locked).unwrap(); + let path = locked.join("s.jsonl"); + write_turns(&path, 1); + std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o000)).unwrap(); + // Root can still look inside: nothing to check then. + let checkable = path.try_exists().is_err(); + let agent = agent_at(&path); + let mut w = LocalTranscriptWorker::default(); + wait(&mut w, &agent, Instant::now()); + let snap = w.snapshot(&agent); + std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o700)).unwrap(); + if checkable { + assert!(snap.failure.is_some()); + assert_eq!(snap.turns, None); + } + } + + #[test] + fn status_change_at_the_same_status_rechecks() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("s.jsonl"); + write_turns(&path, 1); + let mut agent = agent_at(&path); + agent.status = AgentStatus::Active; + let mut w = LocalTranscriptWorker::default(); + let t0 = Instant::now(); + wait(&mut w, &agent, t0); + w.poll( + Some((&agent, AccountDir::Known(None))), + t0 + Duration::from_millis(500), + ); + assert!(!w.pending, "not due yet"); + // Same status, new status time (a second prompt while Blocked). + agent.status_time += 1.0; + w.poll( + Some((&agent, AccountDir::Known(None))), + t0 + Duration::from_millis(600), + ); + assert!(w.pending); + } + + #[test] + fn unknown_account_dir_reads_no_email() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("s.jsonl"); + write_turns(&path, 1); + std::fs::write( + dir.path().join(".claude.json"), + r#"{"oauthAccount":{"emailAddress":"luna@example.com"}}"#, + ) + .unwrap(); + let config_dir = dir.path().to_string_lossy().into_owned(); + let agent = agent_at(&path); + let mut w = LocalTranscriptWorker::default(); + let mut read = |account: AccountDir<'_>| { + w.force = true; + w.poll(Some((&agent, account)), Instant::now()); + for _ in 0..200 { + if !w.pending { + break; + } + std::thread::sleep(Duration::from_millis(10)); + w.poll(Some((&agent, account)), Instant::now()); + } + w.snapshot(&agent).email.map(String::from) + }; + assert_eq!( + read(AccountDir::Known(Some(&config_dir))).as_deref(), + Some("luna@example.com") + ); + assert_eq!(read(AccountDir::Unknown), None); + } + + #[test] + fn dead_worker_is_dropped() { + let (tx, _requests) = mpsc::channel(); + let (results, rx) = mpsc::channel::(); + drop(results); + let mut w = LocalTranscriptWorker { + channels: Some((tx, rx)), + pending: true, + ..Default::default() + }; + assert!(w.poll(None, Instant::now())); + assert!(w.channels.is_none()); + assert!(!w.pending); + assert!(!w.has_pending_timer()); + } + + #[test] + fn failure_keeps_previous_turns() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("s.jsonl"); + write_turns(&path, 2); + let agent = agent_at(&path); + let mut w = LocalTranscriptWorker::default(); + let t0 = Instant::now(); + wait(&mut w, &agent, t0); + std::fs::remove_file(&path).unwrap(); + std::fs::create_dir(&path).unwrap(); + w.force_refresh(&agent); + wait(&mut w, &agent, t0); + let snap = w.snapshot(&agent); + assert_eq!(snap.turns.unwrap().len(), 2); + assert!(snap.failure.is_some()); + } + + fn a_prompt() -> ScreenPrompt { + let screen = "──────────────────────────────── +Fruit? + +❯ 1. Apple + 2. Pear + +Enter to select · Esc to cancel"; + parse_screen(&screen.lines().map(String::from).collect::>()).unwrap() + } + + /// A worker whose replies the test writes: `requests` sees what the + /// worker asks for, `replies` answers it. + fn scripted() -> (LocalTranscriptWorker, Receiver, Sender) { + let (tx, requests) = mpsc::channel(); + let (replies, rx) = mpsc::channel(); + let w = LocalTranscriptWorker { + channels: Some((tx, rx)), + ..Default::default() + }; + (w, requests, replies) + } + + /// Ask for `agent`'s screen and return the request the worker sent. + fn ask(w: &mut LocalTranscriptWorker, requests: &Receiver, agent: &Agent) -> Request { + w.force_refresh(agent); + w.poll(Some((agent, AccountDir::Known(None))), Instant::now()); + assert!(w.pending); + requests.try_recv().unwrap() + } + + fn answer( + w: &mut LocalTranscriptWorker, + replies: &Sender, + agent: &Agent, + req: Request, + prompt: Option, + ) { + replies + .send(Reply { + source: req.source, + turns: Ok(Vec::new()), + email: None, + prompt, + prompt_epoch: req.prompt_epoch, + }) + .unwrap(); + w.poll(Some((agent, AccountDir::Known(None))), Instant::now()); + assert!(!w.pending); + } + + /// SEC-003: a screen read sent before an answer shows the answered + /// prompt; it must not come back when that read returns. + #[test] + fn a_read_sent_before_an_answer_does_not_bring_its_prompt_back() { + let dir = tempfile::tempdir().unwrap(); + let agent = agent_at(&dir.path().join("s.jsonl")); + let (mut w, requests, replies) = scripted(); + let req = ask(&mut w, &requests, &agent); + answer(&mut w, &replies, &agent, req, Some(a_prompt())); + assert!(w.snapshot(&agent).prompt.is_some()); + + let stale = ask(&mut w, &requests, &agent); + w.drop_prompt(&agent); + answer(&mut w, &replies, &agent, stale, Some(a_prompt())); + assert!(w.snapshot(&agent).prompt.is_none()); + + // A read sent after the answer shows the screen again. + let fresh = ask(&mut w, &requests, &agent); + answer(&mut w, &replies, &agent, fresh, Some(a_prompt())); + assert!(w.snapshot(&agent).prompt.is_some()); + } + + /// Answering one agent leaves another agent's prompt alone, even with + /// a read of that other agent in flight. + #[test] + fn answering_one_agent_keeps_anothers_prompt() { + let dir = tempfile::tempdir().unwrap(); + let luna = agent_at(&dir.path().join("luna.jsonl")); + let mut nova = agent_at(&dir.path().join("nova.jsonl")); + nova.name = "nova".into(); + let (mut w, requests, replies) = scripted(); + let req = ask(&mut w, &requests, &luna); + answer(&mut w, &replies, &luna, req, Some(a_prompt())); + let req = ask(&mut w, &requests, &nova); + answer(&mut w, &replies, &nova, req, Some(a_prompt())); + + let in_flight = ask(&mut w, &requests, &nova); + w.drop_prompt(&luna); + assert!(w.snapshot(&luna).prompt.is_none()); + answer(&mut w, &replies, &nova, in_flight, Some(a_prompt())); + assert!(w.snapshot(&nova).prompt.is_some()); + + // A dropped read leaves the prompt it would have replaced. + let in_flight = ask(&mut w, &requests, &nova); + w.drop_prompt(&nova); + w.set_prompt_for_test(&nova, a_prompt()); + answer(&mut w, &replies, &nova, in_flight, None); + assert!(w.snapshot(&nova).prompt.is_some()); + } +} diff --git a/src/tui/transcript_view/markdown.rs b/src/tui/transcript_view/markdown.rs new file mode 100644 index 00000000..074998c8 --- /dev/null +++ b/src/tui/transcript_view/markdown.rs @@ -0,0 +1,335 @@ +//! Just enough Markdown for agent text: headings, bullets, **bold**, +//! `code` and fenced blocks. Anything else is shown as written. +use ratatui::prelude::*; +use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; + +use crate::tui::theme::palette; + +/// A run of text in one style. +pub type Segment = (String, Style); + +fn code_style() -> Style { + Style::default().fg(palette::CYAN) +} + +fn is_fence(line: &str) -> bool { + line.trim_start().starts_with("```") +} + +/// Fence lines in `lines`; an odd count leaves a code block open. +pub fn fence_count(lines: &[String]) -> usize { + lines.iter().filter(|l| is_fence(l)).count() +} + +/// `#`s and the space after them, when the line is a heading. +fn heading_text(line: &str) -> Option<&str> { + let hashes = line.len() - line.trim_start_matches('#').len(); + if (1..=6).contains(&hashes) { + line[hashes..].strip_prefix(' ') + } else { + None + } +} + +/// Leading indent and the item text, when the line is a `-`/`*` bullet. +fn bullet_text(line: &str) -> Option<(&str, &str)> { + let body = line.trim_start(); + let indent = &line[..line.len() - body.len()]; + body.strip_prefix("- ") + .or_else(|| body.strip_prefix("* ")) + .map(|text| (indent, text)) +} + +/// `**bold**` and `` `code` `` inside one line. Unclosed markers stay as text. +fn inline(text: &str, base: Style) -> Vec { + let mut out: Vec = Vec::new(); + let mut plain = String::new(); + let mut rest = text; + while !rest.is_empty() { + let marked = if let Some(after) = rest.strip_prefix("**") { + after.find("**").filter(|&end| end > 0).map(|end| { + ( + &after[..end], + &after[end + 2..], + base.add_modifier(Modifier::BOLD), + ) + }) + } else if let Some(after) = rest.strip_prefix('`') { + after + .find('`') + .filter(|&end| end > 0) + .map(|end| (&after[..end], &after[end + 1..], code_style())) + } else { + None + }; + match marked { + Some((inner, after, style)) => { + if !plain.is_empty() { + out.push((std::mem::take(&mut plain), base)); + } + out.push((inner.to_string(), style)); + rest = after; + } + None => { + let ch = rest.chars().next().expect("non-empty"); + plain.push(ch); + rest = &rest[ch.len_utf8()..]; + } + } + } + if !plain.is_empty() { + out.push((plain, base)); + } + out +} + +/// One line styled; `in_code` carries an open fence across lines. `None` +/// for fence lines, which are not shown. +pub fn line(text: &str, in_code: &mut bool, base: Style) -> Option> { + if is_fence(text) { + *in_code = !*in_code; + return None; + } + if *in_code { + return Some(vec![(text.to_string(), code_style())]); + } + if let Some(title) = heading_text(text) { + return Some(inline(title, base.add_modifier(Modifier::BOLD))); + } + if let Some((indent, item)) = bullet_text(text) { + let mut out = vec![(format!("{indent}• "), base)]; + out.extend(inline(item, base)); + return Some(out); + } + Some(inline(text, base)) +} + +/// The line as plain text, for one-line summaries. +pub fn plain(text: &str) -> String { + let mut in_code = false; + line(text, &mut in_code, Style::default()) + .map(|segs| segs.into_iter().map(|(s, _)| s).collect()) + .unwrap_or_default() +} + +pub fn is_table_row(text: &str) -> bool { + let t = text.trim(); + t.len() > 1 && t.starts_with('|') && t.ends_with('|') +} + +fn table_cells(row: &str) -> Vec<&str> { + let t = row.trim(); + t[1..t.len() - 1].split('|').map(str::trim).collect() +} + +fn is_separator(cells: &[&str]) -> bool { + cells.iter().all(|c| { + let c = c.trim_matches(':'); + !c.is_empty() && c.chars().all(|ch| ch == '-') + }) +} + +/// `|`-rows as aligned columns, header bold, the `---` row as a rule. +/// `None` when the table does not fit in `width`. +pub fn table(rows: &[String], base: Style, width: usize) -> Option>> { + let parsed: Vec> = rows.iter().map(|r| table_cells(r)).collect(); + let header_rows = parsed.iter().position(|c| is_separator(c)).unwrap_or(0); + let cols = parsed.iter().map(Vec::len).max()?; + let styled: Vec>>> = parsed + .iter() + .enumerate() + .map(|(i, cells)| { + if is_separator(cells) { + return None; + } + let style = if i < header_rows { + base.add_modifier(Modifier::BOLD) + } else { + base + }; + Some(cells.iter().map(|c| inline(c, style)).collect()) + }) + .collect(); + let cell_width = |segs: &Vec| segs.iter().map(|(s, _)| s.width()).sum::(); + let mut widths = vec![0; cols]; + for row in styled.iter().flatten() { + for (i, cell) in row.iter().enumerate() { + widths[i] = widths[i].max(cell_width(cell)); + } + } + let sep = " │ "; + if widths.iter().sum::() + sep.width() * (cols - 1) > width { + return None; + } + let rule = Style::default().fg(palette::FG_DIM); + Some( + styled + .iter() + .map(|row| match row { + None => vec![( + widths + .iter() + .map(|w| "─".repeat(*w)) + .collect::>() + .join("─┼─"), + rule, + )], + Some(cells) => { + let mut out: Vec = Vec::new(); + for (i, w) in widths.iter().enumerate() { + if i > 0 { + out.push((sep.to_string(), rule)); + } + let cell = cells.get(i).cloned().unwrap_or_default(); + let pad = w - cell_width(&cell); + out.extend(cell); + if i + 1 < cols { + out.push((" ".repeat(pad), base)); + } + } + out + } + }) + .collect(), + ) +} + +pub fn spans(segments: Vec) -> Vec> { + segments + .into_iter() + .filter(|(s, _)| !s.is_empty()) + .map(|(s, style)| Span::styled(s, style)) + .collect() +} + +/// Split into rows of at most `width` columns, breaking after the last +/// space when the row has one. +pub fn wrap(segments: &[Segment], width: usize) -> Vec> { + let mut rows: Vec> = vec![Vec::new()]; + let mut used = 0; + for (text, style) in segments { + for ch in text.chars() { + let w = ch.width().unwrap_or(0); + let row = rows.last_mut().expect("non-empty"); + if used + w > width && !row.is_empty() { + let carry = match row.iter().rposition(|(c, _)| *c == ' ') { + Some(i) if i > 0 && !ch.is_whitespace() => row.split_off(i + 1), + _ => Vec::new(), + }; + used = carry.iter().map(|(c, _)| c.width().unwrap_or(0)).sum(); + rows.push(carry); + } + rows.last_mut().expect("non-empty").push((ch, *style)); + used += w; + } + } + rows.into_iter() + .map(|row| { + let mut out: Vec = Vec::new(); + for (ch, style) in row { + match out.last_mut() { + Some((s, st)) if *st == style => s.push(ch), + _ => out.push((ch.to_string(), style)), + } + } + out + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn texts(segs: &[Segment]) -> Vec<&str> { + segs.iter().map(|(s, _)| s.as_str()).collect() + } + + #[test] + fn inline_markers() { + let base = Style::default(); + let segs = inline("a **b** `c` d", base); + assert_eq!(texts(&segs), vec!["a ", "b", " ", "c", " d"]); + assert!(segs[1].1.add_modifier.contains(Modifier::BOLD)); + assert_eq!(segs[3].1, code_style()); + // Unclosed or empty markers stay literal. + assert_eq!(texts(&inline("x ** y `z", base)), vec!["x ** y `z"]); + assert_eq!(texts(&inline("****", base)), vec!["****"]); + } + + #[test] + fn block_lines() { + let base = Style::default(); + let mut code = false; + let h = line("## Title **bold**", &mut code, base).unwrap(); + assert_eq!(texts(&h), vec!["Title ", "bold"]); + assert!(h[0].1.add_modifier.contains(Modifier::BOLD)); + assert_eq!( + texts(&line(" - item", &mut code, base).unwrap()), + vec![" • ", "item"] + ); + assert_eq!( + texts(&line("**bold** line", &mut code, base).unwrap()), + vec!["bold", " line"] + ); + assert!(line("```rust", &mut code, base).is_none()); + assert!(code); + let c = line("let **x** = 1;", &mut code, base).unwrap(); + assert_eq!(c, vec![("let **x** = 1;".into(), code_style())]); + assert!(line("```", &mut code, base).is_none()); + assert!(!code); + assert_eq!( + texts(&line("#no-space", &mut code, base).unwrap()), + vec!["#no-space"] + ); + assert_eq!(plain("- **a** `b`"), "• a b"); + assert_eq!(fence_count(&["```".into(), "x".into()]), 1); + } + + #[test] + fn tables_align() { + let rows: Vec = [ + "| ID | Tool |", + "|---|:--:|", + "| **GO** | `git diff` |", + "| a |", + ] + .iter() + .map(|s| s.to_string()) + .collect(); + assert!(rows.iter().all(|r| is_table_row(r))); + assert!(!is_table_row("| open")); + let out = table(&rows, Style::default(), 40).unwrap(); + let joined: Vec = out.iter().map(|r| texts(r).concat()).collect(); + assert_eq!( + joined, + vec![ + "ID │ Tool", + "─────┼─────────", + "GO │ git diff", + "a │ " + ] + ); + assert!(out[0][0].1.add_modifier.contains(Modifier::BOLD)); + assert!(table(&rows, Style::default(), 10).is_none()); + } + + #[test] + fn wrap_keeps_styles_and_breaks_at_spaces() { + let bold = Style::default().add_modifier(Modifier::BOLD); + let segs = vec![ + ("ab ".to_string(), Style::default()), + ("cdef".to_string(), bold), + ]; + let rows = wrap(&segs, 5); + assert_eq!(rows.len(), 2); + assert_eq!(texts(&rows[0]), vec!["ab "]); + assert_eq!(rows[1], vec![("cdef".to_string(), bold)]); + let rows = wrap(&[("ABCD".to_string(), Style::default())], 4); + assert_eq!( + rows.iter().map(|r| texts(r).concat()).collect::>(), + vec!["AB", "CD"] + ); + assert_eq!(wrap(&[], 4), vec![Vec::::new()]); + } +} diff --git a/src/tui/transcript_view/mod.rs b/src/tui/transcript_view/mod.rs new file mode 100644 index 00000000..045d5b04 --- /dev/null +++ b/src/tui/transcript_view/mod.rs @@ -0,0 +1,680 @@ +//! Conversation tab in vertical view: the selected agent's recent turns +//! (request, tools used, response), local or over the relay. +pub mod convert; +pub mod local; +pub mod markdown; +pub mod model; +pub mod prompt; +pub mod remote; +pub mod render; + +use std::collections::{HashMap, HashSet}; +use std::time::Instant; + +pub use model::*; + +use crate::tui::app::App; +use crate::tui::model::{Agent, CursorTarget, InputMode, ViewMode}; +use local::AccountDir; + +#[derive(Default)] +pub struct TranscriptView { + pub tab: RightTab, + /// Tab shown on entering vertical view (`HCOM_TUI_TAB`). + pub default_tab: RightTab, + /// `c` pressed in inline view: open the tab once vertical view starts. + pub open_on_switch: bool, + /// Per agent: full-text view (every turn, whole text). + pub expanded: HashMap, + pub local: local::LocalTranscriptWorker, + pub remote: remote::RemoteTranscriptWorker, + /// Agents with an answer on its way: one at a time, so a repeated key + /// does not land on whatever prompt comes next. + pub answering: HashSet, + /// The agent whose conversation is shown: the scroll is its own. + shown: Option, +} + +/// `HCOM_TUI_TAB=chat` opens vertical view on the conversation tab. +pub fn default_tab_from_env(value: Option<&str>) -> RightTab { + match value.map(str::trim) { + Some(v) if v.eq_ignore_ascii_case("chat") || v.eq_ignore_ascii_case("conversation") => { + RightTab::Conversation + } + _ => RightTab::Activity, + } +} + +impl TranscriptView { + pub fn on_enter_vertical(&mut self) { + self.tab = if self.open_on_switch { + RightTab::Conversation + } else { + self.default_tab + }; + self.open_on_switch = false; + } + + pub fn is_expanded(&self, agent: &Agent) -> bool { + self.expanded + .get(&agent.display_name()) + .copied() + .unwrap_or(false) + } + + pub fn has_pending_timer(&self) -> bool { + self.local.has_pending_timer() || self.remote.has_pending_timer() + } +} + +impl App { + pub(crate) fn conversation_visible(&self) -> bool { + self.ui.view_mode == ViewMode::Vertical && self.transcript.tab == RightTab::Conversation + } + + /// The conversation tab shows a working agent: keep its spinner moving. + pub(crate) fn transcript_spinning(&self) -> bool { + self.conversation_visible() + && matches!( + resolve_target(&self.data, self.cursor_target()), + ConversationTarget::Local { agent, .. } | ConversationTarget::Remote { agent, .. } + if agent.status == crate::tui::model::AgentStatus::Active + ) + } + + pub(crate) fn transcript_poll(&mut self, now: Instant) -> bool { + let cursor = self.cursor_target(); + let visible = self.conversation_visible(); + let App { + data, + transcript, + ui, + .. + } = self; + let target = if visible { + resolve_target(data, cursor) + } else { + ConversationTarget::NoAgent + }; + let shown = match &target { + ConversationTarget::Local { agent, .. } + | ConversationTarget::Remote { agent, .. } + | ConversationTarget::LocalNoPath(agent) + | ConversationTarget::Unsupported(agent) => Some(agent.display_name()), + ConversationTarget::NoAgent => None, + }; + if transcript.shown != shown { + // Another conversation opens at its latest turn, not at the + // scroll left in the previous one. Command output drawn over the + // tab keeps its place; closing it resets the scroll anyway. + transcript.shown = shown; + if ui.mode != InputMode::CommandOutput { + ui.msg_scroll = 0; + } + } + match target { + ConversationTarget::Local { agent, .. } => { + let account = if matches!(cursor, CursorTarget::StoppedAgent(_)) { + AccountDir::Unknown + } else { + match data.tool_config_dirs.get(&agent.name) { + Some(dir) => AccountDir::Known(dir.as_deref()), + None => AccountDir::Unknown, + } + }; + transcript.local.poll(Some((agent, account)), now) + | transcript.remote.poll(None, now) + } + ConversationTarget::Remote { agent, .. } => { + transcript.local.poll(None, now) | transcript.remote.poll(Some(agent), now) + } + _ => transcript.local.poll(None, now) | transcript.remote.poll(None, now), + } + } + + /// `c`: toggle activity/conversation; from inline view, switch to vertical first. + pub(crate) fn transcript_toggle_tab(&mut self) { + if self.ui.view_mode == ViewMode::Inline { + self.transcript.open_on_switch = true; + self.ui.switch_viewport = true; + return; + } + self.transcript.tab = match self.transcript.tab { + RightTab::Activity => RightTab::Conversation, + RightTab::Conversation => RightTab::Activity, + }; + self.ui.msg_scroll = 0; + } + + /// The prompt the agent under the cursor waits on, when the + /// conversation tab shows it. + pub(crate) fn waiting_prompt(&self) -> Option<(&Agent, &prompt::ScreenPrompt)> { + if !self.conversation_visible() { + return None; + } + match resolve_target(&self.data, self.cursor_target()) { + ConversationTarget::Local { agent, .. } + if agent.status == crate::tui::model::AgentStatus::Blocked => + { + self.transcript + .local + .snapshot(agent) + .prompt + .map(|p| (agent, p)) + } + _ => None, + } + } + + /// The local agent under the cursor whose answer is still on its way, + /// when the conversation tab shows it. + fn answering_agent(&self) -> Option { + if !self.conversation_visible() { + return None; + } + match resolve_target(&self.data, self.cursor_target()) { + ConversationTarget::Local { agent, .. } + if self.transcript.answering.contains(&agent.name) => + { + Some(agent.name.clone()) + } + _ => None, + } + } + + /// A digit picks an option of the waiting prompt; Enter moves on from a + /// multi-select question. False when the key is not for the prompt. + pub(crate) fn transcript_answer_key(&mut self, code: crossterm::event::KeyCode) -> bool { + use crate::tui::model::Flash; + use crate::tui::theme::Theme; + use crossterm::event::KeyCode; + // The answered prompt is dropped until the screen is read again, so + // this comes first: otherwise a repeated Enter would compose. + if matches!(code, KeyCode::Char('1'..='9') | KeyCode::Enter) + && let Some(name) = self.answering_agent() + { + self.ui.flash = Some(Flash::new( + format!("Still answering {name}: wait for its screen to update"), + Theme::flash_info(), + )); + return true; + } + let Some((agent, prompt)) = self.waiting_prompt() else { + return false; + }; + let name = agent.name.clone(); + let steps = match code { + KeyCode::Char(c @ '1'..='9') => { + let n = (c as u8 - b'0') as usize; + match prompt.pick(n) { + Some(steps) => steps, + None => { + if prompt.notes_in_use() { + self.ui.flash = Some(Flash::new( + format!("Notes are open: answer in {name}'s terminal"), + Theme::flash_info(), + )); + } else if prompt.options.iter().any(|o| o.number == n) { + self.ui.flash = Some(Flash::new( + format!("Option {n} needs typing: answer it in {name}'s terminal"), + Theme::flash_info(), + )); + } + return true; + } + } + } + KeyCode::Enter => match prompt.next() { + Some(step) => vec![step], + None => return false, + }, + _ => return false, + }; + let agent = agent.clone(); + match self.enqueue_rpc(crate::tui::rpc_async::RpcOp::AnswerPrompt { + name: name.clone(), + steps, + }) { + Ok(()) => { + self.transcript.answering.insert(name); + self.transcript.local.drop_prompt(&agent); + } + Err(e) => { + self.ui.flash = Some(Flash::new( + format!("Answer failed: {e}"), + Theme::flash_err(), + )); + } + } + true + } + + /// `R`: refresh the conversation now. Does nothing outside the tab. + pub(crate) fn transcript_refresh(&mut self, now: Instant) { + if !self.conversation_visible() { + return; + } + let cursor = self.cursor_target(); + let App { + data, transcript, .. + } = self; + match resolve_target(data, cursor) { + ConversationTarget::Local { agent, .. } => transcript.local.force_refresh(agent), + ConversationTarget::Remote { agent, .. } => transcript.remote.force_refresh(agent, now), + _ => {} + } + } + + /// `e`: toggle the full-text view for the agent under the cursor. + pub(crate) fn transcript_toggle_expand(&mut self) { + if !self.conversation_visible() { + return; + } + let cursor = self.cursor_target(); + let App { + data, transcript, .. + } = self; + let name = match resolve_target(data, cursor) { + ConversationTarget::Local { agent, .. } + | ConversationTarget::Remote { agent, .. } + | ConversationTarget::Unsupported(agent) + | ConversationTarget::LocalNoPath(agent) => agent.display_name(), + ConversationTarget::NoAgent => return, + }; + let e = transcript.expanded.entry(name).or_insert(false); + *e = !*e; + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::tui::test_helpers::{make_test_agent, offline_app}; + use crossterm::event::{KeyCode, KeyModifiers}; + + fn vertical_app() -> App { + let mut app = offline_app(); + app.data.agents = vec![make_test_agent("luna", 0.0)]; + app.ui.view_mode = ViewMode::Vertical; + app + } + + /// Scrolled up in one conversation, then on to another: it opens at its + /// latest turn. Staying on the same agent keeps the scroll. + #[test] + fn another_conversation_opens_at_its_latest_turn() { + let mut app = vertical_app(); + app.data.agents.push(make_test_agent("nova", 0.0)); + app.transcript.tab = RightTab::Conversation; + let now = Instant::now(); + app.transcript_poll(now); + app.ui.msg_scroll = 5; + app.transcript_poll(now); + assert_eq!(app.ui.msg_scroll, 5); + + app.ui.cursor = 1; + assert_eq!(app.cursor_target(), CursorTarget::Agent(1)); + app.transcript_poll(now); + assert_eq!(app.ui.msg_scroll, 0); + + // Command output drawn over the tab keeps its own place. + app.ui.mode = InputMode::CommandOutput; + app.ui.msg_scroll = 7; + app.ui.cursor = 0; + app.transcript_poll(now); + assert_eq!(app.ui.msg_scroll, 7); + } + + #[test] + fn c_toggles_tab_in_vertical() { + let mut app = vertical_app(); + app.handle_key(KeyCode::Char('c'), KeyModifiers::NONE); + assert_eq!(app.transcript.tab, RightTab::Conversation); + app.handle_key(KeyCode::Char('c'), KeyModifiers::NONE); + assert_eq!(app.transcript.tab, RightTab::Activity); + } + + #[test] + fn c_in_inline_switches_to_vertical_conversation() { + let mut app = offline_app(); + app.ui.view_mode = ViewMode::Inline; + app.handle_key(KeyCode::Char('c'), KeyModifiers::NONE); + assert!(app.ui.switch_viewport); + assert!(app.transcript.open_on_switch); + app.transcript.on_enter_vertical(); + assert_eq!(app.transcript.tab, RightTab::Conversation); + // Back to inline and vertical again with `\`: activity. + app.transcript.on_enter_vertical(); + assert_eq!(app.transcript.tab, RightTab::Activity); + } + + #[test] + fn enter_composes_only_on_conversation_tab() { + use crate::tui::model::InputMode; + let mut app = vertical_app(); + app.handle_key(KeyCode::Enter, KeyModifiers::NONE); + assert_eq!(app.ui.mode, InputMode::Navigate); + assert!(app.ui.selected.contains("luna")); + + let mut app = vertical_app(); + app.transcript.tab = RightTab::Conversation; + app.handle_key(KeyCode::Enter, KeyModifiers::NONE); + assert_eq!(app.ui.mode, InputMode::Compose); + assert_eq!(app.ui.input, "@luna "); + assert!(app.ui.selected.is_empty()); + } + + #[test] + fn enter_composes_to_the_agent_being_read() { + use crate::tui::model::InputMode; + let mut app = vertical_app(); + app.data.agents.push(make_test_agent("nova", 0.0)); + app.transcript.tab = RightTab::Conversation; + app.ui.selected.insert("nova".into()); + app.ui.cursor = 0; + app.handle_key(KeyCode::Enter, KeyModifiers::NONE); + assert_eq!(app.ui.mode, InputMode::Compose); + assert_eq!(app.ui.input, "@luna ", "the selection is not the recipient"); + // `m` still messages the selection. + app.ui.mode = InputMode::Navigate; + app.handle_key(KeyCode::Char('m'), KeyModifiers::NONE); + assert_eq!(app.ui.input, "@nova "); + } + + #[test] + fn enter_on_a_header_row_does_not_compose() { + use crate::tui::model::InputMode; + let mut app = vertical_app(); + app.data.stopped_agents = vec![make_test_agent("old", 0.0)]; + app.transcript.tab = RightTab::Conversation; + app.ui.cursor = 1; + assert_eq!(app.cursor_target(), CursorTarget::StoppedHeader); + app.handle_key(KeyCode::Enter, KeyModifiers::NONE); + assert_eq!(app.ui.mode, InputMode::Navigate); + assert!(app.ui.input.is_empty()); + } + + /// `luna` blocked, with a prompt read off its screen. + fn waiting_app(screen: &str) -> App { + let mut app = vertical_app(); + app.transcript.tab = RightTab::Conversation; + let luna = &mut app.data.agents[0]; + luna.status = crate::tui::model::AgentStatus::Blocked; + luna.status_context = "approval".into(); + luna.transcript_path = Some("/nonexistent/s.jsonl".into()); + let lines: Vec = screen.lines().map(String::from).collect(); + let prompt = prompt::parse_screen(&lines).unwrap(); + app.transcript + .local + .set_prompt_for_test(&app.data.agents[0], prompt); + app + } + + const QUESTION: &str = "──────────────────────────────── +Fruit? + +❯ 1. Apple + 2. Pear + 3. Type something. + +Enter to select · Esc to cancel"; + + const MULTI: &str = "──────────────────────────────── +Drink? + +❯ 1. [ ] Water + 2. [ ] Tea + +Enter to select · Esc to cancel"; + + #[test] + fn digits_answer_a_waiting_prompt() { + let mut app = waiting_app(QUESTION); + assert!(app.waiting_prompt().is_some()); + app.handle_key(KeyCode::Char('2'), KeyModifiers::NONE); + // Sent (offline: no RPC worker, so it reports the failure). + let flash = app.ui.flash.take().unwrap(); + assert!(flash.text.contains("Answer failed"), "{}", flash.text); + + app.handle_key(KeyCode::Char('3'), KeyModifiers::NONE); + let flash = app.ui.flash.take().unwrap(); + assert!(flash.text.contains("needs typing"), "{}", flash.text); + + // Not an option: swallowed without a message. + app.handle_key(KeyCode::Char('7'), KeyModifiers::NONE); + assert!(app.ui.flash.is_none()); + + // Enter still composes on a single-select question. + app.handle_key(KeyCode::Enter, KeyModifiers::NONE); + assert_eq!(app.ui.mode, crate::tui::model::InputMode::Compose); + } + + /// SEC-003: while an answer is on its way, the prompt it answered is + /// gone and a repeated key for the same agent is not sent: it would + /// otherwise land on whatever prompt the agent shows next. + #[test] + fn a_repeated_key_waits_for_the_answer_in_flight() { + let mut app = waiting_app(QUESTION); + app.rpc_client = Some(crate::tui::rpc_async::RpcClient::start_with(|_| { + Err("offline".into()) + })); + app.handle_key(KeyCode::Char('1'), KeyModifiers::NONE); + assert!(app.ui.flash.is_none()); + assert!(app.transcript.answering.contains("luna")); + assert!( + app.waiting_prompt().is_none(), + "the answered prompt is dropped" + ); + let sent = |app: &App| app.rpc_client.as_ref().unwrap().pending_count(); + assert_eq!(sent(&app), 1); + + // A screen read from before the answer shows the same prompt again. + let lines: Vec = QUESTION.lines().map(String::from).collect(); + app.transcript + .local + .set_prompt_for_test(&app.data.agents[0], prompt::parse_screen(&lines).unwrap()); + app.handle_key(KeyCode::Char('2'), KeyModifiers::NONE); + let flash = app.ui.flash.take().unwrap(); + assert!( + flash.text.contains("Still answering luna"), + "{}", + flash.text + ); + assert_eq!(sent(&app), 1, "nothing more is sent"); + + // The answer comes back failed: the next key may go out, and the + // screen is read again so the dropped prompt shows up. + app.transcript.local.take_refresh_request(); + for _ in 0..200 { + if app.drain_rpc_results() { + break; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + assert!(app.transcript.answering.is_empty()); + assert!(app.transcript.local.take_refresh_request()); + app.ui.flash = None; + app.handle_key(KeyCode::Char('2'), KeyModifiers::NONE); + assert!(app.ui.flash.is_none()); + assert!(app.transcript.answering.contains("luna")); + } + + /// Right after an answer no prompt is cached until the screen is read + /// again: a second key is still held, and Enter does not compose. + #[test] + fn a_key_right_after_an_answer_is_held() { + use crate::tui::model::InputMode; + for screen in [QUESTION, MULTI] { + let mut app = waiting_app(screen); + app.rpc_client = Some(crate::tui::rpc_async::RpcClient::start_with(|_| { + Err("offline".into()) + })); + let first = if screen == MULTI { + KeyCode::Enter + } else { + KeyCode::Char('1') + }; + app.handle_key(first, KeyModifiers::NONE); + assert!(app.ui.flash.is_none()); + assert!(app.waiting_prompt().is_none()); + let sent = |app: &App| app.rpc_client.as_ref().unwrap().pending_count(); + assert_eq!(sent(&app), 1); + + for key in [KeyCode::Enter, KeyCode::Char('2')] { + app.handle_key(key, KeyModifiers::NONE); + assert_eq!(app.ui.mode, InputMode::Navigate, "{key:?} composed"); + assert!(app.ui.input.is_empty()); + let flash = app.ui.flash.take().unwrap(); + assert!( + flash.text.contains("Still answering luna"), + "{}", + flash.text + ); + } + assert_eq!(sent(&app), 1, "nothing more is sent"); + } + } + + #[test] + fn enter_moves_on_from_multi_select() { + let mut app = waiting_app(MULTI); + app.handle_key(KeyCode::Enter, KeyModifiers::NONE); + assert_eq!(app.ui.mode, crate::tui::model::InputMode::Navigate); + assert!(app.ui.flash.take().unwrap().text.contains("Answer failed")); + } + + #[test] + fn digits_are_ordinary_keys_without_a_waiting_prompt() { + let mut app = waiting_app(QUESTION); + app.transcript.tab = RightTab::Activity; + assert!(app.waiting_prompt().is_none()); + app.handle_key(KeyCode::Char('1'), KeyModifiers::NONE); + assert!(app.ui.flash.is_none()); + + app.transcript.tab = RightTab::Conversation; + app.data.agents[0].status = crate::tui::model::AgentStatus::Listening; + assert!(app.waiting_prompt().is_none()); + } + + #[test] + fn env_default_tab() { + assert_eq!(default_tab_from_env(None), RightTab::Activity); + assert_eq!(default_tab_from_env(Some("chat")), RightTab::Conversation); + assert_eq!( + default_tab_from_env(Some(" Conversation ")), + RightTab::Conversation + ); + assert_eq!(default_tab_from_env(Some("activity")), RightTab::Activity); + + let mut view = TranscriptView { + default_tab: RightTab::Conversation, + ..Default::default() + }; + view.on_enter_vertical(); + assert_eq!(view.tab, RightTab::Conversation); + } + + #[test] + fn expand_is_remembered_per_agent() { + let mut app = vertical_app(); + app.data.agents.push(make_test_agent("nova", 0.0)); + app.transcript.tab = RightTab::Conversation; + app.handle_key(KeyCode::Char('e'), KeyModifiers::NONE); + let luna = app.data.agents[0].clone(); + let nova = app.data.agents[1].clone(); + assert!(app.transcript.is_expanded(&luna)); + assert!(!app.transcript.is_expanded(&nova)); + } + + #[test] + fn refresh_and_expand_do_nothing_on_activity_tab() { + let mut app = vertical_app(); + app.handle_key(KeyCode::Char('e'), KeyModifiers::NONE); + app.handle_key(KeyCode::Char('R'), KeyModifiers::SHIFT); + assert!(app.transcript.expanded.is_empty()); + assert_eq!(app.transcript.tab, RightTab::Activity); + } + + #[test] + fn no_agents_resolves_to_no_agent() { + let mut app = offline_app(); + app.ui.view_mode = ViewMode::Vertical; + app.handle_key(KeyCode::Char('c'), KeyModifiers::NONE); + assert!(matches!( + resolve_target(&app.data, app.cursor_target()), + ConversationTarget::NoAgent + )); + } + + #[test] + fn conversation_tab_renders() { + use ratatui::Terminal; + use ratatui::backend::TestBackend; + let mut app = vertical_app(); + app.transcript.tab = RightTab::Conversation; + let mut terminal = Terminal::new(TestBackend::new(100, 20)).unwrap(); + terminal + .draw(|f| crate::tui::render::render(f, &mut app)) + .unwrap(); + let buf = terminal.backend().buffer().clone(); + // Wide glyphs leave a blank cell after them; drop spaces before matching. + let text: String = buf + .content() + .iter() + .map(|c| c.symbol()) + .collect::() + .replace(' ', ""); + assert!(text.contains("[chat]"), "tab strip missing"); + assert!(text.contains("luna"), "header missing"); + } + + fn screen_text(app: &mut App, width: u16) -> String { + use ratatui::Terminal; + use ratatui::backend::TestBackend; + let mut terminal = Terminal::new(TestBackend::new(width, 20)).unwrap(); + terminal + .draw(|f| crate::tui::render::render(f, app)) + .unwrap(); + let buf = terminal.backend().buffer().clone(); + buf.content() + .iter() + .map(|c| c.symbol()) + .collect::() + .replace(' ', "") + } + + #[test] + fn command_output_shows_on_the_conversation_tab() { + let mut app = vertical_app(); + app.transcript.tab = RightTab::Conversation; + app.ui.command_result = Some(crate::tui::model::CommandResult { + label: "relay status".into(), + output: vec!["relay-output-line".into()], + }); + assert!(screen_text(&mut app, 100).contains("relay-output-line")); + } + + #[test] + fn activity_heading_stops_before_the_tab_strip() { + let mut app = vertical_app(); + let long = "x".repeat(80); + app.ui.selected.insert(long.clone()); + let mut terminal = + ratatui::Terminal::new(ratatui::backend::TestBackend::new(100, 20)).unwrap(); + terminal + .draw(|f| crate::tui::render::render(f, &mut app)) + .unwrap(); + let buf = terminal.backend().buffer(); + let row = (0..buf.area.height) + .map(|y| { + (0..buf.area.width) + .map(|x| buf[(x, y)].symbol()) + .collect::() + }) + .find(|r| r.contains("[activity]")) + .expect("tab strip row"); + // The heading leaves a gap instead of running under the strip. + assert!(row.contains(" [activity]"), "{row}"); + assert!(row.contains("xxx"), "{row}"); + } +} diff --git a/src/tui/transcript_view/model.rs b/src/tui/transcript_view/model.rs new file mode 100644 index 00000000..60876b79 --- /dev/null +++ b/src/tui/transcript_view/model.rs @@ -0,0 +1,192 @@ +//! Display model for the conversation tab: what a turn looks like once the +//! transcript is parsed, and which agent the cursor points the tab at. +use std::path::PathBuf; +use std::time::Instant; + +use crate::transcript::{self, TranscriptBackend}; +use crate::tui::model::{Agent, CursorTarget}; +use crate::tui::state::DataState; + +/// Right-panel tab in vertical view. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum RightTab { + #[default] + Activity, + Conversation, +} + +/// One turn, already cut down for display; the raw transcript is not kept. +#[derive(Clone, Debug, PartialEq)] +pub struct TurnView { + pub position: usize, + /// "HH:MM", or "--:--" when the timestamp can't be read. + pub time: String, + /// Up to the first three non-empty request lines. + pub request_lines: Vec, + pub request_more: bool, + pub request_summary: String, + /// The last non-empty lines of the assistant text. + pub response_tail: Vec, + pub response_omitted: usize, + pub response_summary: String, + pub tools: ToolSummary, + /// Whole request and response, for the full-text view. + pub request_full: Vec, + pub response_full: Vec, + /// Turn start, Unix seconds. + pub started_at: Option, + /// The request was only the `` wake trigger; the message itself + /// reached the agent through a hook. + pub hcom_wake: bool, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum ToolSummary { + /// The source didn't say which tools ran (remote reply without `tools`). + Unknown, + /// No tool ran this turn. + None, + Some { + /// Most used first; ties by name. + counts: Vec<(String, usize)>, + files: Vec, + extra_files: usize, + }, +} + +impl ToolSummary { + pub fn total(&self) -> Option { + match self { + Self::Some { counts, .. } => Some(counts.iter().map(|c| c.1).sum()), + Self::None => Some(0), + Self::Unknown => None, + } + } +} + +/// A local transcript and the parser for it. Also the local cache key. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct LocalSource { + pub name: String, + pub path: PathBuf, + pub session: Option, + pub backend: TranscriptBackend, +} + +impl LocalSource { + pub fn for_agent(agent: &Agent) -> Option { + if agent.is_remote() { + return None; + } + Some(Self { + name: agent.name.clone(), + path: agent.transcript_path.as_ref()?.into(), + session: agent.session_id.clone(), + backend: transcript::backend_for_tool(agent.tool.canonical()?)?, + }) + } +} + +/// What the conversation tab shows for the row under the cursor. +pub enum ConversationTarget<'a> { + NoAgent, + /// The tool has no transcript parser. + Unsupported(&'a Agent), + /// Supported tool, but no transcript path recorded yet. + LocalNoPath(&'a Agent), + Local { + agent: &'a Agent, + }, + Remote { + agent: &'a Agent, + }, +} + +pub fn resolve_target(data: &DataState, cursor: CursorTarget) -> ConversationTarget<'_> { + let agent = match cursor { + CursorTarget::Agent(i) => data.agents.get(i), + CursorTarget::StoppedAgent(i) => data.stopped_agents.get(i), + CursorTarget::RemoteAgent(i) => data.remote_agents.get(i), + _ => None, + }; + let Some(agent) = agent else { + return ConversationTarget::NoAgent; + }; + let supported = agent + .tool + .canonical() + .is_some_and(|tool| transcript::backend_for_tool(tool).is_some()); + if !supported { + return ConversationTarget::Unsupported(agent); + } + if agent.is_remote() { + return ConversationTarget::Remote { agent }; + } + match LocalSource::for_agent(agent) { + Some(_) => ConversationTarget::Local { agent }, + None => ConversationTarget::LocalNoPath(agent), + } +} + +/// What the renderer reads from a worker. +#[derive(Clone, Copy, Debug, Default)] +pub struct ConversationSnapshot<'a> { + /// `None` until a read has succeeded. + pub turns: Option<&'a [TurnView]>, + pub last_success: Option, + /// Why the latest refresh failed; cleared by the next success. + pub failure: Option<&'a str>, + pub loading: bool, + /// Logged-in account; only the local worker knows it. + pub email: Option<&'a str>, + /// A choice prompt waiting on the agent's screen; local Claude only. + pub prompt: Option<&'a super::prompt::ScreenPrompt>, +} + +pub fn parse_turn_epoch(ts: &str) -> Option { + chrono::DateTime::parse_from_rfc3339(ts) + .ok() + .map(|t| t.timestamp_millis() as f64 / 1000.0) +} + +pub fn format_turn_time(ts: &str) -> String { + if let Ok(t) = chrono::DateTime::parse_from_rfc3339(ts) { + return t.with_timezone(&chrono::Local).format("%H:%M").to_string(); + } + if ts.len() >= 16 && ts.as_bytes()[10] == b'T' && ts.is_char_boundary(16) { + return ts[11..16].to_string(); + } + "--:--".to_string() +} + +/// Key for a remote agent: its DB row name, `base:DEVICE`. +pub fn remote_account_key(agent: &Agent) -> Option { + agent + .device_name + .as_ref() + .map(|d| format!("{}:{}", agent.name, d)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn turn_time_falls_back() { + assert_eq!(format_turn_time("garbage"), "--:--"); + assert_eq!(format_turn_time(""), "--:--"); + assert_eq!(format_turn_time("2026-10-22T16:02:11"), "16:02"); + } + + #[test] + fn tool_total() { + assert_eq!(ToolSummary::Unknown.total(), None); + assert_eq!(ToolSummary::None.total(), Some(0)); + let s = ToolSummary::Some { + counts: vec![("Read".into(), 4), ("Edit".into(), 2)], + files: vec![], + extra_files: 0, + }; + assert_eq!(s.total(), Some(6)); + } +} diff --git a/src/tui/transcript_view/prompt.rs b/src/tui/transcript_view/prompt.rs new file mode 100644 index 00000000..6b189b65 --- /dev/null +++ b/src/tui/transcript_view/prompt.rs @@ -0,0 +1,681 @@ +//! A numbered choice prompt on a blocked agent's terminal screen (Claude's +//! `AskUserQuestion` or a permission prompt), and the keys that answer it. +//! +//! Claude writes a question to its transcript only after it is answered, so +//! the screen is the one place to read it while it waits. Measured on Claude +//! Code 2.x: a digit picks an option; one single-select question is answered +//! by it. With several questions or a multi-select one the prompt has tabs: +//! a single-select digit moves to the next tab, multi-select digits toggle +//! and → moves on, and the last tab submits on `1`. Options with previews +//! draw a preview box to their right; there a digit only moves the cursor, +//! and Enter picks the option under it. +use crate::commands::term::{ScreenCheck, ScreenStep}; +use unicode_width::UnicodeWidthStr; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ScreenOption { + pub number: usize, + pub label: String, + pub description: String, + /// `Some` for a multi-select checkbox. + pub checked: Option, +} + +impl ScreenOption { + /// Options that open a text box in the agent's terminal. + pub fn needs_typing(&self) -> bool { + let label = self.label.trim_end_matches('.'); + label == "Type something" || label == "Chat about this" + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ScreenPrompt { + /// The tab row (`☒ fruit ☐ drink ✔ Submit`), when there is one. + pub tabs: Option, + pub question: Vec, + pub options: Vec, + /// Text that is on screen only while this prompt waits. + anchor: String, + /// `Some` when the options carry previews. + preview: Option, +} + +/// The parts of a preview prompt that decide where Enter lands. +#[derive(Debug, Clone, PartialEq, Eq)] +struct Preview { + /// The option under the cursor. + cursor: usize, + /// The notes line after `Notes:`. While notes are open or written, a + /// digit is typed into them. + notes: Option, +} + +const RULE: char = '─'; +const DASH: char = '╌'; +const FOOTER: &str = "Esc to cancel"; +const NOTES: &str = "Notes:"; +const NOTES_EMPTY: &str = "press n to add notes"; + +fn is_rule(line: &str) -> bool { + let t = line.trim(); + t.chars().count() >= 8 && t.chars().all(|c| c == RULE) +} + +/// The dashed lines a permission prompt draws around a command. +fn is_dashed(line: &str) -> bool { + let t = line.trim(); + t.chars().count() >= 8 && t.chars().all(|c| c == DASH) +} + +/// `❯ 1. label` or ` 2. label`: (number, label, under the cursor). +fn option_line(line: &str) -> Option<(usize, &str, bool)> { + let t = line.trim_start(); + let (cursor, t) = match t.strip_prefix('❯') { + Some(rest) => (true, rest.trim_start()), + None => (false, t), + }; + let dot = t.find(". ")?; + let number: usize = t[..dot].parse().ok()?; + (1..=9) + .contains(&number) + .then(|| (number, t[dot + 2..].trim(), cursor)) +} + +/// The display column of `corner` in `line`. +fn column_of(line: &str, corner: char) -> Option { + line.find(corner).map(|i| line[..i].width()) +} + +/// The column the preview box starts at: its top corner is on option 1's +/// line, its bottom corner at the same column further down. +fn preview_column(block: &[String]) -> Option { + let column = column_of(block.first()?, '┌')?; + block[1..] + .iter() + .any(|l| column_of(l, '└') == Some(column)) + .then_some(column) +} + +/// `line` left of display column `column`. +fn left_of(line: &str, column: usize) -> &str { + let mut width = 0; + for (i, c) in line.char_indices() { + width += unicode_width::UnicodeWidthChar::width(c).unwrap_or(0); + if width > column { + return &line[..i]; + } + } + line +} + +fn checkbox(label: &str) -> (Option, &str) { + if let Some(rest) = label.strip_prefix("[ ] ") { + (Some(false), rest) + } else if let Some(rest) = label.strip_prefix("[✔] ") { + (Some(true), rest) + } else { + (None, label) + } +} + +/// The prompt at the bottom of `lines`, if one is waiting there. +pub fn parse_screen(lines: &[String]) -> Option { + let cursor = lines + .iter() + .rposition(|l| option_line(l).is_some_and(|(_, _, c)| c))?; + // The cursor can sit on an option below a rule (`4. Chat about this`): + // the block starts at option 1, and the question above its rule. + let first = lines[..=cursor] + .iter() + .rposition(|l| option_line(l).is_some_and(|(n, _, _)| n == 1))?; + // A permission prompt draws its command between dashed lines, and a + // command line can look like a rule. The top is the nearest rule outside + // a dashed pair. A dashed line above it means the pairs cannot be told + // apart (a command can draw dashed lines too), so nothing is read. + let mut inside = false; + let mut top = None; + for (i, line) in lines[..first].iter().enumerate().rev() { + if is_dashed(line) { + inside = !inside; + } else if !inside && is_rule(line) { + top = Some(i); + break; + } + } + let top = top?; + if lines[..top].iter().any(|l| is_dashed(l)) { + return None; + } + // The top is only taken outside a pair, so the count below it is even. + // More than one pair means an unpaired line may have been paired off by + // one above the real top, and the top may be a scrollback rule. + if lines[top + 1..first] + .iter() + .filter(|l| is_dashed(l)) + .count() + > 2 + { + return None; + } + let top = top + 1; + + let mut tabs = None; + let mut question = Vec::new(); + for line in &lines[top..first] { + let t = line.trim(); + if t.is_empty() || is_dashed(t) { + continue; + } + if t.contains("✔ Submit") && (t.starts_with('←') || t.ends_with('→')) { + tabs = Some(t.trim_matches(['←', '→', ' ']).to_string()); + } else { + question.push(t.to_string()); + } + } + + let end = lines[first..] + .iter() + .position(|l| l.contains(FOOTER)) + .map_or(lines.len(), |i| first + i); + let footer = end < lines.len(); + // Below a preview box only the options are read: the box, the notes + // line and an unnumbered `Chat about this` are not descriptions. + let column = preview_column(&lines[first..end]); + let mut notes = None; + let mut options: Vec = Vec::new(); + for line in &lines[first..end] { + if column.is_some() + && let Some(rest) = line.trim().strip_prefix(NOTES) + { + notes = Some(rest.trim().to_string()); + continue; + } + let line = column.map_or(line.as_str(), |c| left_of(line, c)); + let t = line.trim(); + if let Some((number, label, _)) = option_line(line) { + let (checked, label) = checkbox(label); + options.push(ScreenOption { + number, + label: label.to_string(), + description: String::new(), + checked, + }); + } else if column.is_none() + && !t.is_empty() + && !is_rule(t) + && t != "Submit" + && let Some(last) = options.last_mut() + { + if !last.description.is_empty() { + last.description.push(' '); + } + last.description.push_str(t); + } + } + let numbered = options.iter().enumerate().all(|(i, o)| o.number == i + 1); + if options.is_empty() || !numbered { + return None; + } + let anchor = if footer { + FOOTER.to_string() + } else { + lines[cursor] + .trim() + .trim_start_matches('❯') + .trim_start() + .to_string() + }; + let preview = column.map(|_| Preview { + cursor: option_line(&lines[cursor]).map_or(0, |(n, _, _)| n), + notes, + }); + Some(ScreenPrompt { + tabs, + question, + options, + anchor, + preview, + }) +} + +impl ScreenPrompt { + pub fn is_multi(&self) -> bool { + self.options.iter().any(|o| o.checked.is_some()) + } + + /// Notes are open or written on a preview prompt, so a digit would be + /// typed into them. + pub fn notes_in_use(&self) -> bool { + self.preview + .as_ref() + .is_some_and(|p| p.notes.as_deref() != Some(NOTES_EMPTY)) + } + + /// Digit `n`: the steps that pick option `n`. `None` for an option that + /// needs typing in the terminal, no such option, or notes in use. + pub fn pick(&self, n: usize) -> Option>> { + let option = self.options.iter().find(|o| o.number == n)?; + if option.needs_typing() { + return None; + } + let digit = ScreenStep { + expect: self.clone(), + keys: vec![n.to_string().into_bytes()], + }; + if self.preview.is_none() { + return Some(vec![digit]); + } + if self.is_multi() || self.notes_in_use() { + return None; + } + // The digit only moves the cursor. Enter picks the option under it, + // so it goes out only once the screen shows the cursor on `n`. + let mut moved = self.clone(); + if let Some(preview) = &mut moved.preview { + preview.cursor = n; + } + Some(vec![ + digit, + ScreenStep { + expect: moved, + keys: vec![b"\r".to_vec()], + }, + ]) + } + + /// Enter on a multi-select question: move to the next tab. + pub fn next(&self) -> Option> { + self.is_multi().then(|| ScreenStep { + expect: self.clone(), + keys: vec![b"\x1b[C".to_vec()], + }) + } +} + +/// The screen is read again just before a key goes out, and must parse to +/// this same prompt: question, options, tab row and anchor. A different +/// prompt that repeats this one's text in its own command does not match. +impl ScreenCheck for ScreenPrompt { + fn shown_on(&self, lines: &[String]) -> bool { + parse_screen(lines).as_ref() == Some(self) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn screen(text: &str) -> Vec { + text.lines().map(String::from).collect() + } + + const TABBED: &str = " ⎿ earlier output +──────────────────────────────────────────────────────────────────────────────── +← ☐ fruit ☐ drink ✔ Submit → + +Fruit? + +❯ 1. Apple + Pick an apple + 2. Pear + 3. Type something. +──────────────────────────────────────────────────────────────────────────────── + 4. Chat about this + +Enter to select · Tab/Arrow keys to navigate · Esc to cancel"; + + #[test] + fn reads_a_tabbed_question() { + let p = parse_screen(&screen(TABBED)).unwrap(); + assert_eq!(p.tabs.as_deref(), Some("☐ fruit ☐ drink ✔ Submit")); + assert_eq!(p.question, vec!["Fruit?"]); + let labels: Vec<&str> = p.options.iter().map(|o| o.label.as_str()).collect(); + assert_eq!( + labels, + ["Apple", "Pear", "Type something.", "Chat about this"] + ); + assert_eq!(p.options[0].description, "Pick an apple"); + assert!(!p.is_multi()); + assert_eq!( + p.pick(2), + Some(vec![ScreenStep { + expect: p.clone(), + keys: vec![b"2".to_vec()], + }]) + ); + assert_eq!(p.anchor, FOOTER); + assert!(p.pick(2).unwrap()[0].expect.shown_on(&screen(TABBED))); + assert_eq!(p.pick(3), None, "typing happens in the terminal"); + assert_eq!(p.pick(4), None); + assert_eq!(p.pick(7), None); + assert_eq!(p.next(), None); + } + + #[test] + fn reads_multi_select_checkboxes() { + let text = "──────────────────────────────── +← ☒ fruit ☐ drink ✔ Submit → + +Drink? + +❯ 1. [✔] Water + Still water + 2. [ ] Tea + 3. [ ] Type something + Submit +──────────────────────────────── + 4. Chat about this + +Enter to select · Tab/Arrow keys to navigate · Esc to cancel"; + let p = parse_screen(&screen(text)).unwrap(); + assert!(p.is_multi()); + assert_eq!(p.options[0].checked, Some(true)); + assert_eq!(p.options[0].label, "Water"); + assert_eq!(p.options[1].checked, Some(false)); + assert_eq!(p.options[1].description, ""); + assert_eq!(p.pick(3), None); + let next = p.next().unwrap(); + assert_eq!(next.keys, vec![b"\x1b[C".to_vec()]); + // Checkbox state is part of the match: a toggle in the terminal + // makes the step stale. + assert!(next.expect.shown_on(&screen(text))); + assert!( + !next + .expect + .shown_on(&screen(&text.replace("[✔] Water", "[ ] Water"))) + ); + assert!( + !next + .expect + .shown_on(&screen(&text.replace("[ ] Tea", "[✔] Tea"))) + ); + } + + #[test] + fn reads_the_review_tab() { + let text = "──────────────────────────────── +← ☒ fruit ☒ drink ✔ Submit → + +Review your answers + + ● Fruit? + → Pear + +Ready to submit your answers? + +❯ 1. Submit answers + 2. Cancel"; + let p = parse_screen(&screen(text)).unwrap(); + assert_eq!(p.question.last().unwrap(), "Ready to submit your answers?"); + // No footer on this screen: the cursor line anchors it. + let expect = p.pick(1).unwrap().remove(0).expect; + assert_eq!(expect.tabs.as_deref(), Some("☒ fruit ☒ drink ✔ Submit")); + assert!(expect.question.contains(&"Review your answers".to_string())); + assert_eq!(expect.anchor, "1. Submit answers"); + assert!(expect.shown_on(&screen(text))); + // The cursor moved to Cancel: no longer the screen the key was for. + let moved = text + .replace("❯ 1. Submit answers", " 1. Submit answers") + .replace(" 2. Cancel", "❯ 2. Cancel"); + assert!(!expect.shown_on(&screen(&moved))); + } + + #[test] + fn reads_a_permission_prompt() { + let text = "──────────────────────────────── + Bash command + Create probe-perm.txt in the working directory +╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ + touch probe-perm.txt +╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ + This command requires approval + + Do you want to proceed? + ❯ 1. Yes + 2. Yes, and don't ask again for touch commands in + /private/tmp/scratchpad + 3. No + + Esc to cancel · Tab to amend"; + let p = parse_screen(&screen(text)).unwrap(); + assert_eq!(p.tabs, None); + assert_eq!( + p.question, + vec![ + "Bash command", + "Create probe-perm.txt in the working directory", + "touch probe-perm.txt", + "This command requires approval", + "Do you want to proceed?", + ] + ); + assert_eq!(p.options.len(), 3); + assert_eq!(p.options[1].description, "/private/tmp/scratchpad"); + // The command line is matched, not only the generic first line. + let expect = p.pick(1).unwrap().remove(0).expect; + assert!(expect.shown_on(&screen(text))); + let other = text.replace(" touch probe-perm.txt\n", " rm -rf build\n"); + assert_ne!(other, text); + assert!(!expect.shown_on(&screen(&other))); + } + + /// `PERMISSION` with `command` in place of its one command line. + fn permission_with(command: &str) -> String { + const PERMISSION: &str = " ⎿ earlier output +──────────────────────────────── + Bash command + Run the build +╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ +COMMAND +╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ + This command requires approval + + Do you want to proceed? + ❯ 1. Yes + 2. No + + Esc to cancel · Tab to amend"; + PERMISSION.replace("COMMAND", command) + } + + /// SEC-002: a rule-like line inside the command is not the prompt's + /// top, so no command line is left out of what the user approves. + #[test] + fn a_rule_inside_the_command_keeps_the_whole_command() { + let text = permission_with( + " make clean + ──────────────────────────────── + make build", + ); + let p = parse_screen(&screen(&text)).unwrap(); + assert_eq!( + p.question, + vec![ + "Bash command", + "Run the build", + "make clean", + "────────────────────────────────", + "make build", + "This command requires approval", + "Do you want to proceed?", + ] + ); + // The line above the rule is part of the match as well. + let other = text.replace(" make clean\n", " make other\n"); + assert!(!p.pick(1).unwrap()[0].expect.shown_on(&screen(&other))); + } + + /// SEC-002: when the command draws its own rule and dashed line, the + /// prompt's bounds are not known, and the prompt cannot be answered + /// from the TUI. + #[test] + fn a_command_that_draws_prompt_lines_is_not_read() { + // A rule and a dashed line that look like the start of a prompt. + let forged = permission_with( + " make clean +──────────────────────────────── + Bash command + Run the build +╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ + make build", + ); + assert_eq!(parse_screen(&screen(&forged)), None); + // Dashed lines that do not pair up. + let unpaired = permission_with( + " make build +╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌", + ); + assert_eq!(parse_screen(&screen(&unpaired)), None); + // An unpaired dashed line with a rule inside it. + let unpaired_rule = permission_with( + " make clean +──────────────────────────────── +╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ + make build", + ); + assert_eq!(parse_screen(&screen(&unpaired_rule)), None); + // An unpaired dashed line in the command, paired off by one in the + // scrollback: the top would land on a scrollback rule. + let scrollback = format!( + " ⎿ older output +──────────────────────────────── +╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ +{}", + permission_with( + " make build +╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌" + ) + ); + assert_eq!(parse_screen(&screen(&scrollback)), None); + // The plain prompt still reads. + assert!(parse_screen(&screen(&permission_with(" make build"))).is_some()); + } + + #[test] + fn reads_with_the_cursor_below_the_rule() { + let text = TABBED + .replace("❯ 1. Apple", " 1. Apple") + .replace(" 4. Chat about this", "❯ 4. Chat about this"); + let p = parse_screen(&screen(&text)).unwrap(); + assert_eq!(p.question, vec!["Fruit?"]); + assert_eq!(p.options.len(), 4); + assert_eq!(p.options[0].description, "Pick an apple"); + assert_eq!(p.pick(1).unwrap()[0].expect.anchor, FOOTER); + } + + /// Measured on Claude Code 2.1.309: two questions whose options carry + /// previews, on the first tab. + const PREVIEW: &str = " ⎿ earlier output + +──────────────────────────────────────────────────────────────────────────────── +← ☐ Wind unit ☐ Temp unit ✔ Submit → + +Which unit should the app use for wind speed? + +❯ 1. km/h ┌──────────────────────────────────────────┐ + 2. m/s │ Wind: 12 km/h NE, gusts up to 20 km/h │ + 3. mph └──────────────────────────────────────────┘ + + Notes: press n to add notes + +──────────────────────────────────────────────────────────────────────────────── + Chat about this + +Enter to select · ↑/↓ to navigate · n to add notes · Tab to switch questions · +Esc to cancel"; + + /// `PREVIEW` with the cursor on option 2, its preview redrawn. + fn preview_on_two() -> String { + PREVIEW + .replace("❯ 1. km/h", " 1. km/h") + .replace(" 2. m/s ", "❯ 2. m/s ") + .replace( + "│ Wind: 12 km/h NE, gusts up to 20 km/h │", + "│ Wind: 3.3 m/s NE, gusts up to 5.6 m/s │", + ) + } + + #[test] + fn reads_options_beside_a_preview() { + let p = parse_screen(&screen(PREVIEW)).unwrap(); + assert_eq!( + p.tabs.as_deref(), + Some("☐ Wind unit ☐ Temp unit ✔ Submit") + ); + assert_eq!( + p.question, + vec!["Which unit should the app use for wind speed?"] + ); + let labels: Vec<&str> = p.options.iter().map(|o| o.label.as_str()).collect(); + assert_eq!(labels, ["km/h", "m/s", "mph"]); + assert!(p.options.iter().all(|o| o.description.is_empty())); + assert!(!p.notes_in_use()); + } + + #[test] + fn a_preview_pick_sends_enter_once_the_cursor_is_on_it() { + let p = parse_screen(&screen(PREVIEW)).unwrap(); + let steps = p.pick(2).unwrap(); + assert_eq!(steps.len(), 2); + assert_eq!(steps[0].keys, vec![b"2".to_vec()]); + assert_eq!(steps[1].keys, vec![b"\r".to_vec()]); + assert!(steps[0].expect.shown_on(&screen(PREVIEW))); + // Enter waits for the cursor on 2: not before the digit lands. + assert!(!steps[1].expect.shown_on(&screen(PREVIEW))); + assert!(steps[1].expect.shown_on(&screen(&preview_on_two()))); + // Nor on another option. + let on_three = PREVIEW + .replace("❯ 1. km/h", " 1. km/h") + .replace(" 3. mph ", "❯ 3. mph "); + assert!(!steps[1].expect.shown_on(&screen(&on_three))); + // The option under the cursor already: the digit changes nothing. + let steps = p.pick(1).unwrap(); + assert!(steps[1].expect.shown_on(&screen(PREVIEW))); + assert_eq!(p.pick(4), None); + } + + #[test] + fn a_preview_with_notes_in_use_is_not_answered() { + // Open: a digit would be typed into the notes. + let open = PREVIEW.replace(NOTES_EMPTY, "Add notes on this design…"); + let p = parse_screen(&screen(&open)).unwrap(); + assert!(p.notes_in_use()); + assert_eq!(p.pick(2), None); + // Closed with text: Enter would send the notes along. + let written = PREVIEW.replace(NOTES_EMPTY, "2"); + assert_eq!(parse_screen(&screen(&written)).unwrap().pick(2), None); + // Notes opened between the digit and Enter. + let steps = parse_screen(&screen(PREVIEW)).unwrap().pick(2).unwrap(); + let typed = preview_on_two().replace(NOTES_EMPTY, ""); + assert!(!steps[1].expect.shown_on(&screen(&typed))); + // No notes line found: not answered. + let gone = PREVIEW.replace("Notes: press n to add notes", ""); + assert_eq!(parse_screen(&screen(&gone)).unwrap().pick(2), None); + } + + #[test] + fn a_box_without_its_bottom_corner_is_not_a_preview() { + let text = TABBED.replace("❯ 1. Apple", "❯ 1. Apple ┌─┐"); + let p = parse_screen(&screen(&text)).unwrap(); + assert_eq!(p.options[0].label, "Apple ┌─┐"); + assert_eq!(p.pick(1).unwrap().len(), 1); + } + + #[test] + fn nothing_waiting() { + // Answered: the question remains in the scrollback, the selector does not. + let answered = "⏺ User answered Claude's questions: + ⎿ · Fruit? → Pear +──────────────────────────────── +❯ +────────────────────────────────"; + assert_eq!(parse_screen(&screen(answered)), None); + assert_eq!(parse_screen(&[]), None); + // A numbered list in agent output is not a prompt without a cursor. + let list = "──────────────────────────────── + 1. first + 2. second"; + assert_eq!(parse_screen(&screen(list)), None); + } +} diff --git a/src/tui/transcript_view/remote.rs b/src/tui/transcript_view/remote.rs new file mode 100644 index 00000000..a81cdd2e --- /dev/null +++ b/src/tui/transcript_view/remote.rs @@ -0,0 +1,622 @@ +//! Remote conversations come over the relay transcript RPC. Nothing polls: +//! a request goes out when the cursor settles on an agent, when that agent's +//! synced status changes, or on `R`. Each refresh is one job of at most three +//! RPCs, shrinking the request when the reply may have been too big. +use std::sync::Arc; +use std::sync::mpsc::{self, Receiver, Sender}; +use std::time::{Duration, Instant}; + +use serde_json::{Value, json}; + +use super::convert::turns_from_remote_content; +use super::model::{ConversationSnapshot, TurnView}; +use crate::tui::model::{Agent, AgentStatus}; + +pub const REMOTE_LAST: usize = 5; +pub const RETRY_LAST: usize = 2; +pub const FINAL_LAST: usize = 1; +pub const MAX_IN_FLIGHT: usize = 2; +pub const CURSOR_SETTLE: Duration = Duration::from_millis(400); +pub const STATUS_DEBOUNCE: Duration = Duration::from_secs(1); +pub const STATUS_MAX_WAIT: Duration = Duration::from_secs(3); +const CACHE_LIMIT: usize = 32; +/// Set by the relay when it cut a reply to fit a publish. +const TRUNCATED_MARKER: &str = "_relay_truncated"; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RemoteRequest { + /// `base:DEVICE` + pub key: String, + pub device: String, + pub base_name: String, + pub last: usize, + pub detailed: bool, +} + +impl RemoteRequest { + /// Same payload `hcom transcript name:DEVICE --json` sends. + pub fn params(&self) -> Value { + json!({ + "target": self.base_name, + "display_target": self.key, + "origin_device": self.device, + "last": self.last, + "json": true, + "detailed": self.detailed, + }) + } +} + +pub type Dispatcher = Arc Result + Send + Sync>; + +fn real_dispatch(req: &RemoteRequest) -> Result { + use crate::relay::control::{RPC_DEFAULT_TIMEOUT, dispatch_remote, rpc_action}; + let db = crate::db::HcomDb::open().map_err(|e| e.to_string())?; + dispatch_remote( + &db, + &req.device, + Some(&req.key), + rpc_action::TRANSCRIPT, + &req.params(), + RPC_DEFAULT_TIMEOUT, + ) +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum FailureReason { + Timeout, + Offline, + Truncated, + Format, + Remote(String), +} + +impl FailureReason { + pub fn label(&self) -> String { + match self { + Self::Timeout => "timeout".into(), + Self::Offline => "offline".into(), + Self::Truncated => "truncated".into(), + Self::Format => "bad format".into(), + Self::Remote(m) => m.clone(), + } + } + + /// A reply the broker drops for size shows up as a timeout. + fn size_suspect(&self) -> bool { + matches!(self, Self::Timeout | Self::Truncated) + } +} + +fn classify(result: &Result) -> Result, FailureReason> { + match result { + Err(e) if e.starts_with("timed out waiting for rpc_result") => Err(FailureReason::Timeout), + Err(e) if e.contains(" is offline ") => Err(FailureReason::Offline), + // The relay cut the result to fit a publish (`relay::push`). + Err(e) if e.contains("too large to relay") => Err(FailureReason::Truncated), + Err(e) => Err(FailureReason::Remote( + e.lines() + .next() + .unwrap_or("remote error") + .chars() + .take(40) + .collect(), + )), + Ok(v) if v.get(TRUNCATED_MARKER).is_some() => Err(FailureReason::Truncated), + Ok(v) => { + let content = v + .get("content") + .and_then(Value::as_str) + .ok_or(FailureReason::Format)?; + turns_from_remote_content(content).map_err(|_| FailureReason::Format) + } + } +} + +struct Job { + attempt: u8, + reasons: Vec, + request_id: u64, + /// A send held back by the in-flight cap: (last, detailed). + held: Option<(usize, bool)>, +} + +struct Entry { + key: String, + device: String, + base_name: String, + turns: Option>, + last_success: Option, + failure: Option, + job: Option, + /// A trigger arrived while a job ran; run one more after it. + waiting: bool, + baseline: Option<(AgentStatus, u64)>, + /// (first, latest) unhandled status change. + status_change: Option<(Instant, Instant)>, +} + +struct Reply { + key: String, + request_id: u64, + result: Result, +} + +pub struct RemoteTranscriptWorker { + dispatcher: Dispatcher, + tx: Sender, + rx: Receiver, + in_flight: usize, + next_request_id: u64, + entries: Vec, + cursor: Option<(String, Instant)>, + settled: bool, +} + +impl Default for RemoteTranscriptWorker { + fn default() -> Self { + Self::with_dispatcher(Arc::new(real_dispatch)) + } +} + +fn key_of(agent: &Agent) -> String { + format!( + "{}:{}", + agent.name, + agent.device_name.as_deref().unwrap_or("") + ) +} + +fn status_key(agent: &Agent) -> (AgentStatus, u64) { + (agent.status, agent.status_time.to_bits()) +} + +impl RemoteTranscriptWorker { + pub fn with_dispatcher(dispatcher: Dispatcher) -> Self { + let (tx, rx) = mpsc::channel(); + Self { + dispatcher, + tx, + rx, + in_flight: 0, + next_request_id: 0, + entries: Vec::new(), + cursor: None, + settled: false, + } + } + + fn entry_index(&mut self, agent: &Agent) -> usize { + let key = key_of(agent); + if let Some(i) = self.entries.iter().position(|e| e.key == key) { + return i; + } + if self.entries.len() >= CACHE_LIMIT + && let Some(old) = self.entries.iter().position(|e| e.job.is_none()) + { + self.entries.remove(old); + } + self.entries.push(Entry { + key, + device: agent.device_name.clone().unwrap_or_default(), + base_name: agent.name.clone(), + turns: None, + last_success: None, + failure: None, + job: None, + waiting: false, + baseline: None, + status_change: None, + }); + self.entries.len() - 1 + } + + fn trigger(&mut self, idx: usize) { + if self.entries[idx].job.is_some() { + self.entries[idx].waiting = true; + return; + } + let request_id = self.next_request_id; + self.next_request_id += 1; + self.entries[idx].job = Some(Job { + attempt: 1, + reasons: Vec::new(), + request_id, + held: None, + }); + self.send(idx, REMOTE_LAST, true); + } + + fn send(&mut self, idx: usize, last: usize, detailed: bool) { + let entry = &mut self.entries[idx]; + let Some(job) = entry.job.as_mut() else { + return; + }; + if self.in_flight >= MAX_IN_FLIGHT { + job.held = Some((last, detailed)); + return; + } + job.held = None; + self.in_flight += 1; + let req = RemoteRequest { + key: entry.key.clone(), + device: entry.device.clone(), + base_name: entry.base_name.clone(), + last, + detailed, + }; + let request_id = job.request_id; + let dispatcher = self.dispatcher.clone(); + let tx = self.tx.clone(); + std::thread::spawn(move || { + let result = + std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| dispatcher(&req))) + .unwrap_or_else(|_| Err("dispatch panicked".into())); + let _ = tx.send(Reply { + key: req.key, + request_id, + result, + }); + }); + } + + fn finish(&mut self, idx: usize, cursor_key: Option<&str>) { + let entry = &mut self.entries[idx]; + entry.job = None; + if entry.waiting { + entry.waiting = false; + if cursor_key == Some(entry.key.as_str()) { + self.trigger(idx); + } + } + } + + fn handle_reply(&mut self, reply: Reply, now: Instant, cursor_key: Option<&str>) -> bool { + self.in_flight = self.in_flight.saturating_sub(1); + let Some(idx) = self.entries.iter().position(|e| e.key == reply.key) else { + return false; + }; + let entry = &mut self.entries[idx]; + if entry.job.as_ref().map(|j| j.request_id) != Some(reply.request_id) { + return false; + } + match classify(&reply.result) { + Ok(turns) => { + entry.turns = Some(turns); + entry.last_success = Some(now); + entry.failure = None; + self.finish(idx, cursor_key); + } + Err(reason) => { + let job = entry.job.as_mut().expect("checked above"); + job.reasons.push(reason.clone()); + let next = match job.attempt { + 1 => Some((RETRY_LAST, true)), + 2 if job.reasons.iter().all(FailureReason::size_suspect) => { + Some((FINAL_LAST, false)) + } + _ => None, + }; + match next { + Some((last, detailed)) => { + job.attempt += 1; + self.send(idx, last, detailed); + } + None => { + entry.failure = Some(reason.label()); + self.finish(idx, cursor_key); + } + } + } + } + true + } + + /// `cursor` is the remote agent under the cursor while the tab is visible. + pub fn poll(&mut self, cursor: Option<&Agent>, now: Instant) -> bool { + let cursor_key = cursor.map(key_of); + let mut changed = false; + + while let Ok(reply) = self.rx.try_recv() { + changed |= self.handle_reply(reply, now, cursor_key.as_deref()); + } + + // Sends held by the in-flight cap. New jobs only for the cursor's agent. + for idx in 0..self.entries.len() { + if self.in_flight >= MAX_IN_FLIGHT { + break; + } + let entry = &mut self.entries[idx]; + let Some(job) = entry.job.as_mut() else { + continue; + }; + let Some((last, detailed)) = job.held else { + continue; + }; + if job.attempt == 1 && cursor_key.as_deref() != Some(entry.key.as_str()) { + entry.job = None; + entry.waiting = false; + continue; + } + self.send(idx, last, detailed); + } + + match cursor { + None => { + self.cursor = None; + for e in &mut self.entries { + e.status_change = None; + } + } + Some(agent) => { + let key = cursor_key.clone().expect("cursor is some"); + let idx = self.entry_index(agent); + if self.cursor.as_ref().map(|c| &c.0) != Some(&key) { + for e in &mut self.entries { + e.status_change = None; + } + self.cursor = Some((key, now)); + self.settled = false; + self.entries[idx].baseline = Some(status_key(agent)); + changed = true; + } else { + let since = self.cursor.as_ref().map(|c| c.1).unwrap_or(now); + if !self.settled && now.saturating_duration_since(since) >= CURSOR_SETTLE { + self.settled = true; + self.trigger(idx); + changed = true; + } + let entry = &mut self.entries[idx]; + let current = status_key(agent); + if entry.baseline != Some(current) { + entry.baseline = Some(current); + let first = entry.status_change.map(|s| s.0).unwrap_or(now); + entry.status_change = Some((first, now)); + } + if let Some((first, latest)) = entry.status_change + && (now.saturating_duration_since(latest) >= STATUS_DEBOUNCE + || now.saturating_duration_since(first) >= STATUS_MAX_WAIT) + { + entry.status_change = None; + if self.settled { + self.trigger(idx); + changed = true; + } + } + } + } + } + changed + } + + pub fn snapshot(&self, agent: &Agent) -> ConversationSnapshot<'_> { + let key = key_of(agent); + match self.entries.iter().find(|e| e.key == key) { + Some(e) => ConversationSnapshot { + turns: e.turns.as_deref(), + last_success: e.last_success, + failure: e.failure.as_deref(), + loading: e.job.is_some(), + email: None, + prompt: None, + }, + None => ConversationSnapshot::default(), + } + } + + /// Refresh now, skipping the debounce. Before the cursor settles this is + /// the settle's request too, so the agent gets one RPC, not two. + pub fn force_refresh(&mut self, agent: &Agent, now: Instant) { + let idx = self.entry_index(agent); + let key = key_of(agent); + if self.cursor.as_ref().map(|c| &c.0) != Some(&key) { + for e in &mut self.entries { + e.status_change = None; + } + self.cursor = Some((key, now)); + self.entries[idx].baseline = Some(status_key(agent)); + } + self.settled = true; + self.trigger(idx); + } + + pub fn has_pending_timer(&self) -> bool { + self.in_flight > 0 + || (self.cursor.is_some() && !self.settled) + || self.entries.iter().any(|e| { + e.status_change.is_some() || e.job.as_ref().is_some_and(|j| j.held.is_some()) + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::tui::test_helpers::make_test_agent; + use std::sync::Mutex; + + type Log = Arc>>; + + fn worker(replies: Vec>) -> (RemoteTranscriptWorker, Log) { + let log: Log = Arc::default(); + let queue = Arc::new(Mutex::new(replies)); + let l = log.clone(); + let w = RemoteTranscriptWorker::with_dispatcher(Arc::new(move |req: &RemoteRequest| { + l.lock().unwrap().push(req.clone()); + let mut q = queue.lock().unwrap(); + if q.is_empty() { + Ok(ok_reply()) + } else { + q.remove(0) + } + })); + (w, log) + } + + fn ok_reply() -> Value { + json!({"target": "luna", "content": r#"[{"position":1,"user":"q","action":"a","tools":[]}]"#}) + } + + fn remote(name: &str) -> Agent { + let mut a = make_test_agent(name, 0.0); + a.device_name = Some("ABCD".into()); + a + } + + fn settle(w: &mut RemoteTranscriptWorker, a: &Agent, now: Instant) { + for _ in 0..300 { + w.poll(Some(a), now); + if w.in_flight == 0 && w.entries.iter().all(|e| e.job.is_none()) { + return; + } + std::thread::sleep(Duration::from_millis(5)); + } + panic!("jobs never finished"); + } + + #[test] + fn request_after_cursor_settles() { + let (mut w, log) = worker(vec![]); + let a = remote("luna"); + let t0 = Instant::now(); + w.poll(Some(&a), t0); + w.poll(Some(&a), t0 + Duration::from_millis(399)); + assert!(log.lock().unwrap().is_empty()); + w.poll(Some(&a), t0 + Duration::from_millis(400)); + settle(&mut w, &a, t0 + Duration::from_millis(400)); + let log = log.lock().unwrap(); + assert_eq!(log.len(), 1); + assert_eq!( + log[0].params(), + json!({"target":"luna","display_target":"luna:ABCD","origin_device":"ABCD", + "last":5,"json":true,"detailed":true}) + ); + assert_eq!(w.snapshot(&a).turns.unwrap().len(), 1); + } + + #[test] + fn passing_cursor_sends_nothing() { + let (mut w, log) = worker(vec![]); + let t0 = Instant::now(); + for (i, n) in ["a", "b", "c", "d"].iter().enumerate() { + w.poll(Some(&remote(n)), t0 + Duration::from_millis(100 * i as u64)); + } + let d = remote("d"); + w.poll(Some(&d), t0 + Duration::from_millis(700)); + settle(&mut w, &d, t0 + Duration::from_millis(700)); + let log = log.lock().unwrap(); + assert_eq!(log.len(), 1); + assert_eq!(log[0].base_name, "d"); + } + + #[test] + fn timeouts_shrink_then_fail_keeps_cache() { + let timeout = || Err::("timed out waiting for rpc_result (10s)".to_string()); + let (mut w, log) = worker(vec![Ok(ok_reply()), timeout(), timeout(), timeout()]); + let a = remote("luna"); + let t0 = Instant::now(); + w.poll(Some(&a), t0); + w.poll(Some(&a), t0 + CURSOR_SETTLE); + settle(&mut w, &a, t0 + CURSOR_SETTLE); + w.force_refresh(&a, t0); + settle(&mut w, &a, t0 + CURSOR_SETTLE); + let lasts: Vec<(usize, bool)> = log + .lock() + .unwrap() + .iter() + .map(|r| (r.last, r.detailed)) + .collect(); + assert_eq!(lasts, vec![(5, true), (5, true), (2, true), (1, false)]); + let snap = w.snapshot(&a); + assert_eq!(snap.failure, Some("timeout")); + assert_eq!(snap.turns.unwrap().len(), 1); + } + + #[test] + fn offline_stops_after_one_retry() { + let off = || Err::("device ABCD is offline (last seen 5m ago)".to_string()); + let (mut w, log) = worker(vec![off(), off()]); + let a = remote("luna"); + let t0 = Instant::now(); + w.poll(Some(&a), t0); + w.poll(Some(&a), t0 + CURSOR_SETTLE); + settle(&mut w, &a, t0 + CURSOR_SETTLE); + assert_eq!(log.lock().unwrap().len(), 2); + assert_eq!(w.snapshot(&a).failure, Some("offline")); + assert!(w.snapshot(&a).turns.is_none()); + } + + #[test] + fn status_change_triggers_after_debounce() { + let (mut w, log) = worker(vec![]); + let mut a = remote("luna"); + let t0 = Instant::now(); + w.poll(Some(&a), t0); + w.poll(Some(&a), t0 + CURSOR_SETTLE); + settle(&mut w, &a, t0 + CURSOR_SETTLE); + a.status_time += 1.0; + let t1 = t0 + Duration::from_secs(10); + w.poll(Some(&a), t1); + w.poll(Some(&a), t1 + Duration::from_millis(900)); + assert_eq!(log.lock().unwrap().len(), 1); + w.poll(Some(&a), t1 + STATUS_DEBOUNCE); + settle(&mut w, &a, t1 + STATUS_DEBOUNCE); + assert_eq!(log.lock().unwrap().len(), 2); + } + + #[test] + fn refresh_before_settle_is_the_settle_request() { + let (mut w, log) = worker(vec![]); + let a = remote("luna"); + let t0 = Instant::now(); + w.poll(Some(&a), t0); + w.force_refresh(&a, t0 + Duration::from_millis(100)); + settle(&mut w, &a, t0 + Duration::from_millis(100)); + assert_eq!(log.lock().unwrap().len(), 1); + w.poll(Some(&a), t0 + CURSOR_SETTLE); + settle(&mut w, &a, t0 + CURSOR_SETTLE * 2); + assert_eq!(log.lock().unwrap().len(), 1, "no second request at settle"); + assert!(!w.has_pending_timer()); + } + + #[test] + fn oversized_reply_shrinks_to_one_turn() { + let big = || { + Err::( + "transcript failed: rpc result too large to relay (300000 bytes); the relayed copy was cut" + .to_string(), + ) + }; + let (mut w, log) = worker(vec![big(), big()]); + let a = remote("luna"); + let t0 = Instant::now(); + w.poll(Some(&a), t0); + w.poll(Some(&a), t0 + CURSOR_SETTLE); + settle(&mut w, &a, t0 + CURSOR_SETTLE); + let lasts: Vec<(usize, bool)> = log + .lock() + .unwrap() + .iter() + .map(|r| (r.last, r.detailed)) + .collect(); + assert_eq!(lasts, vec![(5, true), (2, true), (1, false)]); + assert_eq!(w.snapshot(&a).turns.unwrap().len(), 1); + } + + #[test] + fn classify_replies() { + assert_eq!( + classify(&Ok(json!({"content": "nope"}))), + Err(FailureReason::Format) + ); + assert_eq!( + classify(&Ok(json!({"content": "[]", "_relay_truncated": true}))), + Err(FailureReason::Truncated) + ); + let long = "transcript failed: ".to_string() + &"x".repeat(80); + match classify(&Err(long)) { + Err(FailureReason::Remote(m)) => assert_eq!(m.chars().count(), 40), + other => panic!("{other:?}"), + } + } +} diff --git a/src/tui/transcript_view/render.rs b/src/tui/transcript_view/render.rs new file mode 100644 index 00000000..7cde3c80 --- /dev/null +++ b/src/tui/transcript_view/render.rs @@ -0,0 +1,899 @@ +//! Conversation tab: tab strip, header line and turn list. +use std::time::Instant; + +use ratatui::prelude::*; +use ratatui::widgets::Paragraph; +use unicode_width::UnicodeWidthStr; + +use super::convert::fill_wake_requests; +use super::markdown; +use super::model::{ + ConversationSnapshot, ConversationTarget, RightTab, ToolSummary, TurnView, remote_account_key, + resolve_target, +}; +use super::prompt::ScreenPrompt; +use crate::tui::app::App; +use crate::tui::model::{Agent, AgentStatus, Message, format_duration_short}; +use crate::tui::render::SPINNER_FRAMES; +use crate::tui::render::messages::{ScrollMetrics, render_scrolled}; +use crate::tui::render::{fit_spans, truncate_display}; +use crate::tui::theme::{Theme, palette}; + +const TAB_ACTIVITY: &str = "[activity]"; +const TAB_CONVERSATION: &str = "[chat]"; + +fn tabstrip_width() -> usize { + TAB_ACTIVITY.width() + 1 + TAB_CONVERSATION.width() +} + +/// The heading row left of the tab strip. +pub fn heading_area(area: Rect) -> Rect { + Rect { + width: area.width.saturating_sub(tabstrip_width() as u16 + 1), + ..area + } +} + +/// Draw `[activity] [chat]` at the right end of the heading row. +pub fn render_tabstrip(frame: &mut Frame, area: Rect, tab: RightTab) { + let w = tabstrip_width() as u16; + if area.width < w { + return; + } + let style = |on: bool| if on { Theme::title() } else { Theme::dim() }; + let line = Line::from(vec![ + Span::styled(TAB_ACTIVITY, style(tab == RightTab::Activity)), + Span::raw(" "), + Span::styled(TAB_CONVERSATION, style(tab == RightTab::Conversation)), + ]); + let x = area.x + area.width - w; + frame.render_widget(Paragraph::new(line), Rect::new(x, area.y, w, 1)); +} + +pub fn render_conversation(frame: &mut Frame, head: Rect, body: Rect, app: &App) -> ScrollMetrics { + let now = Instant::now(); + let head_w = (head.width as usize).saturating_sub(tabstrip_width() + 1); + let width = body.width.saturating_sub(1) as usize; + let target = resolve_target(&app.data, app.cursor_target()); + let live_agent = target_agent(&target); + + let (header, lines): (Vec>, Vec>) = match target { + ConversationTarget::NoAgent => ( + vec![Span::styled(" chat", Theme::dim())], + status_line("select an agent"), + ), + ConversationTarget::Unsupported(agent) | ConversationTarget::LocalNoPath(agent) => ( + header_line(agent, None, &ConversationSnapshot::default(), now, head_w), + status_line("no conversation"), + ), + ConversationTarget::Local { agent, .. } => { + let snap = app.transcript.local.snapshot(agent); + let expanded = app.transcript.is_expanded(agent); + ( + header_line(agent, snap.email, &snap, now, head_w), + snapshot_lines(&snap, &agent.name, &app.data.messages, expanded, width), + ) + } + ConversationTarget::Remote { agent, .. } => { + let snap = app.transcript.remote.snapshot(agent); + let email = remote_account_key(agent) + .and_then(|k| app.data.remote_accounts.get(&k)) + .map(String::as_str); + let expanded = app.transcript.is_expanded(agent); + let key = remote_account_key(agent).unwrap_or_else(|| agent.name.clone()); + ( + header_line(agent, email, &snap, now, head_w), + snapshot_lines(&snap, &key, &app.data.messages, expanded, width), + ) + } + }; + + let mut lines = lines; + if let Some(agent) = live_agent + && let Some(live) = live_line(agent, app.ui.tick, width) + { + lines.push(live); + } + if let Some((_, prompt)) = app.waiting_prompt() { + lines.extend(prompt_lines(prompt, width)); + } + + let head_rect = Rect::new(head.x, head.y, head_w as u16, head.height.min(1)); + frame.render_widget( + Paragraph::new(Line::from(fit_spans(header, head_w))), + head_rect, + ); + render_scrolled(frame, body, lines, app.ui.msg_scroll, app.ui.last_msg_total) +} + +fn target_agent<'a>(target: &ConversationTarget<'a>) -> Option<&'a Agent> { + match target { + ConversationTarget::Local { agent, .. } | ConversationTarget::Remote { agent, .. } => { + Some(agent) + } + _ => None, + } +} + +/// While the agent works: a spinner, what it is doing, and for how long. +/// While it waits on a prompt in its terminal: what it waits on, and that +/// messages are held until then. +pub fn live_line(agent: &Agent, tick: u64, width: usize) -> Option> { + if width == 0 { + return None; + } + if agent.status == AgentStatus::Blocked { + let waiting = match agent.context_display() { + c if c.is_empty() => "blocked".to_string(), + c => format!("blocked: {}", c.lines().next().unwrap_or("")), + }; + return Some(Line::from(Span::styled( + fit( + format!( + "■ {waiting} · messages wait until answered · {}", + agent.age_display() + ), + width, + ), + Theme::blocked(), + ))); + } + if agent.status != AgentStatus::Active { + return None; + } + let frame = SPINNER_FRAMES[(tick as usize / 2) % SPINNER_FRAMES.len()]; + let doing = if agent.status_context.starts_with("tool:") { + agent.context_display() + } else { + "thinking".to_string() + }; + let age = format!(" · {}", agent.age_display()); + let room = width.saturating_sub(frame.width() + 1 + age.width()); + let doing = truncate_display(doing.lines().next().unwrap_or(""), room.max(1)); + Some(Line::from(Span::styled( + fit(format!("{frame} {doing}{age}"), width), + Theme::active(), + ))) +} + +/// The prompt waiting on the agent's screen, as numbered options, and the +/// keys that answer it from here. +pub fn prompt_lines(prompt: &ScreenPrompt, width: usize) -> Vec> { + if width == 0 { + return Vec::new(); + } + let dim = Theme::dim(); + let mut lines = Vec::new(); + // Wrapped rows line up under the text after `lead`. + let push = + |lead: markdown::Segment, body: Vec, lines: &mut Vec>| { + let hang = lead.0.width(); + if hang >= width { + let mut segments = vec![lead]; + segments.extend(body); + for row in markdown::wrap(&segments, width) { + lines.push(Line::from(markdown::spans(row))); + } + return; + } + for (r, row) in markdown::wrap(&body, width - hang).into_iter().enumerate() { + let mut spans = if r == 0 { + vec![Span::styled(lead.0.clone(), lead.1)] + } else { + vec![Span::raw(" ".repeat(hang))] + }; + spans.extend(markdown::spans(row)); + lines.push(Line::from(spans)); + } + }; + let indent = || (" ".to_string(), Style::default()); + if let Some(tabs) = &prompt.tabs { + push(indent(), vec![(tabs.clone(), Theme::blocked())], &mut lines); + } + for text in &prompt.question { + push(indent(), vec![(text.clone(), Theme::title())], &mut lines); + } + for option in &prompt.options { + let mark = match option.checked { + None => "", + Some(true) => "[x] ", + Some(false) => "[ ] ", + }; + let style = if option.needs_typing() { + dim + } else { + Style::default() + }; + let lead = format!(" {mark}{} ", option.number); + let under = " ".repeat(lead.width()); + push( + (lead, Style::default().fg(palette::FG_DIM)), + vec![(option.label.clone(), style)], + &mut lines, + ); + // The screen's own layout: the description sits under the label. + // A label too long for the screen also continues there. + if !option.description.is_empty() { + push( + (under, Style::default()), + vec![(option.description.clone(), dim)], + &mut lines, + ); + } + } + let last = prompt + .options + .iter() + .filter(|o| !o.needs_typing()) + .map(|o| o.number) + .max() + .unwrap_or(1); + let hint = if prompt.notes_in_use() { + "notes are open · answer in the terminal".to_string() + } else if prompt.is_multi() { + format!("1-{last} toggle · enter next · typing answers in the terminal") + } else { + format!("1-{last} answer · typing answers in the terminal") + }; + push(indent(), vec![(hint, dim)], &mut lines); + lines +} + +fn status_line(text: &str) -> Vec> { + vec![Line::from(Span::styled(format!(" {text}"), Theme::dim()))] +} + +/// `agent` is the name messages mention it by. +fn snapshot_lines( + snap: &ConversationSnapshot, + agent: &str, + messages: &[Message], + full: bool, + width: usize, +) -> Vec> { + match snap.turns { + None if snap.failure.is_some() => status_line("could not load the conversation"), + None => status_line("loading…"), + Some([]) => status_line("no conversation"), + Some(turns) => { + let mut turns = turns.to_vec(); + fill_wake_requests(&mut turns, agent, messages); + body_lines(&turns, full, width) + } + } +} + +/// `name · tool · email · refresh`. When too wide, email shrinks first, then +/// the tool; name and refresh state are kept. +pub fn header_line( + agent: &Agent, + email: Option<&str>, + snap: &ConversationSnapshot, + now: Instant, + width: usize, +) -> Vec> { + let name = agent.display_name(); + let mut tool = agent.tool.name().to_string(); + let mut email = email.unwrap_or("—").to_string(); + let refresh = refresh_label(snap, now); + let sep = " · "; + let fixed = 2 + + name.width() + + if refresh.is_empty() { + 0 + } else { + sep.width() + refresh.width() + }; + let used = |tool: &str, email: &str| fixed + 2 * sep.width() + tool.width() + email.width(); + if used(&tool, &email) > width { + let over = used(&tool, &email) - width; + email = truncate_display(&email, email.width().saturating_sub(over).max(1)); + } + if used(&tool, &email) > width { + let over = used(&tool, &email) - width; + tool = truncate_display(&tool, tool.width().saturating_sub(over).max(1)); + } + let dot = || Span::styled(sep, Theme::dim()); + let mut spans = vec![ + Span::raw(" "), + Span::styled(name, Theme::title()), + dot(), + Span::styled(tool, Style::default().fg(palette::FG_DIM)), + dot(), + Span::styled(email, Style::default().fg(palette::FG_DIM)), + ]; + if !refresh.is_empty() { + let style = if snap.failure.is_some() { + Theme::blocked() + } else { + Theme::dim() + }; + spans.push(dot()); + spans.push(Span::styled(refresh, style)); + } + spans +} + +pub fn refresh_label(snap: &ConversationSnapshot, now: Instant) -> String { + let ago = |t: Instant| match format_duration_short(now.saturating_duration_since(t).as_secs()) { + d if d == "now" => "just now".to_string(), + d => format!("{d} ago"), + }; + match (snap.failure, snap.last_success) { + (Some(reason), Some(t)) => format!("as of {} · refresh failed ({reason})", ago(t)), + (Some(reason), None) => format!("refresh failed ({reason})"), + (None, Some(t)) => format!("updated {}", ago(t)), + (None, None) if snap.loading => "loading".to_string(), + (None, None) => String::new(), + } +} + +fn fit(s: String, width: usize) -> String { + truncate_display(&s, width) +} + +/// Turns, oldest first. Compact: the latest turn is expanded and earlier +/// ones are one line each. Full: every turn with its whole text, wrapped. +pub fn body_lines(turns: &[TurnView], full: bool, width: usize) -> Vec> { + if width == 0 { + return vec![Line::raw("")]; + } + let mut lines = Vec::new(); + for (i, turn) in turns.iter().enumerate() { + if full { + full_turn(turn, width, &mut lines); + } else if i + 1 == turns.len() { + expanded_turn(turn, width, &mut lines); + } else { + lines.push(Line::from(Span::styled( + fit(collapsed_turn(turn), width), + Theme::dim(), + ))); + } + } + lines +} + +fn collapsed_turn(turn: &TurnView) -> String { + let request = if turn.request_summary.is_empty() { + "(no request)".to_string() + } else { + markdown::plain(&turn.request_summary) + }; + let response = if turn.response_summary.is_empty() { + "(no response)".to_string() + } else { + markdown::plain(&turn.response_summary) + }; + let tag = match turn.tools.total() { + Some(0) => String::new(), + Some(n) => format!(" Tool×{n}"), + None => " Tool —".to_string(), + }; + format!( + "▸ #{} {} {request} → {response}{tag}", + turn.position, turn.time + ) +} + +fn turn_rule(turn: &TurnView, width: usize, lines: &mut Vec>) { + let rule = format!("── #{} · {} ", turn.position, turn.time); + let fill = width.saturating_sub(rule.width()); + lines.push(Line::from(Span::styled( + fit(format!("{rule}{}", "─".repeat(fill)), width), + Theme::dim(), + ))); +} + +/// Text lines under a turn marker (`▶ `, `◀ `), Markdown-styled: the marker +/// on the first row, two spaces before the rest. `wrap` folds long lines; +/// otherwise each is cut to `width`. `in_code` is the fence state the +/// first line starts in. +#[allow(clippy::too_many_arguments)] +fn push_text( + text: &[String], + marker: &str, + base: Style, + width: usize, + wrap: bool, + mut in_code: bool, + more: bool, + lines: &mut Vec>, +) { + let inner = width.saturating_sub(2).max(1); + let mut first = true; + let last = text.len().saturating_sub(1); + let mut i = 0; + while i < text.len() { + // A run of `|` rows is laid out as one table when it fits. + if !in_code && markdown::is_table_row(&text[i]) { + let end = (i..text.len()) + .find(|&j| !markdown::is_table_row(&text[j])) + .unwrap_or(text.len()); + if let Some(rows) = markdown::table(&text[i..end], base, inner) { + for row in rows { + let prefix = if first { marker } else { " " }; + first = false; + let mut spans = vec![Span::styled(prefix.to_string(), base)]; + spans.extend(markdown::spans(row)); + lines.push(Line::from(fit_spans(spans, width))); + } + i = end; + continue; + } + } + let raw = &text[i]; + let at_last = i == last; + i += 1; + let Some(mut segs) = markdown::line(raw, &mut in_code, base) else { + continue; + }; + if more && at_last { + segs.push(("…".into(), base)); + } + // Wrapped rows of a bullet line up under its text. + let hang = match segs.first() { + Some((lead, _)) if lead.ends_with("• ") => lead.width(), + _ => 0, + }; + let rows = if wrap { + markdown::wrap(&segs, inner.saturating_sub(hang).max(1)) + } else { + vec![segs] + }; + for (r, row) in rows.into_iter().enumerate() { + let prefix = if first { marker } else { " " }; + first = false; + let mut spans = vec![Span::styled(prefix.to_string(), base)]; + if r > 0 && hang > 0 { + spans.push(Span::raw(" ".repeat(hang))); + } + spans.extend(markdown::spans(row)); + lines.push(Line::from(fit_spans(spans, width))); + } + } +} + +fn plain_line(text: &str, style: Style, width: usize, lines: &mut Vec>) { + lines.push(Line::from(Span::styled( + fit(text.to_string(), width), + style, + ))); +} + +fn full_turn(turn: &TurnView, width: usize, lines: &mut Vec>) { + turn_rule(turn, width, lines); + let req = Style::default().fg(palette::BLUE); + if turn.request_full.is_empty() { + plain_line("▶ (no request)", req, width, lines); + } + push_text( + &turn.request_full, + "▶ ", + req, + width, + true, + false, + false, + lines, + ); + if let Some(tools) = tools_line(&turn.tools) { + let dim = Style::default().fg(palette::FG_DIM); + for row in markdown::wrap(&[(tools, dim)], width.max(1)) { + lines.push(Line::from(markdown::spans(row))); + } + } + let resp = Style::default().fg(palette::FG); + if turn.response_full.is_empty() { + plain_line("◀ (no response)", resp, width, lines); + } + push_text( + &turn.response_full, + "◀ ", + resp, + width, + true, + false, + false, + lines, + ); +} + +fn expanded_turn(turn: &TurnView, width: usize, lines: &mut Vec>) { + turn_rule(turn, width, lines); + + let req = Style::default().fg(palette::BLUE); + if turn.request_lines.is_empty() { + plain_line("▶ (no request)", req, width, lines); + } + push_text( + &turn.request_lines, + "▶ ", + req, + width, + false, + false, + turn.request_more, + lines, + ); + + if let Some(tools) = tools_line(&turn.tools) { + plain_line(&tools, Style::default().fg(palette::FG_DIM), width, lines); + } + + let resp = Style::default().fg(palette::FG); + if turn.response_tail.is_empty() { + plain_line("◀ (no response)", resp, width, lines); + return; + } + let marker = if turn.response_omitted > 0 { + plain_line( + &format!("◀ … ({} earlier lines)", turn.response_omitted), + Theme::dim(), + width, + lines, + ); + " " + } else { + "◀ " + }; + // A fence opened in the omitted part leaves the tail inside code. + let omitted_fences = markdown::fence_count(&turn.response_full) + .saturating_sub(markdown::fence_count(&turn.response_tail)); + let in_code = omitted_fences % 2 == 1; + push_text( + &turn.response_tail, + marker, + resp, + width, + false, + in_code, + false, + lines, + ); +} + +fn tools_line(tools: &ToolSummary) -> Option { + match tools { + ToolSummary::None => None, + ToolSummary::Unknown => Some("⚙ —".into()), + ToolSummary::Some { + counts, + files, + extra_files, + } => { + let mut s = format!( + "⚙ {}", + counts + .iter() + .map(|(n, c)| format!("{n}×{c}")) + .collect::>() + .join(" ") + ); + if !files.is_empty() { + s.push_str(" · "); + s.push_str(&files.join(", ")); + if *extra_files > 0 { + s.push_str(&format!(" +{extra_files}")); + } + } + Some(s) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::time::Duration; + + fn turn(position: usize, req: &str, resp: &str, tools: ToolSummary) -> TurnView { + TurnView { + position, + time: "16:02".into(), + request_lines: if req.is_empty() { + vec![] + } else { + vec![req.into()] + }, + request_more: false, + request_summary: req.into(), + response_tail: if resp.is_empty() { + vec![] + } else { + vec![resp.into()] + }, + response_omitted: 0, + response_summary: resp.into(), + tools, + request_full: req.lines().map(String::from).collect(), + response_full: resp.lines().map(String::from).collect(), + started_at: None, + hcom_wake: false, + } + } + + fn text(lines: &[Line]) -> Vec { + lines + .iter() + .map(|l| l.spans.iter().map(|s| s.content.as_ref()).collect()) + .collect() + } + + #[test] + fn prompt_lists_screen_options() { + let screen: Vec = "──────────────────────────────── +← ☒ fruit ☐ drink ✔ Submit → + +Drink? + +❯ 1. [✔] Water + Still water + 2. [ ] Tea + 3. [ ] Type something + Submit + +Enter to select · Esc to cancel" + .lines() + .map(String::from) + .collect(); + let prompt = super::super::prompt::parse_screen(&screen).unwrap(); + let out = text(&prompt_lines(&prompt, 80)); + assert_eq!( + out, + vec![ + " ☒ fruit ☐ drink ✔ Submit", + " Drink?", + " [x] 1 Water", + " Still water", + " [ ] 2 Tea", + " [ ] 3 Type something", + " 1-2 toggle · enter next · typing answers in the terminal", + ] + ); + for w in 2..20 { + for line in text(&prompt_lines(&prompt, w)) { + assert!(line.width() <= w, "{line:?} at {w}"); + } + } + assert!(prompt_lines(&prompt, 0).is_empty()); + } + + #[test] + fn wrapped_option_rows_hang_under_the_label() { + let screen: Vec = "──────────────────────────────── + Edit file + format.py +╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ + 1 -old + 1 +new +╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ + Do you want to make this edit to format.py? + ❯ 1. Yes + 2. Yes, and switch to accept edits (auto-approve file edits and common file + commands) for this session (shift+tab) + 3. No + + Esc to cancel · Tab to amend" + .lines() + .map(String::from) + .collect(); + let prompt = super::super::prompt::parse_screen(&screen).unwrap(); + let out = text(&prompt_lines(&prompt, 40)); + let at = out.iter().position(|l| l.starts_with(" 2 ")).unwrap(); + assert_eq!(out[at].trim_end(), " 2 Yes, and switch to accept edits"); + // Every row up to option 3 continues under the label. + let rest: Vec<&String> = out[at + 1..] + .iter() + .take_while(|l| !l.starts_with(" 3 ")) + .collect(); + assert!(rest.len() >= 2, "{out:?}"); + for line in rest { + assert!(line.starts_with(" ") && line.width() <= 40, "{line:?}"); + } + } + + #[test] + fn latest_turn_expanded_previous_collapsed() { + let tools = ToolSummary::Some { + counts: vec![("Bash".into(), 3)], + files: vec![], + extra_files: 0, + }; + let turns = vec![ + turn(12, "analyze it", "feasible", tools.clone()), + turn(13, "fork", "created", ToolSummary::Unknown), + turn(14, "run the tests", "6 passed", tools), + ]; + let out = text(&body_lines(&turns, false, 80)); + assert_eq!(out[0], "▸ #12 16:02 analyze it → feasible Tool×3"); + assert_eq!(out[1], "▸ #13 16:02 fork → created Tool —"); + assert!(out[2].starts_with("── #14 · 16:02 ")); + assert_eq!(out[3], "▶ run the tests"); + assert_eq!(out[4], "⚙ Bash×3"); + assert_eq!(out[5], "◀ 6 passed"); + + let all = text(&body_lines(&turns, true, 80)); + assert!(all[0].starts_with("── #12")); + } + + #[test] + fn empty_request_and_response() { + let out = text(&body_lines( + &[turn(1, "", "", ToolSummary::None)], + false, + 80, + )); + assert_eq!(out[1], "▶ (no request)"); + assert_eq!(out[2], "◀ (no response)"); + } + + #[test] + fn narrow_width_never_panics() { + let t = turn( + 1, + "wide ask 🦀🦀🦀", + "reply 🦀", + ToolSummary::Unknown, + ); + for w in 0..12 { + for l in text(&body_lines(std::slice::from_ref(&t), true, w)) { + assert!(l.width() <= w.max(1), "{l:?} at {w}"); + } + } + } + + #[test] + fn refresh_labels() { + let now = Instant::now() + Duration::from_secs(100); + let t = now - Duration::from_secs(45); + let mut snap = ConversationSnapshot { + last_success: Some(t), + ..Default::default() + }; + assert_eq!(refresh_label(&snap, now), "updated 45s ago"); + let fresh = ConversationSnapshot { + last_success: Some(now), + ..Default::default() + }; + assert_eq!(refresh_label(&fresh, now), "updated just now"); + snap.failure = Some("offline"); + assert_eq!( + refresh_label(&snap, now), + "as of 45s ago · refresh failed (offline)" + ); + snap.last_success = None; + assert_eq!(refresh_label(&snap, now), "refresh failed (offline)"); + } + + #[test] + fn header_shrinks_email_first() { + let agent = crate::tui::test_helpers::make_test_agent("rina", 0.0); + let snap = ConversationSnapshot::default(); + let now = Instant::now(); + let joined = |s: Vec| s.iter().map(|s| s.content.to_string()).collect::(); + let full = joined(header_line( + &agent, + Some("someone@example.com"), + &snap, + now, + 200, + )); + assert!( + full.contains("rina · claude · someone@example.com"), + "{full}" + ); + let none = joined(header_line(&agent, None, &snap, now, 200)); + assert!(none.ends_with("· —"), "{none}"); + let narrow = joined(header_line( + &agent, + Some("someone@example.com"), + &snap, + now, + 24, + )); + assert!(narrow.contains("rina")); + assert!(narrow.contains('…')); + } + + #[test] + fn full_mode_shows_every_turn_wrapped() { + let mut first = turn(1, "q1", "a1", ToolSummary::None); + first.response_full = vec!["ABCDEF".into(), "".into(), "end".into()]; + let second = turn(2, "q2", "a2", ToolSummary::None); + let out = text(&body_lines(&[first, second], true, 8)); + assert_eq!( + out, + vec![ + "── #1 ·…", + "▶ q1", + "◀ ABC", + " DEF", + " ", + " end", + "── #2 ·…", + "▶ q2", + "◀ a2", + ] + ); + } + + #[test] + fn live_line_while_active_or_blocked() { + use crate::tui::test_helpers::make_test_agent; + let mut a = make_test_agent("luna", 0.0); + a.status = AgentStatus::Listening; + assert!(live_line(&a, 0, 40).is_none()); + + a.status = AgentStatus::Active; + a.status_context = "tool:Bash".into(); + a.status_detail = "cargo test\nsecond line".into(); + let l = text(&[live_line(&a, 0, 40).unwrap()]).remove(0); + assert!(l.starts_with("⠋ Bash: cargo test"), "{l}"); + let next = text(&[live_line(&a, 2, 40).unwrap()]).remove(0); + assert!(next.starts_with("⠙ "), "{next}"); + + a.status_context = "prompt".into(); + let l = text(&[live_line(&a, 0, 40).unwrap()]).remove(0); + assert!(l.contains("thinking"), "{l}"); + for w in 1..12 { + let l = text(&[live_line(&a, 0, w).unwrap()]).remove(0); + assert!(l.width() <= w, "{l:?} at {w}"); + } + + // Waiting on a prompt: says so, and that messages are held. + a.status = AgentStatus::Blocked; + a.status_context = "approval".into(); + a.status_detail = String::new(); + let line = live_line(&a, 0, 80).unwrap(); + let l = text(std::slice::from_ref(&line)).remove(0); + assert!( + l.starts_with("■ blocked: approval · messages wait until answered"), + "{l}" + ); + assert_eq!(line.spans[0].style, Theme::blocked()); + for w in 1..12 { + let l = text(&[live_line(&a, 0, w).unwrap()]).remove(0); + assert!(l.width() <= w, "{l:?} at {w}"); + } + } + + #[test] + fn markdown_in_turns() { + let mut t = turn( + 1, + "**request**", + "## Result\n- `a.rs` changed", + ToolSummary::None, + ); + t.response_tail = vec!["## Result".into(), "- `a.rs` changed".into()]; + t.response_summary = "- `a.rs` changed".into(); + let out = text(&body_lines(std::slice::from_ref(&t), false, 40)); + assert_eq!(out[1], "▶ request"); + assert_eq!(out[2], "◀ Result"); + assert_eq!(out[3], " • a.rs changed"); + // Collapsed summaries drop the markers. + let out = text(&body_lines( + &[t.clone(), turn(2, "q", "a", ToolSummary::None)], + false, + 60, + )); + assert!(out[0].contains("request → • a.rs changed"), "{}", out[0]); + + // The tail starts inside a block opened in the omitted part. + let mut code = turn(3, "q", "", ToolSummary::None); + code.response_full = vec!["```".into(), "let **x** = 1;".into(), "```".into()]; + code.response_tail = vec!["let **x** = 1;".into(), "```".into()]; + code.response_omitted = 1; + let out = text(&body_lines(std::slice::from_ref(&code), false, 40)); + assert_eq!(out.last().unwrap(), " let **x** = 1;"); + } + + #[test] + fn wrapped_bullet_hangs_under_its_text() { + let mut t = turn(1, "q", "", ToolSummary::None); + t.response_full = vec!["- aaa bbb ccc".into()]; + let out = text(&body_lines(std::slice::from_ref(&t), true, 12)); + assert_eq!(out[2..], ["◀ • aaa ", " bbb ccc"]); + } +}