From fb900626a96b4940ec3f789ee3ffce162c9ffd58 Mon Sep 17 00:00:00 2001 From: leilen Date: Thu, 22 Oct 2026 18:48:10 +0900 Subject: [PATCH 01/14] Add a conversation tab to the TUI's vertical view Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 8 + README.md | 3 + src/accounts.rs | 179 ++++++++ src/commands/help.rs | 4 + src/config.rs | 6 + src/instance_binding.rs | 3 + src/main.rs | 1 + src/relay/pull.rs | 23 + src/relay/push.rs | 38 +- src/tui/app.rs | 13 +- src/tui/db.rs | 64 +++ src/tui/input.rs | 38 +- src/tui/mod.rs | 5 + src/tui/render/agents.rs | 2 +- src/tui/render/messages.rs | 2 +- src/tui/render/mod.rs | 30 +- src/tui/state.rs | 8 +- src/tui/transcript_view/convert.rs | 457 +++++++++++++++++++ src/tui/transcript_view/local.rs | 370 +++++++++++++++ src/tui/transcript_view/markdown.rs | 335 ++++++++++++++ src/tui/transcript_view/mod.rs | 275 +++++++++++ src/tui/transcript_view/model.rs | 190 ++++++++ src/tui/transcript_view/remote.rs | 570 +++++++++++++++++++++++ src/tui/transcript_view/render.rs | 684 ++++++++++++++++++++++++++++ 24 files changed, 3275 insertions(+), 33 deletions(-) create mode 100644 src/accounts.rs create mode 100644 src/tui/transcript_view/convert.rs create mode 100644 src/tui/transcript_view/local.rs create mode 100644 src/tui/transcript_view/markdown.rs create mode 100644 src/tui/transcript_view/mod.rs create mode 100644 src/tui/transcript_view/model.rs create mode 100644 src/tui/transcript_view/remote.rs create mode 100644 src/tui/transcript_view/render.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f3ffa9bb..dc943c50f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ 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 an 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`. +- Showed a spinner with the current tool and elapsed time under the conversation while the agent is working. + ## 0.7.31 — 2026-10-21 ### Breaking changes diff --git a/README.md b/README.md index 530ffa55e..8057536ef 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 000000000..dc9c5c052 --- /dev/null +++ b/src/accounts.rs @@ -0,0 +1,179 @@ +//! 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; + +/// 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); + // Padded payloads decode too. + 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); + 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/help.rs b/src/commands/help.rs index e286ad647..509566aa2 100644 --- a/src/commands/help.rs +++ b/src/commands/help.rs @@ -662,6 +662,10 @@ 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`; diff --git a/src/config.rs b/src/config.rs index 5207ddc64..64ceab26b 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 b4446b325..48474b2b1 100644 --- a/src/instance_binding.rs +++ b/src/instance_binding.rs @@ -217,6 +217,9 @@ fn capture_context() -> serde_json::Map { "GITPOD_WORKSPACE_ID", "CLOUD_SHELL", "REPL_ID", + // Where the tool keeps its login; the TUI reads the account from it. + "CLAUDE_CONFIG_DIR", + "CODEX_HOME", ]; let mut env_map = serde_json::Map::new(); for key in &env_keys { diff --git a/src/main.rs b/src/main.rs index 9a01e5600..bcb3c8349 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/pull.rs b/src/relay/pull.rs index 01130e13c..a249247f3 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 00a15aa2e..d3497eb6a 100644 --- a/src/relay/push.rs +++ b/src/relay/push.rs @@ -283,15 +283,33 @@ 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. +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 dir = launch_context + .and_then(|c| serde_json::from_str::(c).ok()) + .and_then(|v| v.get("env")?.get(key)?.as_str().map(String::from)) + .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 { let short_id = device_short_id_for_db(db, device_uuid); + let share_account = crate::config::HcomConfig::load(None).relay_share_account; 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 +330,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 +344,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 +363,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)) } diff --git a/src/tui/app.rs b/src/tui/app.rs index 41ded6af4..0d313cc9d 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; } @@ -298,8 +301,11 @@ impl App { .iter() .any(|a| a.status == AgentStatus::Launching) || self.ejector.is_replaying() - || rpc_pending; - let poll_ms = if animating || resize_cooldown > 0 { + || rpc_pending + || self.transcript.has_pending_timer(); + // Spinner redraws only; data reload keeps its idle pace. + let spinning = self.transcript_spinning(); + let poll_ms = if animating || spinning || resize_cooldown > 0 { 80 } else { 500 @@ -345,7 +351,7 @@ impl App { self.ui.tick += 1; // Animations need redraws for spinner frames etc. - if animating { + if animating || spinning { dirty = true; } @@ -491,6 +497,7 @@ mod tests { source: Box::new(NullSource), rpc_client: None, usage: Default::default(), + transcript: Default::default(), } } diff --git a/src/tui/db.rs b/src/tui/db.rs index e4fbca279..8c0545320 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,71 @@ 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. +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()) + .filter(|d| !d.is_empty()) + { + dirs.insert(name, 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. diff --git a/src/tui/input.rs b/src/tui/input.rs index 836bd80d9..f8423a324 100644 --- a/src/tui/input.rs +++ b/src/tui/input.rs @@ -377,6 +377,8 @@ impl App { self.ui.msg_scroll = self.ui.msg_scroll.saturating_sub(5); } + // Conversation tab: Enter messages the agent being read. + KeyCode::Enter if self.conversation_visible() => self.compose_to_targets(), // SELECTION — in inline mode, auto-apply as scrollback filter KeyCode::Enter | KeyCode::Char(' ') => { self.toggle_select_at_cursor(); @@ -471,20 +473,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 +505,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 +1292,23 @@ 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) { + 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(); + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/tui/mod.rs b/src/tui/mod.rs index 0c5aaf3da..cd2ad1611 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 f919e22bf..d3af6f096 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 d9f54f020..37366aca6 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 c28ef668a..cbb531d72 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,14 @@ 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}; + let scroll = if app.transcript.tab == RightTab::Conversation { + conversation::render_conversation(frame, body_right[0], body_right[1], app) + } else { + render_messages_heading(frame, 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; @@ -1434,14 +1441,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 +1770,10 @@ 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("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/state.rs b/src/tui/state.rs index 08d5c85fc..d9ca4b1b1 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,10 @@ 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. + pub tool_config_dirs: HashMap, } impl DataState { @@ -37,6 +41,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 000000000..ab111f3df --- /dev/null +++ b/src/tui/transcript_view/convert.rs @@ -0,0 +1,457 @@ +//! 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; + +fn nonempty_lines(text: &str) -> Vec { + text.lines() + .map(str::trim) + .filter(|l| !l.is_empty()) + .map(String::from) + .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. +fn clean_request(user: &str) -> String { + let user = strip_block(user, "local-command-caveat"); + if 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 user.contains("") { + 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 let Some(command) = tag_body(&user, "bash-input") { + return format!("! {command}"); + } + let output: Vec<&str> = ["bash-stdout", "bash-stderr", "local-command-stdout"] + .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().cloned().unwrap_or_default(), + request_lines: request.into_iter().take(REQUEST_LINES).collect(), + response_summary: response.last().cloned().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(|_| ())?; + Ok(rows.iter().filter_map(turn_from_remote_json).collect()) +} + +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!("줄 {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], "줄 13"); + assert_eq!(t.response_summary, "줄 20"); + assert_eq!(t.tools, ToolSummary::None); + } + + #[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\n질문 하나", + "a\n\n\nb \n\n", + &[], + &[], + )); + assert_eq!(t.request_summary, "/personal 질문 하나"); + assert_eq!(t.request_full, vec!["/personal 질문 하나"]); + 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"], "얼마나 남았어?", 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: 얼마나 남았어?", "nova: all hands", "line 2"] + ); + assert_eq!(turns[1].request_summary, "bigboss: 얼마나 남았어?"); + + // 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"); + } + + #[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_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 000000000..e97d6836a --- /dev/null +++ b/src/tui/transcript_view/local.rs @@ -0,0 +1,370 @@ +//! 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}; +use std::time::{Duration, Instant, SystemTime}; + +use super::convert::turn_from_exchange; +use super::model::{ConversationSnapshot, LocalSource, TurnView}; +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; + +type Stamp = Vec)>>; + +struct Request { + source: LocalSource, + config_dir: Option, + tool: Option, + force: bool, +} + +struct Reply { + source: LocalSource, + turns: Result, String>, + email: Option, +} + +struct Entry { + source: LocalSource, + turns: Option>, + last_success: Option, + failure: Option, + email: Option, +} + +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) + .ok() + .map(|m| (m.len(), m.modified().ok())) + }) + .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 = if !req.source.path.exists() { + // Not written yet: an empty conversation, not an error. + Ok(Vec::new()) + } else { + 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) -> Reply { + let turns = read_turns(&req, cache); + let email = req + .tool + .and_then(|tool| emails.email(tool, req.config_dir.as_deref().map(Path::new))); + Reply { + source: req.source, + turns, + email, + } +} + +/// Transcripts only grow while the agent works; idle rechecks catch late writes. +fn recheck_interval(status: Option) -> Duration { + match status { + Some(AgentStatus::Active) => Duration::from_secs(2), + _ => Duration::from_secs(30), + } +} + +#[derive(Default)] +pub struct LocalTranscriptWorker { + channels: Option<(Sender, Receiver)>, + pending: bool, + checked: Option, + selected: Option, + status: Option, + 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, Option<&str>)>, 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); + if self.status != status { + self.status = status; + self.checked = None; + } + + if let Some((_, rx)) = &self.channels { + while let Ok(reply) = rx.try_recv() { + self.pending = false; + changed |= self.selected.as_ref() == Some(&reply.source); + let idx = match self.entries.iter().position(|e| e.source == reply.source) { + Some(i) => i, + None => { + if self.entries.len() >= CACHE_LIMIT { + self.entries.remove(0); + } + self.entries.push(Entry { + source: reply.source.clone(), + turns: None, + last_success: None, + failure: None, + email: None, + }); + self.entries.len() - 1 + } + }; + let entry = &mut self.entries[idx]; + 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; + } + } + + let due = self.force + || self + .checked + .is_none_or(|t| now.saturating_duration_since(t) >= recheck_interval(self.status)); + if !self.pending + && due + && let Some((agent, config_dir)) = agent + && let Some(source) = self.selected.clone() + { + 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(); + while let Ok(req) = requests.recv() { + if results.send(collect(req, &mut cache, &mut emails)).is_err() { + break; + } + } + }); + (tx, rx) + }); + self.pending = tx + .send(Request { + source, + config_dir: config_dir.map(String::from), + tool: agent.tool.canonical(), + force: self.force, + }) + .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(), + }, + 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 + } +} + +#[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, 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, 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()) || w.channels.is_none()); + assert!(w.channels.is_none()); + wait(&mut w, &agent, Instant::now()); + assert_eq!(w.snapshot(&agent).turns, Some(&[][..])); + } + + #[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()); + } +} diff --git a/src/tui/transcript_view/markdown.rs b/src/tui/transcript_view/markdown.rs new file mode 100644 index 000000000..196bd09c9 --- /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("## 제목 **굵게**", &mut code, base).unwrap(); + assert_eq!(texts(&h), vec!["제목 ", "굵게"]); + 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 = [ + "| 대상 | 방법 |", + "|---|:--:|", + "| **커밋** | `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![ + "대상 │ 방법", + "─────┼─────────", + "커밋 │ 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(&[("가나다라".to_string(), Style::default())], 4); + assert_eq!( + rows.iter().map(|r| texts(r).concat()).collect::>(), + vec!["가나", "다라"] + ); + 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 000000000..b9de5404f --- /dev/null +++ b/src/tui/transcript_view/mod.rs @@ -0,0 +1,275 @@ +//! 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 remote; +pub mod render; + +use std::collections::HashMap; +use std::time::Instant; + +pub use model::*; + +use crate::tui::app::App; +use crate::tui::model::{Agent, ViewMode}; + +#[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, +} + +/// `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, .. + } = self; + let target = if visible { + resolve_target(data, cursor) + } else { + ConversationTarget::NoAgent + }; + match target { + ConversationTarget::Local { agent, .. } => { + let dir = data.tool_config_dirs.get(&agent.name).map(String::as_str); + transcript.local.poll(Some((agent, dir)), 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; + } + + /// `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 + } + + #[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 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"); + } +} diff --git a/src/tui/transcript_view/model.rs b/src/tui/transcript_view/model.rs new file mode 100644 index 000000000..a96c9db30 --- /dev/null +++ b/src/tui/transcript_view/model.rs @@ -0,0 +1,190 @@ +//! 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>, +} + +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/remote.rs b/src/tui/transcript_view/remote.rs new file mode 100644 index 000000000..0aa46bc34 --- /dev/null +++ b/src/tui/transcript_view/remote.rs @@ -0,0 +1,570 @@ +//! 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), + 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, + }, + None => ConversationSnapshot::default(), + } + } + + /// Refresh now, skipping the debounce. + pub fn force_refresh(&mut self, agent: &Agent, _now: Instant) { + let idx = self.entry_index(agent); + 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 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 000000000..a72ab16d5 --- /dev/null +++ b/src/tui/transcript_view/render.rs @@ -0,0 +1,684 @@ +//! 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 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() +} + +/// 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); + } + + 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. +pub fn live_line(agent: &Agent, tick: u64, width: usize) -> Option> { + if agent.status != AgentStatus::Active || width == 0 { + 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(), + ))) +} + +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 latest_turn_expanded_previous_collapsed() { + let tools = ToolSummary::Some { + counts: vec![("Bash".into(), 3)], + files: vec![], + extra_files: 0, + }; + let turns = vec![ + turn(12, "분석해줘", "가능 판정", tools.clone()), + turn(13, "fork", "생성", ToolSummary::Unknown), + turn(14, "테스트 돌려봐", "6개 통과", tools), + ]; + let out = text(&body_lines(&turns, false, 80)); + assert_eq!(out[0], "▸ #12 16:02 분석해줘 → 가능 판정 Tool×3"); + assert_eq!(out[1], "▸ #13 16:02 fork → 생성 Tool —"); + assert!(out[2].starts_with("── #14 · 16:02 ")); + assert_eq!(out[3], "▶ 테스트 돌려봐"); + assert_eq!(out[4], "⚙ Bash×3"); + assert_eq!(out[5], "◀ 6개 통과"); + + 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, "한글 요청 🦀🦀🦀", "응답 🦀", 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!["가나다라마바".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", + "◀ 가나다", + " 라마바", + " ", + " end", + "── #2 ·…", + "▶ q2", + "◀ a2", + ] + ); + } + + #[test] + fn live_line_only_while_active() { + 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}"); + } + } + + #[test] + fn markdown_in_turns() { + let mut t = turn(1, "**요청**", "## 결과\n- `a.rs` 수정", ToolSummary::None); + t.response_tail = vec!["## 결과".into(), "- `a.rs` 수정".into()]; + t.response_summary = "- `a.rs` 수정".into(); + let out = text(&body_lines(std::slice::from_ref(&t), false, 40)); + assert_eq!(out[1], "▶ 요청"); + assert_eq!(out[2], "◀ 결과"); + assert_eq!(out[3], " • a.rs 수정"); + // Collapsed summaries drop the markers. + let out = text(&body_lines( + &[t.clone(), turn(2, "q", "a", ToolSummary::None)], + false, + 60, + )); + assert!(out[0].contains("요청 → • a.rs 수정"), "{}", 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"]); + } +} From fe0bb17b94ab4900fe8882f1b6c6e372ed165ac9 Mon Sep 17 00:00:00 2001 From: leilen Date: Thu, 22 Oct 2026 22:50:18 +0900 Subject: [PATCH 02/14] Answer a blocked agent's choice prompt from the conversation tab While a local Claude agent waits on a question or a permission prompt, the conversation tab reads the options off its screen and lists them; a digit picks one and Enter moves on from a multi-select question. Keys go out only while the same prompt is still on screen. Blocked agents are also marked in the conversation and in the compose footer, since messages to them wait until the prompt is answered. Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 2 + src/commands/term.rs | 89 ++++++++ src/tui/actions.rs | 10 + src/tui/input.rs | 35 ++++ src/tui/render/mod.rs | 32 ++- src/tui/rpc_async.rs | 15 ++ src/tui/transcript_view/local.rs | 47 ++++- src/tui/transcript_view/mod.rs | 141 +++++++++++++ src/tui/transcript_view/model.rs | 2 + src/tui/transcript_view/prompt.rs | 328 ++++++++++++++++++++++++++++++ src/tui/transcript_view/remote.rs | 1 + src/tui/transcript_view/render.rs | 147 ++++++++++++- 12 files changed, 836 insertions(+), 13 deletions(-) create mode 100644 src/tui/transcript_view/prompt.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index dc943c50f..c4fc6ff7c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ UTC release dates from [PyPI](https://pypi.org/project/hcom/#history). - 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 an 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`. - 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 diff --git a/src/commands/term.rs b/src/commands/term.rs index 1b57ff13b..bd1491820 100644 --- a/src/commands/term.rs +++ b/src/commands/term.rs @@ -117,6 +117,74 @@ pub fn inject_text_remote_result( Ok(label) } +/// One step of answering a prompt in an agent's terminal: every `expect` +/// string must be on screen before `keys` are sent, one chunk at a time. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ScreenStep { + pub expect: Vec, + 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); + +/// Whitespace is dropped before matching: prompts wrap and indent long text. +fn squash(s: &str) -> String { + s.chars().filter(|c| !c.is_whitespace()).collect() +} + +pub(crate) fn screen_shows(lines: &[String], expect: &[String]) -> bool { + let screen = squash(&lines.concat()); + expect.iter().all(|e| screen.contains(&squash(e))) +} + +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 its text is on screen. Stops at the first +/// step whose text does not appear, so keys never land in a prompt that +/// changed or was already answered in the terminal. +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 { + if screen_lines(port).is_some_and(|lines| screen_shows(&lines, &step.expect)) { + break; + } + if Instant::now() >= deadline { + return Err(if i == 0 { + format!("the prompt is no longer on {name}'s screen; nothing sent") + } else { + 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. @@ -468,6 +536,27 @@ mod tests { use std::net::TcpListener; use std::thread; + #[test] + fn screen_shows_ignores_wrapping() { + let lines: Vec = [ + " · 검증 로직은 그대로인데 기존 테스트가", + " 확인하지 않는 케이스가 있습니다.", + "Enter to select · Esc to cancel", + ] + .iter() + .map(|s| s.to_string()) + .collect(); + assert!(screen_shows( + &lines, + &[ + "검증 로직은 그대로인데 기존 테스트가 확인하지 않는 케이스가 있습니다.".into(), + "Esc to cancel".into() + ] + )); + assert!(!screen_shows(&lines, &["Ready to submit".into()])); + assert!(screen_shows(&lines, &[])); + } + fn test_db() -> HcomDb { let dir = tempfile::tempdir().unwrap(); let db_path = dir.path().join("test.db"); diff --git a/src/tui/actions.rs b/src/tui/actions.rs index 03198e6f3..21786a428 100644 --- a/src/tui/actions.rs +++ b/src/tui/actions.rs @@ -275,6 +275,16 @@ impl App { RpcOp::Command { cmd } => { self.show_command_output(&cmd, rpc_output_lines(result.result)); } + RpcOp::AnswerPrompt { name, .. } => match result.result { + Ok(resp) if resp.ok() => { + // Read the screen again for the next question or tab. + if let Some(agent) = self.data.agents.iter().find(|a| a.name == name) { + self.transcript.local.force_refresh(agent); + } + self.reload_data_force(); + } + other => self.ui.flash = Some(rpc_error_flash("Answer failed", other)), + }, } } diff --git a/src/tui/input.rs b/src/tui/input.rs index f8423a324..1cbce3675 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 @@ -1307,6 +1311,21 @@ impl App { } 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)] @@ -1511,6 +1530,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/render/mod.rs b/src/tui/render/mod.rs index cbb531d72..ffc97f0d9 100644 --- a/src/tui/render/mod.rs +++ b/src/tui/render/mod.rs @@ -1399,14 +1399,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() { @@ -1774,6 +1786,10 @@ fn render_help(frame: &mut Frame, help_scroll: u16) { 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 ba9a8b7a1..0d05b2b2e 100644 --- a/src/tui/rpc_async.rs +++ b/src/tui/rpc_async.rs @@ -51,6 +51,11 @@ pub enum RpcOp { Command { cmd: String, }, + /// Answer a question waiting in an agent's terminal. + AnswerPrompt { + name: String, + steps: Vec, + }, } pub struct RpcResult { @@ -159,6 +164,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/transcript_view/local.rs b/src/tui/transcript_view/local.rs index e97d6836a..126651535 100644 --- a/src/tui/transcript_view/local.rs +++ b/src/tui/transcript_view/local.rs @@ -6,6 +6,7 @@ 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}; @@ -21,12 +22,15 @@ struct Request { config_dir: Option, tool: Option, force: bool, + /// The agent waits on its terminal: read the prompt off its screen. + prompt_of: Option, } struct Reply { source: LocalSource, turns: Result, String>, email: Option, + prompt: Option, } struct Entry { @@ -35,6 +39,7 @@ struct Entry { last_success: Option, failure: Option, email: Option, + prompt: Option, } struct Cached { @@ -110,22 +115,34 @@ fn read_turns(req: &Request, cache: &mut Vec) -> Result, S }) } -fn collect(req: Request, cache: &mut Vec, emails: &mut EmailCache) -> Reply { +fn collect( + req: Request, + cache: &mut Vec, + emails: &mut EmailCache, + db: &mut Option, +) -> Reply { let turns = read_turns(&req, cache); let email = req .tool .and_then(|tool| emails.email(tool, req.config_dir.as_deref().map(Path::new))); + 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, } } /// Transcripts only grow while the agent works; idle rechecks catch late writes. fn recheck_interval(status: Option) -> Duration { match status { - Some(AgentStatus::Active) => Duration::from_secs(2), + Some(AgentStatus::Active | AgentStatus::Blocked) => Duration::from_secs(2), _ => Duration::from_secs(30), } } @@ -175,6 +192,7 @@ impl LocalTranscriptWorker { last_success: None, failure: None, email: None, + prompt: None, }); self.entries.len() - 1 } @@ -189,6 +207,7 @@ impl LocalTranscriptWorker { Err(e) => entry.failure = Some(e), } entry.email = reply.email; + entry.prompt = reply.prompt; } } @@ -207,8 +226,12 @@ impl LocalTranscriptWorker { std::thread::spawn(move || { let mut cache = Vec::new(); let mut emails = EmailCache::default(); + let mut db = None; while let Ok(req) = requests.recv() { - if results.send(collect(req, &mut cache, &mut emails)).is_err() { + if results + .send(collect(req, &mut cache, &mut emails, &mut db)) + .is_err() + { break; } } @@ -221,6 +244,9 @@ impl LocalTranscriptWorker { config_dir: config_dir.map(String::from), tool: agent.tool.canonical(), force: self.force, + prompt_of: (agent.status == AgentStatus::Blocked + && agent.tool.canonical() == Some(Tool::Claude)) + .then(|| agent.name.clone()), }) .is_ok(); self.checked = Some(now); @@ -244,6 +270,7 @@ impl LocalTranscriptWorker { failure: e.failure.as_deref(), loading, email: e.email.as_deref(), + prompt: e.prompt.as_ref(), }, None => ConversationSnapshot { loading, @@ -263,6 +290,20 @@ impl LocalTranscriptWorker { pub fn has_pending_timer(&self) -> bool { self.pending } + + #[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"); + self.entries.retain(|e| e.source != source); + self.entries.push(Entry { + source, + turns: Some(Vec::new()), + last_success: None, + failure: None, + email: None, + prompt: Some(prompt), + }); + } } #[cfg(test)] diff --git a/src/tui/transcript_view/mod.rs b/src/tui/transcript_view/mod.rs index b9de5404f..b48ddb567 100644 --- a/src/tui/transcript_view/mod.rs +++ b/src/tui/transcript_view/mod.rs @@ -4,6 +4,7 @@ pub mod convert; pub mod local; pub mod markdown; pub mod model; +pub mod prompt; pub mod remote; pub mod render; @@ -112,6 +113,70 @@ impl App { 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, + } + } + + /// 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; + let Some((agent, prompt)) = self.waiting_prompt() else { + return false; + }; + let name = agent.name.clone(); + let step = match code { + KeyCode::Char(c @ '1'..='9') => { + let n = (c as u8 - b'0') as usize; + match prompt.pick(n) { + Some(step) => step, + None => { + 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) => step, + None => return false, + }, + _ => return false, + }; + if let Err(e) = self.enqueue_rpc(crate::tui::rpc_async::RpcOp::AnswerPrompt { + name, + steps: vec![step], + }) { + 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() { @@ -201,6 +266,82 @@ mod tests { assert!(app.ui.selected.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 = "──────────────────────────────── +과일? + +❯ 1. 사과 + 2. 배 + 3. Type something. + +Enter to select · Esc to cancel"; + + const MULTI: &str = "──────────────────────────────── +음료? + +❯ 1. [ ] 물 + 2. [ ] 차 + +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); + } + + #[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); diff --git a/src/tui/transcript_view/model.rs b/src/tui/transcript_view/model.rs index a96c9db30..60876b796 100644 --- a/src/tui/transcript_view/model.rs +++ b/src/tui/transcript_view/model.rs @@ -139,6 +139,8 @@ pub struct ConversationSnapshot<'a> { 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 { diff --git a/src/tui/transcript_view/prompt.rs b/src/tui/transcript_view/prompt.rs new file mode 100644 index 000000000..fa535aed6 --- /dev/null +++ b/src/tui/transcript_view/prompt.rs @@ -0,0 +1,328 @@ +//! 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`. +use crate::commands::term::ScreenStep; + +#[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, +} + +const RULE: char = '─'; +const DASH: char = '╌'; +const FOOTER: &str = "Esc to cancel"; + +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)) +} + +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))?; + let top = lines[..cursor].iter().rposition(|l| is_rule(l))? + 1; + let first = (top..=cursor).find(|&i| option_line(&lines[i]).is_some())?; + + 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 mut options: Vec = Vec::new(); + let mut footer = false; + for line in &lines[first..] { + let t = line.trim(); + if t.contains(FOOTER) { + footer = true; + break; + } + 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 !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() + }; + Some(ScreenPrompt { + tabs, + question, + options, + anchor, + }) +} + +impl ScreenPrompt { + pub fn is_multi(&self) -> bool { + self.options.iter().any(|o| o.checked.is_some()) + } + + fn expect(&self) -> Vec { + let mut expect: Vec = self.question.first().cloned().into_iter().collect(); + expect.push(self.anchor.clone()); + expect + } + + /// Digit `n`: the step that sends it. `None` for an option that needs + /// typing in the terminal, or no such option. + pub fn pick(&self, n: usize) -> Option { + let option = self.options.iter().find(|o| o.number == n)?; + (!option.needs_typing()).then(|| ScreenStep { + expect: self.expect(), + keys: vec![n.to_string().into_bytes()], + }) + } + + /// Enter on a multi-select question: move to the next tab. + pub fn next(&self) -> Option { + self.is_multi().then(|| ScreenStep { + expect: self.expect(), + keys: vec![b"\x1b[C".to_vec()], + }) + } +} + +#[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 → + +과일? + +❯ 1. 사과 + 사과를 선택합니다 + 2. 배 + 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!["과일?"]); + let labels: Vec<&str> = p.options.iter().map(|o| o.label.as_str()).collect(); + assert_eq!(labels, ["사과", "배", "Type something.", "Chat about this"]); + assert_eq!(p.options[0].description, "사과를 선택합니다"); + assert!(!p.is_multi()); + assert_eq!( + p.pick(2), + Some(ScreenStep { + expect: vec!["과일?".into(), FOOTER.into()], + keys: vec![b"2".to_vec()], + }) + ); + 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 → + +음료? + +❯ 1. [✔] 물 + 기본 생수 + 2. [ ] 차 + 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, "물"); + assert_eq!(p.options[1].checked, Some(false)); + assert_eq!(p.options[1].description, ""); + assert_eq!(p.pick(3), None); + assert_eq!(p.next().unwrap().keys, vec![b"\x1b[C".to_vec()]); + } + + #[test] + fn reads_the_review_tab() { + let text = "──────────────────────────────── +← ☒ fruit ☒ drink ✔ Submit → + +Review your answers + + ● 과일? + → 배 + +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. + assert_eq!( + p.pick(1).unwrap().expect, + vec![ + "Review your answers".to_string(), + "1. Submit answers".to_string() + ] + ); + } + + #[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"); + assert_eq!( + p.pick(1).unwrap().expect, + vec!["Bash command".to_string(), FOOTER.to_string()] + ); + } + + #[test] + fn nothing_waiting() { + // Answered: the question remains in the scrollback, the selector does not. + let answered = "⏺ User answered Claude's questions: + ⎿ · 과일? → 배 +──────────────────────────────── +❯ +────────────────────────────────"; + 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 index 0aa46bc34..74285d9f3 100644 --- a/src/tui/transcript_view/remote.rs +++ b/src/tui/transcript_view/remote.rs @@ -396,6 +396,7 @@ impl RemoteTranscriptWorker { failure: e.failure.as_deref(), loading: e.job.is_some(), email: None, + prompt: None, }, None => ConversationSnapshot::default(), } diff --git a/src/tui/transcript_view/render.rs b/src/tui/transcript_view/render.rs index a72ab16d5..bf91b7e29 100644 --- a/src/tui/transcript_view/render.rs +++ b/src/tui/transcript_view/render.rs @@ -11,6 +11,7 @@ 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; @@ -85,6 +86,9 @@ pub fn render_conversation(frame: &mut Frame, head: Rect, body: Rect, app: &App) { 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( @@ -104,8 +108,29 @@ fn target_agent<'a>(target: &ConversationTarget<'a>) -> Option<&'a Agent> { } /// 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 agent.status != AgentStatus::Active || width == 0 { + 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()]; @@ -123,6 +148,70 @@ pub fn live_line(agent: &Agent, tick: u64, width: usize) -> Option ))) } +/// 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(); + let push = |segments: Vec, lines: &mut Vec>| { + for row in markdown::wrap(&segments, width) { + lines.push(Line::from(markdown::spans(row))); + } + }; + if let Some(tabs) = &prompt.tabs { + push(vec![(format!(" {tabs}"), Theme::blocked())], &mut lines); + } + for text in &prompt.question { + push( + vec![ + (" ".to_string(), Style::default()), + (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 mut segs = vec![ + ( + format!(" {mark}{} ", option.number), + Style::default().fg(palette::FG_DIM), + ), + (option.label.clone(), style), + ]; + if !option.description.is_empty() { + segs.push((format!(" {}", option.description), dim)); + } + push(segs, &mut lines); + } + let last = prompt + .options + .iter() + .filter(|o| !o.needs_typing()) + .map(|o| o.number) + .max() + .unwrap_or(1); + let hint = 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(vec![(hint, dim)], &mut lines); + lines +} + fn status_line(text: &str) -> Vec> { vec![Line::from(Span::styled(format!(" {text}"), Theme::dim()))] } @@ -504,6 +593,44 @@ mod tests { .collect() } + #[test] + fn prompt_lists_screen_options() { + let screen: Vec = "──────────────────────────────── +← ☒ fruit ☐ drink ✔ Submit → + +음료? + +❯ 1. [✔] 물 + 기본 생수 + 2. [ ] 차 + 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", + " 음료?", + " [x] 1 물 기본 생수", + " [ ] 2 차", + " [ ] 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 latest_turn_expanded_previous_collapsed() { let tools = ToolSummary::Some { @@ -625,7 +752,7 @@ mod tests { } #[test] - fn live_line_only_while_active() { + 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; @@ -646,6 +773,22 @@ mod tests { 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] From 9e6cd3a61ade803549dbbadaad67980a07ee0db0 Mon Sep 17 00:00:00 2001 From: leilen Date: Thu, 22 Oct 2026 23:38:14 +0900 Subject: [PATCH 03/14] Harden the conversation tab's prompt answers and account sharing Prompt answers match the whole prompt (question, command, options and checkbox state), keep spaces inside text, refuse steps with nothing to match, and report a silent terminal apart from a changed prompt. The local reader rechecks on a new status time, survives a panic or a dead worker, and parses with the cursor below the rule. `hcom config relay_share_account 1` is accepted; peers cannot set it over `config_set`. Launch contexts record config dirs as absolute paths, or "" for the default, so agents bound before that and stopped agents share and show no e-mail. `build_state` takes the setting instead of reloading config. `relay new` drops peers' shared e-mails. The chat tab's Enter messages the agent being read and only from agent rows; command output shows on either tab; the activity heading stops before the tab strip. `R` before the cursor settles is the settle's request, an oversized relay reply shrinks like a truncated one, one bad remote row fails the reply, quoted wrapper tags stay as written, compact lines keep indentation, and transcript timers no longer force redraws or faster data reloads. Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 2 +- src/accounts.rs | 6 +- src/commands/config.rs | 35 ++++++- src/commands/help.rs | 3 +- src/commands/term.rs | 160 ++++++++++++++++++++++++++--- src/instance_binding.rs | 34 +++++- src/relay/client.rs | 9 +- src/relay/control.rs | 37 +++++-- src/relay/mod.rs | 11 ++ src/relay/push.rs | 77 +++++++++++--- src/tui/app.rs | 80 +++++++++++++-- src/tui/input.rs | 28 +++-- src/tui/render/mod.rs | 6 +- src/tui/transcript_view/convert.rs | 90 ++++++++++++++-- src/tui/transcript_view/local.rs | 158 ++++++++++++++++++++++++---- src/tui/transcript_view/mod.rs | 92 ++++++++++++++++- src/tui/transcript_view/prompt.rs | 74 ++++++++++--- src/tui/transcript_view/remote.rs | 55 +++++++++- src/tui/transcript_view/render.rs | 8 ++ 19 files changed, 847 insertions(+), 118 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c4fc6ff7c..3c6e742e4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,7 @@ UTC release dates from [PyPI](https://pypi.org/project/hcom/#history). ### 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 an 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`. +- 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. diff --git a/src/accounts.rs b/src/accounts.rs index dc9c5c052..c0ece2004 100644 --- a/src/accounts.rs +++ b/src/accounts.rs @@ -5,6 +5,9 @@ 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 { @@ -140,12 +143,13 @@ mod tests { 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); - // Padded payloads decode too. + // 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"}"#) diff --git a/src/commands/config.rs b/src/commands/config.rs index 3df79d2e0..678dc24c2 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 509566aa2..68a682790 100644 --- a/src/commands/help.rs +++ b/src/commands/help.rs @@ -668,7 +668,8 @@ const CONFIG_KEYS_HELP: &[HelpEntry] = &[ ), ]; -/// 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 bd1491820..118bc50e0 100644 --- a/src/commands/term.rs +++ b/src/commands/term.rs @@ -130,14 +130,22 @@ const STEP_SCREEN_TIMEOUT: Duration = Duration::from_secs(2); /// rather than one pasted string. const KEY_GAP: Duration = Duration::from_millis(120); -/// Whitespace is dropped before matching: prompts wrap and indent long text. +/// Whitespace runs fold to one space, so text that wraps and indents +/// differently still matches, while `echo "a b"` stays distinct from +/// `echo "ab"`. fn squash(s: &str) -> String { - s.chars().filter(|c| !c.is_whitespace()).collect() + s.split_whitespace().collect::>().join(" ") +} + +/// A step with nothing to match would send its keys blind. +fn has_expect(expect: &[String]) -> bool { + expect.iter().any(|e| !squash(e).is_empty()) } pub(crate) fn screen_shows(lines: &[String], expect: &[String]) -> bool { - let screen = squash(&lines.concat()); - expect.iter().all(|e| screen.contains(&squash(e))) + // Rows join with a space: a wrapped line breaks where a space was. + let screen = squash(&lines.join(" ")); + has_expect(expect) && expect.iter().all(|e| screen.contains(&squash(e))) } fn screen_lines(port: i32) -> Option> { @@ -158,21 +166,38 @@ pub(crate) fn screen_of(db: &HcomDb, name: &str) -> Option> { } /// Send each step's keys once its text is on screen. Stops at the first -/// step whose text does not appear, so keys never land in a prompt that -/// changed or was already answered in the terminal. +/// step whose text does not appear. Best effort: 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 { + if steps.iter().any(|s| !has_expect(&s.expect)) { + return Err(format!("nothing to match on {name}'s screen; nothing sent")); + } 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 { - if screen_lines(port).is_some_and(|lines| screen_shows(&lines, &step.expect)) { + let lines = screen_lines(port); + if lines + .as_ref() + .is_some_and(|lines| screen_shows(lines, &step.expect)) + { break; } if Instant::now() >= deadline { - return Err(if i == 0 { - format!("the prompt is no longer on {name}'s screen; nothing sent") - } else { - format!("{name}'s screen changed after step {i}; answer the rest there") + 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)); @@ -554,7 +579,16 @@ mod tests { ] )); assert!(!screen_shows(&lines, &["Ready to submit".into()])); - assert!(screen_shows(&lines, &[])); + // Nothing to match is not a match. + assert!(!screen_shows(&lines, &[])); + assert!(!screen_shows(&lines, &[" ".into()])); + } + + #[test] + fn screen_shows_keeps_spaces_inside_text() { + let lines = vec![" echo \"a b\"".to_string(), "Esc to cancel".to_string()]; + assert!(screen_shows(&lines, &["echo \"a b\"".into()])); + assert!(!screen_shows(&lines, &["echo \"ab\"".into()])); } fn test_db() -> HcomDb { @@ -647,6 +681,108 @@ 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 + } + + const PROMPT: [&str; 4] = [ + " Bash command", + " touch probe-perm.txt", + " ❯ 1. Yes", + " Esc to cancel · Tab to amend", + ]; + + fn step(expect: &[&str]) -> ScreenStep { + ScreenStep { + expect: expect.iter().map(|e| e.to_string()).collect(), + keys: vec![b"2".to_vec()], + } + } + + #[test] + fn answer_on_screen_sends_only_to_the_matching_prompt() { + let db = test_db(); + let keys = fake_terminal(&db, &PROMPT); + + let err = answer_on_screen( + &db, + "luna", + &[step(&["Bash command", "rm -rf build", "Esc to cancel"])], + ) + .unwrap_err(); + assert_eq!( + err, + "the prompt is no longer on luna's screen; nothing sent" + ); + let err = answer_on_screen(&db, "luna", &[step(&[" "])]).unwrap_err(); + assert!(err.contains("nothing sent"), "{err}"); + assert!(keys.lock().unwrap().is_empty()); + + let ok = answer_on_screen( + &db, + "luna", + &[step(&[ + "Bash command", + "touch probe-perm.txt", + "Esc to cancel", + ])], + ); + assert_eq!(ok, Ok("Sent keys to luna".to_string())); + for _ in 0..100 { + if !keys.lock().unwrap().is_empty() { + break; + } + thread::sleep(Duration::from_millis(10)); + } + assert_eq!(*keys.lock().unwrap(), vec![b"2".to_vec()]); + } + + #[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(&["Esc to cancel"])]).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/instance_binding.rs b/src/instance_binding.rs index 48474b2b1..3603fe0a1 100644 --- a/src/instance_binding.rs +++ b/src/instance_binding.rs @@ -217,9 +217,6 @@ fn capture_context() -> serde_json::Map { "GITPOD_WORKSPACE_ID", "CLOUD_SHELL", "REPL_ID", - // Where the tool keeps its login; the TUI reads the account from it. - "CLAUDE_CONFIG_DIR", - "CODEX_HOME", ]; let mut env_map = serde_json::Map::new(); for key in &env_keys { @@ -229,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 @@ -1404,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/relay/client.rs b/src/relay/client.rs index e65133ab5..ea6de4bd7 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 c7f22cdf3..56d9a76b1 100644 --- a/src/relay/control.rs +++ b/src/relay/control.rs @@ -937,6 +937,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 +1065,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 +1081,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 +1092,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 { @@ -2024,6 +2028,21 @@ 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"] { + 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 +2141,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 +2182,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 +2198,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 +2208,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 +2235,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 138a530de..a2e6eedb3 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/push.rs b/src/relay/push.rs index d3497eb6a..df70596af 100644 --- a/src/relay/push.rs +++ b/src/relay/push.rs @@ -284,16 +284,18 @@ 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. +/// 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 dir = launch_context - .and_then(|c| serde_json::from_str::(c).ok()) - .and_then(|v| v.get("env")?.get(key)?.as_str().map(String::from)) - .filter(|d| !d.is_empty()); + 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()? @@ -302,9 +304,9 @@ fn shared_account_email(tool: Option<&str>, launch_context: Option<&str>) -> Opt /// 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 share_account = crate::config::HcomConfig::load(None).relay_share_account; let instances = match db.conn().prepare( "SELECT name, status, status_context, status_detail, status_time, parent_name, @@ -460,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()) @@ -548,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); @@ -652,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); @@ -974,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!( @@ -984,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); @@ -1021,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!( @@ -1065,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. @@ -1087,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")); @@ -1105,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/app.rs b/src/tui/app.rs index 0d313cc9d..672de29ad 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -301,15 +301,14 @@ impl App { .iter() .any(|a| a.status == AgentStatus::Launching) || self.ejector.is_replaying() - || rpc_pending - || self.transcript.has_pending_timer(); - // Spinner redraws only; data reload keeps its idle pace. - let spinning = self.transcript_spinning(); - let poll_ms = if animating || spinning || resize_cooldown > 0 { - 80 - } else { - 500 - }; + || rpc_pending; + 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 { @@ -351,12 +350,12 @@ impl App { self.ui.tick += 1; // Animations need redraws for spinner frames etc. - if animating || spinning { + 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(); @@ -389,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::*; @@ -676,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/input.rs b/src/tui/input.rs index 1cbce3675..85da956f4 100644 --- a/src/tui/input.rs +++ b/src/tui/input.rs @@ -381,8 +381,14 @@ impl App { self.ui.msg_scroll = self.ui.msg_scroll.saturating_sub(5); } - // Conversation tab: Enter messages the agent being read. - KeyCode::Enter if self.conversation_visible() => self.compose_to_targets(), + // 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(); @@ -1299,15 +1305,19 @@ 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(); - 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); + for name in names { + self.ui.input.push_str(&format!("@{} ", name)); } self.ui.input_cursor = self.ui.input.len(); } diff --git a/src/tui/render/mod.rs b/src/tui/render/mod.rs index ffc97f0d9..3cdf02b7d 100644 --- a/src/tui/render/mod.rs +++ b/src/tui/render/mod.rs @@ -568,10 +568,12 @@ fn render_vertical(frame: &mut Frame, app: &mut App, area: Rect) { let body_right = Layout::vertical([Constraint::Length(1), Constraint::Min(0)]).split(body[2]); use crate::tui::transcript_view::{RightTab, render as conversation}; - let scroll = if app.transcript.tab == RightTab::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, body_right[0], app); + 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); diff --git a/src/tui/transcript_view/convert.rs b/src/tui/transcript_view/convert.rs index ab111f3df..e3046d589 100644 --- a/src/tui/transcript_view/convert.rs +++ b/src/tui/transcript_view/convert.rs @@ -11,11 +11,11 @@ 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() - .map(str::trim) - .filter(|l| !l.is_empty()) - .map(String::from) + .filter(|l| !l.trim().is_empty()) + .map(|l| l.trim_end().to_string()) .collect() } @@ -56,10 +56,32 @@ fn strip_block(text: &str, tag: &str) -> String { /// 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. +/// 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 let Some(name) = tag_body(&user, "command-name") { + 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() @@ -67,17 +89,23 @@ fn clean_request(user: &str) -> String { format!("{name} {args}") }; } - if user.contains("") { + 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 let Some(command) = tag_body(&user, "bash-input") { + if opens(&user, &["bash-input"]) + && let Some(command) = tag_body(&user, "bash-input") + { return format!("! {command}"); } - let output: Vec<&str> = ["bash-stdout", "bash-stderr", "local-command-stdout"] + 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()) @@ -104,9 +132,15 @@ fn build_turn( position, time: format_turn_time(timestamp), request_more: request.len() > REQUEST_LINES, - request_summary: request.first().cloned().unwrap_or_default(), + 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().cloned().unwrap_or_default(), + response_summary: response + .last() + .map(|l| l.trim().to_string()) + .unwrap_or_default(), response_tail: response[omitted..].to_vec(), response_omitted: omitted, tools, @@ -195,7 +229,11 @@ pub fn turn_from_remote_json(row: &Value) -> Option { pub fn turns_from_remote_content(content: &str) -> Result, ()> { let rows: Vec = serde_json::from_str(content).map_err(|_| ())?; - Ok(rows.iter().filter_map(turn_from_remote_json).collect()) + // 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 { @@ -312,6 +350,21 @@ mod tests { 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", "", &[], &[])); @@ -419,6 +472,14 @@ mod tests { "" ); 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] @@ -447,6 +508,13 @@ mod tests { 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() diff --git a/src/tui/transcript_view/local.rs b/src/tui/transcript_view/local.rs index 126651535..40c55f81a 100644 --- a/src/tui/transcript_view/local.rs +++ b/src/tui/transcript_view/local.rs @@ -1,7 +1,7 @@ //! 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}; +use std::sync::mpsc::{self, Receiver, Sender, TryRecvError}; use std::time::{Duration, Instant, SystemTime}; use super::convert::turn_from_exchange; @@ -17,6 +17,16 @@ const CACHE_LIMIT: usize = 32; type Stamp = Vec)>>; +/// 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, @@ -125,6 +135,7 @@ fn collect( 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(); @@ -153,7 +164,8 @@ pub struct LocalTranscriptWorker { pending: bool, checked: Option, selected: Option, - status: Option, + /// Status and its time: a new prompt while still Blocked rechecks too. + status: Option<(AgentStatus, u64)>, force: bool, entries: Vec, } @@ -161,7 +173,7 @@ pub struct LocalTranscriptWorker { 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, Option<&str>)>, now: Instant) -> bool { + 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 { @@ -170,14 +182,23 @@ impl LocalTranscriptWorker { self.force = false; changed = true; } - let status = agent.map(|(a, _)| a.status); + 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 { - while let Ok(reply) = rx.try_recv() { + 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 idx = match self.entries.iter().position(|e| e.source == reply.source) { @@ -210,14 +231,20 @@ impl LocalTranscriptWorker { 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)); + || 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, config_dir)) = agent + && let Some((agent, account)) = agent && let Some(source) = self.selected.clone() { let (tx, _) = self.channels.get_or_insert_with(|| { @@ -228,10 +255,22 @@ impl LocalTranscriptWorker { let mut emails = EmailCache::default(); let mut db = None; while let Ok(req) = requests.recv() { - if results - .send(collect(req, &mut cache, &mut emails, &mut db)) - .is_err() - { + let source = req.source.clone(); + 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, + } + }); + if results.send(reply).is_err() { break; } } @@ -241,8 +280,15 @@ impl LocalTranscriptWorker { self.pending = tx .send(Request { source, - config_dir: config_dir.map(String::from), - tool: agent.tool.canonical(), + 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)) @@ -339,7 +385,7 @@ mod tests { fn wait(worker: &mut LocalTranscriptWorker, agent: &Agent, now: Instant) { for _ in 0..200 { - worker.poll(Some((agent, None)), now); + worker.poll(Some((agent, AccountDir::Known(None))), now); if !worker.pending { return; } @@ -364,7 +410,10 @@ mod tests { assert!(snap.last_success.is_some()); // Not due yet: no request. - w.poll(Some((&agent, None)), t0 + Duration::from_secs(1)); + w.poll( + Some((&agent, AccountDir::Known(None))), + t0 + Duration::from_secs(1), + ); assert!(!w.pending); // Active agent rechecks after 2s. write_turns(&path, 13); @@ -385,12 +434,85 @@ mod tests { 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()) || w.channels.is_none()); + 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(&[][..])); } + #[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(); diff --git a/src/tui/transcript_view/mod.rs b/src/tui/transcript_view/mod.rs index b48ddb567..48a1f67dd 100644 --- a/src/tui/transcript_view/mod.rs +++ b/src/tui/transcript_view/mod.rs @@ -14,7 +14,8 @@ use std::time::Instant; pub use model::*; use crate::tui::app::App; -use crate::tui::model::{Agent, ViewMode}; +use crate::tui::model::{Agent, CursorTarget, ViewMode}; +use local::AccountDir; #[derive(Default)] pub struct TranscriptView { @@ -89,8 +90,13 @@ impl App { }; match target { ConversationTarget::Local { agent, .. } => { - let dir = data.tool_config_dirs.get(&agent.name).map(String::as_str); - transcript.local.poll(Some((agent, dir)), now) | transcript.remote.poll(None, now) + let account = if matches!(cursor, CursorTarget::StoppedAgent(_)) { + AccountDir::Unknown + } else { + AccountDir::Known(data.tool_config_dirs.get(&agent.name).map(String::as_str)) + }; + 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) @@ -266,6 +272,36 @@ mod tests { 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(); @@ -413,4 +449,54 @@ Enter to select · Esc to cancel"; 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/prompt.rs b/src/tui/transcript_view/prompt.rs index fa535aed6..63916142e 100644 --- a/src/tui/transcript_view/prompt.rs +++ b/src/tui/transcript_view/prompt.rs @@ -80,8 +80,12 @@ pub fn parse_screen(lines: &[String]) -> Option { let cursor = lines .iter() .rposition(|l| option_line(l).is_some_and(|(_, _, c)| c))?; - let top = lines[..cursor].iter().rposition(|l| is_rule(l))? + 1; - let first = (top..=cursor).find(|&i| option_line(&lines[i]).is_some())?; + // 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))?; + let top = lines[..first].iter().rposition(|l| is_rule(l))? + 1; let mut tabs = None; let mut question = Vec::new(); @@ -150,8 +154,19 @@ impl ScreenPrompt { self.options.iter().any(|o| o.checked.is_some()) } + /// Everything this prompt shows, so a key never answers a different + /// prompt that happens to share a first line (`Bash command`). fn expect(&self) -> Vec { - let mut expect: Vec = self.question.first().cloned().into_iter().collect(); + let mut expect: Vec = self.tabs.iter().cloned().collect(); + expect.extend(self.question.iter().cloned()); + expect.extend(self.options.iter().map(|o| { + let checkbox = match o.checked { + Some(true) => "[✔] ", + Some(false) => "[ ] ", + None => "", + }; + format!("{}. {checkbox}{}", o.number, o.label) + })); expect.push(self.anchor.clone()); expect } @@ -210,7 +225,15 @@ Enter to select · Tab/Arrow keys to navigate · Esc to cancel"; assert_eq!( p.pick(2), Some(ScreenStep { - expect: vec!["과일?".into(), FOOTER.into()], + expect: vec![ + "☐ fruit ☐ drink ✔ Submit".into(), + "과일?".into(), + "1. 사과".into(), + "2. 배".into(), + "3. Type something.".into(), + "4. Chat about this".into(), + FOOTER.into(), + ], keys: vec![b"2".to_vec()], }) ); @@ -243,7 +266,12 @@ Enter to select · Tab/Arrow keys to navigate · Esc to cancel"; assert_eq!(p.options[1].checked, Some(false)); assert_eq!(p.options[1].description, ""); assert_eq!(p.pick(3), None); - assert_eq!(p.next().unwrap().keys, vec![b"\x1b[C".to_vec()]); + 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.contains(&"1. [✔] 물".to_string())); + assert!(next.expect.contains(&"2. [ ] 차".to_string())); } #[test] @@ -263,13 +291,10 @@ Ready to submit your answers? 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. - assert_eq!( - p.pick(1).unwrap().expect, - vec![ - "Review your answers".to_string(), - "1. Submit answers".to_string() - ] - ); + let expect = p.pick(1).unwrap().expect; + assert_eq!(expect.first().unwrap(), "☒ fruit ☒ drink ✔ Submit"); + assert!(expect.contains(&"Review your answers".to_string())); + assert_eq!(expect.last().unwrap(), "1. Submit answers"); } #[test] @@ -303,12 +328,35 @@ Ready to submit your answers? ); 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. assert_eq!( p.pick(1).unwrap().expect, - vec!["Bash command".to_string(), FOOTER.to_string()] + vec![ + "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", + "3. No", + FOOTER, + ] ); } + #[test] + fn reads_with_the_cursor_below_the_rule() { + let text = TABBED + .replace("❯ 1. 사과", " 1. 사과") + .replace(" 4. Chat about this", "❯ 4. Chat about this"); + let p = parse_screen(&screen(&text)).unwrap(); + assert_eq!(p.question, vec!["과일?"]); + assert_eq!(p.options.len(), 4); + assert_eq!(p.options[0].description, "사과를 선택합니다"); + assert_eq!(p.pick(1).unwrap().expect.last().unwrap(), FOOTER); + } + #[test] fn nothing_waiting() { // Answered: the question remains in the scrollback, the selector does not. diff --git a/src/tui/transcript_view/remote.rs b/src/tui/transcript_view/remote.rs index 74285d9f3..a81cdd2e4 100644 --- a/src/tui/transcript_view/remote.rs +++ b/src/tui/transcript_view/remote.rs @@ -92,6 +92,8 @@ fn classify(result: &Result) -> Result, FailureReas 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() @@ -402,9 +404,19 @@ impl RemoteTranscriptWorker { } } - /// Refresh now, skipping the debounce. - pub fn force_refresh(&mut self, agent: &Agent, _now: Instant) { + /// 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); } @@ -552,6 +564,45 @@ mod tests { 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!( diff --git a/src/tui/transcript_view/render.rs b/src/tui/transcript_view/render.rs index bf91b7e29..4fea5c530 100644 --- a/src/tui/transcript_view/render.rs +++ b/src/tui/transcript_view/render.rs @@ -26,6 +26,14 @@ 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; From c3bbe78b607ba82da8c194533dfc5cf434e68d0d Mon Sep 17 00:00:00 2001 From: leilen Date: Thu, 22 Oct 2026 23:56:21 +0900 Subject: [PATCH 04/14] Compare remote config guards on the uppercased field name Config keys resolve case-insensitively, but normalize_config_field kept any input that already started with "HCOM_" as-is. A relay peer could send a mixed-case name such as HCOM_relay_share_account and get past the remote guards, which compare against the uppercase form. Uppercase the whole field before comparing so the share-account, relay_enabled and secret-field guards all see the same name config resolves. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/relay/control.rs | 34 ++++++++++++++++++++++------------ 1 file changed, 22 insertions(+), 12 deletions(-) diff --git a/src/relay/control.rs b/src/relay/control.rs index 56d9a76b1..60a6f6b40 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}") } } @@ -1979,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] @@ -2031,7 +2036,12 @@ mod tests { #[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"] { + 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"}), From 9a4889ad02869880ea65b2aebf4a6d84260fccb6 Mon Sep 17 00:00:00 2001 From: leilen Date: Fri, 23 Oct 2026 00:21:46 +0900 Subject: [PATCH 05/14] Send a prompt answer only to the exact prompt it was read from answer_on_screen matched the cached prompt as substrings of the whole screen, so a later prompt that repeats the answered one's text in its own command could receive a repeated digit. The screen is now parsed again before the keys go out and must equal the cached ScreenPrompt (question, options, tab row, anchor). A submitted answer drops the cached prompt (and any screen read already in flight) and blocks a second answer to the same agent until the first returns. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/commands/term.rs | 154 +++++++++++++----------------- src/tui/actions.rs | 20 ++-- src/tui/rpc_async.rs | 8 +- src/tui/transcript_view/local.rs | 28 +++++- src/tui/transcript_view/mod.rs | 79 +++++++++++++-- src/tui/transcript_view/prompt.rs | 94 +++++++++--------- 6 files changed, 227 insertions(+), 156 deletions(-) diff --git a/src/commands/term.rs b/src/commands/term.rs index 118bc50e0..750f8a9b6 100644 --- a/src/commands/term.rs +++ b/src/commands/term.rs @@ -117,11 +117,18 @@ pub fn inject_text_remote_result( Ok(label) } -/// One step of answering a prompt in an agent's terminal: every `expect` -/// string must be on screen before `keys` are sent, one chunk at a time. +/// 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: Vec, +pub struct ScreenStep { + pub expect: C, pub keys: Vec>, } @@ -130,24 +137,6 @@ const STEP_SCREEN_TIMEOUT: Duration = Duration::from_secs(2); /// rather than one pasted string. const KEY_GAP: Duration = Duration::from_millis(120); -/// Whitespace runs fold to one space, so text that wraps and indents -/// differently still matches, while `echo "a b"` stays distinct from -/// `echo "ab"`. -fn squash(s: &str) -> String { - s.split_whitespace().collect::>().join(" ") -} - -/// A step with nothing to match would send its keys blind. -fn has_expect(expect: &[String]) -> bool { - expect.iter().any(|e| !squash(e).is_empty()) -} - -pub(crate) fn screen_shows(lines: &[String], expect: &[String]) -> bool { - // Rows join with a space: a wrapped line breaks where a space was. - let screen = squash(&lines.join(" ")); - has_expect(expect) && expect.iter().all(|e| screen.contains(&squash(e))) -} - fn screen_lines(port: i32) -> Option> { let screen = query_screen(port)?; Some( @@ -165,14 +154,15 @@ pub(crate) fn screen_of(db: &HcomDb, name: &str) -> Option> { screen_lines(get_inject_port(db, name)?) } -/// Send each step's keys once its text is on screen. Stops at the first -/// step whose text does not appear. Best effort: the screen is read just +/// 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 { - if steps.iter().any(|s| !has_expect(&s.expect)) { - return Err(format!("nothing to match on {name}'s screen; nothing sent")); - } +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; @@ -180,7 +170,7 @@ pub fn answer_on_screen(db: &HcomDb, name: &str, steps: &[ScreenStep]) -> Result let lines = screen_lines(port); if lines .as_ref() - .is_some_and(|lines| screen_shows(lines, &step.expect)) + .is_some_and(|lines| step.expect.shown_on(lines)) { break; } @@ -561,36 +551,6 @@ mod tests { use std::net::TcpListener; use std::thread; - #[test] - fn screen_shows_ignores_wrapping() { - let lines: Vec = [ - " · 검증 로직은 그대로인데 기존 테스트가", - " 확인하지 않는 케이스가 있습니다.", - "Enter to select · Esc to cancel", - ] - .iter() - .map(|s| s.to_string()) - .collect(); - assert!(screen_shows( - &lines, - &[ - "검증 로직은 그대로인데 기존 테스트가 확인하지 않는 케이스가 있습니다.".into(), - "Esc to cancel".into() - ] - )); - assert!(!screen_shows(&lines, &["Ready to submit".into()])); - // Nothing to match is not a match. - assert!(!screen_shows(&lines, &[])); - assert!(!screen_shows(&lines, &[" ".into()])); - } - - #[test] - fn screen_shows_keeps_spaces_inside_text() { - let lines = vec![" echo \"a b\"".to_string(), "Esc to cancel".to_string()]; - assert!(screen_shows(&lines, &["echo \"a b\"".into()])); - assert!(!screen_shows(&lines, &["echo \"ab\"".into()])); - } - fn test_db() -> HcomDb { let dir = tempfile::tempdir().unwrap(); let db_path = dir.path().join("test.db"); @@ -712,56 +672,76 @@ mod tests { keys } - const PROMPT: [&str; 4] = [ + 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 step(expect: &[&str]) -> ScreenStep { + fn lines(screen: &[&str]) -> Vec { + screen.iter().map(|l| l.to_string()).collect() + } + + fn step(screen: &[&str]) -> ScreenStep { ScreenStep { - expect: expect.iter().map(|e| e.to_string()).collect(), + 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 err = answer_on_screen( - &db, - "luna", - &[step(&["Bash command", "rm -rf build", "Esc to cancel"])], - ) - .unwrap_err(); + 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" ); - let err = answer_on_screen(&db, "luna", &[step(&[" "])]).unwrap_err(); - assert!(err.contains("nothing sent"), "{err}"); assert!(keys.lock().unwrap().is_empty()); - let ok = answer_on_screen( - &db, - "luna", - &[step(&[ - "Bash command", - "touch probe-perm.txt", - "Esc to cancel", - ])], - ); + let ok = answer_on_screen(&db, "luna", &[step(&PROMPT)]); assert_eq!(ok, Ok("Sent keys to luna".to_string())); - for _ in 0..100 { - if !keys.lock().unwrap().is_empty() { - break; - } - thread::sleep(Duration::from_millis(10)); - } - assert_eq!(*keys.lock().unwrap(), vec![b"2".to_vec()]); + 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] @@ -779,7 +759,7 @@ mod tests { rusqlite::params!["luna", port], ) .unwrap(); - let err = answer_on_screen(&db, "luna", &[step(&["Esc to cancel"])]).unwrap_err(); + let err = answer_on_screen(&db, "luna", &[step(&PROMPT)]).unwrap_err(); assert_eq!(err, "no response from luna's terminal; nothing sent"); } diff --git a/src/tui/actions.rs b/src/tui/actions.rs index 21786a428..81ca2e3cb 100644 --- a/src/tui/actions.rs +++ b/src/tui/actions.rs @@ -275,16 +275,20 @@ impl App { RpcOp::Command { cmd } => { self.show_command_output(&cmd, rpc_output_lines(result.result)); } - RpcOp::AnswerPrompt { name, .. } => match result.result { - Ok(resp) if resp.ok() => { - // Read the screen again for the next question or tab. - if let Some(agent) = self.data.agents.iter().find(|a| a.name == name) { - self.transcript.local.force_refresh(agent); + RpcOp::AnswerPrompt { name, .. } => { + // Done either way: the next key may go out. + self.transcript.answering.remove(&name); + match result.result { + Ok(resp) if resp.ok() => { + // Read the screen again for the next question or tab. + if let Some(agent) = self.data.agents.iter().find(|a| a.name == name) { + self.transcript.local.force_refresh(agent); + } + self.reload_data_force(); } - self.reload_data_force(); + other => self.ui.flash = Some(rpc_error_flash("Answer failed", other)), } - other => self.ui.flash = Some(rpc_error_flash("Answer failed", other)), - }, + } } } diff --git a/src/tui/rpc_async.rs b/src/tui/rpc_async.rs index 0d05b2b2e..156f2ece6 100644 --- a/src/tui/rpc_async.rs +++ b/src/tui/rpc_async.rs @@ -54,7 +54,9 @@ pub enum RpcOp { /// Answer a question waiting in an agent's terminal. AnswerPrompt { name: String, - steps: Vec, + steps: Vec< + crate::commands::term::ScreenStep, + >, }, } @@ -74,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::(); diff --git a/src/tui/transcript_view/local.rs b/src/tui/transcript_view/local.rs index 40c55f81a..d3ceea7aa 100644 --- a/src/tui/transcript_view/local.rs +++ b/src/tui/transcript_view/local.rs @@ -34,6 +34,7 @@ struct Request { force: bool, /// The agent waits on its terminal: read the prompt off its screen. prompt_of: Option, + prompt_epoch: u64, } struct Reply { @@ -41,6 +42,7 @@ struct Reply { turns: Result, String>, email: Option, prompt: Option, + prompt_epoch: u64, } struct Entry { @@ -132,6 +134,7 @@ fn collect( 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))); @@ -147,6 +150,7 @@ fn collect( turns, email, prompt, + prompt_epoch, } } @@ -168,6 +172,9 @@ pub struct LocalTranscriptWorker { status: Option<(AgentStatus, u64)>, force: bool, entries: Vec, + /// Bumped when an answered prompt is dropped: a screen read sent + /// before the answer must not bring it back. + prompt_epoch: u64, } impl LocalTranscriptWorker { @@ -228,7 +235,9 @@ impl LocalTranscriptWorker { Err(e) => entry.failure = Some(e), } entry.email = reply.email; - entry.prompt = reply.prompt; + entry.prompt = reply + .prompt + .filter(|_| reply.prompt_epoch == self.prompt_epoch); } } if gone { @@ -256,6 +265,7 @@ impl LocalTranscriptWorker { 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) })) @@ -268,6 +278,7 @@ impl LocalTranscriptWorker { turns: Err("read panicked".to_string()), email: None, prompt: None, + prompt_epoch, } }); if results.send(reply).is_err() { @@ -293,6 +304,7 @@ impl LocalTranscriptWorker { prompt_of: (agent.status == AgentStatus::Blocked && agent.tool.canonical() == Some(Tool::Claude)) .then(|| agent.name.clone()), + prompt_epoch: self.prompt_epoch, }) .is_ok(); self.checked = Some(now); @@ -337,6 +349,20 @@ impl LocalTranscriptWorker { 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) { + let source = LocalSource::for_agent(agent); + self.prompt_epoch += 1; + if let Some(entry) = self + .entries + .iter_mut() + .find(|e| Some(&e.source) == source.as_ref()) + { + entry.prompt = None; + } + } + #[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"); diff --git a/src/tui/transcript_view/mod.rs b/src/tui/transcript_view/mod.rs index 48a1f67dd..4c55341c4 100644 --- a/src/tui/transcript_view/mod.rs +++ b/src/tui/transcript_view/mod.rs @@ -8,7 +8,7 @@ pub mod prompt; pub mod remote; pub mod render; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::time::Instant; pub use model::*; @@ -28,6 +28,9 @@ pub struct TranscriptView { 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, } /// `HCOM_TUI_TAB=chat` opens vertical view on the conversation tab. @@ -149,6 +152,7 @@ impl App { return false; }; let name = agent.name.clone(); + let answering = self.transcript.answering.contains(&name); let step = match code { KeyCode::Char(c @ '1'..='9') => { let n = (c as u8 - b'0') as usize; @@ -171,14 +175,28 @@ impl App { }, _ => return false, }; - if let Err(e) = self.enqueue_rpc(crate::tui::rpc_async::RpcOp::AnswerPrompt { - name, - steps: vec![step], - }) { + if answering { self.ui.flash = Some(Flash::new( - format!("Answer failed: {e}"), - Theme::flash_err(), + format!("Still answering {name}: wait for its screen to update"), + Theme::flash_info(), )); + return true; + } + let agent = agent.clone(); + match self.enqueue_rpc(crate::tui::rpc_async::RpcOp::AnswerPrompt { + name: name.clone(), + steps: vec![step], + }) { + 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 } @@ -357,6 +375,53 @@ Enter to select · Esc to cancel"; 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: the next key may go out. + 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()); + app.ui.flash = None; + app.handle_key(KeyCode::Char('2'), KeyModifiers::NONE); + assert!(app.ui.flash.is_none()); + assert!(app.transcript.answering.contains("luna")); + } + #[test] fn enter_moves_on_from_multi_select() { let mut app = waiting_app(MULTI); diff --git a/src/tui/transcript_view/prompt.rs b/src/tui/transcript_view/prompt.rs index 63916142e..0f32cb87b 100644 --- a/src/tui/transcript_view/prompt.rs +++ b/src/tui/transcript_view/prompt.rs @@ -7,7 +7,7 @@ //! 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`. -use crate::commands::term::ScreenStep; +use crate::commands::term::{ScreenCheck, ScreenStep}; #[derive(Debug, Clone, PartialEq, Eq)] pub struct ScreenOption { @@ -154,42 +154,34 @@ impl ScreenPrompt { self.options.iter().any(|o| o.checked.is_some()) } - /// Everything this prompt shows, so a key never answers a different - /// prompt that happens to share a first line (`Bash command`). - fn expect(&self) -> Vec { - let mut expect: Vec = self.tabs.iter().cloned().collect(); - expect.extend(self.question.iter().cloned()); - expect.extend(self.options.iter().map(|o| { - let checkbox = match o.checked { - Some(true) => "[✔] ", - Some(false) => "[ ] ", - None => "", - }; - format!("{}. {checkbox}{}", o.number, o.label) - })); - expect.push(self.anchor.clone()); - expect - } - /// Digit `n`: the step that sends it. `None` for an option that needs /// typing in the terminal, or no such option. - pub fn pick(&self, n: usize) -> Option { + pub fn pick(&self, n: usize) -> Option> { let option = self.options.iter().find(|o| o.number == n)?; (!option.needs_typing()).then(|| ScreenStep { - expect: self.expect(), + expect: self.clone(), keys: vec![n.to_string().into_bytes()], }) } /// Enter on a multi-select question: move to the next tab. - pub fn next(&self) -> Option { + pub fn next(&self) -> Option> { self.is_multi().then(|| ScreenStep { - expect: self.expect(), + 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::*; @@ -225,18 +217,12 @@ Enter to select · Tab/Arrow keys to navigate · Esc to cancel"; assert_eq!( p.pick(2), Some(ScreenStep { - expect: vec![ - "☐ fruit ☐ drink ✔ Submit".into(), - "과일?".into(), - "1. 사과".into(), - "2. 배".into(), - "3. Type something.".into(), - "4. Chat about this".into(), - FOOTER.into(), - ], + expect: p.clone(), keys: vec![b"2".to_vec()], }) ); + assert_eq!(p.anchor, FOOTER); + assert!(p.pick(2).unwrap().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); @@ -270,8 +256,17 @@ Enter to select · Tab/Arrow keys to navigate · Esc to cancel"; 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.contains(&"1. [✔] 물".to_string())); - assert!(next.expect.contains(&"2. [ ] 차".to_string())); + assert!(next.expect.shown_on(&screen(text))); + assert!( + !next + .expect + .shown_on(&screen(&text.replace("[✔] 물", "[ ] 물"))) + ); + assert!( + !next + .expect + .shown_on(&screen(&text.replace("[ ] 차", "[✔] 차"))) + ); } #[test] @@ -292,9 +287,15 @@ Ready to submit your answers? 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().expect; - assert_eq!(expect.first().unwrap(), "☒ fruit ☒ drink ✔ Submit"); - assert!(expect.contains(&"Review your answers".to_string())); - assert_eq!(expect.last().unwrap(), "1. Submit answers"); + 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] @@ -329,20 +330,11 @@ Ready to submit your answers? 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. - assert_eq!( - p.pick(1).unwrap().expect, - vec![ - "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", - "3. No", - FOOTER, - ] - ); + let expect = p.pick(1).unwrap().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))); } #[test] @@ -354,7 +346,7 @@ Ready to submit your answers? assert_eq!(p.question, vec!["과일?"]); assert_eq!(p.options.len(), 4); assert_eq!(p.options[0].description, "사과를 선택합니다"); - assert_eq!(p.pick(1).unwrap().expect.last().unwrap(), FOOTER); + assert_eq!(p.pick(1).unwrap().expect.anchor, FOOTER); } #[test] From 2250f3ffe5c18b33fe8e78d71f5992484ce33cc8 Mon Sep 17 00:00:00 2001 From: leilen Date: Fri, 23 Oct 2026 00:23:18 +0900 Subject: [PATCH 06/14] Keep a rule-like command line from cutting the prompt's top MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit parse_screen took the nearest rule above option 1 as the prompt's top, so a command line made of `─` dropped every command line above it from the approval view and its screen match. The top is now the nearest rule outside a dashed pair, and a screen whose dashed lines do not pair up, or that has a dashed line above the top, is not read at all. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/tui/transcript_view/prompt.rs | 99 ++++++++++++++++++++++++++++++- 1 file changed, 98 insertions(+), 1 deletion(-) diff --git a/src/tui/transcript_view/prompt.rs b/src/tui/transcript_view/prompt.rs index 0f32cb87b..42eacee0b 100644 --- a/src/tui/transcript_view/prompt.rs +++ b/src/tui/transcript_view/prompt.rs @@ -85,7 +85,25 @@ pub fn parse_screen(lines: &[String]) -> Option { let first = lines[..=cursor] .iter() .rposition(|l| option_line(l).is_some_and(|(n, _, _)| n == 1))?; - let top = lines[..first].iter().rposition(|l| is_rule(l))? + 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; + } + let top = top + 1; let mut tabs = None; let mut question = Vec::new(); @@ -337,6 +355,85 @@ Ready to submit your answers? 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().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); + // 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 From 842c1e0ab72a2bed63db5c12efe23638c95462bf Mon Sep 17 00:00:00 2001 From: leilen Date: Fri, 23 Oct 2026 00:48:38 +0900 Subject: [PATCH 07/14] Hold keys while an answer is in flight and scope the prompt epoch per agent A screen with an unpaired dashed line in the command could pair it off against a dashed line in the scrollback, so the prompt's top landed on a scrollback rule and scrollback lines led the approval view. The dashed lines between the top and option 1 always number evenly, so a parity check cannot see this; more than one pair there is now not read. Answering drops the cached prompt until the screen is read again, so a second digit or Enter never reached the "still answering" check and Enter opened compose. That check now runs before the prompt lookup. The prompt epoch was one counter for the worker: answering one agent discarded, and cleared, another agent's prompt from a read in flight. It now lives on each agent's entry, and a stale read leaves the cached prompt as it is. Tests cover the scrollback screen, keys after an answer with no prompt reinjected, and the epoch filter against a read sent before the answer. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/tui/transcript_view/local.rs | 192 +++++++++++++++++++++++------- src/tui/transcript_view/mod.rs | 71 +++++++++-- src/tui/transcript_view/prompt.rs | 24 ++++ 3 files changed, 238 insertions(+), 49 deletions(-) diff --git a/src/tui/transcript_view/local.rs b/src/tui/transcript_view/local.rs index d3ceea7aa..d763aa995 100644 --- a/src/tui/transcript_view/local.rs +++ b/src/tui/transcript_view/local.rs @@ -52,6 +52,38 @@ struct Entry { 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 { @@ -172,9 +204,6 @@ pub struct LocalTranscriptWorker { status: Option<(AgentStatus, u64)>, force: bool, entries: Vec, - /// Bumped when an answered prompt is dropped: a screen read sent - /// before the answer must not bring it back. - prompt_epoch: u64, } impl LocalTranscriptWorker { @@ -208,24 +237,7 @@ impl LocalTranscriptWorker { }; self.pending = false; changed |= self.selected.as_ref() == Some(&reply.source); - let idx = match self.entries.iter().position(|e| e.source == reply.source) { - Some(i) => i, - None => { - if self.entries.len() >= CACHE_LIMIT { - self.entries.remove(0); - } - self.entries.push(Entry { - source: reply.source.clone(), - turns: None, - last_success: None, - failure: None, - email: None, - prompt: None, - }); - self.entries.len() - 1 - } - }; - let entry = &mut self.entries[idx]; + let entry = entry_in(&mut self.entries, &reply.source); match reply.turns { Ok(turns) => { entry.turns = Some(turns); @@ -235,9 +247,11 @@ impl LocalTranscriptWorker { Err(e) => entry.failure = Some(e), } entry.email = reply.email; - entry.prompt = reply - .prompt - .filter(|_| reply.prompt_epoch == self.prompt_epoch); + // 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 { @@ -256,6 +270,11 @@ impl LocalTranscriptWorker { && 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(); @@ -304,7 +323,7 @@ impl LocalTranscriptWorker { prompt_of: (agent.status == AgentStatus::Blocked && agent.tool.canonical() == Some(Tool::Claude)) .then(|| agent.name.clone()), - prompt_epoch: self.prompt_epoch, + prompt_epoch, }) .is_ok(); self.checked = Some(now); @@ -352,13 +371,9 @@ impl LocalTranscriptWorker { /// 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) { - let source = LocalSource::for_agent(agent); - self.prompt_epoch += 1; - if let Some(entry) = self - .entries - .iter_mut() - .find(|e| Some(&e.source) == source.as_ref()) - { + if let Some(source) = LocalSource::for_agent(agent) { + let entry = entry_in(&mut self.entries, &source); + entry.prompt_epoch += 1; entry.prompt = None; } } @@ -366,15 +381,9 @@ impl LocalTranscriptWorker { #[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"); - self.entries.retain(|e| e.source != source); - self.entries.push(Entry { - source, - turns: Some(Vec::new()), - last_success: None, - failure: None, - email: None, - prompt: Some(prompt), - }); + let entry = entry_in(&mut self.entries, &source); + entry.turns.get_or_insert_with(Vec::new); + entry.prompt = Some(prompt); } } @@ -556,4 +565,105 @@ mod tests { assert_eq!(snap.turns.unwrap().len(), 2); assert!(snap.failure.is_some()); } + + fn a_prompt() -> ScreenPrompt { + let screen = "──────────────────────────────── +과일? + +❯ 1. 사과 + 2. 배 + +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/mod.rs b/src/tui/transcript_view/mod.rs index 4c55341c4..3d16001ab 100644 --- a/src/tui/transcript_view/mod.rs +++ b/src/tui/transcript_view/mod.rs @@ -142,17 +142,43 @@ impl App { } } + /// 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 answering = self.transcript.answering.contains(&name); let step = match code { KeyCode::Char(c @ '1'..='9') => { let n = (c as u8 - b'0') as usize; @@ -175,13 +201,6 @@ impl App { }, _ => return false, }; - if answering { - self.ui.flash = Some(Flash::new( - format!("Still answering {name}: wait for its screen to update"), - Theme::flash_info(), - )); - return true; - } let agent = agent.clone(); match self.enqueue_rpc(crate::tui::rpc_async::RpcOp::AnswerPrompt { name: name.clone(), @@ -422,6 +441,42 @@ Enter to select · Esc to cancel"; 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); diff --git a/src/tui/transcript_view/prompt.rs b/src/tui/transcript_view/prompt.rs index 42eacee0b..f73b4a2ab 100644 --- a/src/tui/transcript_view/prompt.rs +++ b/src/tui/transcript_view/prompt.rs @@ -103,6 +103,17 @@ pub fn parse_screen(lines: &[String]) -> Option { 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; @@ -430,6 +441,19 @@ COMMAND 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()); } From c91e94b7f2ef8f8ed5e0b4a21c3b2dff111e5bec Mon Sep 17 00:00:00 2001 From: leilen Date: Fri, 23 Oct 2026 10:20:33 +0900 Subject: [PATCH 08/14] Write the transcript view's test data in English The conversation tab tests used Korean text for prompts, turns and markdown samples. They now use English. The wrap and table alignment tests need double-width characters, so they use full-width Latin letters instead and keep checking display width. Co-Authored-By: Claude Haiku 5.5 --- src/tui/transcript_view/convert.rs | 18 ++++----- src/tui/transcript_view/local.rs | 6 +-- src/tui/transcript_view/markdown.rs | 16 ++++---- src/tui/transcript_view/mod.rs | 12 +++--- src/tui/transcript_view/prompt.rs | 43 +++++++++++---------- src/tui/transcript_view/render.rs | 60 +++++++++++++++++------------ 6 files changed, 84 insertions(+), 71 deletions(-) diff --git a/src/tui/transcript_view/convert.rs b/src/tui/transcript_view/convert.rs index e3046d589..fe332fc0a 100644 --- a/src/tui/transcript_view/convert.rs +++ b/src/tui/transcript_view/convert.rs @@ -341,12 +341,12 @@ mod tests { #[test] fn long_response_keeps_tail() { - let action: String = (1..=20).map(|i| format!("줄 {i}\n")).collect(); + 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], "줄 13"); - assert_eq!(t.response_summary, "줄 20"); + assert_eq!(t.response_tail[0], "line 13"); + assert_eq!(t.response_summary, "line 20"); assert_eq!(t.tools, ToolSummary::None); } @@ -378,13 +378,13 @@ mod tests { #[test] fn slash_command_request_and_full_text() { let t = turn_from_exchange(&ex( - "personal\n/personal\n질문 하나", + "personal\n/personal\none question", "a\n\n\nb \n\n", &[], &[], )); - assert_eq!(t.request_summary, "/personal 질문 하나"); - assert_eq!(t.request_full, vec!["/personal 질문 하나"]); + 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!( @@ -416,7 +416,7 @@ mod tests { let mut turns = vec![first, wake]; let messages = vec![ msg("bigboss", &["lava:TUGA"], "too early", 90.0), - msg("bigboss", &["lava:TUGA"], "얼마나 남았어?", 199.5), + 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), @@ -426,9 +426,9 @@ mod tests { assert_eq!(turns[0].request_summary, "q"); assert_eq!( turns[1].request_full, - vec!["bigboss: 얼마나 남았어?", "nova: all hands", "line 2"] + vec!["bigboss: how long left?", "nova: all hands", "line 2"] ); - assert_eq!(turns[1].request_summary, "bigboss: 얼마나 남았어?"); + assert_eq!(turns[1].request_summary, "bigboss: how long left?"); // Nothing found: the trigger stays. let mut lone = vec![turns[1].clone()]; diff --git a/src/tui/transcript_view/local.rs b/src/tui/transcript_view/local.rs index d763aa995..e81b7c646 100644 --- a/src/tui/transcript_view/local.rs +++ b/src/tui/transcript_view/local.rs @@ -568,10 +568,10 @@ mod tests { fn a_prompt() -> ScreenPrompt { let screen = "──────────────────────────────── -과일? +Fruit? -❯ 1. 사과 - 2. 배 +❯ 1. Apple + 2. Pear Enter to select · Esc to cancel"; parse_screen(&screen.lines().map(String::from).collect::>()).unwrap() diff --git a/src/tui/transcript_view/markdown.rs b/src/tui/transcript_view/markdown.rs index 196bd09c9..074998c87 100644 --- a/src/tui/transcript_view/markdown.rs +++ b/src/tui/transcript_view/markdown.rs @@ -261,8 +261,8 @@ mod tests { fn block_lines() { let base = Style::default(); let mut code = false; - let h = line("## 제목 **굵게**", &mut code, base).unwrap(); - assert_eq!(texts(&h), vec!["제목 ", "굵게"]); + 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()), @@ -289,9 +289,9 @@ mod tests { #[test] fn tables_align() { let rows: Vec = [ - "| 대상 | 방법 |", + "| ID | Tool |", "|---|:--:|", - "| **커밋** | `git diff` |", + "| **GO** | `git diff` |", "| a |", ] .iter() @@ -304,9 +304,9 @@ mod tests { assert_eq!( joined, vec![ - "대상 │ 방법", + "ID │ Tool", "─────┼─────────", - "커밋 │ git diff", + "GO │ git diff", "a │ " ] ); @@ -325,10 +325,10 @@ mod tests { assert_eq!(rows.len(), 2); assert_eq!(texts(&rows[0]), vec!["ab "]); assert_eq!(rows[1], vec![("cdef".to_string(), bold)]); - let rows = wrap(&[("가나다라".to_string(), Style::default())], 4); + let rows = wrap(&[("ABCD".to_string(), Style::default())], 4); assert_eq!( rows.iter().map(|r| texts(r).concat()).collect::>(), - vec!["가나", "다라"] + 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 index 3d16001ab..76ac020ed 100644 --- a/src/tui/transcript_view/mod.rs +++ b/src/tui/transcript_view/mod.rs @@ -356,19 +356,19 @@ mod tests { } const QUESTION: &str = "──────────────────────────────── -과일? +Fruit? -❯ 1. 사과 - 2. 배 +❯ 1. Apple + 2. Pear 3. Type something. Enter to select · Esc to cancel"; const MULTI: &str = "──────────────────────────────── -음료? +Drink? -❯ 1. [ ] 물 - 2. [ ] 차 +❯ 1. [ ] Water + 2. [ ] Tea Enter to select · Esc to cancel"; diff --git a/src/tui/transcript_view/prompt.rs b/src/tui/transcript_view/prompt.rs index f73b4a2ab..7b480d0a6 100644 --- a/src/tui/transcript_view/prompt.rs +++ b/src/tui/transcript_view/prompt.rs @@ -223,11 +223,11 @@ mod tests { ──────────────────────────────────────────────────────────────────────────────── ← ☐ fruit ☐ drink ✔ Submit → -과일? +Fruit? -❯ 1. 사과 - 사과를 선택합니다 - 2. 배 +❯ 1. Apple + Pick an apple + 2. Pear 3. Type something. ──────────────────────────────────────────────────────────────────────────────── 4. Chat about this @@ -238,10 +238,13 @@ Enter to select · Tab/Arrow keys to navigate · Esc to cancel"; 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!["과일?"]); + assert_eq!(p.question, vec!["Fruit?"]); let labels: Vec<&str> = p.options.iter().map(|o| o.label.as_str()).collect(); - assert_eq!(labels, ["사과", "배", "Type something.", "Chat about this"]); - assert_eq!(p.options[0].description, "사과를 선택합니다"); + 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), @@ -263,11 +266,11 @@ Enter to select · Tab/Arrow keys to navigate · Esc to cancel"; let text = "──────────────────────────────── ← ☒ fruit ☐ drink ✔ Submit → -음료? +Drink? -❯ 1. [✔] 물 - 기본 생수 - 2. [ ] 차 +❯ 1. [✔] Water + Still water + 2. [ ] Tea 3. [ ] Type something Submit ──────────────────────────────── @@ -277,7 +280,7 @@ 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, "물"); + 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); @@ -289,12 +292,12 @@ Enter to select · Tab/Arrow keys to navigate · Esc to cancel"; assert!( !next .expect - .shown_on(&screen(&text.replace("[✔] 물", "[ ] 물"))) + .shown_on(&screen(&text.replace("[✔] Water", "[ ] Water"))) ); assert!( !next .expect - .shown_on(&screen(&text.replace("[ ] 차", "[✔] 차"))) + .shown_on(&screen(&text.replace("[ ] Tea", "[✔] Tea"))) ); } @@ -305,8 +308,8 @@ Enter to select · Tab/Arrow keys to navigate · Esc to cancel"; Review your answers - ● 과일? - → 배 + ● Fruit? + → Pear Ready to submit your answers? @@ -461,12 +464,12 @@ COMMAND #[test] fn reads_with_the_cursor_below_the_rule() { let text = TABBED - .replace("❯ 1. 사과", " 1. 사과") + .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!["과일?"]); + assert_eq!(p.question, vec!["Fruit?"]); assert_eq!(p.options.len(), 4); - assert_eq!(p.options[0].description, "사과를 선택합니다"); + assert_eq!(p.options[0].description, "Pick an apple"); assert_eq!(p.pick(1).unwrap().expect.anchor, FOOTER); } @@ -474,7 +477,7 @@ COMMAND fn nothing_waiting() { // Answered: the question remains in the scrollback, the selector does not. let answered = "⏺ User answered Claude's questions: - ⎿ · 과일? → 배 + ⎿ · Fruit? → Pear ──────────────────────────────── ❯ ────────────────────────────────"; diff --git a/src/tui/transcript_view/render.rs b/src/tui/transcript_view/render.rs index 4fea5c530..116d8b928 100644 --- a/src/tui/transcript_view/render.rs +++ b/src/tui/transcript_view/render.rs @@ -606,11 +606,11 @@ mod tests { let screen: Vec = "──────────────────────────────── ← ☒ fruit ☐ drink ✔ Submit → -음료? +Drink? -❯ 1. [✔] 물 - 기본 생수 - 2. [ ] 차 +❯ 1. [✔] Water + Still water + 2. [ ] Tea 3. [ ] Type something Submit @@ -624,9 +624,9 @@ Enter to select · Esc to cancel" out, vec![ " ☒ fruit ☐ drink ✔ Submit", - " 음료?", - " [x] 1 물 기본 생수", - " [ ] 2 차", + " Drink?", + " [x] 1 Water Still water", + " [ ] 2 Tea", " [ ] 3 Type something", " 1-2 toggle · enter next · typing answers in the terminal", ] @@ -647,17 +647,17 @@ Enter to select · Esc to cancel" extra_files: 0, }; let turns = vec![ - turn(12, "분석해줘", "가능 판정", tools.clone()), - turn(13, "fork", "생성", ToolSummary::Unknown), - turn(14, "테스트 돌려봐", "6개 통과", tools), + 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 분석해줘 → 가능 판정 Tool×3"); - assert_eq!(out[1], "▸ #13 16:02 fork → 생성 Tool —"); + 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], "▶ 테스트 돌려봐"); + assert_eq!(out[3], "▶ run the tests"); assert_eq!(out[4], "⚙ Bash×3"); - assert_eq!(out[5], "◀ 6개 통과"); + assert_eq!(out[5], "◀ 6 passed"); let all = text(&body_lines(&turns, true, 80)); assert!(all[0].starts_with("── #12")); @@ -676,7 +676,12 @@ Enter to select · Esc to cancel" #[test] fn narrow_width_never_panics() { - let t = turn(1, "한글 요청 🦀🦀🦀", "응답 🦀", ToolSummary::Unknown); + 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}"); @@ -740,7 +745,7 @@ Enter to select · Esc to cancel" #[test] fn full_mode_shows_every_turn_wrapped() { let mut first = turn(1, "q1", "a1", ToolSummary::None); - first.response_full = vec!["가나다라마바".into(), "".into(), "end".into()]; + 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!( @@ -748,8 +753,8 @@ Enter to select · Esc to cancel" vec![ "── #1 ·…", "▶ q1", - "◀ 가나다", - " 라마바", + "◀ ABC", + " DEF", " ", " end", "── #2 ·…", @@ -801,20 +806,25 @@ Enter to select · Esc to cancel" #[test] fn markdown_in_turns() { - let mut t = turn(1, "**요청**", "## 결과\n- `a.rs` 수정", ToolSummary::None); - t.response_tail = vec!["## 결과".into(), "- `a.rs` 수정".into()]; - t.response_summary = "- `a.rs` 수정".into(); + 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], "▶ 요청"); - assert_eq!(out[2], "◀ 결과"); - assert_eq!(out[3], " • a.rs 수정"); + 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("요청 → • a.rs 수정"), "{}", out[0]); + 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); From 806af1cb6046df561c45f41bf7740a27c490fd83 Mon Sep 17 00:00:00 2001 From: leilen Date: Fri, 23 Oct 2026 10:34:24 +0900 Subject: [PATCH 09/14] Line wrapped prompt options up under their label A long option label or its description wrapped back to the pane's left edge, and a permission option whose label continued on a second screen line showed that continuation after two spaces on the same row. The description now sits on its own rows under the label, as the agent's screen draws it, and every wrapped row of the tabs, question, options and hint keeps the indent of the text it continues. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/tui/transcript_view/render.rs | 104 ++++++++++++++++++++++-------- 1 file changed, 78 insertions(+), 26 deletions(-) diff --git a/src/tui/transcript_view/render.rs b/src/tui/transcript_view/render.rs index 116d8b928..009edd788 100644 --- a/src/tui/transcript_view/render.rs +++ b/src/tui/transcript_view/render.rs @@ -164,22 +164,34 @@ pub fn prompt_lines(prompt: &ScreenPrompt, width: usize) -> Vec> { } let dim = Theme::dim(); let mut lines = Vec::new(); - let push = |segments: Vec, lines: &mut Vec>| { - for row in markdown::wrap(&segments, width) { - lines.push(Line::from(markdown::spans(row))); - } - }; + // 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(vec![(format!(" {tabs}"), Theme::blocked())], &mut lines); + push(indent(), vec![(tabs.clone(), Theme::blocked())], &mut lines); } for text in &prompt.question { - push( - vec![ - (" ".to_string(), Style::default()), - (text.clone(), Theme::title()), - ], - &mut lines, - ); + push(indent(), vec![(text.clone(), Theme::title())], &mut lines); } for option in &prompt.options { let mark = match option.checked { @@ -192,17 +204,22 @@ pub fn prompt_lines(prompt: &ScreenPrompt, width: usize) -> Vec> { } else { Style::default() }; - let mut segs = vec![ - ( - format!(" {mark}{} ", option.number), - Style::default().fg(palette::FG_DIM), - ), - (option.label.clone(), style), - ]; + 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() { - segs.push((format!(" {}", option.description), dim)); + push( + (under, Style::default()), + vec![(option.description.clone(), dim)], + &mut lines, + ); } - push(segs, &mut lines); } let last = prompt .options @@ -212,11 +229,11 @@ pub fn prompt_lines(prompt: &ScreenPrompt, width: usize) -> Vec> { .max() .unwrap_or(1); let hint = if prompt.is_multi() { - format!(" 1-{last} toggle · enter next · typing answers in the terminal") + format!("1-{last} toggle · enter next · typing answers in the terminal") } else { - format!(" 1-{last} answer · typing answers in the terminal") + format!("1-{last} answer · typing answers in the terminal") }; - push(vec![(hint, dim)], &mut lines); + push(indent(), vec![(hint, dim)], &mut lines); lines } @@ -625,7 +642,8 @@ Enter to select · Esc to cancel" vec![ " ☒ fruit ☐ drink ✔ Submit", " Drink?", - " [x] 1 Water Still water", + " [x] 1 Water", + " Still water", " [ ] 2 Tea", " [ ] 3 Type something", " 1-2 toggle · enter next · typing answers in the terminal", @@ -639,6 +657,40 @@ Enter to select · Esc to cancel" 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 { From a8a3f2e0a14214f85bb8d88675fc2af37179c157 Mon Sep 17 00:00:00 2001 From: leilen Date: Fri, 23 Oct 2026 13:25:04 +0900 Subject: [PATCH 10/14] Answer preview questions from the conversation tab When a question gives its options previews, Claude Code draws them beside a preview box: a digit only moves the cursor there and Enter confirms. The tab now reads those options without the box, sends the digit, checks the screen again for the cursor on that option, then sends Enter. If the notes field is open or already has text, a digit would go into the notes, so the tab does not answer and says to answer in the terminal. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/tui/transcript_view/mod.rs | 15 +- src/tui/transcript_view/prompt.rs | 229 +++++++++++++++++++++++++++--- src/tui/transcript_view/render.rs | 4 +- 3 files changed, 222 insertions(+), 26 deletions(-) diff --git a/src/tui/transcript_view/mod.rs b/src/tui/transcript_view/mod.rs index 76ac020ed..e9397bf6e 100644 --- a/src/tui/transcript_view/mod.rs +++ b/src/tui/transcript_view/mod.rs @@ -179,13 +179,18 @@ impl App { return false; }; let name = agent.name.clone(); - let step = match code { + let steps = match code { KeyCode::Char(c @ '1'..='9') => { let n = (c as u8 - b'0') as usize; match prompt.pick(n) { - Some(step) => step, + Some(steps) => steps, None => { - if prompt.options.iter().any(|o| o.number == n) { + 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(), @@ -196,7 +201,7 @@ impl App { } } KeyCode::Enter => match prompt.next() { - Some(step) => step, + Some(step) => vec![step], None => return false, }, _ => return false, @@ -204,7 +209,7 @@ impl App { let agent = agent.clone(); match self.enqueue_rpc(crate::tui::rpc_async::RpcOp::AnswerPrompt { name: name.clone(), - steps: vec![step], + steps, }) { Ok(()) => { self.transcript.answering.insert(name); diff --git a/src/tui/transcript_view/prompt.rs b/src/tui/transcript_view/prompt.rs index 7b480d0a6..6b189b659 100644 --- a/src/tui/transcript_view/prompt.rs +++ b/src/tui/transcript_view/prompt.rs @@ -6,8 +6,11 @@ //! 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`. +//! 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 { @@ -34,11 +37,25 @@ pub struct ScreenPrompt { 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(); @@ -65,6 +82,33 @@ fn option_line(line: &str) -> Option<(usize, &str, bool)> { .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) @@ -130,14 +174,25 @@ pub fn parse_screen(lines: &[String]) -> Option { } } + 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(); - let mut footer = false; - for line in &lines[first..] { - let t = line.trim(); - if t.contains(FOOTER) { - footer = true; - break; + 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 { @@ -146,7 +201,8 @@ pub fn parse_screen(lines: &[String]) -> Option { description: String::new(), checked, }); - } else if !t.is_empty() + } else if column.is_none() + && !t.is_empty() && !is_rule(t) && t != "Submit" && let Some(last) = options.last_mut() @@ -170,11 +226,16 @@ pub fn parse_screen(lines: &[String]) -> Option { .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, }) } @@ -183,14 +244,44 @@ impl ScreenPrompt { self.options.iter().any(|o| o.checked.is_some()) } - /// Digit `n`: the step that sends it. `None` for an option that needs - /// typing in the terminal, or no such option. - pub fn pick(&self, n: usize) -> Option> { + /// 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)?; - (!option.needs_typing()).then(|| ScreenStep { + 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. @@ -248,13 +339,13 @@ Enter to select · Tab/Arrow keys to navigate · Esc to cancel"; assert!(!p.is_multi()); assert_eq!( p.pick(2), - Some(ScreenStep { + Some(vec![ScreenStep { expect: p.clone(), keys: vec![b"2".to_vec()], - }) + }]) ); assert_eq!(p.anchor, FOOTER); - assert!(p.pick(2).unwrap().expect.shown_on(&screen(TABBED))); + 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); @@ -318,7 +409,7 @@ Ready to submit your answers? 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().expect; + 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"); @@ -362,7 +453,7 @@ Ready to submit your answers? 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().expect; + 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); @@ -412,7 +503,7 @@ COMMAND ); // 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().expect.shown_on(&screen(&other))); + assert!(!p.pick(1).unwrap()[0].expect.shown_on(&screen(&other))); } /// SEC-002: when the command draws its own rule and dashed line, the @@ -470,7 +561,105 @@ COMMAND 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().expect.anchor, FOOTER); + 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] diff --git a/src/tui/transcript_view/render.rs b/src/tui/transcript_view/render.rs index 009edd788..7cde3c802 100644 --- a/src/tui/transcript_view/render.rs +++ b/src/tui/transcript_view/render.rs @@ -228,7 +228,9 @@ pub fn prompt_lines(prompt: &ScreenPrompt, width: usize) -> Vec> { .map(|o| o.number) .max() .unwrap_or(1); - let hint = if prompt.is_multi() { + 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") From d056d74c61df019f438f55095a027519fae44141 Mon Sep 17 00:00:00 2001 From: leilen Date: Fri, 23 Oct 2026 13:37:03 +0900 Subject: [PATCH 11/14] Read the screen again after a failed answer The answered prompt is dropped as soon as the answer is queued. When the answer then failed, nothing asked for a new screen read, and an agent that stays blocked brings no status change either, so the prompt stayed hidden until something else triggered a read. Request the read whatever the result. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/tui/actions.rs | 13 ++++++------- src/tui/transcript_view/local.rs | 7 +++++++ src/tui/transcript_view/mod.rs | 5 ++++- 3 files changed, 17 insertions(+), 8 deletions(-) diff --git a/src/tui/actions.rs b/src/tui/actions.rs index 81ca2e3cb..c8d3ddfd2 100644 --- a/src/tui/actions.rs +++ b/src/tui/actions.rs @@ -278,14 +278,13 @@ impl App { 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() => { - // Read the screen again for the next question or tab. - if let Some(agent) = self.data.agents.iter().find(|a| a.name == name) { - self.transcript.local.force_refresh(agent); - } - self.reload_data_force(); - } + 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/transcript_view/local.rs b/src/tui/transcript_view/local.rs index e81b7c646..2dcf8e0ef 100644 --- a/src/tui/transcript_view/local.rs +++ b/src/tui/transcript_view/local.rs @@ -378,9 +378,16 @@ impl LocalTranscriptWorker { } } + #[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); diff --git a/src/tui/transcript_view/mod.rs b/src/tui/transcript_view/mod.rs index e9397bf6e..689dcb0fe 100644 --- a/src/tui/transcript_view/mod.rs +++ b/src/tui/transcript_view/mod.rs @@ -432,7 +432,9 @@ Enter to select · Esc to cancel"; ); assert_eq!(sent(&app), 1, "nothing more is sent"); - // The answer comes back: the next key may go out. + // 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; @@ -440,6 +442,7 @@ Enter to select · Esc to cancel"; 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()); From 4a02799e5634e17d9fb607a78daa0738e67c1fab Mon Sep 17 00:00:00 2001 From: leilen Date: Fri, 23 Oct 2026 13:43:56 +0900 Subject: [PATCH 12/14] Keep an unrecorded account unknown and reset the scroll per conversation An agent launched before its tool config dir was recorded has no key in its launch context, unlike a recorded default (""). Both were read as the default account, so such an agent could show the default login's e-mail. It is now left unknown, and no e-mail is shown for it. The conversation tab shares the message pane's scroll. Moving to another agent kept the scroll of the previous conversation, so the new one opened partway up instead of at its latest turn. The scroll now resets when the agent shown changes. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/tui/db.rs | 28 +++++++++++++++++++--- src/tui/state.rs | 5 ++-- src/tui/transcript_view/mod.rs | 44 ++++++++++++++++++++++++++++++++-- 3 files changed, 70 insertions(+), 7 deletions(-) diff --git a/src/tui/db.rs b/src/tui/db.rs index 8c0545320..5b5dd4270 100644 --- a/src/tui/db.rs +++ b/src/tui/db.rs @@ -331,10 +331,12 @@ fn load_all(conn: &Connection, default_limit: usize) -> DataState { /// 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) { +) -> (HashMap>, HashMap) { let mut dirs = HashMap::new(); let mut shared: HashMap> = HashMap::new(); let Ok(mut stmt) = @@ -367,9 +369,9 @@ fn load_account_maps( .get("env") .and_then(|e| e.get(key)) .and_then(|d| d.as_str()) - .filter(|d| !d.is_empty()) { - dirs.insert(name, dir.to_string()); + // "" is the tool's default. + dirs.insert(name, (!dir.is_empty()).then(|| dir.to_string())); } } let mut accounts = HashMap::new(); @@ -1512,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/state.rs b/src/tui/state.rs index d9ca4b1b1..2de9ed286 100644 --- a/src/tui/state.rs +++ b/src/tui/state.rs @@ -25,8 +25,9 @@ pub struct DataState { 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. - pub tool_config_dirs: 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 { diff --git a/src/tui/transcript_view/mod.rs b/src/tui/transcript_view/mod.rs index 689dcb0fe..5b2cc2038 100644 --- a/src/tui/transcript_view/mod.rs +++ b/src/tui/transcript_view/mod.rs @@ -31,6 +31,8 @@ pub struct TranscriptView { /// 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. @@ -84,19 +86,38 @@ impl App { let cursor = self.cursor_target(); let visible = self.conversation_visible(); let App { - data, transcript, .. + 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. + transcript.shown = shown; + ui.msg_scroll = 0; + } match target { ConversationTarget::Local { agent, .. } => { let account = if matches!(cursor, CursorTarget::StoppedAgent(_)) { AccountDir::Unknown } else { - AccountDir::Known(data.tool_config_dirs.get(&agent.name).map(String::as_str)) + 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) @@ -275,6 +296,25 @@ mod tests { 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); + } + #[test] fn c_toggles_tab_in_vertical() { let mut app = vertical_app(); From ab802d614316c3e13a7ad33f081507092f984caf Mon Sep 17 00:00:00 2001 From: leilen Date: Fri, 23 Oct 2026 13:52:37 +0900 Subject: [PATCH 13/14] Keep command output's scroll when the shown agent changes Command output drawn over the conversation tab uses the same scroll. A reload that changed the shown agent reset it, so a long result jumped to the bottom. The reset now waits while command output is open; closing it resets the scroll anyway. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/tui/transcript_view/mod.rs | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/src/tui/transcript_view/mod.rs b/src/tui/transcript_view/mod.rs index 5b2cc2038..045d5b047 100644 --- a/src/tui/transcript_view/mod.rs +++ b/src/tui/transcript_view/mod.rs @@ -14,7 +14,7 @@ use std::time::Instant; pub use model::*; use crate::tui::app::App; -use crate::tui::model::{Agent, CursorTarget, ViewMode}; +use crate::tui::model::{Agent, CursorTarget, InputMode, ViewMode}; use local::AccountDir; #[derive(Default)] @@ -105,9 +105,12 @@ impl App { }; if transcript.shown != shown { // Another conversation opens at its latest turn, not at the - // scroll left in the previous one. + // scroll left in the previous one. Command output drawn over the + // tab keeps its place; closing it resets the scroll anyway. transcript.shown = shown; - ui.msg_scroll = 0; + if ui.mode != InputMode::CommandOutput { + ui.msg_scroll = 0; + } } match target { ConversationTarget::Local { agent, .. } => { @@ -313,6 +316,13 @@ mod tests { 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] From 0270e358974f3b919cd8b21aa1efa0e01814f8c3 Mon Sep 17 00:00:00 2001 From: leilen Date: Fri, 23 Oct 2026 13:52:37 +0900 Subject: [PATCH 14/14] Report an unreadable transcript path instead of an empty conversation metadata().ok() and exists() turned permission and other I/O errors into "not written yet", so the tab showed an empty conversation and no failure. Only a missing file is empty now; other errors are reported, and the cache stamp keeps the error kind so it does not match a cached empty read. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/tui/transcript_view/local.rs | 68 ++++++++++++++++++++++---------- 1 file changed, 48 insertions(+), 20 deletions(-) diff --git a/src/tui/transcript_view/local.rs b/src/tui/transcript_view/local.rs index 2dcf8e0ef..e192d385b 100644 --- a/src/tui/transcript_view/local.rs +++ b/src/tui/transcript_view/local.rs @@ -15,7 +15,8 @@ use crate::tui::model::{Agent, AgentStatus}; pub const LOCAL_LAST: usize = 10; const CACHE_LIMIT: usize = 32; -type Stamp = Vec)>>; +/// 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)] @@ -103,8 +104,8 @@ fn stamp_of(source: &LocalSource) -> Stamp { .iter() .map(|p| { std::fs::metadata(p) - .ok() .map(|m| (m.len(), m.modified().ok())) + .map_err(|e| e.kind()) }) .collect() } @@ -118,25 +119,28 @@ fn read_turns(req: &Request, cache: &mut Vec) -> Result, S { return Ok(hit.turns.clone()); } - let turns = if !req.source.path.exists() { + let turns = match req.source.path.try_exists() { // Not written yet: an empty conversation, not an error. - Ok(Vec::new()) - } else { - 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()) + 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); @@ -483,6 +487,30 @@ mod tests { 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();