diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b5ede68..c883e39a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,30 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] +## [0.8.36] - 2026-05-14 + +### Added + +- **The right sidebar can be hidden for copy-friendly terminals.** + `sidebar_focus = "hidden"` (or `Ctrl+Alt+0` for the current session) removes + the Work/Tasks/Agents/Context rail so raw terminal selection cannot copy + sidebar borders alongside transcript text. + +### Changed + +- **Sub-agent completion handoffs are leaner and more cache-friendly.** + Internal `` sentinels now point to the preceding + human summary line instead of duplicating the summary, elapsed time, and + step count inside JSON sent to the parent model. +- **Prefix stability is visible beside cache telemetry by default.** The + footer now includes the prefix-stability chip in the default status layout, + and low last-request cache hit rates are no longer colored as hard errors + when the system/tool prefix itself is stable. +- **RLM batch helpers now require an explicit independence assertion.** + `sub_query_batch`, `sub_query_map`, and low-level `*_batched` helpers refuse + dependency-unsafe parallel fanout unless callers pass + `dependency_mode="independent"`, and RLM now exposes `sub_query_sequence` + for A-to-B dependent work. ## [0.8.35] - 2026-05-13 @@ -4106,6 +4129,7 @@ Welcome — and thank you. - Example skills and launch assets [Unreleased]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.8.35...HEAD +[0.8.36]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.8.35...v0.8.36 [0.8.35]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.8.34...v0.8.35 [0.8.34]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.8.33...v0.8.34 [0.8.33]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.8.32...v0.8.33 diff --git a/Cargo.lock b/Cargo.lock index 882f8491..c55a62b8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1160,7 +1160,7 @@ dependencies = [ [[package]] name = "deepseek-agent" -version = "0.8.35" +version = "0.8.36" dependencies = [ "deepseek-config", "serde", @@ -1168,7 +1168,7 @@ dependencies = [ [[package]] name = "deepseek-app-server" -version = "0.8.35" +version = "0.8.36" dependencies = [ "anyhow", "axum", @@ -1190,7 +1190,7 @@ dependencies = [ [[package]] name = "deepseek-config" -version = "0.8.35" +version = "0.8.36" dependencies = [ "anyhow", "deepseek-secrets", @@ -1202,7 +1202,7 @@ dependencies = [ [[package]] name = "deepseek-core" -version = "0.8.35" +version = "0.8.36" dependencies = [ "anyhow", "chrono", @@ -1220,7 +1220,7 @@ dependencies = [ [[package]] name = "deepseek-execpolicy" -version = "0.8.35" +version = "0.8.36" dependencies = [ "anyhow", "deepseek-protocol", @@ -1229,7 +1229,7 @@ dependencies = [ [[package]] name = "deepseek-hooks" -version = "0.8.35" +version = "0.8.36" dependencies = [ "anyhow", "async-trait", @@ -1243,7 +1243,7 @@ dependencies = [ [[package]] name = "deepseek-mcp" -version = "0.8.35" +version = "0.8.36" dependencies = [ "anyhow", "serde", @@ -1252,7 +1252,7 @@ dependencies = [ [[package]] name = "deepseek-protocol" -version = "0.8.35" +version = "0.8.36" dependencies = [ "serde", "serde_json", @@ -1260,7 +1260,7 @@ dependencies = [ [[package]] name = "deepseek-secrets" -version = "0.8.35" +version = "0.8.36" dependencies = [ "dirs", "keyring", @@ -1273,7 +1273,7 @@ dependencies = [ [[package]] name = "deepseek-state" -version = "0.8.35" +version = "0.8.36" dependencies = [ "anyhow", "chrono", @@ -1285,7 +1285,7 @@ dependencies = [ [[package]] name = "deepseek-tools" -version = "0.8.35" +version = "0.8.36" dependencies = [ "anyhow", "async-trait", @@ -1298,7 +1298,7 @@ dependencies = [ [[package]] name = "deepseek-tui" -version = "0.8.35" +version = "0.8.36" dependencies = [ "anyhow", "arboard", @@ -1361,7 +1361,7 @@ dependencies = [ [[package]] name = "deepseek-tui-cli" -version = "0.8.35" +version = "0.8.36" dependencies = [ "anyhow", "chrono", @@ -1386,7 +1386,7 @@ dependencies = [ [[package]] name = "deepseek-tui-core" -version = "0.8.35" +version = "0.8.36" [[package]] name = "deltae" diff --git a/Cargo.toml b/Cargo.toml index 20c227d5..d941949d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,7 +19,7 @@ default-members = ["crates/cli", "crates/app-server", "crates/tui"] resolver = "2" [workspace.package] -version = "0.8.35" +version = "0.8.36" edition = "2024" # Rust 1.88 stabilized `let_chains` in `if`/`while` conditions, which the # codebase relies on extensively. Cargo enforces this so users on older diff --git a/crates/agent/Cargo.toml b/crates/agent/Cargo.toml index 6d848674..e15650e6 100644 --- a/crates/agent/Cargo.toml +++ b/crates/agent/Cargo.toml @@ -7,5 +7,5 @@ repository.workspace = true description = "Model/provider registry and fallback strategy for DeepSeek workspace architecture" [dependencies] -deepseek-config = { path = "../config", version = "0.8.35" } +deepseek-config = { path = "../config", version = "0.8.36" } serde.workspace = true diff --git a/crates/app-server/Cargo.toml b/crates/app-server/Cargo.toml index bf2ec57c..8d1c0e60 100644 --- a/crates/app-server/Cargo.toml +++ b/crates/app-server/Cargo.toml @@ -10,15 +10,15 @@ description = "Codex-style app-server transport for DeepSeek workspace architect anyhow.workspace = true axum.workspace = true clap.workspace = true -deepseek-agent = { path = "../agent", version = "0.8.35" } -deepseek-config = { path = "../config", version = "0.8.35" } -deepseek-core = { path = "../core", version = "0.8.35" } -deepseek-execpolicy = { path = "../execpolicy", version = "0.8.35" } -deepseek-hooks = { path = "../hooks", version = "0.8.35" } -deepseek-mcp = { path = "../mcp", version = "0.8.35" } -deepseek-protocol = { path = "../protocol", version = "0.8.35" } -deepseek-state = { path = "../state", version = "0.8.35" } -deepseek-tools = { path = "../tools", version = "0.8.35" } +deepseek-agent = { path = "../agent", version = "0.8.36" } +deepseek-config = { path = "../config", version = "0.8.36" } +deepseek-core = { path = "../core", version = "0.8.36" } +deepseek-execpolicy = { path = "../execpolicy", version = "0.8.36" } +deepseek-hooks = { path = "../hooks", version = "0.8.36" } +deepseek-mcp = { path = "../mcp", version = "0.8.36" } +deepseek-protocol = { path = "../protocol", version = "0.8.36" } +deepseek-state = { path = "../state", version = "0.8.36" } +deepseek-tools = { path = "../tools", version = "0.8.36" } serde.workspace = true serde_json.workspace = true tokio.workspace = true diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index 3c175c88..75c34c49 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -14,13 +14,13 @@ path = "src/main.rs" anyhow.workspace = true clap.workspace = true clap_complete.workspace = true -deepseek-agent = { path = "../agent", version = "0.8.35" } -deepseek-app-server = { path = "../app-server", version = "0.8.35" } -deepseek-config = { path = "../config", version = "0.8.35" } -deepseek-execpolicy = { path = "../execpolicy", version = "0.8.35" } -deepseek-mcp = { path = "../mcp", version = "0.8.35" } -deepseek-secrets = { path = "../secrets", version = "0.8.35" } -deepseek-state = { path = "../state", version = "0.8.35" } +deepseek-agent = { path = "../agent", version = "0.8.36" } +deepseek-app-server = { path = "../app-server", version = "0.8.36" } +deepseek-config = { path = "../config", version = "0.8.36" } +deepseek-execpolicy = { path = "../execpolicy", version = "0.8.36" } +deepseek-mcp = { path = "../mcp", version = "0.8.36" } +deepseek-secrets = { path = "../secrets", version = "0.8.36" } +deepseek-state = { path = "../state", version = "0.8.36" } chrono.workspace = true dirs.workspace = true serde.workspace = true diff --git a/crates/config/Cargo.toml b/crates/config/Cargo.toml index 2e7a4d81..db91205a 100644 --- a/crates/config/Cargo.toml +++ b/crates/config/Cargo.toml @@ -8,7 +8,7 @@ description = "Config schema and precedence model for DeepSeek workspace archite [dependencies] anyhow.workspace = true -deepseek-secrets = { path = "../secrets", version = "0.8.35" } +deepseek-secrets = { path = "../secrets", version = "0.8.36" } dirs.workspace = true serde.workspace = true toml.workspace = true diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index 9edbf8e7..81456cb7 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -9,13 +9,13 @@ description = "Core runtime boundaries for DeepSeek workspace architecture" [dependencies] anyhow.workspace = true chrono.workspace = true -deepseek-agent = { path = "../agent", version = "0.8.35" } -deepseek-config = { path = "../config", version = "0.8.35" } -deepseek-execpolicy = { path = "../execpolicy", version = "0.8.35" } -deepseek-hooks = { path = "../hooks", version = "0.8.35" } -deepseek-mcp = { path = "../mcp", version = "0.8.35" } -deepseek-protocol = { path = "../protocol", version = "0.8.35" } -deepseek-state = { path = "../state", version = "0.8.35" } -deepseek-tools = { path = "../tools", version = "0.8.35" } +deepseek-agent = { path = "../agent", version = "0.8.36" } +deepseek-config = { path = "../config", version = "0.8.36" } +deepseek-execpolicy = { path = "../execpolicy", version = "0.8.36" } +deepseek-hooks = { path = "../hooks", version = "0.8.36" } +deepseek-mcp = { path = "../mcp", version = "0.8.36" } +deepseek-protocol = { path = "../protocol", version = "0.8.36" } +deepseek-state = { path = "../state", version = "0.8.36" } +deepseek-tools = { path = "../tools", version = "0.8.36" } serde_json.workspace = true uuid.workspace = true diff --git a/crates/execpolicy/Cargo.toml b/crates/execpolicy/Cargo.toml index 1b95a475..7affb7a7 100644 --- a/crates/execpolicy/Cargo.toml +++ b/crates/execpolicy/Cargo.toml @@ -8,5 +8,5 @@ description = "Execution policy and approval model parity for DeepSeek workspace [dependencies] anyhow.workspace = true -deepseek-protocol = { path = "../protocol", version = "0.8.35" } +deepseek-protocol = { path = "../protocol", version = "0.8.36" } serde.workspace = true diff --git a/crates/hooks/Cargo.toml b/crates/hooks/Cargo.toml index db2063a3..4e60418f 100644 --- a/crates/hooks/Cargo.toml +++ b/crates/hooks/Cargo.toml @@ -10,7 +10,7 @@ description = "Hook dispatch and notifications parity for DeepSeek workspace arc anyhow.workspace = true async-trait.workspace = true chrono.workspace = true -deepseek-protocol = { path = "../protocol", version = "0.8.35" } +deepseek-protocol = { path = "../protocol", version = "0.8.36" } reqwest.workspace = true serde.workspace = true serde_json.workspace = true diff --git a/crates/secrets/src/lib.rs b/crates/secrets/src/lib.rs index 52df4836..1b42f690 100644 --- a/crates/secrets/src/lib.rs +++ b/crates/secrets/src/lib.rs @@ -202,19 +202,25 @@ impl InMemoryKeyringStore { impl KeyringStore for InMemoryKeyringStore { fn get(&self, key: &str) -> Result, SecretsError> { - Ok(self.entries.lock().unwrap().get(key).cloned()) + let guard = self.entries.lock().map_err(|e| { + SecretsError::Keyring(format!("InMemoryKeyringStore mutex poisoned: {e}")) + })?; + Ok(guard.get(key).cloned()) } fn set(&self, key: &str, value: &str) -> Result<(), SecretsError> { - self.entries - .lock() - .unwrap() - .insert(key.to_string(), value.to_string()); + let mut guard = self.entries.lock().map_err(|e| { + SecretsError::Keyring(format!("InMemoryKeyringStore mutex poisoned: {e}")) + })?; + guard.insert(key.to_string(), value.to_string()); Ok(()) } fn delete(&self, key: &str) -> Result<(), SecretsError> { - self.entries.lock().unwrap().remove(key); + let mut guard = self.entries.lock().map_err(|e| { + SecretsError::Keyring(format!("InMemoryKeyringStore mutex poisoned: {e}")) + })?; + guard.remove(key); Ok(()) } diff --git a/crates/tools/Cargo.toml b/crates/tools/Cargo.toml index 25dcefef..e245b556 100644 --- a/crates/tools/Cargo.toml +++ b/crates/tools/Cargo.toml @@ -9,7 +9,7 @@ description = "Tool invocation lifecycle, schema validation, and scheduler paral [dependencies] anyhow.workspace = true async-trait.workspace = true -deepseek-protocol = { path = "../protocol", version = "0.8.35" } +deepseek-protocol = { path = "../protocol", version = "0.8.36" } serde.workspace = true serde_json.workspace = true tokio.workspace = true diff --git a/crates/tui/CHANGELOG.md b/crates/tui/CHANGELOG.md index 9b5ede68..c883e39a 100644 --- a/crates/tui/CHANGELOG.md +++ b/crates/tui/CHANGELOG.md @@ -5,7 +5,30 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] +## [0.8.36] - 2026-05-14 + +### Added + +- **The right sidebar can be hidden for copy-friendly terminals.** + `sidebar_focus = "hidden"` (or `Ctrl+Alt+0` for the current session) removes + the Work/Tasks/Agents/Context rail so raw terminal selection cannot copy + sidebar borders alongside transcript text. + +### Changed + +- **Sub-agent completion handoffs are leaner and more cache-friendly.** + Internal `` sentinels now point to the preceding + human summary line instead of duplicating the summary, elapsed time, and + step count inside JSON sent to the parent model. +- **Prefix stability is visible beside cache telemetry by default.** The + footer now includes the prefix-stability chip in the default status layout, + and low last-request cache hit rates are no longer colored as hard errors + when the system/tool prefix itself is stable. +- **RLM batch helpers now require an explicit independence assertion.** + `sub_query_batch`, `sub_query_map`, and low-level `*_batched` helpers refuse + dependency-unsafe parallel fanout unless callers pass + `dependency_mode="independent"`, and RLM now exposes `sub_query_sequence` + for A-to-B dependent work. ## [0.8.35] - 2026-05-13 @@ -4106,6 +4129,7 @@ Welcome — and thank you. - Example skills and launch assets [Unreleased]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.8.35...HEAD +[0.8.36]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.8.35...v0.8.36 [0.8.35]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.8.34...v0.8.35 [0.8.34]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.8.33...v0.8.34 [0.8.33]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.8.32...v0.8.33 diff --git a/crates/tui/Cargo.toml b/crates/tui/Cargo.toml index 28887b54..e9ebb170 100644 --- a/crates/tui/Cargo.toml +++ b/crates/tui/Cargo.toml @@ -21,8 +21,8 @@ path = "src/main.rs" [dependencies] anyhow = "1.0.100" arboard = "3.4" -deepseek-secrets = { path = "../secrets", version = "0.8.35" } -deepseek-tools = { path = "../tools", version = "0.8.35" } +deepseek-secrets = { path = "../secrets", version = "0.8.36" } +deepseek-tools = { path = "../tools", version = "0.8.36" } schemaui = { version = "0.12.0", default-features = false, optional = true } async-stream = "0.3.6" async-trait = "0.1" diff --git a/crates/tui/src/commands/core.rs b/crates/tui/src/commands/core.rs index 4256f513..c75660a2 100644 --- a/crates/tui/src/commands/core.rs +++ b/crates/tui/src/commands/core.rs @@ -629,6 +629,9 @@ mod tests { #[test] fn model_switch_clears_turn_cache_history() { let mut app = create_test_app(); + // Keep the assertion independent of the developer's saved default model. + app.auto_model = false; + app.model = "deepseek-v4-pro".to_string(); app.push_turn_cache_record(TurnCacheRecord { input_tokens: 100, output_tokens: 25, diff --git a/crates/tui/src/config.rs b/crates/tui/src/config.rs index c9ae5b5e..a651fb5b 100644 --- a/crates/tui/src/config.rs +++ b/crates/tui/src/config.rs @@ -561,12 +561,13 @@ pub struct SearchConfig { /// /// Order in the user's `Vec` is preserved: items in the left /// cluster (`Mode`, `Model`, `Cost`, `Status`) render in the order given; -/// right-cluster chips (`Coherence`, `Agents`, `ReasoningReplay`, `Cache`, -/// `ContextPercent`, `GitBranch`, `LastToolElapsed`, `RateLimit`) likewise -/// honour ordering inside their cluster. The split between left and right is -/// deliberate — left holds steady identity (mode/model/cost), right holds -/// transient signals — so we route each variant to the correct side rather -/// than letting users reorder across the spacer. +/// right-cluster chips (`Coherence`, `Agents`, `ReasoningReplay`, +/// `PrefixStability`, `Cache`, `ContextPercent`, `GitBranch`, +/// `LastToolElapsed`, `RateLimit`) likewise honour ordering inside their +/// cluster. The split between left and right is deliberate — left holds steady +/// identity (mode/model/cost), right holds transient signals — so we route +/// each variant to the correct side rather than letting users reorder across +/// the spacer. /// /// Variants without a current data source (`RateLimit`, `LastToolElapsed`) /// are intentionally exposed today so the picker is forward-compatible; they @@ -589,6 +590,8 @@ pub enum StatusItem { Agents, /// Reasoning-replay token count ("rsn 12.3k"). ReasoningReplay, + /// Prefix stability ("P 100%"). + PrefixStability, /// Cache hit rate ("cache 73%"). Cache, /// Context-window utilisation percent ("48%"). @@ -615,6 +618,7 @@ impl StatusItem { StatusItem::Coherence, StatusItem::Agents, StatusItem::ReasoningReplay, + StatusItem::PrefixStability, StatusItem::Cache, ] } @@ -630,6 +634,7 @@ impl StatusItem { StatusItem::Coherence => "coherence", StatusItem::Agents => "agents", StatusItem::ReasoningReplay => "reasoning_replay", + StatusItem::PrefixStability => "prefix_stability", StatusItem::Cache => "cache", StatusItem::ContextPercent => "context_percent", StatusItem::GitBranch => "git_branch", @@ -649,6 +654,7 @@ impl StatusItem { StatusItem::Coherence => "Coherence interventions", StatusItem::Agents => "Sub-agents in flight", StatusItem::ReasoningReplay => "Reasoning replay tokens", + StatusItem::PrefixStability => "Prefix stability", StatusItem::Cache => "Prompt cache hit rate", StatusItem::ContextPercent => "Context window %", StatusItem::GitBranch => "Git branch", @@ -669,6 +675,7 @@ impl StatusItem { StatusItem::Coherence => "shown only when the engine intervenes", StatusItem::Agents => "agents or RLM work in progress", StatusItem::ReasoningReplay => "thinking tokens replayed each turn", + StatusItem::PrefixStability => "whether system/tools stayed cacheable", StatusItem::Cache => "% of prompt served from cache", StatusItem::ContextPercent => "tokens used / model context window", StatusItem::GitBranch => "current workspace branch", @@ -688,6 +695,7 @@ impl StatusItem { StatusItem::Coherence, StatusItem::Agents, StatusItem::ReasoningReplay, + StatusItem::PrefixStability, StatusItem::Cache, StatusItem::ContextPercent, StatusItem::GitBranch, diff --git a/crates/tui/src/config_ui.rs b/crates/tui/src/config_ui.rs index 5a1d2a1e..a0915dd8 100644 --- a/crates/tui/src/config_ui.rs +++ b/crates/tui/src/config_ui.rs @@ -232,6 +232,7 @@ pub enum SidebarFocusValue { Tasks, Agents, Context, + Hidden, } #[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] @@ -271,6 +272,7 @@ pub enum StatusItemValue { Coherence, Agents, ReasoningReplay, + PrefixStability, Cache, ContextPercent, GitBranch, @@ -835,6 +837,7 @@ impl SidebarFocusValue { Self::Tasks => "tasks", Self::Agents => "agents", Self::Context => "context", + Self::Hidden => "hidden", } } } @@ -972,6 +975,7 @@ impl From<&str> for SidebarFocusValue { SidebarFocus::Tasks => Self::Tasks, SidebarFocus::Agents => Self::Agents, SidebarFocus::Context => Self::Context, + SidebarFocus::Hidden => Self::Hidden, } } } @@ -986,6 +990,7 @@ impl From for StatusItemValue { StatusItem::Coherence => Self::Coherence, StatusItem::Agents => Self::Agents, StatusItem::ReasoningReplay => Self::ReasoningReplay, + StatusItem::PrefixStability => Self::PrefixStability, StatusItem::Cache => Self::Cache, StatusItem::ContextPercent => Self::ContextPercent, StatusItem::GitBranch => Self::GitBranch, @@ -1005,6 +1010,7 @@ impl From for StatusItem { StatusItemValue::Coherence => Self::Coherence, StatusItemValue::Agents => Self::Agents, StatusItemValue::ReasoningReplay => Self::ReasoningReplay, + StatusItemValue::PrefixStability => Self::PrefixStability, StatusItemValue::Cache => Self::Cache, StatusItemValue::ContextPercent => Self::ContextPercent, StatusItemValue::GitBranch => Self::GitBranch, diff --git a/crates/tui/src/localization.rs b/crates/tui/src/localization.rs index e1e98997..bcb7d881 100644 --- a/crates/tui/src/localization.rs +++ b/crates/tui/src/localization.rs @@ -1100,7 +1100,9 @@ fn english(id: MessageId) -> &'static str { } MessageId::KbJumpPlanAgentYolo => "Jump directly to Plan / Agent / YOLO mode", MessageId::KbAltJumpPlanAgentYolo => "Alternative jump to Plan / Agent / YOLO mode", - MessageId::KbFocusSidebar => "Focus Work / Tasks / Agents / Context / Auto sidebar", + MessageId::KbFocusSidebar => { + "Focus Work / Tasks / Agents / Context / Auto sidebar; Ctrl+Alt+0 hides it" + } MessageId::KbTogglePlanAgent => "Toggle between Plan and Agent modes", MessageId::KbSessionPicker => "Open the session picker", MessageId::KbPasteAttach => "Paste text or attach a clipboard image", @@ -1481,7 +1483,7 @@ fn japanese(id: MessageId) -> Option<&'static str> { MessageId::KbJumpPlanAgentYolo => "Plan / Agent / YOLO モードに直接ジャンプ", MessageId::KbAltJumpPlanAgentYolo => "Plan / Agent / YOLO モードへの代替ジャンプ", MessageId::KbFocusSidebar => { - "Work / Tasks / Agents / Context / Auto サイドバーにフォーカス" + "Work / Tasks / Agents / Context / Auto / Hidden サイドバーにフォーカス" } MessageId::KbTogglePlanAgent => "Plan モードと Agent モードを切り替え", MessageId::KbSessionPicker => "セッションピッカーを開く", @@ -1802,7 +1804,7 @@ fn chinese_simplified(id: MessageId) -> Option<&'static str> { } MessageId::KbJumpPlanAgentYolo => "直接跳转到 Plan / Agent / YOLO 模式", MessageId::KbAltJumpPlanAgentYolo => "替代快捷键跳转到 Plan / Agent / YOLO 模式", - MessageId::KbFocusSidebar => "聚焦 Work / 任务 / 代理 / Context / 自动侧边栏", + MessageId::KbFocusSidebar => "聚焦 Work / 任务 / 代理 / Context / 自动 / 隐藏侧边栏", MessageId::KbTogglePlanAgent => "在 Plan 和 Agent 模式之间切换", MessageId::KbSessionPicker => "打开会话选择器", MessageId::KbPasteAttach => "粘贴文本或附加剪贴板图片", @@ -2162,7 +2164,9 @@ fn portuguese_brazil(id: MessageId) -> Option<&'static str> { } MessageId::KbJumpPlanAgentYolo => "Pular direto para modo Plan / Agent / YOLO", MessageId::KbAltJumpPlanAgentYolo => "Salto alternativo para modo Plan / Agent / YOLO", - MessageId::KbFocusSidebar => "Focar barra lateral Work / Tasks / Agents / Context / Auto", + MessageId::KbFocusSidebar => { + "Focar barra lateral Work / Tasks / Agents / Context / Auto / Ocultar" + } MessageId::KbTogglePlanAgent => "Alternar entre modos Plan e Agent", MessageId::KbSessionPicker => "Abrir seletor de sessões", MessageId::KbPasteAttach => "Colar texto ou anexar imagem da área de transferência", @@ -2550,7 +2554,9 @@ fn spanish_latin_america(id: MessageId) -> Option<&'static str> { } MessageId::KbJumpPlanAgentYolo => "Saltar directo a modo Plan / Agent / YOLO", MessageId::KbAltJumpPlanAgentYolo => "Salto alternativo a modo Plan / Agent / YOLO", - MessageId::KbFocusSidebar => "Enfocar barra lateral Plan / Todos / Tasks / Agents / Auto", + MessageId::KbFocusSidebar => { + "Enfocar barra lateral Work / Tasks / Agents / Context / Auto / Ocultar" + } MessageId::KbTogglePlanAgent => "Alternar entre modos Plan y Agent", MessageId::KbSessionPicker => "Abrir selector de sesiones", MessageId::KbPasteAttach => "Pegar texto o adjuntar imagen del portapapeles", diff --git a/crates/tui/src/prompts/base.md b/crates/tui/src/prompts/base.md index e1ea5ed9..8b10f786 100644 --- a/crates/tui/src/prompts/base.md +++ b/crates/tui/src/prompts/base.md @@ -117,13 +117,15 @@ The dispatcher runs parallel tool calls simultaneously. Serializing independent RLM is a persistent Python REPL for context that is too large or too repetitive to keep in the parent transcript. Open a named session with `rlm_open`, run bounded code with `rlm_eval`, read large returned payloads through `handle_read`, tune feedback with `rlm_configure`, and close finished sessions with `rlm_close`. -Inside the REPL, use deterministic Python for exact work and the RLM helper functions for semantic work. The current helper family is `peek`, `search`, `chunk`, `context_meta`, `sub_query`, `sub_query_batch`, `sub_query_map`, `sub_rlm`, `finalize`, and `evaluate_progress`. These are in-REPL helpers, not separate model-visible tools. Three patterns, not one — choose based on the shape of the work: +Inside the REPL, use deterministic Python for exact work and the RLM helper functions for semantic work. The current helper family is `peek`, `search`, `chunk`, `context_meta`, `sub_query`, `sub_query_batch`, `sub_query_map`, `sub_query_sequence`, `sub_rlm`, `finalize`, and `evaluate_progress`. These are in-REPL helpers, not separate model-visible tools. Four patterns, not one — choose based on the shape of the work: The RLM paper's core design is symbolic state: the long input and intermediate values live in the REPL environment, not copied into the root model context. Inspect with bounded slices, transform with Python, batch child calls programmatically, and keep large intermediate strings in variables or `var_handle`s. Do not paste the whole body back into a prompt or verbalize a long list of sub-calls when a loop can launch them. **CHUNK** — A single input that genuinely doesn't fit in your context window (a whole file > 50K tokens, a long transcript, a multi-document corpus). Split it, process each chunk, synthesize. -**BATCH** — Many independent items that each need LLM attention (classify 20 entries, extract fields from 30 documents, score 15 candidates). Use `sub_query_batch` for parallel execution — it fans out to the same DeepSeek client and finishes in one turn what would take 15 sequential reads. +**BATCH** — Many independent items that each need LLM attention (classify 20 entries, extract fields from 30 documents, score 15 candidates). Use `sub_query_batch(..., dependency_mode="independent", safety_note="...")` for parallel execution — it fans out to the same DeepSeek client and finishes in one turn what would take 15 sequential reads. Batch helpers refuse to run unless you explicitly assert independence. + +**SEQUENCE** — Data-dependent work where A feeds B, ordered migrations, global-state refactors, rollback-sensitive plans, or anything where parallel children could conflict. Use `sub_query_sequence(...)` or an explicit Python `for` loop with `sub_query(...)`, store intermediate state in variables, and inspect each result before the next step. Do not use RLM batch helpers for this shape. **RECURSE** — A problem that benefits from decomposition + critique. Use `sub_query` or `sub_rlm` to have a sub-LLM review your reasoning, identify gaps, or explore alternative approaches. The sub-LLM returns a synthesized answer you verify against live tool output. @@ -195,19 +197,19 @@ Use `agent_open` for independent investigations or implementation slices that ca Use `agent_eval` to send follow-up input, block for completion, or retrieve the current session projection. Use `agent_close` to cancel or release a session that is no longer useful. Keep tiny single-read/search tasks local so the transcript stays compact. ### `rlm_open` / `rlm_eval` / `rlm_configure` / `rlm_close` -Use persistent RLM sessions for long-context semantic work, bulk classification/extraction, and decomposition where a Python REPL plus child LLM helpers is useful. Use deterministic Python inside RLM for exact counts and structured aggregation; use `grep_files` or `exec_shell` directly when that is the clearest deterministic check. Close sessions when their context is no longer needed. +Use persistent RLM sessions for long-context semantic work, bulk classification/extraction, and decomposition where a Python REPL plus child LLM helpers is useful. Use deterministic Python inside RLM for exact counts and structured aggregation; use `grep_files` or `exec_shell` directly when that is the clearest deterministic check. Batch RLM child calls only after asserting independence with `dependency_mode="independent"`; use `sub_query_sequence` for dependent chains. Close sessions when their context is no longer needed. ## Internal Sub-agent Completion Events When you open a sub-agent via `agent_open`, the child runs independently. The runtime may send you an internal `` completion event when it finishes. This event is not user input. It carries: - `agent_id` — the child's identifier -- `summary` — a human-readable summary of what the child found or did - `status` — `"completed"` or `"failed"` -- `error` — present only when `status` is `"failed"` +- `summary_location` / `error_location` — the human-readable summary or error is on the line immediately before the sentinel +- `details` — currently `agent_eval`, the tool to call when you need the full projection or transcript handle **Integration protocol:** -1. When you see ``, read the `summary` field first. +1. When you see ``, read the human summary line immediately before it first. 2. Integrate the child's findings into your work — do not re-do what the child already did. 3. If the summary is insufficient, call `agent_eval` with the agent name or id to pull the current structured projection or transcript handle. 4. If the child failed (`"failed"`), assess whether the failure blocks your plan or whether you can proceed with a fallback. diff --git a/crates/tui/src/repl/runtime.rs b/crates/tui/src/repl/runtime.rs index 95e511b4..cf2bddc0 100644 --- a/crates/tui/src/repl/runtime.rs +++ b/crates/tui/src/repl/runtime.rs @@ -74,11 +74,15 @@ pub enum RpcRequest { #[serde(default)] system: Option, }, - /// `llm_query_batched(prompts, model=None)` + /// `llm_query_batched(prompts, model=None, dependency_mode="independent")` LlmBatch { prompts: Vec, #[serde(default)] model: Option, + #[serde(default)] + dependency_mode: Option, + #[serde(default)] + safety_note: Option, }, /// `rlm_query(prompt, model=None)` — recursive sub-RLM (paper's `sub_RLM`). Rlm { @@ -86,11 +90,15 @@ pub enum RpcRequest { #[serde(default)] model: Option, }, - /// `rlm_query_batched(prompts, model=None)` + /// `rlm_query_batched(prompts, model=None, dependency_mode="independent")` RlmBatch { prompts: Vec, #[serde(default)] model: Option, + #[serde(default)] + dependency_mode: Option, + #[serde(default)] + safety_note: Option, }, } @@ -587,11 +595,39 @@ def llm_query(prompt, model=None, max_tokens=None, system=None): return resp.get("text","") return str(resp) -def llm_query_batched(prompts, model=None): - """Run multiple sub-LLM calls concurrently. The model arg is accepted for compatibility but ignored.""" +def _normalize_dependency_mode(mode): + if mode is None: + return "" + return str(mode).strip().lower().replace("-", "_").replace(" ", "_") + +def _batch_dependency_error(helper, prompts, dependency_mode): + mode = _normalize_dependency_mode(dependency_mode) + if mode in ("independent", "parallel_safe", "map_reduce"): + return None + if mode in ("sequential", "dependent", "ordered", "chain", "serial"): + return ( + f"[{helper}: refused parallel batch because dependency_mode={dependency_mode!r}. " + "Use sub_query_sequence(...) or an explicit for-loop with sub_query(...) so each step can consume the previous result.]" + ) + return ( + f"[{helper}: batch helpers require dependency_mode='independent'. " + "Use only for independent slices/items; for A->B dependencies, global-state refactors, migrations, or rollback-sensitive work, use sub_query_sequence(...).]" + ) + +def llm_query_batched(prompts, model=None, dependency_mode=None, safety_note=None): + """Run independent sub-LLM calls concurrently. Declare dependency_mode='independent'.""" if not isinstance(prompts, (list, tuple)): return ["[llm_query_batched: prompts must be a list]"] - resp = _rpc({"type":"llm_batch","prompts":[str(p) for p in prompts],"model":model}) + err = _batch_dependency_error("llm_query_batched", prompts, dependency_mode) + if err is not None: + return [err for _ in prompts] + resp = _rpc({ + "type":"llm_batch", + "prompts":[str(p) for p in prompts], + "model":model, + "dependency_mode":dependency_mode, + "safety_note":safety_note, + }) if isinstance(resp, dict) and resp.get("error"): return [f"[llm_query_batched: {resp['error']}]" for _ in prompts] results = (resp or {}).get("results", []) if isinstance(resp, dict) else [] @@ -614,11 +650,20 @@ def rlm_query(prompt, model=None): return resp.get("text","") return str(resp) -def rlm_query_batched(prompts, model=None): - """Run multiple recursive sub-RLMs in parallel. The model arg is accepted for compatibility but ignored.""" +def rlm_query_batched(prompts, model=None, dependency_mode=None, safety_note=None): + """Run independent recursive sub-RLMs in parallel. Declare dependency_mode='independent'.""" if not isinstance(prompts, (list, tuple)): return ["[rlm_query_batched: prompts must be a list]"] - resp = _rpc({"type":"rlm_batch","prompts":[str(p) for p in prompts],"model":model}) + err = _batch_dependency_error("rlm_query_batched", prompts, dependency_mode) + if err is not None: + return [err for _ in prompts] + resp = _rpc({ + "type":"rlm_batch", + "prompts":[str(p) for p in prompts], + "model":model, + "dependency_mode":dependency_mode, + "safety_note":safety_note, + }) if isinstance(resp, dict) and resp.get("error"): return [f"[rlm_query_batched: {resp['error']}]" for _ in prompts] results = (resp or {}).get("results", []) if isinstance(resp, dict) else [] @@ -655,23 +700,55 @@ def sub_query(prompt, slice=None, timeout_secs=None, **kwargs): """One child LLM call, optionally scoped to a bounded slice.""" return llm_query(_prompt_with_slice(prompt, slice)) -def sub_query_batch(prompt, slices, timeout_secs=None, **kwargs): - """Apply one prompt to many bounded slices concurrently.""" +def sub_query_batch(prompt, slices, timeout_secs=None, dependency_mode=None, safety_note=None, **kwargs): + """Apply one prompt to many independent bounded slices concurrently.""" if not isinstance(slices, (list, tuple)): return ["[sub_query_batch: slices must be a list]"] - return llm_query_batched([_prompt_with_slice(prompt, s) for s in slices]) + return llm_query_batched( + [_prompt_with_slice(prompt, s) for s in slices], + dependency_mode=dependency_mode, + safety_note=safety_note, + ) -def sub_query_map(prompts, slices=None, timeout_secs=None, **kwargs): - """Run N distinct prompts, optionally paired with N bounded slices.""" +def sub_query_map(prompts, slices=None, timeout_secs=None, dependency_mode=None, safety_note=None, **kwargs): + """Run N distinct independent prompts, optionally paired with N bounded slices.""" if not isinstance(prompts, (list, tuple)): return ["[sub_query_map: prompts must be a list]"] if slices is None: - return llm_query_batched([str(p) for p in prompts]) + return llm_query_batched( + [str(p) for p in prompts], + dependency_mode=dependency_mode, + safety_note=safety_note, + ) if not isinstance(slices, (list, tuple)): return ["[sub_query_map: slices must be a list]"] if len(prompts) != len(slices): return [f"[sub_query_map: size mismatch ({len(prompts)}/{len(slices)})]" for _ in prompts] - return llm_query_batched([_prompt_with_slice(p, s) for p, s in zip(prompts, slices)]) + return llm_query_batched( + [_prompt_with_slice(p, s) for p, s in zip(prompts, slices)], + dependency_mode=dependency_mode, + safety_note=safety_note, + ) + +def sub_query_sequence(prompt, slices, carry_prompt=None, timeout_secs=None, **kwargs): + """Apply one prompt to slices sequentially, feeding each result into the next step.""" + if not isinstance(slices, (list, tuple)): + return ["[sub_query_sequence: slices must be a list]"] + out = [] + previous = "" + carry = str(carry_prompt or "Previous step result; treat it as required input for this step:") + total = len(slices) + for i, s in enumerate(slices): + step_prompt = _prompt_with_slice(prompt, s) + if previous: + step_prompt = ( + f"{step_prompt}\n\n--- dependency_state step {i}/{total} ---\n" + f"{carry}\n{previous}" + ) + result = llm_query(step_prompt) + out.append(result) + previous = result + return out def sub_rlm(prompt, source=None, timeout_secs=None, **kwargs): """Recursive sub-RLM call for tasks that need their own decomposition.""" @@ -864,8 +941,9 @@ _BOOTSTRAP_NAMES = { "_SID","_REQ","_RESP","_FINAL","_ERR","_RUN","_END","_DONE","_READY", "_rpc","_ctx_file","_context","_slice_chars","_slice_lines","_BOOTSTRAP_NAMES","_main_loop", "_emit_final","_json_safe","_slice_text","_prompt_with_slice", + "_normalize_dependency_mode","_batch_dependency_error", "llm_query","llm_query_batched","rlm_query","rlm_query_batched", - "sub_query","sub_query_batch","sub_query_map","sub_rlm", + "sub_query","sub_query_batch","sub_query_map","sub_query_sequence","sub_rlm", "FINAL","FINAL_VAR","SHOW_VARS","repl_get","repl_set", "context_meta","peek","search","chunk","chunk_context","chunk_coverage", "finalize","evaluate_progress","content", @@ -1281,7 +1359,8 @@ mod tests { let mut rt = PythonRuntime::new().await.expect("spawn"); let round = rt .run( - "outs = llm_query_batched(['a','b','c']); print('|'.join(outs))", + "outs = llm_query_batched(['a','b','c'], dependency_mode='independent', safety_note='same independent classification')\n\ + print('|'.join(outs))", Some(&bridge), ) .await @@ -1293,6 +1372,80 @@ mod tests { rt.shutdown().await; } + #[tokio::test] + async fn batched_helpers_require_independence_declaration() { + let bridge = StubBridge::new(); + let mut rt = PythonRuntime::new().await.expect("spawn"); + let round = rt + .run( + "outs = sub_query_batch('summarize', [{'text': 'a'}, {'text': 'b'}])\n\ + print(outs[0])", + Some(&bridge), + ) + .await + .expect("execute"); + + assert!( + round.stdout.contains("dependency_mode='independent'"), + "{}", + round.stdout + ); + assert_eq!(round.rpc_count, 0); + rt.shutdown().await; + } + + #[tokio::test] + async fn dependent_batch_mode_points_to_sequence_helper() { + let bridge = StubBridge::new(); + let mut rt = PythonRuntime::new().await.expect("spawn"); + let round = rt + .run( + "outs = llm_query_batched(['migrate A', 'migrate B'], dependency_mode='sequential')\n\ + print(outs[0])", + Some(&bridge), + ) + .await + .expect("execute"); + + assert!( + round.stdout.contains("sub_query_sequence"), + "{}", + round.stdout + ); + assert_eq!(round.rpc_count, 0); + rt.shutdown().await; + } + + #[tokio::test] + async fn sub_query_sequence_feeds_prior_result_into_next_prompt() { + let bridge = StubBridge::new(); + let calls = Arc::clone(&bridge.calls); + + let mut rt = PythonRuntime::new().await.expect("spawn"); + let round = rt + .run( + "outs = sub_query_sequence('process this step', [{'text': 'A'}, {'text': 'B'}])\n\ + print(len(outs))", + Some(&bridge), + ) + .await + .expect("execute"); + + assert!(round.stdout.contains("2"), "{}", round.stdout); + assert_eq!(round.rpc_count, 2); + + let recorded = calls.lock().await; + assert_eq!(recorded.len(), 2); + let second_prompt = match &recorded[1] { + RpcRequest::Llm { prompt, .. } => prompt, + other => panic!("expected second Llm request, got {other:?}"), + }; + assert!(second_prompt.contains("--- dependency_state step 1/2 ---")); + assert!(second_prompt.contains("stub#0: process this step")); + drop(recorded); + rt.shutdown().await; + } + #[tokio::test] async fn no_dispatcher_returns_unavailable_sentinel() { let mut rt = PythonRuntime::new().await.expect("spawn"); diff --git a/crates/tui/src/rlm/bridge.rs b/crates/tui/src/rlm/bridge.rs index 3e30dd74..21933715 100644 --- a/crates/tui/src/rlm/bridge.rs +++ b/crates/tui/src/rlm/bridge.rs @@ -151,8 +151,13 @@ impl RlmBridge { SingleResp { text, error: None } } - async fn dispatch_llm_batch(&self, prompts: Vec, _model: Option) -> BatchResp { - if let Some(resp) = batch_guard(prompts.len()) { + async fn dispatch_llm_batch( + &self, + prompts: Vec, + _model: Option, + dependency_mode: Option, + ) -> BatchResp { + if let Some(resp) = batch_guard(prompts.len(), dependency_mode.as_deref()) { return resp; } @@ -217,8 +222,13 @@ impl RlmBridge { } } - async fn dispatch_rlm_batch(&self, prompts: Vec, _model: Option) -> BatchResp { - if let Some(resp) = batch_guard(prompts.len()) { + async fn dispatch_rlm_batch( + &self, + prompts: Vec, + _model: Option, + dependency_mode: Option, + ) -> BatchResp { + if let Some(resp) = batch_guard(prompts.len(), dependency_mode.as_deref()) { return resp; } @@ -231,7 +241,7 @@ impl RlmBridge { } } -fn batch_guard(prompt_count: usize) -> Option { +fn batch_guard(prompt_count: usize, dependency_mode: Option<&str>) -> Option { if prompt_count == 0 { return Some(BatchResp { results: vec![] }); } @@ -245,6 +255,27 @@ fn batch_guard(prompt_count: usize) -> Option { .collect(), }); } + let mode = dependency_mode + .unwrap_or_default() + .trim() + .to_ascii_lowercase() + .replace(['-', ' '], "_"); + if !matches!( + mode.as_str(), + "independent" | "parallel_safe" | "map_reduce" + ) { + return Some(BatchResp { + results: (0..prompt_count) + .map(|_| SingleResp { + text: String::new(), + error: Some( + "batch requires dependency_mode='independent'; use sub_query_sequence or sequential sub_query calls for dependent work" + .to_string(), + ), + }) + .collect(), + }); + } None } @@ -263,15 +294,27 @@ impl RpcDispatcher for RlmBridge { } => { RpcResponse::Single(self.dispatch_llm(prompt, model, max_tokens, system).await) } - RpcRequest::LlmBatch { prompts, model } => { - RpcResponse::Batch(self.dispatch_llm_batch(prompts, model).await) - } + RpcRequest::LlmBatch { + prompts, + model, + dependency_mode, + safety_note: _, + } => RpcResponse::Batch( + self.dispatch_llm_batch(prompts, model, dependency_mode) + .await, + ), RpcRequest::Rlm { prompt, model } => { RpcResponse::Single(self.dispatch_rlm(prompt, model).await) } - RpcRequest::RlmBatch { prompts, model } => { - RpcResponse::Batch(self.dispatch_rlm_batch(prompts, model).await) - } + RpcRequest::RlmBatch { + prompts, + model, + dependency_mode, + safety_note: _, + } => RpcResponse::Batch( + self.dispatch_rlm_batch(prompts, model, dependency_mode) + .await, + ), } }) } @@ -317,18 +360,19 @@ mod tests { #[test] fn batch_guard_allows_non_empty_batches_at_the_cap() { - assert!(batch_guard(MAX_BATCH).is_none()); + assert!(batch_guard(MAX_BATCH, Some("independent")).is_none()); } #[test] fn batch_guard_returns_empty_response_for_empty_batches() { - let response = batch_guard(0).expect("empty batch should be handled"); + let response = batch_guard(0, None).expect("empty batch should be handled"); assert!(response.results.is_empty()); } #[test] fn batch_guard_returns_one_error_per_oversized_prompt() { - let response = batch_guard(MAX_BATCH + 2).expect("oversized batch should be handled"); + let response = batch_guard(MAX_BATCH + 2, Some("independent")) + .expect("oversized batch should be handled"); assert_eq!(response.results.len(), MAX_BATCH + 2); assert!(response.results.iter().all(|result| { result.text.is_empty() @@ -339,6 +383,28 @@ mod tests { })); } + #[test] + fn batch_guard_requires_explicit_independence_for_parallel_work() { + let response = batch_guard(2, None).expect("missing dependency mode should be handled"); + assert_eq!(response.results.len(), 2); + assert!(response.results.iter().all(|result| { + result.text.is_empty() + && result + .error + .as_deref() + .is_some_and(|err| err.contains("dependency_mode='independent'")) + })); + + let response = batch_guard(2, Some("sequential")) + .expect("dependent dependency mode should be handled"); + assert!(response.results.iter().all(|result| { + result + .error + .as_deref() + .is_some_and(|err| err.contains("sub_query_sequence")) + })); + } + #[tokio::test] async fn llm_dispatch_pins_configured_child_model() { let mock = Arc::new(MockLlmClient::new(Vec::new())); @@ -427,6 +493,8 @@ mod tests { .dispatch(RpcRequest::LlmBatch { prompts: vec!["a".to_string(), "b".to_string(), "c".to_string()], model: Some("batch-model".to_string()), + dependency_mode: Some("independent".to_string()), + safety_note: Some("test prompts are independent".to_string()), }) .await; diff --git a/crates/tui/src/rlm/prompt.rs b/crates/tui/src/rlm/prompt.rs index 91db97e3..42a00217 100644 --- a/crates/tui/src/rlm/prompt.rs +++ b/crates/tui/src/rlm/prompt.rs @@ -22,8 +22,9 @@ The REPL exposes: - `chunk(max_chars=20000, overlap=0)` - full-coverage chunks with index/start/end/text fields. - `chunk_coverage(chunks)` - coverage summary for chunks produced by `chunk`. - `sub_query(prompt, slice=None)` - one child LLM call, optionally scoped to one bounded slice. -- `sub_query_batch(prompt, slices)` - apply one prompt to many bounded slices concurrently. -- `sub_query_map(prompts, slices=None)` - run N distinct prompts, optionally paired with N bounded slices. +- `sub_query_batch(prompt, slices, dependency_mode="independent", safety_note="...")` - apply one prompt to many independent bounded slices concurrently. +- `sub_query_map(prompts, slices=None, dependency_mode="independent", safety_note="...")` - run N distinct independent prompts, optionally paired with N bounded slices. +- `sub_query_sequence(prompt, slices, carry_prompt=None)` - process dependent slices sequentially, feeding each child result into the next step. - `sub_rlm(prompt, source=None)` - recursive sub-RLM for a sub-task that needs its own decomposition. Pass a bounded source, not the whole body. - `SHOW_VARS()` - list user variables and their types. - `repl_set(name, value)` / `repl_get(name)` - explicit cross-round storage. @@ -60,11 +61,15 @@ partials = sub_query_batch( "Extract the facts needed for the user's question from this slice. " "Return only grounded facts and cite the slice index/range.", chunks, + dependency_mode="independent", + safety_note="each chunk is read-only evidence extraction; no step consumes another step's output", ) print({"coverage": coverage, "partials": len(partials)}) ``` Use deterministic Python first for counts, regex, parsing, sorting, dedupe, joins, and coverage. You do NO math by asking a child model to count; if Python can enumerate, parse, or simulate it exactly, do that in Python. +Parallel safety gate: `sub_query_batch`, `sub_query_map`, and low-level `*_batched` helpers are only for independent map-reduce work. Do not batch tasks where A's output feeds B, multi-file refactors with shared global state, database or schema migrations with ordered steps, rollback-sensitive edits, or any task that requires a sequential invariant. For dependent work, use `sub_query_sequence(...)` or an explicit Python `for` loop with `sub_query(...)`, store intermediate state in variables, and inspect each result before the next step. + 4. Recurse ```repl combined = "\n\n".join(partials) @@ -92,6 +97,7 @@ Rules - Use the bounded helpers (`context_meta`, `peek`, `search`, `chunk`) to inspect input. - Use `sub_query`, `sub_query_batch`, `sub_query_map`, or `sub_rlm` before finalizing unless the task is purely deterministic and fully computed in Python. +- Batch helpers require an explicit `dependency_mode="independent"` assertion. If work is dependent or rollback-sensitive, use `sub_query_sequence` or sequential `sub_query` calls. - End only by calling `finalize(value, confidence=...)`. - For exact counts, totals, parsing, and structured aggregates, compute with Python. Do not ask a child LLM to count. - For whole-input map-reduce, include coverage in the final answer: chunks processed, total chunks, and whether every char range was included. If you only processed a subset, say that explicitly. @@ -138,6 +144,7 @@ mod tests { "sub_query", "sub_query_batch", "sub_query_map", + "sub_query_sequence", "sub_rlm", "finalize", "evaluate_progress", @@ -174,6 +181,15 @@ mod tests { assert!(s.contains("chunks processed")); } + #[test] + fn rlm_prompt_requires_batch_dependency_safety() { + let s = body(); + assert!(s.contains("dependency_mode=\"independent\"")); + assert!(s.contains("sub_query_sequence")); + assert!(s.contains("database or schema migrations")); + assert!(s.contains("rollback-sensitive")); + } + #[test] fn rlm_prompt_mentions_symbolic_state_contract() { let s = body(); diff --git a/crates/tui/src/rlm/turn.rs b/crates/tui/src/rlm/turn.rs index 5f8a3881..7ade29e4 100644 --- a/crates/tui/src/rlm/turn.rs +++ b/crates/tui/src/rlm/turn.rs @@ -324,7 +324,8 @@ async fn run_rlm_turn_impl( text: "You called FINAL(...) without ever running a ```repl block. \ That defeats the recursive language model — you're guessing \ from the preview alone. Emit a ```repl block now that uses \ - `llm_query`, `llm_query_batched`, or `rlm_query` against \ + `llm_query`, `sub_query_sequence`, or an explicitly independent \ + `llm_query_batched(..., dependency_mode=\"independent\")` against \ `context` to actually compute the answer." .to_string(), cache_control: None, @@ -383,7 +384,8 @@ async fn run_rlm_turn_impl( role: "user".to_string(), content: vec![ContentBlock::Text { text: "Reminder: emit Python inside a ```repl … ``` fence. \ - Use `llm_query` / `llm_query_batched` / `rlm_query` to \ + Use `llm_query`, `sub_query_sequence`, or \ + `llm_query_batched(..., dependency_mode=\"independent\")` to \ process `context` and call `FINAL(value)` when done." .to_string(), cache_control: None, @@ -595,7 +597,7 @@ fn build_metadata_message( .to_string(), ); parts.push( - "- `llm_query_batched([p1, p2, ...])` — concurrent fan-out; `model` is ignored" + "- `llm_query_batched([p1, p2, ...], dependency_mode=\"independent\")` — concurrent fan-out for independent prompts only; `model` is ignored" .to_string(), ); parts.push( @@ -603,7 +605,15 @@ fn build_metadata_message( .to_string(), ); parts.push( - "- `rlm_query_batched([p1, p2, ...])` — concurrent recursive sub-RLMs; `model` is ignored" + "- `rlm_query_batched([p1, p2, ...], dependency_mode=\"independent\")` — concurrent recursive sub-RLMs for independent prompts only; `model` is ignored" + .to_string(), + ); + parts.push( + "- `sub_query_sequence(prompt, slices)` — sequential child calls for A->B dependencies and rollback-sensitive work" + .to_string(), + ); + parts.push( + "- Batch safety: never batch dependent steps, global-state refactors, schema migrations, or rollback-sensitive tasks" .to_string(), ); parts.push("- `SHOW_VARS()` — list user variables".to_string()); diff --git a/crates/tui/src/settings.rs b/crates/tui/src/settings.rs index 0aecd5a8..b55901d0 100644 --- a/crates/tui/src/settings.rs +++ b/crates/tui/src/settings.rs @@ -217,7 +217,7 @@ pub struct Settings { pub default_mode: String, /// Sidebar width as percentage of terminal width pub sidebar_width_percent: u16, - /// Sidebar focus mode: auto, work, tasks, agents, context + /// Sidebar focus mode: auto, work, tasks, agents, context, hidden pub sidebar_focus: String, /// Enable the session-context panel (#504). Shows working set, tokens, /// cost, MCP/LSP status, cycle count, and memory info. @@ -585,9 +585,10 @@ impl Settings { "tasks" => "tasks", "agents" | "subagents" | "sub-agents" => "agents", "context" | "session" => "context", + "hidden" | "hide" | "closed" | "off" | "none" => "hidden", _ => { anyhow::bail!( - "Failed to update setting: invalid sidebar focus '{value}'. Expected: auto, work, tasks, agents, context." + "Failed to update setting: invalid sidebar focus '{value}'. Expected: auto, work, tasks, agents, context, hidden." ) } }; @@ -768,7 +769,7 @@ impl Settings { ("sidebar_width", "Sidebar width percentage: 10-50"), ( "sidebar_focus", - "Sidebar focus: auto, work, tasks, agents, context", + "Sidebar focus: auto, work, tasks, agents, context, hidden", ), ( "context_panel", @@ -934,6 +935,7 @@ fn normalize_sidebar_focus(value: &str) -> &str { "tasks" => "tasks", "agents" | "subagents" | "sub-agents" => "agents", "context" | "session" => "context", + "hidden" | "hide" | "closed" | "off" | "none" => "hidden", _ => "auto", } } @@ -1092,6 +1094,12 @@ mod tests { settings.set("focus", "context").expect("context focus"); assert_eq!(settings.sidebar_focus, "context"); + settings.set("focus", "hidden").expect("hidden focus"); + assert_eq!(settings.sidebar_focus, "hidden"); + + settings.set("focus", "off").expect("off alias"); + assert_eq!(settings.sidebar_focus, "hidden"); + let err = settings .set("sidebar_focus", "classic") .expect_err("classic is not a supported public focus"); diff --git a/crates/tui/src/tools/rlm.rs b/crates/tui/src/tools/rlm.rs index 19d9b917..086181e8 100644 --- a/crates/tui/src/tools/rlm.rs +++ b/crates/tui/src/tools/rlm.rs @@ -161,7 +161,9 @@ impl ToolSpec for RlmEvalTool { "Run one Python REPL block against a named RLM context. Returns a \ bounded projection of stdout/stderr plus metadata. If the code calls \ FINAL/finalize, the final value is stored as a var_handle retrievable \ - with handle_read instead of copied unbounded into the parent context." + with handle_read instead of copied unbounded into the parent context. \ + Batch child helpers require dependency_mode='independent'; use \ + sub_query_sequence or a sequential loop for dependent work." } fn input_schema(&self) -> Value { diff --git a/crates/tui/src/tools/subagent/mod.rs b/crates/tui/src/tools/subagent/mod.rs index fda1926f..e4ffe006 100644 --- a/crates/tui/src/tools/subagent/mod.rs +++ b/crates/tui/src/tools/subagent/mod.rs @@ -3264,25 +3264,30 @@ pub(crate) fn emit_parent_completion( /// Build a `` JSON sentinel for a successful child. /// Intended to surface in the parent's transcript so the model recognizes /// child completion and can decide whether to read the full result via -/// `agent_result`. +/// `agent_eval`. +/// +/// Keep this payload deliberately lean. The human summary is emitted on the +/// line immediately before the sentinel; duplicating it here bloats the next +/// parent request's cache-miss tail. Wall-clock duration is useful UI +/// telemetry, but it is volatile and not useful for model coordination. fn subagent_done_sentinel(agent_id: &str, res: &SubAgentResult) -> String { let payload = json!({ "agent_id": agent_id, "agent_type": res.agent_type.as_str(), "status": subagent_status_name(&res.status), - "duration_ms": res.duration_ms, - "steps": res.steps_taken, - "summary": summarize_subagent_result(res), + "summary_location": "previous_line", + "details": "agent_eval", }); format!("{payload}") } /// Build a `` sentinel for a failed child. -fn subagent_failed_sentinel(agent_id: &str, err: &str) -> String { +fn subagent_failed_sentinel(agent_id: &str, _err: &str) -> String { let payload = json!({ "agent_id": agent_id, "status": "failed", - "error": err, + "error_location": "previous_line", + "details": "agent_eval", }); format!("{payload}") } diff --git a/crates/tui/src/tools/subagent/tests.rs b/crates/tui/src/tools/subagent/tests.rs index b094190f..3b0f097e 100644 --- a/crates/tui/src/tools/subagent/tests.rs +++ b/crates/tui/src/tools/subagent/tests.rs @@ -1076,6 +1076,11 @@ fn subagent_done_sentinel_format_is_well_formed() { assert_eq!(parsed["agent_id"], "agent_xyz"); assert_eq!(parsed["status"], "completed"); assert_eq!(parsed["agent_type"], "general"); + assert_eq!(parsed["summary_location"], "previous_line"); + assert_eq!(parsed["details"], "agent_eval"); + assert!(parsed.get("summary").is_none()); + assert!(parsed.get("duration_ms").is_none()); + assert!(parsed.get("steps").is_none()); } #[test] @@ -1087,7 +1092,9 @@ fn subagent_failed_sentinel_format_is_well_formed() { let parsed: serde_json::Value = serde_json::from_str(inner).expect("inner JSON parses"); assert_eq!(parsed["agent_id"], "agent_zzz"); assert_eq!(parsed["status"], "failed"); - assert_eq!(parsed["error"], "boom"); + assert_eq!(parsed["error_location"], "previous_line"); + assert_eq!(parsed["details"], "agent_eval"); + assert!(parsed.get("error").is_none()); } #[test] @@ -1733,4 +1740,8 @@ fn subagent_completion_payload_carries_existing_sentinel_format() { second.contains("\"agent_id\":\"agent_test\""), "sentinel JSON includes agent_id" ); + assert!( + !second.contains("Found three errors."), + "sentinel should not duplicate the human summary line" + ); } diff --git a/crates/tui/src/tui/app.rs b/crates/tui/src/tui/app.rs index 6b030262..4802f68a 100644 --- a/crates/tui/src/tui/app.rs +++ b/crates/tui/src/tui/app.rs @@ -232,6 +232,7 @@ pub enum SidebarFocus { Tasks, Agents, Context, + Hidden, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -278,6 +279,7 @@ impl SidebarFocus { "tasks" => Self::Tasks, "agents" | "subagents" | "sub-agents" => Self::Agents, "context" | "session" => Self::Context, + "hidden" | "hide" | "closed" | "off" | "none" => Self::Hidden, _ => Self::Auto, } } @@ -291,6 +293,7 @@ impl SidebarFocus { Self::Tasks => "tasks", Self::Agents => "agents", Self::Context => "context", + Self::Hidden => "hidden", } } } @@ -4275,7 +4278,10 @@ mod tests { assert_eq!(SidebarFocus::from_setting("tasks"), SidebarFocus::Tasks); assert_eq!(SidebarFocus::from_setting("agents"), SidebarFocus::Agents); assert_eq!(SidebarFocus::from_setting("context"), SidebarFocus::Context); + assert_eq!(SidebarFocus::from_setting("hidden"), SidebarFocus::Hidden); + assert_eq!(SidebarFocus::from_setting("off"), SidebarFocus::Hidden); assert_eq!(SidebarFocus::Work.as_setting(), "work"); + assert_eq!(SidebarFocus::Hidden.as_setting(), "hidden"); } #[test] diff --git a/crates/tui/src/tui/composer_ui.rs b/crates/tui/src/tui/composer_ui.rs new file mode 100644 index 00000000..dd1b3f61 --- /dev/null +++ b/crates/tui/src/tui/composer_ui.rs @@ -0,0 +1,141 @@ +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + +use crate::tui::app::App; + +const COMPOSER_ARROW_SCROLL_LINES: usize = 3; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum EscapeAction { + CloseSlashMenu, + CancelRequest, + DiscardQueuedDraft, + ClearInput, + Noop, +} + +pub(crate) fn next_escape_action(app: &App, slash_menu_open: bool) -> EscapeAction { + if slash_menu_open { + EscapeAction::CloseSlashMenu + } else if app.is_loading { + EscapeAction::CancelRequest + } else if app.queued_draft.is_some() && app.input.is_empty() { + EscapeAction::DiscardQueuedDraft + } else if !app.input.is_empty() { + EscapeAction::ClearInput + } else { + EscapeAction::Noop + } +} + +pub(crate) fn select_previous_slash_menu_entry(app: &mut App, entry_count: usize) { + if entry_count == 0 { + return; + } + let selected = app.slash_menu_selected.min(entry_count.saturating_sub(1)); + app.slash_menu_selected = (selected + entry_count - 1) % entry_count; +} + +pub(crate) fn select_next_slash_menu_entry(app: &mut App, entry_count: usize) { + if entry_count == 0 { + return; + } + let selected = app.slash_menu_selected.min(entry_count.saturating_sub(1)); + app.slash_menu_selected = (selected + 1) % entry_count; +} + +pub(crate) fn handle_composer_history_arrow( + app: &mut App, + key: KeyEvent, + slash_menu_open: bool, + mention_menu_open: bool, +) -> bool { + if slash_menu_open || mention_menu_open { + return false; + } + if key.modifiers.contains(KeyModifiers::ALT) || key.modifiers.contains(KeyModifiers::SUPER) { + return false; + } + + // When `composer_arrows_scroll` is enabled and the composer is empty, + // plain Up/Down scroll the transcript. This helps terminals that map + // trackpad gestures to arrow keys. Otherwise arrows always navigate + // input history regardless of composer state (#1117). + let scroll_on_empty = app.composer_arrows_scroll && app.input.trim().is_empty(); + + match key.code { + KeyCode::Up => { + if scroll_on_empty { + app.scroll_up(COMPOSER_ARROW_SCROLL_LINES); + } else { + app.history_up(); + } + true + } + KeyCode::Down => { + if scroll_on_empty { + app.scroll_down(COMPOSER_ARROW_SCROLL_LINES); + } else { + app.history_down(); + } + true + } + _ => false, + } +} + +pub(crate) fn is_word_cursor_modifier(modifiers: KeyModifiers) -> bool { + modifiers.contains(KeyModifiers::CONTROL) || modifiers.contains(KeyModifiers::ALT) +} + +pub(crate) fn is_composer_newline_key(key: KeyEvent) -> bool { + match key.code { + KeyCode::Char('j') => key.modifiers.contains(KeyModifiers::CONTROL), + KeyCode::Enter => { + key.modifiers.contains(KeyModifiers::ALT) + || (key.modifiers.contains(KeyModifiers::SHIFT) + && !key.modifiers.contains(KeyModifiers::CONTROL)) + } + _ => false, + } +} + +pub(crate) fn handle_history_search_key(app: &mut App, key: KeyEvent) { + match key.code { + KeyCode::Enter => { + let _ = app.accept_history_search(); + } + KeyCode::Esc => { + app.cancel_history_search(); + } + KeyCode::Char('c') | KeyCode::Char('C') + if key.modifiers.contains(KeyModifiers::CONTROL) => + { + app.cancel_history_search(); + } + KeyCode::Backspace => { + app.history_search_backspace(); + } + KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => { + while app + .history_search_query() + .is_some_and(|query| !query.is_empty()) + { + app.history_search_backspace(); + } + } + KeyCode::Up => { + app.history_search_select_previous(); + } + KeyCode::Down => { + app.history_search_select_next(); + } + KeyCode::Char(ch) + if key.modifiers.is_empty() + || key.modifiers == KeyModifiers::SHIFT + || key.modifiers == KeyModifiers::NONE => + { + app.history_search_insert_char(ch); + } + _ => {} + } +} diff --git a/crates/tui/src/tui/file_tree.rs b/crates/tui/src/tui/file_tree.rs index 9a1309cf..34cd1486 100644 --- a/crates/tui/src/tui/file_tree.rs +++ b/crates/tui/src/tui/file_tree.rs @@ -18,7 +18,7 @@ use ratatui::{ use crate::deepseek_theme::Theme; use crate::palette; -use crate::tui::ui::truncate_line_to_width; +use crate::tui::ui_text::truncate_line_to_width; // --------------------------------------------------------------------------- // Public API diff --git a/crates/tui/src/tui/footer_ui.rs b/crates/tui/src/tui/footer_ui.rs new file mode 100644 index 00000000..8086076c --- /dev/null +++ b/crates/tui/src/tui/footer_ui.rs @@ -0,0 +1,764 @@ +use ratatui::{Frame, layout::Rect, style::Style, text::Span}; +use std::time::Instant; +#[cfg(test)] +use unicode_width::UnicodeWidthStr; + +use crate::core::coherence::CoherenceState; +use crate::palette; +use crate::tools::subagent::SubAgentStatus; +use crate::tui::app::App; +use crate::tui::format_helpers; +use crate::tui::history::{HistoryCell, ToolCell, ToolStatus, summarize_tool_output}; +use crate::tui::key_shortcuts; +use crate::tui::subagent_routing::{active_fanout_counts, running_agent_count}; +use crate::tui::ui::{ + active_foreground_shell_running, context_usage_snapshot, selected_detail_footer_label, + status_color, +}; +use crate::tui::ui_text::truncate_line_to_width; +use crate::tui::widgets::{FooterProps, FooterToast, FooterWidget, Renderable}; +use crate::tui::workspace_context; + +pub(crate) fn render_footer(f: &mut Frame, area: Rect, app: &mut App) { + if area.width == 0 || area.height == 0 { + return; + } + + // Pull in the toast first so we don't re-borrow `app` mutably mid-build, + // then build the FooterProps once. The widget itself is a pure render — + // it owns no `App` knowledge; all width-aware layout lives in the widget. + // + // The quit-confirmation prompt takes precedence over normal status toasts + // because it represents a transient instruction the user must respond to + // within ~2s. Mirrors codex-rs's `FooterMode::QuitShortcutReminder`. + let quit_prompt = if app.quit_is_armed() { + Some(FooterToast { + text: crate::localization::tr( + app.ui_locale, + crate::localization::MessageId::FooterPressCtrlCAgain, + ) + .to_string(), + color: palette::STATUS_WARNING, + }) + } else { + None + }; + let toast = quit_prompt.or_else(|| { + app.active_status_toast().map(|toast| FooterToast { + text: toast.text, + color: status_color(toast.level), + }) + }); + + // Drive every cluster from the user's configured `status_items`. Mode + // and Model are always rendered by `FooterProps` itself (their position + // is structural — cluster gating is handled by the widget), so we only + // gate the optional clusters here. If a variant is missing from + // `status_items`, its span vec stays empty and the footer hides it. + let mut props = render_footer_from(app, &app.status_items, toast); + // FooterProps is mut so the working-strip animation can layer on top. + + // Animate the spacer between the left status line and the right-hand + // chips whenever a turn is live: model loading/streaming, compacting, or + // sub-agents in flight. The spout strip is gated on `fancy_animations` + // (the "do I want a whale at all" knob); `low_motion` now governs only + // streaming pacing (typewriter vs upstream), not the spout. Dot-pulse + // counter ticks every 400 ms so `working` → `working...` reads at a + // calm pace regardless of motion mode. + if footer_working_strip_active(app) { + let now_ms = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis() as u64) + .unwrap_or(0); + let dot_frame = now_ms / 400; + // Surface one compact live status row in the footer whenever a turn + // is live. Tool turns get the current action plus active/done counts; + // non-tool work falls back to the existing dot-pulse label. + props.state_label = active_subagent_status_label(app) + .or_else(|| active_tool_status_label(app)) + .unwrap_or_else(|| crate::tui::widgets::footer_working_label(dot_frame, app.ui_locale)); + props.state_color = palette::DEEPSEEK_SKY; + + // Water-spout frame source: wall-clock milliseconds. The sine-wave + // math in `footer_working_strip_glyph_at` was tuned for this cadence + // (`t = frame / 1000.0`, primary term × 8.0 ≈ 1.3 Hz at 1 ms ticks), + // so frame must advance at ~1000 units/sec to produce the intended + // animation feel. `fancy_animations = false` hides the strip + // entirely; the textual `working...` pulse still keeps a heartbeat + // regardless. + if app.fancy_animations { + props.working_strip_frame = Some(now_ms); + } + } else if props.state_label == "ready" + && let Some(label) = selected_detail_footer_label(app) + { + props.state_label = label; + props.state_color = palette::TEXT_MUTED; + } + + let widget = FooterWidget::new(props); + let buf = f.buffer_mut(); + widget.render(area, buf); +} + +/// Whether the footer should animate the water-spout strip. Driven by the +/// underlying live-work flags so the strip stays visible for the *entire* +/// turn — not just the moments where bytes are streaming. `is_loading` can +/// flicker off between LLM rounds within a single turn (tool execution, +/// reasoning replay, capacity refresh, etc.), so we ALSO gate on the turn +/// itself still being in flight via `runtime_turn_status == "in_progress"`. +/// Without that, the user sees the strip vanish for seconds at a time even +/// though the agent is still working. +pub(crate) fn footer_working_strip_active(app: &App) -> bool { + let turn_in_progress = app.runtime_turn_status.as_deref() == Some("in_progress"); + app.is_loading || app.is_compacting || running_agent_count(app) > 0 || turn_in_progress +} + +pub(crate) fn is_noisy_subagent_progress(status: &str) -> bool { + let status = status.trim().to_ascii_lowercase(); + status.contains("requesting model response") +} + +pub(crate) fn subagent_objective_summary(app: &App, id: &str) -> Option { + app.subagent_cache + .iter() + .find(|agent| agent.agent_id == id) + .map(|agent| summarize_tool_output(&agent.assignment.objective)) + .filter(|summary| !summary.is_empty()) +} + +pub(crate) fn friendly_subagent_progress(app: &App, id: &str, status: &str) -> String { + if !is_noisy_subagent_progress(status) { + return summarize_tool_output(status); + } + + if let Some(summary) = subagent_objective_summary(app, id) { + return format!("working on {summary}"); + } + if let Some(existing) = app.agent_progress.get(id) + && !is_noisy_subagent_progress(existing) + && existing != "working" + { + return existing.clone(); + } + "working".to_string() +} + +pub(crate) fn active_subagent_status_label(app: &App) -> Option { + let running = running_agent_count(app); + let fanout = active_fanout_counts(app); + let (display_running, total) = if let Some((fanout_running, fanout_total)) = fanout { + if fanout_running == 0 { + return None; + } + (fanout_running, fanout_total) + } else { + if running == 0 { + return None; + } + (running, running) + }; + let detail = app + .subagent_cache + .iter() + .find(|agent| matches!(agent.status, SubAgentStatus::Running)) + .map(|agent| summarize_tool_output(&agent.assignment.objective)) + .filter(|summary| !summary.is_empty()) + .or_else(|| { + app.agent_progress + .values() + .find(|value| !is_noisy_subagent_progress(value) && value.as_str() != "working") + .cloned() + }) + .unwrap_or_else(|| "working".to_string()); + let detail = truncate_line_to_width(&detail, 34); + let elapsed = app + .agent_activity_started_at + .or(app.turn_started_at) + .map(|started| format!("{}s", started.elapsed().as_secs())); + + let mut parts = vec![format!("agents {display_running}/{total}"), detail]; + if let Some(elapsed) = elapsed { + parts.push(elapsed); + } + parts.push("Alt+4".to_string()); + Some(parts.join(" \u{00B7} ")) +} + +#[derive(Default)] +struct ActiveToolStatusSnapshot { + primary_running: Option, + primary_any: Option, + running: usize, + completed: usize, + started_at: Option, +} + +impl ActiveToolStatusSnapshot { + fn record(&mut self, label: String, status: ToolStatus, started_at: Option) { + if self.primary_any.is_none() { + self.primary_any = Some(label.clone()); + } + if status == ToolStatus::Running { + self.running += 1; + if self.primary_running.is_none() { + self.primary_running = Some(label); + } + } else { + self.completed += 1; + } + if let Some(started) = started_at { + self.started_at = Some(match self.started_at { + Some(current) => current.min(started), + None => started, + }); + } + } + + fn total(&self) -> usize { + self.running + self.completed + } +} + +pub(crate) fn active_tool_status_label(app: &App) -> Option { + let active = app.active_cell.as_ref()?; + if active.is_empty() { + return None; + } + + let mut snapshot = ActiveToolStatusSnapshot::default(); + for cell in active.entries() { + collect_active_tool_status(cell, &mut snapshot); + } + if snapshot.total() == 0 { + return None; + } + + let primary = snapshot + .primary_running + .or(snapshot.primary_any) + .unwrap_or_else(|| "tools".to_string()); + let primary = truncate_line_to_width(&primary, 30); + let elapsed = snapshot + .started_at + .or(app.turn_started_at) + .map(|started| format!("{}s", started.elapsed().as_secs())); + + let mut parts = vec![ + primary, + format!("{} active", snapshot.running), + format!("{} done", snapshot.completed), + ]; + if let Some(elapsed) = elapsed { + parts.push(elapsed); + } + if active_foreground_shell_running(app) { + parts.push("Ctrl+B shell".to_string()); + } + parts.push(key_shortcuts::tool_details_shortcut_label().to_string()); + Some(parts.join(" \u{00B7} ")) +} + +fn collect_active_tool_status(cell: &HistoryCell, snapshot: &mut ActiveToolStatusSnapshot) { + let HistoryCell::Tool(tool) = cell else { + return; + }; + match tool { + ToolCell::Exec(exec) => snapshot.record( + format!("run {}", one_line_summary(&exec.command, 80)), + exec.status, + exec.started_at, + ), + ToolCell::Exploring(explore) => { + for entry in &explore.entries { + snapshot.record( + format!("read {}", one_line_summary(&entry.label, 80)), + entry.status, + None, + ); + } + } + ToolCell::PlanUpdate(plan) => { + snapshot.record("update plan".to_string(), plan.status, None); + } + ToolCell::PatchSummary(patch) => { + snapshot.record(format!("patch {}", patch.path), patch.status, None); + } + ToolCell::Review(review) => { + let target = one_line_summary(&review.target, 80); + let label = if target.is_empty() { + "review".to_string() + } else { + format!("review {target}") + }; + snapshot.record(label, review.status, None); + } + ToolCell::DiffPreview(diff) => { + snapshot.record(format!("diff {}", diff.title), ToolStatus::Success, None); + } + ToolCell::Mcp(mcp) => snapshot.record(format!("tool {}", mcp.tool), mcp.status, None), + ToolCell::ViewImage(image) => snapshot.record( + format!("image {}", image.path.display()), + ToolStatus::Success, + None, + ), + ToolCell::WebSearch(search) => { + snapshot.record(format!("search {}", search.query), search.status, None); + } + ToolCell::Generic(generic) => { + // Sub-agent dispatch represents itself through the DelegateCard + // + Agents sidebar. Counting it again here would duplicate the + // status. RLM is different today: it is a foreground tool call, + // so keep it in the live tool footer until the async RLM + // workbench lands (#513). + if matches!(generic.name.as_str(), "agent_open" | "agent_spawn") { + return; + } + snapshot.record(format!("tool {}", generic.name), generic.status, None); + } + } +} + +pub(crate) fn one_line_summary(text: &str, max_width: usize) -> String { + truncate_line_to_width( + &text.split_whitespace().collect::>().join(" "), + max_width, + ) +} + +/// Build [`FooterProps`] from a user-configured `status_items` slice. +/// +/// Variants are routed to their structural cluster: `Mode` and `Model` are +/// always emitted (the widget needs them to lay out the line correctly even +/// when the user toggled them off the picker — we honour the toggle by +/// blanking their visible content rather than collapsing the layout). +/// `Cost` and `Status` belong in the left cluster; the rest in the right. +/// +/// A variant absent from `items` produces an empty span vec, which the +/// footer widget already hides cleanly. This keeps the renderer fully +/// data-driven without changing `FooterProps`'s public shape. +pub(crate) fn render_footer_from( + app: &App, + items: &[crate::config::StatusItem], + toast: Option, +) -> FooterProps { + use crate::config::StatusItem as S; + let has = |item: S| items.contains(&item); + + let (state_label, state_color) = if has(S::Status) { + footer_state_label(app) + } else { + // "ready" is the sentinel the widget uses to skip the status segment; + // pair it with theme text_muted for visual neutrality. + ("ready", app.ui_theme.text_muted) + }; + + let coherence = if has(S::Coherence) { + footer_coherence_spans(app) + } else { + Vec::new() + }; + let agents = if has(S::Agents) { + crate::tui::widgets::footer_agents_chip(running_agent_count(app), app.ui_locale) + } else { + Vec::new() + }; + let reasoning_replay = if has(S::ReasoningReplay) { + footer_reasoning_replay_spans(app) + } else { + Vec::new() + }; + let cache = Vec::new(); + let cache_chip = if has(S::Cache) { + footer_cache_spans(app) + } else { + Vec::new() + }; + let prefix_stability = if has(S::PrefixStability) { + footer_prefix_stability_spans(app) + } else { + Vec::new() + }; + let cost = if has(S::Cost) { + footer_cost_spans(app) + } else { + Vec::new() + }; + + // Build the props; `Mode` and `Model` toggles modulate downstream by + // blanking the rendered text rather than restructuring the widget — the + // user is opting out of the chip, not destroying the bar. + let mut props = FooterProps::from_app( + app, + toast, + state_label, + state_color, + coherence, + agents, + reasoning_replay, + cache, + cost, + ); + if !has(S::Mode) { + props.mode_label = ""; + } + if !has(S::Model) { + props.model.clear(); + } + + // Right-cluster extension chips: append in `items` order so user + // ordering is preserved across the new variants. + let mut extra: Vec> = Vec::new(); + for item in items { + let chip = match *item { + S::PrefixStability => prefix_stability.clone(), + S::Cache => cache_chip.clone(), + S::ContextPercent => footer_context_percent_spans(app), + S::GitBranch => footer_git_branch_spans(app), + S::LastToolElapsed | S::RateLimit => Vec::new(), + _ => continue, + }; + if chip.is_empty() { + continue; + } + if !extra.is_empty() { + extra.push(Span::raw(" ")); + } + extra.extend(chip); + } + if !extra.is_empty() { + // Stack into the cache slot — last existing right-cluster pipe — so + // they appear adjacent without changing FooterProps's API. Chips are + // appended in `items` order, so users can place prefix stability next + // to cache telemetry without adding another FooterProps field. + if !props.cache.is_empty() { + props.cache.push(Span::raw(" ")); + } + props.cache.extend(extra); + } + + props +} + +pub(crate) fn footer_git_branch_spans(app: &App) -> Vec> { + let Some(branch) = workspace_context::branch(&app.workspace) else { + return Vec::new(); + }; + vec![Span::styled( + branch, + Style::default().fg(app.ui_theme.text_muted), + )] +} + +pub(crate) fn footer_prefix_stability_spans(app: &App) -> Vec> { + let Some((label, color)) = format_helpers::prefix_stability_chip(app) else { + return Vec::new(); + }; + vec![Span::styled(label, Style::default().fg(color))] +} + +/// Spans for the "context %" footer chip. Mirrors the header colour ramp so +/// the two surfaces stay visually consistent when both are enabled. +pub(crate) fn footer_context_percent_spans(app: &App) -> Vec> { + let Some((_, _, percent)) = context_usage_snapshot(app) else { + return Vec::new(); + }; + let color = if percent >= 95.0 { + palette::STATUS_ERROR + } else if percent >= 85.0 { + palette::STATUS_WARNING + } else { + palette::TEXT_MUTED + }; + vec![Span::styled( + format!("active ctx {percent:.0}%"), + Style::default().fg(color), + )] +} + +pub(crate) fn footer_cost_spans(app: &App) -> Vec> { + let displayed_cost = app.displayed_session_cost_for_currency(app.cost_currency); + if !should_show_footer_cost(displayed_cost) { + return Vec::new(); + } + vec![Span::styled( + app.format_cost_amount(displayed_cost), + Style::default().fg(palette::TEXT_MUTED), + )] +} + +pub(crate) fn should_show_footer_cost(displayed_cost: f64) -> bool { + displayed_cost.is_finite() && displayed_cost > 0.0 +} + +/// Test-only helper retained as a parity reference for `FooterWidget`'s +/// auxiliary-span composition. Production rendering is performed by the +/// widget itself; the existing footer parity tests still exercise this +/// function directly to guard against drift. +#[cfg(test)] +pub(crate) fn footer_auxiliary_spans(app: &App, max_width: usize) -> Vec> { + // Context % is already shown in the header signal bar — don't + // duplicate it in the footer. The footer carries unique info only: + // prefix stability, coherence, in-flight sub-agents, reasoning + // replay tokens, cache hit rate, and session cost. + let coherence_spans = footer_coherence_spans(app); + let agents_spans = + crate::tui::widgets::footer_agents_chip(running_agent_count(app), app.ui_locale); + let replay_spans = footer_reasoning_replay_spans(app); + let cache_spans = footer_cache_spans(app); + let cost_spans = footer_cost_spans(app); + let prefix_spans = app + .prefix_stability_pct + .map(|_| { + let (label, color) = format_helpers::prefix_stability_chip(app) + .unwrap_or(("P --".to_string(), ratatui::style::Color::DarkGray)); + vec![Span::styled(label, Style::default().fg(color))] + }) + .unwrap_or_default(); + + let parts: Vec<&Vec>> = [ + &coherence_spans, + &agents_spans, + &replay_spans, + &prefix_spans, + &cache_spans, + &cost_spans, + ] + .iter() + .filter(|spans| !spans.is_empty()) + .copied() + .collect(); + + // Try to fit as many parts as possible, dropping from the end. + for end in (0..=parts.len()).rev() { + let mut combined = Vec::new(); + for (i, part) in parts[..end].iter().enumerate() { + if i > 0 { + combined.push(Span::raw(" ")); + } + combined.extend(part.iter().cloned()); + } + if spans_width(&combined) <= max_width { + return combined; + } + } + Vec::new() +} + +pub(crate) fn footer_coherence_spans(app: &App) -> Vec> { + // Only surface coherence when the engine is actively intervening — the + // user-facing signal is "we're doing something different now," not + // "your conversation is getting complex," which the context-percent + // header already covers. `GettingCrowded` is just a soft hint, so we + // suppress it; the active interventions get their own visible label. + let (label, color) = match app.coherence_state { + CoherenceState::Healthy | CoherenceState::GettingCrowded => return Vec::new(), + CoherenceState::RefreshingContext => ("refreshing context", palette::STATUS_WARNING), + CoherenceState::VerifyingRecentWork => ("verifying", palette::DEEPSEEK_SKY), + CoherenceState::ResettingPlan => ("resetting plan", palette::STATUS_ERROR), + }; + + vec![Span::styled(label.to_string(), Style::default().fg(color))] +} + +pub(crate) fn footer_cache_spans(app: &App) -> Vec> { + if app.session.last_prompt_tokens.is_none() && app.session.last_completion_tokens.is_none() { + return Vec::new(); + }; + let Some(hit_tokens) = app.session.last_prompt_cache_hit_tokens else { + return vec![Span::styled( + "Cache: unavailable", + Style::default().fg(palette::TEXT_MUTED), + )]; + }; + let miss_tokens = app + .session + .last_prompt_cache_miss_tokens + .unwrap_or_else(|| { + app.session + .last_prompt_tokens + .unwrap_or(0) + .saturating_sub(hit_tokens) + }); + let total = hit_tokens.saturating_add(miss_tokens); + let percent = if total == 0 { + 0.0 + } else { + (f64::from(hit_tokens) / f64::from(total) * 100.0).clamp(0.0, 100.0) + }; + // Threshold-based coloring for cache hit rate (#396): + // >80%: green (good cache utilization) + // 40-80%: yellow/warning + // <40%: red/dimmed only when the stable prefix is also suspect. + // + // A stable prefix with a low hit rate usually means the latest request + // contains a large new tail (tool results, sub-agent summaries, or fresh + // user input), not that the cacheable prefix is churning. + let prefix_is_stable = app + .prefix_stability_pct + .is_some_and(|pct| pct >= 95 && app.prefix_change_count == 0); + let color = if percent > 80.0 { + palette::STATUS_SUCCESS + } else if percent >= 40.0 { + palette::STATUS_WARNING + } else if prefix_is_stable { + palette::TEXT_MUTED + } else { + palette::STATUS_ERROR + }; + vec![Span::styled( + format!( + "Cache: {:.1}% hit | hit {hit_tokens} | miss {miss_tokens}", + percent + ), + Style::default().fg(color), + )] +} + +/// Render a footer chip showing the size of the `reasoning_content` block +/// replayed on the most recent thinking-mode tool-calling turn (#30). +/// +/// Stays hidden when the count is zero (non-thinking models, first turn, or +/// turns with no tool calls). When replay tokens dominate the input budget +/// (>50%), the chip turns warning-coloured so users notice that thinking +/// replay is the main consumer of context. +pub(crate) fn footer_reasoning_replay_spans(app: &App) -> Vec> { + let Some(replay) = app.session.last_reasoning_replay_tokens else { + return Vec::new(); + }; + if replay == 0 { + return Vec::new(); + } + let label = format!("rsn {}", format_token_count_compact(u64::from(replay))); + let color = match app.session.last_prompt_tokens { + Some(input) if input > 0 && f64::from(replay) / f64::from(input) > 0.5 => { + palette::STATUS_WARNING + } + _ => palette::TEXT_MUTED, + }; + vec![Span::styled(label, Style::default().fg(color))] +} + +#[cfg(test)] +pub(crate) fn footer_status_line_spans(app: &App, max_width: usize) -> Vec> { + if max_width == 0 { + return Vec::new(); + } + + let (mode_label, mode_color) = footer_mode_style(app); + let (status_label, status_color) = footer_state_label(app); + let sep = " \u{00B7} "; + let show_status = status_label != "ready"; + + let fixed_width = mode_label.width() + + sep.width() + + if show_status { + sep.width() + status_label.width() + } else { + 0 + }; + + if max_width <= mode_label.width() { + return vec![Span::styled( + truncate_line_to_width(mode_label, max_width), + Style::default().fg(mode_color), + )]; + } + + let model_budget = max_width.saturating_sub(fixed_width).max(1); + let model_label = truncate_line_to_width(&app.model, model_budget); + + let mut spans = vec![ + Span::styled(mode_label.to_string(), Style::default().fg(mode_color)), + Span::styled(sep.to_string(), Style::default().fg(app.ui_theme.text_dim)), + Span::styled(model_label, Style::default().fg(app.ui_theme.text_hint)), + ]; + + if show_status { + spans.push(Span::styled( + sep.to_string(), + Style::default().fg(app.ui_theme.text_dim), + )); + spans.push(Span::styled( + status_label.to_string(), + Style::default().fg(status_color), + )); + } + + spans +} + +pub(crate) fn footer_state_label(app: &App) -> (&'static str, ratatui::style::Color) { + if app.is_compacting { + return ("compacting \u{238B}", app.ui_theme.status_warning); + } + // Note: we deliberately do NOT show a "thinking" label for `is_loading`. + // The animated water-spout strip in the footer's spacer is the visual + // signal that the model is live; "thinking" was misleading because it + // fired for every kind of in-flight work (tool calls, streaming, etc.), + // not strictly reasoning. Sub-agents still surface "working" because + // that's a distinct lifecycle the user can act on (open `/agents`). + if running_agent_count(app) > 0 { + return ("working", app.ui_theme.status_working); + } + if app.queued_draft.is_some() { + return ("draft", app.ui_theme.text_muted); + } + + if !app.view_stack.is_empty() { + return ("overlay", app.ui_theme.text_muted); + } + + if !app.input.is_empty() { + return ("draft", app.ui_theme.text_muted); + } + + ("ready", app.ui_theme.status_ready) +} + +#[cfg(test)] +pub(crate) fn footer_mode_style(app: &App) -> (&'static str, ratatui::style::Color) { + let label = app.mode.as_setting(); + let color = match app.mode { + crate::tui::app::AppMode::Agent => app.ui_theme.mode_agent, + crate::tui::app::AppMode::Yolo => app.ui_theme.mode_yolo, + crate::tui::app::AppMode::Plan => app.ui_theme.mode_plan, + }; + (label, color) +} + +pub(crate) fn format_token_count_compact(tokens: u64) -> String { + if tokens >= 1_000_000 { + format!("{:.1}M", tokens as f64 / 1_000_000.0) + } else if tokens >= 1_000 { + format!("{:.1}k", tokens as f64 / 1_000.0) + } else { + tokens.to_string() + } +} + +#[cfg(test)] +pub(crate) fn format_context_budget(used: i64, max: u32) -> String { + let max_u64 = u64::from(max); + let max_i64 = i64::from(max); + + if used > max_i64 { + return format!( + ">{}/{}", + format_token_count_compact(max_u64), + format_token_count_compact(max_u64) + ); + } + + let used_u64 = u64::try_from(used.max(0)).unwrap_or(0); + format!( + "{}/{}", + format_token_count_compact(used_u64), + format_token_count_compact(max_u64) + ) +} + +#[cfg(test)] +pub(crate) fn spans_width(spans: &[Span<'_>]) -> usize { + spans.iter().map(|span| span.content.width()).sum() +} diff --git a/crates/tui/src/tui/keybindings.rs b/crates/tui/src/tui/keybindings.rs index 1b851b43..eb9fdc38 100644 --- a/crates/tui/src/tui/keybindings.rs +++ b/crates/tui/src/tui/keybindings.rs @@ -247,7 +247,7 @@ pub const KEYBINDINGS: &[KeybindingEntry] = &[ section: KeybindingSection::Modes, }, KeybindingEntry { - chord: "Alt+! / Alt+@ / Alt+# / Alt+$ / Alt+0", + chord: "Alt+! / Alt+@ / Alt+# / Alt+$ / Alt+0 / Ctrl+Alt+0", description_id: crate::localization::MessageId::KbFocusSidebar, section: KeybindingSection::Modes, }, diff --git a/crates/tui/src/tui/mod.rs b/crates/tui/src/tui/mod.rs index d11cf0e2..34b70ee2 100644 --- a/crates/tui/src/tui/mod.rs +++ b/crates/tui/src/tui/mod.rs @@ -17,8 +17,9 @@ pub mod approval; pub mod auto_router; pub mod backtrack; pub mod clipboard; -mod color_compat; +pub mod color_compat; pub mod command_palette; +pub mod composer_ui; pub mod context_inspector; pub mod context_menu; pub mod diff_render; @@ -30,6 +31,7 @@ pub mod file_mention; pub mod file_picker; pub mod file_picker_relevance; pub mod file_tree; +pub mod footer_ui; pub mod format_helpers; pub mod frame_rate_limiter; pub mod history; @@ -39,6 +41,7 @@ pub mod live_transcript; pub mod markdown_render; mod mcp_routing; pub mod model_picker; +pub mod mouse_ui; pub mod notifications; pub mod onboarding; pub mod osc8; diff --git a/crates/tui/src/tui/mouse_ui.rs b/crates/tui/src/tui/mouse_ui.rs new file mode 100644 index 00000000..589c31ae --- /dev/null +++ b/crates/tui/src/tui/mouse_ui.rs @@ -0,0 +1,640 @@ +use std::time::{Duration, Instant}; + +use crossterm::event::{MouseButton, MouseEvent, MouseEventKind}; +use ratatui::layout::Rect; + +use crate::tui::app::App; +use crate::tui::command_palette::{ + CommandPaletteView, build_entries as build_command_palette_entries, +}; +use crate::tui::context_menu::{ContextMenuEntry, ContextMenuView}; +use crate::tui::history::HistoryCell; +use crate::tui::scrolling::{ScrollDirection, TranscriptScroll}; +use crate::tui::selection::{SelectionAutoscroll, TranscriptSelectionPoint}; +use crate::tui::ui_text::{ + history_cell_to_text, line_to_plain, slice_text, text_display_width, truncate_line_to_width, +}; +use crate::tui::views::{ContextMenuAction, HelpView, ModalKind, ViewEvent}; + +// These functions will need to be imported from ui.rs or we can just import crate::tui::ui::*. +use crate::tui::ui::{ + copy_cell_to_clipboard, detail_target_label, open_context_inspector, + open_details_pager_for_cell, open_pager_for_selection, +}; + +pub(crate) fn should_drop_loading_mouse_motion(app: &App, mouse: MouseEvent) -> bool { + if !app.is_loading { + return false; + } + + match mouse.kind { + MouseEventKind::Moved => true, + MouseEventKind::Drag(_) => { + !app.viewport.transcript_selection.dragging + && !app.viewport.transcript_scrollbar_dragging + } + _ => false, + } +} + +pub(crate) fn handle_mouse_event(app: &mut App, mouse: MouseEvent) -> Vec { + if app.view_stack.top_kind() == Some(ModalKind::ContextMenu) { + if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Right)) { + app.view_stack.pop(); + open_context_menu(app, mouse); + return Vec::new(); + } + return app.view_stack.handle_mouse(mouse); + } + + if !app.view_stack.is_empty() { + app.needs_redraw = true; + return app.view_stack.handle_mouse(mouse); + } + + match mouse.kind { + MouseEventKind::ScrollUp => { + let update = app.viewport.mouse_scroll.on_scroll(ScrollDirection::Up); + app.viewport.pending_scroll_delta = app + .viewport + .pending_scroll_delta + .saturating_add(update.delta_lines); + if update.delta_lines != 0 { + app.user_scrolled_during_stream = true; + app.needs_redraw = true; + } + } + MouseEventKind::ScrollDown => { + let update = app.viewport.mouse_scroll.on_scroll(ScrollDirection::Down); + app.viewport.pending_scroll_delta = app + .viewport + .pending_scroll_delta + .saturating_add(update.delta_lines); + if update.delta_lines != 0 { + app.user_scrolled_during_stream = true; + app.needs_redraw = true; + } + } + MouseEventKind::Down(MouseButton::Left) => { + app.viewport.transcript_scrollbar_dragging = false; + app.viewport.selection_autoscroll = None; + + // Click on the transcript scrollbar gutter starts a scrollbar + // drag so the visible thumb remains interactive for users who + // prefer mouse-based navigation. + if mouse_hits_transcript_scrollbar(app, mouse) { + app.viewport.transcript_scrollbar_dragging = true; + return Vec::new(); + } + + if mouse_hits_rect(mouse, app.viewport.jump_to_latest_button_area) { + app.scroll_to_bottom(); + return Vec::new(); + } + + if let Some(point) = selection_point_from_mouse(app, mouse) { + app.viewport.transcript_selection.anchor = Some(point); + app.viewport.transcript_selection.head = Some(point); + app.viewport.transcript_selection.dragging = true; + + if app.is_loading + && app.viewport.transcript_scroll.is_at_tail() + && let Some(anchor) = TranscriptScroll::anchor_for( + app.viewport.transcript_cache.line_meta(), + app.viewport.last_transcript_top, + ) + { + app.viewport.transcript_scroll = anchor; + } + } else if app.viewport.transcript_selection.is_active() { + app.viewport.transcript_selection.clear(); + } + } + MouseEventKind::Drag(MouseButton::Left) => { + if app.viewport.transcript_scrollbar_dragging { + scroll_transcript_to_mouse_row(app, mouse.row); + return Vec::new(); + } + + if app.viewport.transcript_selection.dragging { + update_selection_drag(app, mouse); + } + } + MouseEventKind::Up(MouseButton::Left) if app.viewport.transcript_scrollbar_dragging => { + app.viewport.transcript_scrollbar_dragging = false; + app.viewport.selection_autoscroll = None; + app.needs_redraw = true; + } + MouseEventKind::Up(MouseButton::Left) if app.viewport.transcript_selection.dragging => { + app.viewport.transcript_selection.dragging = false; + app.viewport.selection_autoscroll = None; + if selection_has_content(app) { + copy_active_selection(app); + } + } + MouseEventKind::Down(MouseButton::Right) => { + open_context_menu(app, mouse); + } + _ => {} + } + + Vec::new() +} + +pub(crate) fn mouse_hits_transcript_scrollbar(app: &App, mouse: MouseEvent) -> bool { + let Some(area) = app.viewport.last_transcript_area else { + return false; + }; + if area.width <= 1 || app.viewport.last_transcript_total <= app.viewport.last_transcript_visible + { + return false; + } + + let scrollbar_col = area.x.saturating_add(area.width.saturating_sub(1)); + mouse.column == scrollbar_col + && mouse.row >= area.y + && mouse.row < area.y.saturating_add(area.height) +} + +pub(crate) fn scroll_transcript_to_mouse_row(app: &mut App, row: u16) -> bool { + let Some(area) = app.viewport.last_transcript_area else { + return false; + }; + let total = app.viewport.last_transcript_total; + let visible = app.viewport.last_transcript_visible; + if area.height == 0 || total <= visible { + return false; + } + + let max_start = total.saturating_sub(visible); + if max_start == 0 { + app.scroll_to_bottom(); + return true; + } + + let max_row = usize::from(area.height.saturating_sub(1)); + let relative_row = usize::from(row.saturating_sub(area.y)).min(max_row); + let numerator = relative_row + .saturating_mul(max_start) + .saturating_add(max_row / 2); + // Round to the nearest transcript offset so short thumbs still feel + // responsive on compact terminals. + let top = numerator.checked_div(max_row).unwrap_or(0); + + app.viewport.transcript_scroll = if top >= max_start { + TranscriptScroll::to_bottom() + } else { + TranscriptScroll::at_line(top) + }; + app.viewport.pending_scroll_delta = 0; + app.user_scrolled_during_stream = !app.viewport.transcript_scroll.is_at_tail(); + app.needs_redraw = true; + true +} + +/// Cadence between auto-scroll ticks while drag-selecting past the +/// transcript edge (#1163). 30 ms ≈ 33 lines/sec, comparable to the feel +/// of a steady scroll-wheel drag. +const SELECTION_AUTOSCROLL_INTERVAL: Duration = Duration::from_millis(30); + +/// Update the transcript selection while the left button is dragging. +/// When the mouse leaves the transcript rect vertically, arm +/// `selection_autoscroll` so the main loop can advance the viewport on a +/// fixed cadence; when the mouse returns inside, disarm it. +pub(crate) fn update_selection_drag(app: &mut App, mouse: MouseEvent) { + if let Some(point) = selection_point_from_mouse(app, mouse) { + app.viewport.transcript_selection.head = Some(point); + app.viewport.selection_autoscroll = None; + return; + } + + let Some(area) = app.viewport.last_transcript_area else { + return; + }; + if area.height == 0 || area.width == 0 { + return; + } + + let direction = if mouse.row < area.y { + -1 + } else if mouse.row >= area.y.saturating_add(area.height) { + 1 + } else { + // Outside horizontally only — leave selection head where it is. + return; + }; + + let max_col = area.x.saturating_add(area.width.saturating_sub(1)); + let column = mouse.column.clamp(area.x, max_col); + + // Fire on the next tick immediately by setting `next_tick` to now. + app.viewport.selection_autoscroll = Some(SelectionAutoscroll { + direction, + column, + next_tick: Instant::now(), + }); + app.needs_redraw = true; +} + +/// Advance the drag-edge auto-scroll one step if its cadence has elapsed. +/// Called once per main-loop iteration. +pub(crate) fn tick_selection_autoscroll(app: &mut App) { + let Some(state) = app.viewport.selection_autoscroll else { + return; + }; + + if !app.viewport.transcript_selection.dragging { + app.viewport.selection_autoscroll = None; + return; + } + + let Some(area) = app.viewport.last_transcript_area else { + return; + }; + if area.height == 0 { + return; + } + + let now = Instant::now(); + if now < state.next_tick { + return; + } + + app.viewport.pending_scroll_delta = app + .viewport + .pending_scroll_delta + .saturating_add(state.direction); + app.user_scrolled_during_stream = true; + + let edge_row = if state.direction < 0 { + area.y + } else { + area.y.saturating_add(area.height.saturating_sub(1)) + }; + if let Some(point) = selection_point_from_position( + area, + state.column, + edge_row, + app.viewport.last_transcript_top, + app.viewport.last_transcript_total, + app.viewport.last_transcript_padding_top, + ) { + app.viewport.transcript_selection.head = Some(point); + } + + app.viewport.selection_autoscroll = Some(SelectionAutoscroll { + next_tick: now + SELECTION_AUTOSCROLL_INTERVAL, + ..state + }); + app.needs_redraw = true; +} + +pub(crate) fn mouse_hits_rect(mouse: MouseEvent, area: Option) -> bool { + let Some(area) = area else { + return false; + }; + + mouse.column >= area.x + && mouse.column < area.x.saturating_add(area.width) + && mouse.row >= area.y + && mouse.row < area.y.saturating_add(area.height) +} + +pub(crate) fn open_context_menu(app: &mut App, mouse: MouseEvent) { + let entries = build_context_menu_entries(app, mouse); + if entries.is_empty() { + return; + } + app.view_stack + .push(ContextMenuView::new(entries, mouse.column, mouse.row)); + app.needs_redraw = true; +} + +pub(crate) fn build_context_menu_entries(app: &App, mouse: MouseEvent) -> Vec { + let mut entries = Vec::new(); + + if selection_has_content(app) { + entries.push(ContextMenuEntry { + label: "Copy selection".to_string(), + description: "write selected transcript text".to_string(), + action: ContextMenuAction::CopySelection, + }); + entries.push(ContextMenuEntry { + label: "Open selection".to_string(), + description: "show selected text in pager".to_string(), + action: ContextMenuAction::OpenSelection, + }); + entries.push(ContextMenuEntry { + label: "Clear selection".to_string(), + description: String::new(), + action: ContextMenuAction::ClearSelection, + }); + } + + if let Some(filtered_cell_index) = transcript_cell_index_from_mouse(app, mouse) { + // Convert filtered index → original virtual index using the + // mapping built in ChatWidget::new. When no cells are collapsed + // this is an identity mapping. + let cell_index = app + .collapsed_cell_map + .get(filtered_cell_index) + .copied() + .unwrap_or(filtered_cell_index); + + let target = detail_target_label(app, cell_index) + .map(|label| truncate_line_to_width(label.as_str(), 28)) + .unwrap_or_else(|| "message".to_string()); + entries.push(ContextMenuEntry { + label: "Open details".to_string(), + description: target, + action: ContextMenuAction::OpenDetails { cell_index }, + }); + entries.push(ContextMenuEntry { + label: "Copy message".to_string(), + description: "write clicked transcript cell".to_string(), + action: ContextMenuAction::CopyCell { cell_index }, + }); + entries.push(ContextMenuEntry { + label: "Open in editor".to_string(), + description: "open file:line in $EDITOR".to_string(), + action: ContextMenuAction::OpenFileAtLine { cell_index }, + }); + // Hide/show cell toggle. + if app.collapsed_cells.contains(&cell_index) { + entries.push(ContextMenuEntry { + label: "Show cell".to_string(), + description: "unhide this transcript cell".to_string(), + action: ContextMenuAction::ShowCell { cell_index }, + }); + } else { + entries.push(ContextMenuEntry { + label: "Hide cell".to_string(), + description: "collapse this transcript cell".to_string(), + action: ContextMenuAction::HideCell { cell_index }, + }); + } + } + + // When cells are hidden, offer a way to show them all. + if !app.collapsed_cells.is_empty() { + let count = app.collapsed_cells.len(); + entries.push(ContextMenuEntry { + label: format!("Show hidden ({count})"), + description: "unhide all collapsed cells".to_string(), + action: ContextMenuAction::ShowAllHidden, + }); + } + + entries.push(ContextMenuEntry { + label: "Paste".to_string(), + description: "insert clipboard into composer".to_string(), + action: ContextMenuAction::Paste, + }); + entries.push(ContextMenuEntry { + label: "Command palette".to_string(), + description: "commands, skills, and tools".to_string(), + action: ContextMenuAction::OpenCommandPalette, + }); + entries.push(ContextMenuEntry { + label: "Context inspector".to_string(), + description: "active context and cache hints".to_string(), + action: ContextMenuAction::OpenContextInspector, + }); + entries.push(ContextMenuEntry { + label: "Help".to_string(), + description: "keybindings and commands".to_string(), + action: ContextMenuAction::OpenHelp, + }); + + entries +} + +pub(crate) fn transcript_cell_index_from_mouse(app: &App, mouse: MouseEvent) -> Option { + let point = selection_point_from_mouse(app, mouse)?; + app.viewport + .transcript_cache + .line_meta() + .get(point.line_index) + .and_then(|meta| meta.cell_line()) + .map(|(cell_index, _)| cell_index) +} + +pub(crate) fn handle_context_menu_action(app: &mut App, action: ContextMenuAction) { + match action { + ContextMenuAction::CopySelection => { + copy_active_selection(app); + } + ContextMenuAction::OpenSelection => { + if !open_pager_for_selection(app) { + app.status_message = Some("No selection to open".to_string()); + } + } + ContextMenuAction::ClearSelection => { + app.viewport.transcript_selection.clear(); + app.status_message = Some("Selection cleared".to_string()); + } + ContextMenuAction::CopyCell { cell_index } => { + copy_cell_to_clipboard(app, cell_index); + } + ContextMenuAction::OpenDetails { cell_index } => { + if !open_details_pager_for_cell(app, cell_index) { + app.status_message = Some("No details available for that line".to_string()); + } + } + ContextMenuAction::Paste => { + app.paste_from_clipboard(); + } + ContextMenuAction::OpenCommandPalette => { + app.view_stack + .push(CommandPaletteView::new(build_command_palette_entries( + app.ui_locale, + &app.skills_dir, + &app.workspace, + &app.mcp_config_path, + app.mcp_snapshot.as_ref(), + ))); + } + ContextMenuAction::OpenContextInspector => { + open_context_inspector(app); + } + ContextMenuAction::OpenHelp => { + app.view_stack.push(HelpView::new_for_locale(app.ui_locale)); + } + ContextMenuAction::OpenFileAtLine { cell_index } => { + let width = app + .viewport + .last_transcript_area + .map(|area| area.width) + .unwrap_or(80); + let text = history_cell_to_text( + app.cell_at_virtual_index(cell_index) + .unwrap_or(&HistoryCell::System { + content: String::new(), + }), + width, + ); + if crate::tui::history::try_open_file_at_line(&text, &app.workspace) { + app.status_message = Some("Opened file in editor".to_string()); + } else { + app.status_message = Some("No file:line pattern found in selection".to_string()); + } + } + ContextMenuAction::HideCell { cell_index } => { + app.collapsed_cells.insert(cell_index); + app.status_message = Some("Cell hidden".to_string()); + } + ContextMenuAction::ShowCell { cell_index } => { + app.collapsed_cells.remove(&cell_index); + app.status_message = Some("Cell shown".to_string()); + } + ContextMenuAction::ShowAllHidden => { + let count = app.collapsed_cells.len(); + app.collapsed_cells.clear(); + app.status_message = Some(format!("{count} hidden cell(s) restored")); + } + } + app.needs_redraw = true; +} + +pub(crate) fn selection_point_from_mouse( + app: &App, + mouse: MouseEvent, +) -> Option { + selection_point_from_position( + app.viewport.last_transcript_area?, + mouse.column, + mouse.row, + app.viewport.last_transcript_top, + app.viewport.last_transcript_total, + app.viewport.last_transcript_padding_top, + ) +} + +pub(crate) fn selection_point_from_position( + area: Rect, + column: u16, + row: u16, + transcript_top: usize, + transcript_total: usize, + padding_top: usize, +) -> Option { + if column < area.x + || column >= area.x + area.width + || row < area.y + || row >= area.y + area.height + { + return None; + } + + if transcript_total == 0 { + return None; + } + + let row = row.saturating_sub(area.y) as usize; + if row < padding_top { + return None; + } + let row = row.saturating_sub(padding_top); + + let col = column.saturating_sub(area.x) as usize; + let line_index = transcript_top + .saturating_add(row) + .min(transcript_total.saturating_sub(1)); + + Some(TranscriptSelectionPoint { + line_index, + column: col, + }) +} + +pub(crate) fn selection_has_content(app: &App) -> bool { + selection_to_text(app).is_some_and(|text| !text.is_empty()) +} + +/// Branches taken by the Ctrl+C key handler. The order encodes priority and is +/// the unit-tested contract for #1337 / #1367: a transcript selection always +/// wins (so users learn that Ctrl+C copies when there's something to copy); +/// otherwise an active turn is interrupted; otherwise the quit-arm flow runs. +#[derive(Debug, PartialEq, Eq)] +pub(crate) enum CtrlCDisposition { + CopySelection, + CancelTurn, + ConfirmExit, + ArmExit, +} + +pub(crate) fn ctrl_c_disposition(app: &App) -> CtrlCDisposition { + if selection_has_content(app) { + CtrlCDisposition::CopySelection + } else if app.is_loading { + CtrlCDisposition::CancelTurn + } else if app.quit_is_armed() { + CtrlCDisposition::ConfirmExit + } else { + CtrlCDisposition::ArmExit + } +} + +pub(crate) fn copy_active_selection(app: &mut App) { + if !app.viewport.transcript_selection.is_active() { + return; + } + if let Some(text) = selection_to_text(app).filter(|text| !text.is_empty()) { + if app.clipboard.write_text(&text).is_ok() { + app.status_message = Some("Selection copied".to_string()); + } else { + app.status_message = Some("Copy failed".to_string()); + } + } else { + app.viewport.transcript_selection.clear(); + app.status_message = Some("No selection to copy".to_string()); + } +} + +pub(crate) fn selection_to_text(app: &App) -> Option { + let (start, end) = app.viewport.transcript_selection.ordered_endpoints()?; + let lines = app.viewport.transcript_cache.lines(); + if lines.is_empty() { + return None; + } + let end_index = end.line_index.min(lines.len().saturating_sub(1)); + let start_index = start.line_index.min(end_index); + + let mut selected_lines = Vec::new(); + #[allow(clippy::needless_range_loop)] + for line_index in start_index..=end_index { + // Rail-prefix decorations are stored as cache metadata rather than + // detected from glyphs, so new decoration types are covered without + // changes to the copy path (#1163). + let rail_width = app.viewport.transcript_cache.rail_prefix_width(line_index); + // Convert the rendered line to plain text (strips OSC-8), then + // slice off the rail prefix so subsequent column offsets operate + // on content-only text. + let full_text = line_to_plain(&lines[line_index]); + let line_text = if rail_width > 0 { + slice_text(&full_text, rail_width, text_display_width(&full_text)) + } else { + full_text + }; + let line_width = text_display_width(&line_text); + // Selection coordinates are recorded in rendered-column space, which + // includes the visual rail prefix. Add rail_width back so the column + // window maps correctly into the rail-stripped text. + let (raw_col_start, raw_col_end) = if start_index == end_index { + (start.column, end.column) + } else if line_index == start_index { + (start.column, line_width.saturating_add(rail_width)) + } else if line_index == end_index { + (0, end.column) + } else { + (0, line_width.saturating_add(rail_width)) + }; + + let col_start = raw_col_start.saturating_sub(rail_width).min(line_width); + let col_end = raw_col_end.saturating_sub(rail_width).min(line_width); + + let slice = slice_text(&line_text, col_start, col_end); + selected_lines.push(slice); + } + Some(selected_lines.join("\n")) +} diff --git a/crates/tui/src/tui/notifications.rs b/crates/tui/src/tui/notifications.rs index f558b67b..fcfe4ba3 100644 --- a/crates/tui/src/tui/notifications.rs +++ b/crates/tui/src/tui/notifications.rs @@ -419,7 +419,7 @@ pub fn text_summary(text: &str) -> Option { let collapsed = sanitized .lines() .map(str::trim) - .filter(|line| !line.is_empty()) + .filter(|line: &&str| !line.is_empty()) .collect::>() .join("\n"); let trimmed = collapsed.trim(); diff --git a/crates/tui/src/tui/sidebar.rs b/crates/tui/src/tui/sidebar.rs index aa4c9bd5..7c833205 100644 --- a/crates/tui/src/tui/sidebar.rs +++ b/crates/tui/src/tui/sidebar.rs @@ -25,7 +25,7 @@ use crate::tools::todo::TodoStatus; use super::app::{App, SidebarFocus, TaskPanelEntry}; use super::history::{GenericToolCell, HistoryCell, ToolCell, ToolStatus, summarize_tool_output}; use super::subagent_routing::active_fanout_counts; -use super::ui::truncate_line_to_width; +use super::ui_text::truncate_line_to_width; /// Tolerance for floating-point cost comparison in the sidebar breakdown. /// Must be large enough that accumulated f64 error across hundreds of turns @@ -51,6 +51,9 @@ pub fn render_sidebar(f: &mut Frame, area: Rect, app: &App) { SidebarFocus::Tasks => render_sidebar_tasks(f, area, app), SidebarFocus::Agents => render_sidebar_subagents(f, area, app), SidebarFocus::Context => render_context_panel(f, area, app), + SidebarFocus::Hidden => Block::default() + .style(Style::default().bg(app.ui_theme.surface_bg)) + .render(area, f.buffer_mut()), } } diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index 9b22bb8a..bd13f707 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -10,8 +10,8 @@ use anyhow::Result; use crossterm::{ event::{ self, DisableBracketedPaste, DisableFocusChange, DisableMouseCapture, EnableBracketedPaste, - EnableFocusChange, EnableMouseCapture, Event, KeyCode, KeyEvent, KeyEventKind, - KeyModifiers, KeyboardEnhancementFlags, MouseButton, MouseEvent, MouseEventKind, + EnableFocusChange, EnableMouseCapture, Event, KeyCode, KeyEventKind, KeyModifiers, + KeyboardEnhancementFlags, }, execute, terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode}, @@ -26,11 +26,9 @@ use ratatui::{ layout::{Constraint, Direction, Layout, Rect, Size}, prelude::Widget, style::Style, - text::Span, widgets::Block, }; use tracing; -use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; use crate::audit::log_sensitive_event; use crate::automation_manager::{AutomationManager, AutomationSchedulerConfig, spawn_scheduler}; @@ -39,7 +37,6 @@ use crate::commands; use crate::compaction::estimate_input_tokens_conservative; use crate::config::{ApiProvider, Config, DEFAULT_NVIDIA_NIM_BASE_URL}; use crate::config_ui::{self, ConfigUiMode, WebConfigSession, WebConfigSessionEvent}; -use crate::core::coherence::CoherenceState; use crate::core::engine::{EngineConfig, EngineHandle, spawn_engine}; use crate::core::events::Event as EngineEvent; use crate::core::ops::Op; @@ -64,37 +61,40 @@ use crate::tui::color_compat::ColorCompatBackend; use crate::tui::command_palette::{ CommandPaletteView, build_entries as build_command_palette_entries, }; +use crate::tui::composer_ui::*; use crate::tui::context_inspector::build_context_inspector_text; -use crate::tui::context_menu::{ContextMenuEntry, ContextMenuView}; use crate::tui::event_broker::EventBroker; use crate::tui::file_picker_relevance; +use crate::tui::footer_ui::{ + friendly_subagent_progress, is_noisy_subagent_progress, one_line_summary, render_footer, +}; use crate::tui::format_helpers; use crate::tui::key_shortcuts; use crate::tui::live_transcript::LiveTranscriptOverlay; use crate::tui::mcp_routing::{add_mcp_message, open_mcp_manager_pager}; +use crate::tui::mouse_ui::*; use crate::tui::notifications; use crate::tui::onboarding; use crate::tui::pager::PagerView; use crate::tui::persistence_actor::{self, PersistRequest}; use crate::tui::plan_prompt::PlanPromptView; -use crate::tui::scrolling::{ScrollDirection, TranscriptScroll}; -use crate::tui::selection::{SelectionAutoscroll, TranscriptSelectionPoint}; +use crate::tui::scrolling::TranscriptScroll; +// SelectionAutoscroll unused use crate::tui::session_picker::SessionPickerView; use crate::tui::shell_job_routing::{ add_shell_job_message, format_shell_job_list, format_shell_poll, open_shell_job_pager, }; use crate::tui::streaming_thinking; use crate::tui::subagent_routing::{ - active_fanout_counts, format_task_list, handle_subagent_mailbox, open_task_pager, - reconcile_subagent_activity_state, running_agent_count, sort_subagents_in_place, - task_mode_label, task_summary_to_panel_entry, + format_task_list, handle_subagent_mailbox, open_task_pager, reconcile_subagent_activity_state, + running_agent_count, sort_subagents_in_place, task_mode_label, task_summary_to_panel_entry, }; #[cfg(test)] use crate::tui::tool_routing::exploring_label; use crate::tui::tool_routing::{ handle_tool_call_complete, handle_tool_call_started, maybe_add_patch_preview, }; -use crate::tui::ui_text::{history_cell_to_text, line_to_plain, slice_text, text_display_width}; +use crate::tui::ui_text::{history_cell_to_text, line_to_plain, truncate_line_to_width}; use crate::tui::user_input::UserInputView; use crate::tui::views::subagent_view_agents; use crate::tui::vim_mode; @@ -114,14 +114,9 @@ use super::history::{ use super::slash_menu::{ apply_slash_menu_selection, try_autocomplete_slash_command, visible_slash_menu_entries, }; -use super::views::{ - ConfigView, ContextMenuAction, HelpView, ModalKind, ShellControlView, ViewEvent, -}; +use super::views::{ConfigView, HelpView, ModalKind, ShellControlView, ViewEvent}; use super::widgets::pending_input_preview::{ContextPreviewItem, PendingInputPreview}; -use super::widgets::{ - ChatWidget, ComposerWidget, FooterProps, FooterToast, FooterWidget, HeaderData, HeaderWidget, - Renderable, -}; +use super::widgets::{ChatWidget, ComposerWidget, HeaderData, HeaderWidget, Renderable}; // === Constants === @@ -135,7 +130,6 @@ const SLASH_MENU_LIMIT: usize = 128; const MENTION_MENU_LIMIT: usize = 6; const MIN_CHAT_HEIGHT: u16 = 3; const MIN_COMPOSER_HEIGHT: u16 = 2; -const COMPOSER_ARROW_SCROLL_LINES: usize = 3; const CONTEXT_WARNING_THRESHOLD_PERCENT: f64 = 85.0; const CONTEXT_CRITICAL_THRESHOLD_PERCENT: f64 = 95.0; const UI_IDLE_POLL_MS: u64 = 48; @@ -153,6 +147,18 @@ const PERIODIC_FULL_REPAINT_EVERY_N: u64 = 50; const TURN_META_PREFIX: &str = ""; const SESSION_TITLE_MAX_CHARS: usize = 32; +fn sidebar_width_for_chat_area(app: &App, chat_width: u16) -> Option { + if app.sidebar_focus == SidebarFocus::Hidden || chat_width < SIDEBAR_VISIBLE_MIN_WIDTH { + return None; + } + + let preferred_sidebar = + (u32::from(chat_width) * u32::from(app.sidebar_width_percent.clamp(10, 50)) / 100) as u16; + let sidebar_width = preferred_sidebar.max(24).min(chat_width.saturating_sub(40)); + + (sidebar_width >= 20).then_some(sidebar_width) +} + type AppTerminal = Terminal>; type PendingToolUses = Vec<(String, String, serde_json::Value)>; @@ -2628,8 +2634,7 @@ async fn run_event_loop( continue; } KeyCode::Char('0') if key.modifiers.contains(KeyModifiers::ALT) => { - app.set_sidebar_focus(SidebarFocus::Auto); - app.status_message = Some("Sidebar focus: auto".to_string()); + apply_alt_0_shortcut(app, key.modifiers); continue; } KeyCode::Char('r') if key.modifiers.contains(KeyModifiers::CONTROL) => { @@ -3377,6 +3382,16 @@ fn apply_alt_4_shortcut(app: &mut App, _modifiers: KeyModifiers) { app.status_message = Some("Sidebar focus: agents".to_string()); } +fn apply_alt_0_shortcut(app: &mut App, modifiers: KeyModifiers) { + if modifiers.contains(KeyModifiers::CONTROL) { + app.set_sidebar_focus(SidebarFocus::Hidden); + app.status_message = Some("Sidebar hidden".to_string()); + } else { + app.set_sidebar_focus(SidebarFocus::Auto); + app.status_message = Some("Sidebar focus: auto".to_string()); + } +} + async fn fetch_available_models(config: &Config) -> Result> { use crate::client::DeepSeekClient; @@ -3706,142 +3721,6 @@ fn replace_matching_assistant_text( // Streaming-thinking lifecycle helpers moved to `tui/streaming_thinking.rs`. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum EscapeAction { - CloseSlashMenu, - CancelRequest, - DiscardQueuedDraft, - ClearInput, - Noop, -} - -fn next_escape_action(app: &App, slash_menu_open: bool) -> EscapeAction { - if slash_menu_open { - EscapeAction::CloseSlashMenu - } else if app.is_loading { - EscapeAction::CancelRequest - } else if app.queued_draft.is_some() && app.input.is_empty() { - EscapeAction::DiscardQueuedDraft - } else if !app.input.is_empty() { - EscapeAction::ClearInput - } else { - EscapeAction::Noop - } -} - -fn select_previous_slash_menu_entry(app: &mut App, entry_count: usize) { - if entry_count == 0 { - return; - } - let selected = app.slash_menu_selected.min(entry_count.saturating_sub(1)); - app.slash_menu_selected = (selected + entry_count - 1) % entry_count; -} - -fn select_next_slash_menu_entry(app: &mut App, entry_count: usize) { - if entry_count == 0 { - return; - } - let selected = app.slash_menu_selected.min(entry_count.saturating_sub(1)); - app.slash_menu_selected = (selected + 1) % entry_count; -} - -fn handle_composer_history_arrow( - app: &mut App, - key: KeyEvent, - slash_menu_open: bool, - mention_menu_open: bool, -) -> bool { - if slash_menu_open || mention_menu_open { - return false; - } - if key.modifiers.contains(KeyModifiers::ALT) || key.modifiers.contains(KeyModifiers::SUPER) { - return false; - } - - // When `composer_arrows_scroll` is enabled and the composer is empty, - // plain Up/Down scroll the transcript. This helps terminals that map - // trackpad gestures to arrow keys. Otherwise arrows always navigate - // input history regardless of composer state (#1117). - let scroll_on_empty = app.composer_arrows_scroll && app.input.trim().is_empty(); - - match key.code { - KeyCode::Up => { - if scroll_on_empty { - app.scroll_up(COMPOSER_ARROW_SCROLL_LINES); - } else { - app.history_up(); - } - true - } - KeyCode::Down => { - if scroll_on_empty { - app.scroll_down(COMPOSER_ARROW_SCROLL_LINES); - } else { - app.history_down(); - } - true - } - _ => false, - } -} - -fn is_word_cursor_modifier(modifiers: KeyModifiers) -> bool { - modifiers.contains(KeyModifiers::CONTROL) || modifiers.contains(KeyModifiers::ALT) -} - -fn is_composer_newline_key(key: KeyEvent) -> bool { - match key.code { - KeyCode::Char('j') => key.modifiers.contains(KeyModifiers::CONTROL), - KeyCode::Enter => { - key.modifiers.contains(KeyModifiers::ALT) - || (key.modifiers.contains(KeyModifiers::SHIFT) - && !key.modifiers.contains(KeyModifiers::CONTROL)) - } - _ => false, - } -} - -fn handle_history_search_key(app: &mut App, key: KeyEvent) { - match key.code { - KeyCode::Enter => { - let _ = app.accept_history_search(); - } - KeyCode::Esc => { - app.cancel_history_search(); - } - KeyCode::Char('c') | KeyCode::Char('C') - if key.modifiers.contains(KeyModifiers::CONTROL) => - { - app.cancel_history_search(); - } - KeyCode::Backspace => { - app.history_search_backspace(); - } - KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => { - while app - .history_search_query() - .is_some_and(|query| !query.is_empty()) - { - app.history_search_backspace(); - } - } - KeyCode::Up => { - app.history_search_select_previous(); - } - KeyCode::Down => { - app.history_search_select_next(); - } - KeyCode::Char(ch) - if key.modifiers.is_empty() - || key.modifiers == KeyModifiers::SHIFT - || key.modifiers == KeyModifiers::NONE => - { - app.history_search_insert_char(ch); - } - _ => {} - } -} - fn build_queued_message(app: &mut App, input: String) -> QueuedMessage { let skill_instruction = app.active_skill.take(); QueuedMessage::new(input, skill_instruction) @@ -4339,7 +4218,7 @@ fn open_text_pager(app: &mut App, title: String, content: String) { )); } -fn open_context_inspector(app: &mut App) { +pub(crate) fn open_context_inspector(app: &mut App) { let width = app .viewport .last_transcript_area @@ -5516,21 +5395,13 @@ fn render(f: &mut Frame, app: &mut App) { chunks[1] }; - if chat_area.width >= SIDEBAR_VISIBLE_MIN_WIDTH { - let preferred_sidebar = (u32::from(chat_area.width) - * u32::from(app.sidebar_width_percent.clamp(10, 50)) - / 100) as u16; - let sidebar_width = preferred_sidebar - .max(24) - .min(chat_area.width.saturating_sub(40)); - if sidebar_width >= 20 { - let split = Layout::default() - .direction(Direction::Horizontal) - .constraints([Constraint::Min(1), Constraint::Length(sidebar_width)]) - .split(chat_area); - chat_area = split[0]; - sidebar_area = Some(split[1]); - } + if let Some(sidebar_width) = sidebar_width_for_chat_area(app, chat_area.width) { + let split = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Min(1), Constraint::Length(sidebar_width)]) + .split(chat_area); + chat_area = split[0]; + sidebar_area = Some(split[1]); } let chat_widget = ChatWidget::new(app, chat_area); @@ -6532,7 +6403,7 @@ fn terminal_event_needs_viewport_recapture(evt: &Event) -> bool { matches!(evt, Event::FocusGained) } -fn status_color(level: StatusToastLevel) -> ratatui::style::Color { +pub(crate) fn status_color(level: StatusToastLevel) -> ratatui::style::Color { match level { StatusToastLevel::Info => palette::DEEPSEEK_SKY, StatusToastLevel::Success => palette::STATUS_SUCCESS, @@ -6599,247 +6470,7 @@ fn render_toast_stack_overlay( } } -fn render_footer(f: &mut Frame, area: Rect, app: &mut App) { - if area.width == 0 || area.height == 0 { - return; - } - - // Pull in the toast first so we don't re-borrow `app` mutably mid-build, - // then build the FooterProps once. The widget itself is a pure render — - // it owns no `App` knowledge; all width-aware layout lives in the widget. - // - // The quit-confirmation prompt takes precedence over normal status toasts - // because it represents a transient instruction the user must respond to - // within ~2s. Mirrors codex-rs's `FooterMode::QuitShortcutReminder`. - let quit_prompt = if app.quit_is_armed() { - Some(FooterToast { - text: crate::localization::tr( - app.ui_locale, - crate::localization::MessageId::FooterPressCtrlCAgain, - ) - .to_string(), - color: palette::STATUS_WARNING, - }) - } else { - None - }; - let toast = quit_prompt.or_else(|| { - app.active_status_toast().map(|toast| FooterToast { - text: toast.text, - color: status_color(toast.level), - }) - }); - - // Drive every cluster from the user's configured `status_items`. Mode - // and Model are always rendered by `FooterProps` itself (their position - // is structural — cluster gating is handled by the widget), so we only - // gate the optional clusters here. If a variant is missing from - // `status_items`, its span vec stays empty and the footer hides it. - let mut props = render_footer_from(app, &app.status_items, toast); - // FooterProps is mut so the working-strip animation can layer on top. - - // Animate the spacer between the left status line and the right-hand - // chips whenever a turn is live: model loading/streaming, compacting, or - // sub-agents in flight. The spout strip is gated on `fancy_animations` - // (the "do I want a whale at all" knob); `low_motion` now governs only - // streaming pacing (typewriter vs upstream), not the spout. Dot-pulse - // counter ticks every 400 ms so `working` → `working...` reads at a - // calm pace regardless of motion mode. - if footer_working_strip_active(app) { - let now_ms = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_millis() as u64) - .unwrap_or(0); - let dot_frame = now_ms / 400; - // Surface one compact live status row in the footer whenever a turn - // is live. Tool turns get the current action plus active/done counts; - // non-tool work falls back to the existing dot-pulse label. - props.state_label = active_subagent_status_label(app) - .or_else(|| active_tool_status_label(app)) - .unwrap_or_else(|| crate::tui::widgets::footer_working_label(dot_frame, app.ui_locale)); - props.state_color = palette::DEEPSEEK_SKY; - - // Water-spout frame source: wall-clock milliseconds. The sine-wave - // math in `footer_working_strip_glyph_at` was tuned for this cadence - // (`t = frame / 1000.0`, primary term × 8.0 ≈ 1.3 Hz at 1 ms ticks), - // so frame must advance at ~1000 units/sec to produce the intended - // animation feel. `fancy_animations = false` hides the strip - // entirely; the textual `working...` pulse still keeps a heartbeat - // regardless. - if app.fancy_animations { - props.working_strip_frame = Some(now_ms); - } - } else if props.state_label == "ready" - && let Some(label) = selected_detail_footer_label(app) - { - props.state_label = label; - props.state_color = palette::TEXT_MUTED; - } - - let widget = FooterWidget::new(props); - let buf = f.buffer_mut(); - widget.render(area, buf); -} - -/// Whether the footer should animate the water-spout strip. Driven by the -/// underlying live-work flags so the strip stays visible for the *entire* -/// turn — not just the moments where bytes are streaming. `is_loading` can -/// flicker off between LLM rounds within a single turn (tool execution, -/// reasoning replay, capacity refresh, etc.), so we ALSO gate on the turn -/// itself still being in flight via `runtime_turn_status == "in_progress"`. -/// Without that, the user sees the strip vanish for seconds at a time even -/// though the agent is still working. -fn footer_working_strip_active(app: &App) -> bool { - let turn_in_progress = app.runtime_turn_status.as_deref() == Some("in_progress"); - app.is_loading || app.is_compacting || running_agent_count(app) > 0 || turn_in_progress -} - -fn is_noisy_subagent_progress(status: &str) -> bool { - let status = status.trim().to_ascii_lowercase(); - status.contains("requesting model response") -} - -fn subagent_objective_summary(app: &App, id: &str) -> Option { - app.subagent_cache - .iter() - .find(|agent| agent.agent_id == id) - .map(|agent| summarize_tool_output(&agent.assignment.objective)) - .filter(|summary| !summary.is_empty()) -} - -fn friendly_subagent_progress(app: &App, id: &str, status: &str) -> String { - if !is_noisy_subagent_progress(status) { - return summarize_tool_output(status); - } - - if let Some(summary) = subagent_objective_summary(app, id) { - return format!("working on {summary}"); - } - if let Some(existing) = app.agent_progress.get(id) - && !is_noisy_subagent_progress(existing) - && existing != "working" - { - return existing.clone(); - } - "working".to_string() -} - -fn active_subagent_status_label(app: &App) -> Option { - let running = running_agent_count(app); - let fanout = active_fanout_counts(app); - let (display_running, total) = if let Some((fanout_running, fanout_total)) = fanout { - if fanout_running == 0 { - return None; - } - (fanout_running, fanout_total) - } else { - if running == 0 { - return None; - } - (running, running) - }; - let detail = app - .subagent_cache - .iter() - .find(|agent| matches!(agent.status, SubAgentStatus::Running)) - .map(|agent| summarize_tool_output(&agent.assignment.objective)) - .filter(|summary| !summary.is_empty()) - .or_else(|| { - app.agent_progress - .values() - .find(|value| !is_noisy_subagent_progress(value) && value.as_str() != "working") - .cloned() - }) - .unwrap_or_else(|| "working".to_string()); - let detail = truncate_line_to_width(&detail, 34); - let elapsed = app - .agent_activity_started_at - .or(app.turn_started_at) - .map(|started| format!("{}s", started.elapsed().as_secs())); - - let mut parts = vec![format!("agents {display_running}/{total}"), detail]; - if let Some(elapsed) = elapsed { - parts.push(elapsed); - } - parts.push("Alt+4".to_string()); - Some(parts.join(" \u{00B7} ")) -} - -#[derive(Default)] -struct ActiveToolStatusSnapshot { - primary_running: Option, - primary_any: Option, - running: usize, - completed: usize, - started_at: Option, -} - -impl ActiveToolStatusSnapshot { - fn record(&mut self, label: String, status: ToolStatus, started_at: Option) { - if self.primary_any.is_none() { - self.primary_any = Some(label.clone()); - } - if status == ToolStatus::Running { - self.running += 1; - if self.primary_running.is_none() { - self.primary_running = Some(label); - } - } else { - self.completed += 1; - } - if let Some(started) = started_at { - self.started_at = Some(match self.started_at { - Some(current) => current.min(started), - None => started, - }); - } - } - - fn total(&self) -> usize { - self.running + self.completed - } -} - -fn active_tool_status_label(app: &App) -> Option { - let active = app.active_cell.as_ref()?; - if active.is_empty() { - return None; - } - - let mut snapshot = ActiveToolStatusSnapshot::default(); - for cell in active.entries() { - collect_active_tool_status(cell, &mut snapshot); - } - if snapshot.total() == 0 { - return None; - } - - let primary = snapshot - .primary_running - .or(snapshot.primary_any) - .unwrap_or_else(|| "tools".to_string()); - let primary = truncate_line_to_width(&primary, 30); - let elapsed = snapshot - .started_at - .or(app.turn_started_at) - .map(|started| format!("{}s", started.elapsed().as_secs())); - - let mut parts = vec![ - primary, - format!("{} active", snapshot.running), - format!("{} done", snapshot.completed), - ]; - if let Some(elapsed) = elapsed { - parts.push(elapsed); - } - if active_foreground_shell_running(app) { - parts.push("Ctrl+B shell".to_string()); - } - parts.push(key_shortcuts::tool_details_shortcut_label().to_string()); - Some(parts.join(" \u{00B7} ")) -} - -fn open_shell_control(app: &mut App) { +pub(crate) fn open_shell_control(app: &mut App) { if !app.is_loading || !active_foreground_shell_running(app) { app.status_message = Some("No foreground shell command to control".to_string()); return; @@ -6849,7 +6480,7 @@ fn open_shell_control(app: &mut App) { app.status_message = Some("Shell control opened".to_string()); } -fn request_foreground_shell_background(app: &mut App) { +pub(crate) fn request_foreground_shell_background(app: &mut App) { if !app.is_loading || !active_foreground_shell_running(app) { app.status_message = Some("No foreground shell command to background".to_string()); return; @@ -6871,7 +6502,7 @@ fn request_foreground_shell_background(app: &mut App) { } } -fn active_foreground_shell_running(app: &App) -> bool { +pub(crate) fn active_foreground_shell_running(app: &App) -> bool { app.active_cell.as_ref().is_some_and(|active| { active.entries().iter().any(|cell| { matches!( @@ -6883,7 +6514,7 @@ fn active_foreground_shell_running(app: &App) -> bool { }) } -fn terminal_pause_has_live_owner(app: &App) -> bool { +pub(crate) fn terminal_pause_has_live_owner(app: &App) -> bool { app.active_cell.as_ref().is_some_and(|active| { active.entries().iter().any(|cell| { matches!( @@ -6894,498 +6525,6 @@ fn terminal_pause_has_live_owner(app: &App) -> bool { }) } -fn collect_active_tool_status(cell: &HistoryCell, snapshot: &mut ActiveToolStatusSnapshot) { - let HistoryCell::Tool(tool) = cell else { - return; - }; - match tool { - ToolCell::Exec(exec) => snapshot.record( - format!("run {}", one_line_summary(&exec.command, 80)), - exec.status, - exec.started_at, - ), - ToolCell::Exploring(explore) => { - for entry in &explore.entries { - snapshot.record( - format!("read {}", one_line_summary(&entry.label, 80)), - entry.status, - None, - ); - } - } - ToolCell::PlanUpdate(plan) => { - snapshot.record("update plan".to_string(), plan.status, None); - } - ToolCell::PatchSummary(patch) => { - snapshot.record(format!("patch {}", patch.path), patch.status, None); - } - ToolCell::Review(review) => { - let target = one_line_summary(&review.target, 80); - let label = if target.is_empty() { - "review".to_string() - } else { - format!("review {target}") - }; - snapshot.record(label, review.status, None); - } - ToolCell::DiffPreview(diff) => { - snapshot.record(format!("diff {}", diff.title), ToolStatus::Success, None); - } - ToolCell::Mcp(mcp) => snapshot.record(format!("tool {}", mcp.tool), mcp.status, None), - ToolCell::ViewImage(image) => snapshot.record( - format!("image {}", image.path.display()), - ToolStatus::Success, - None, - ), - ToolCell::WebSearch(search) => { - snapshot.record(format!("search {}", search.query), search.status, None); - } - ToolCell::Generic(generic) => { - // Sub-agent dispatch represents itself through the DelegateCard - // + Agents sidebar. Counting it again here would duplicate the - // status. RLM is different today: it is a foreground tool call, - // so keep it in the live tool footer until the async RLM - // workbench lands (#513). - if matches!(generic.name.as_str(), "agent_open" | "agent_spawn") { - return; - } - snapshot.record(format!("tool {}", generic.name), generic.status, None); - } - } -} - -fn one_line_summary(text: &str, max_width: usize) -> String { - truncate_line_to_width( - &text.split_whitespace().collect::>().join(" "), - max_width, - ) -} - -/// Build [`FooterProps`] from a user-configured `status_items` slice. -/// -/// Variants are routed to their structural cluster: `Mode` and `Model` are -/// always emitted (the widget needs them to lay out the line correctly even -/// when the user toggled them off the picker — we honour the toggle by -/// blanking their visible content rather than collapsing the layout). -/// `Cost` and `Status` belong in the left cluster; the rest in the right. -/// -/// A variant absent from `items` produces an empty span vec, which the -/// footer widget already hides cleanly. This keeps the renderer fully -/// data-driven without changing `FooterProps`'s public shape. -fn render_footer_from( - app: &App, - items: &[crate::config::StatusItem], - toast: Option, -) -> FooterProps { - use crate::config::StatusItem as S; - let has = |item: S| items.contains(&item); - - let (state_label, state_color) = if has(S::Status) { - footer_state_label(app) - } else { - // "ready" is the sentinel the widget uses to skip the status segment; - // pair it with theme text_muted for visual neutrality. - ("ready", app.ui_theme.text_muted) - }; - - let coherence = if has(S::Coherence) { - footer_coherence_spans(app) - } else { - Vec::new() - }; - let agents = if has(S::Agents) { - crate::tui::widgets::footer_agents_chip(running_agent_count(app), app.ui_locale) - } else { - Vec::new() - }; - let reasoning_replay = if has(S::ReasoningReplay) { - footer_reasoning_replay_spans(app) - } else { - Vec::new() - }; - let cache = if has(S::Cache) { - footer_cache_spans(app) - } else { - Vec::new() - }; - let cost = if has(S::Cost) { - footer_cost_spans(app) - } else { - Vec::new() - }; - - // Build the props; `Mode` and `Model` toggles modulate downstream by - // blanking the rendered text rather than restructuring the widget — the - // user is opting out of the chip, not destroying the bar. - let mut props = FooterProps::from_app( - app, - toast, - state_label, - state_color, - coherence, - agents, - reasoning_replay, - cache, - cost, - ); - if !has(S::Mode) { - props.mode_label = ""; - } - if !has(S::Model) { - props.model.clear(); - } - - // Right-cluster extension chips: append in `items` order so user - // ordering is preserved across the new variants. - let mut extra: Vec> = Vec::new(); - for item in items { - let chip = match *item { - S::ContextPercent => footer_context_percent_spans(app), - S::GitBranch => footer_git_branch_spans(app), - S::LastToolElapsed | S::RateLimit => Vec::new(), - _ => continue, - }; - if chip.is_empty() { - continue; - } - if !extra.is_empty() { - extra.push(Span::raw(" ")); - } - extra.extend(chip); - } - if !extra.is_empty() { - // Stack into the cache slot — last existing right-cluster pipe — so - // they appear adjacent without changing FooterProps's API. Keep - // existing cache spans first so cache hit rate stays before the - // user-added extras. - if !props.cache.is_empty() { - props.cache.push(Span::raw(" ")); - } - props.cache.extend(extra); - } - - props -} - -fn footer_git_branch_spans(app: &App) -> Vec> { - let Some(branch) = workspace_context::branch(&app.workspace) else { - return Vec::new(); - }; - vec![Span::styled( - branch, - Style::default().fg(app.ui_theme.text_muted), - )] -} - -/// Spans for the "context %" footer chip. Mirrors the header colour ramp so -/// the two surfaces stay visually consistent when both are enabled. -fn footer_context_percent_spans(app: &App) -> Vec> { - let Some((_, _, percent)) = context_usage_snapshot(app) else { - return Vec::new(); - }; - let color = if percent >= 95.0 { - palette::STATUS_ERROR - } else if percent >= 85.0 { - palette::STATUS_WARNING - } else { - palette::TEXT_MUTED - }; - vec![Span::styled( - format!("active ctx {percent:.0}%"), - Style::default().fg(color), - )] -} - -fn footer_cost_spans(app: &App) -> Vec> { - let displayed_cost = app.displayed_session_cost_for_currency(app.cost_currency); - if !should_show_footer_cost(displayed_cost) { - return Vec::new(); - } - vec![Span::styled( - app.format_cost_amount(displayed_cost), - Style::default().fg(palette::TEXT_MUTED), - )] -} - -fn should_show_footer_cost(displayed_cost: f64) -> bool { - displayed_cost.is_finite() && displayed_cost > 0.0 -} - -/// Test-only helper retained as a parity reference for `FooterWidget`'s -/// auxiliary-span composition. Production rendering is performed by the -/// widget itself; the existing footer parity tests still exercise this -/// function directly to guard against drift. -#[allow(dead_code)] -fn footer_auxiliary_spans(app: &App, max_width: usize) -> Vec> { - // Context % is already shown in the header signal bar — don't - // duplicate it in the footer. The footer carries unique info only: - // prefix stability, coherence, in-flight sub-agents, reasoning - // replay tokens, cache hit rate, and session cost. - let coherence_spans = footer_coherence_spans(app); - let agents_spans = - crate::tui::widgets::footer_agents_chip(running_agent_count(app), app.ui_locale); - let replay_spans = footer_reasoning_replay_spans(app); - let cache_spans = footer_cache_spans(app); - let cost_spans = footer_cost_spans(app); - let prefix_spans = app - .prefix_stability_pct - .map(|_| { - let (label, color) = format_helpers::prefix_stability_chip(app) - .unwrap_or(("P --".to_string(), ratatui::style::Color::DarkGray)); - vec![Span::styled(label, Style::default().fg(color))] - }) - .unwrap_or_default(); - - let parts: Vec<&Vec>> = [ - &coherence_spans, - &agents_spans, - &replay_spans, - &cache_spans, - &prefix_spans, - &cost_spans, - ] - .iter() - .filter(|spans| !spans.is_empty()) - .copied() - .collect(); - - // Try to fit as many parts as possible, dropping from the end. - for end in (0..=parts.len()).rev() { - let mut combined = Vec::new(); - for (i, part) in parts[..end].iter().enumerate() { - if i > 0 { - combined.push(Span::raw(" ")); - } - combined.extend(part.iter().cloned()); - } - if spans_width(&combined) <= max_width { - return combined; - } - } - Vec::new() -} - -fn footer_coherence_spans(app: &App) -> Vec> { - // Only surface coherence when the engine is actively intervening — the - // user-facing signal is "we're doing something different now," not - // "your conversation is getting complex," which the context-percent - // header already covers. `GettingCrowded` is just a soft hint, so we - // suppress it; the active interventions get their own visible label. - let (label, color) = match app.coherence_state { - CoherenceState::Healthy | CoherenceState::GettingCrowded => return Vec::new(), - CoherenceState::RefreshingContext => ("refreshing context", palette::STATUS_WARNING), - CoherenceState::VerifyingRecentWork => ("verifying", palette::DEEPSEEK_SKY), - CoherenceState::ResettingPlan => ("resetting plan", palette::STATUS_ERROR), - }; - - vec![Span::styled(label.to_string(), Style::default().fg(color))] -} - -fn footer_cache_spans(app: &App) -> Vec> { - if app.session.last_prompt_tokens.is_none() && app.session.last_completion_tokens.is_none() { - return Vec::new(); - }; - let Some(hit_tokens) = app.session.last_prompt_cache_hit_tokens else { - return vec![Span::styled( - "Cache: unavailable", - Style::default().fg(palette::TEXT_MUTED), - )]; - }; - let miss_tokens = app - .session - .last_prompt_cache_miss_tokens - .unwrap_or_else(|| { - app.session - .last_prompt_tokens - .unwrap_or(0) - .saturating_sub(hit_tokens) - }); - let total = hit_tokens.saturating_add(miss_tokens); - let percent = if total == 0 { - 0.0 - } else { - (f64::from(hit_tokens) / f64::from(total) * 100.0).clamp(0.0, 100.0) - }; - // Threshold-based coloring for cache hit rate (#396): - // >80%: green (good cache utilization) - // 40-80%: yellow/warning - // <40%: red/dimmed (poor cache) - let color = if percent > 80.0 { - palette::STATUS_SUCCESS - } else if percent >= 40.0 { - palette::STATUS_WARNING - } else { - palette::STATUS_ERROR - }; - vec![Span::styled( - format!( - "Cache: {:.1}% hit | hit {hit_tokens} | miss {miss_tokens}", - percent - ), - Style::default().fg(color), - )] -} - -/// Render a footer chip showing the size of the `reasoning_content` block -/// replayed on the most recent thinking-mode tool-calling turn (#30). -/// -/// Stays hidden when the count is zero (non-thinking models, first turn, or -/// turns with no tool calls). When replay tokens dominate the input budget -/// (>50%), the chip turns warning-coloured so users notice that thinking -/// replay is the main consumer of context. -fn footer_reasoning_replay_spans(app: &App) -> Vec> { - let Some(replay) = app.session.last_reasoning_replay_tokens else { - return Vec::new(); - }; - if replay == 0 { - return Vec::new(); - } - let label = format!("rsn {}", format_token_count_compact(u64::from(replay))); - let color = match app.session.last_prompt_tokens { - Some(input) if input > 0 && f64::from(replay) / f64::from(input) > 0.5 => { - palette::STATUS_WARNING - } - _ => palette::TEXT_MUTED, - }; - vec![Span::styled(label, Style::default().fg(color))] -} - -#[allow(dead_code)] -fn footer_toast_spans( - toast: &crate::tui::app::StatusToast, - max_width: usize, -) -> Vec> { - let truncated = truncate_line_to_width(&toast.text, max_width.max(1)); - vec![Span::styled( - truncated, - Style::default().fg(status_color(toast.level)), - )] -} - -#[allow(dead_code)] -fn footer_status_line_spans(app: &App, max_width: usize) -> Vec> { - if max_width == 0 { - return Vec::new(); - } - - let (mode_label, mode_color) = footer_mode_style(app); - let (status_label, status_color) = footer_state_label(app); - let sep = " \u{00B7} "; - let show_status = status_label != "ready"; - - let fixed_width = mode_label.width() - + sep.width() - + if show_status { - sep.width() + status_label.width() - } else { - 0 - }; - - if max_width <= mode_label.width() { - return vec![Span::styled( - truncate_line_to_width(mode_label, max_width), - Style::default().fg(mode_color), - )]; - } - - let model_budget = max_width.saturating_sub(fixed_width).max(1); - let model_label = truncate_line_to_width(&app.model, model_budget); - - let mut spans = vec![ - Span::styled(mode_label.to_string(), Style::default().fg(mode_color)), - Span::styled(sep.to_string(), Style::default().fg(app.ui_theme.text_dim)), - Span::styled(model_label, Style::default().fg(app.ui_theme.text_hint)), - ]; - - if show_status { - spans.push(Span::styled( - sep.to_string(), - Style::default().fg(app.ui_theme.text_dim), - )); - spans.push(Span::styled( - status_label.to_string(), - Style::default().fg(status_color), - )); - } - - spans -} - -fn footer_state_label(app: &App) -> (&'static str, ratatui::style::Color) { - if app.is_compacting { - return ("compacting \u{238B}", app.ui_theme.status_warning); - } - // Note: we deliberately do NOT show a "thinking" label for `is_loading`. - // The animated water-spout strip in the footer's spacer is the visual - // signal that the model is live; "thinking" was misleading because it - // fired for every kind of in-flight work (tool calls, streaming, etc.), - // not strictly reasoning. Sub-agents still surface "working" because - // that's a distinct lifecycle the user can act on (open `/agents`). - if running_agent_count(app) > 0 { - return ("working", app.ui_theme.status_working); - } - if app.queued_draft.is_some() { - return ("draft", app.ui_theme.text_muted); - } - - if !app.view_stack.is_empty() { - return ("overlay", app.ui_theme.text_muted); - } - - if !app.input.is_empty() { - return ("draft", app.ui_theme.text_muted); - } - - ("ready", app.ui_theme.status_ready) -} - -#[allow(dead_code)] -fn footer_mode_style(app: &App) -> (&'static str, ratatui::style::Color) { - let label = app.mode.as_setting(); - let color = match app.mode { - crate::tui::app::AppMode::Agent => app.ui_theme.mode_agent, - crate::tui::app::AppMode::Yolo => app.ui_theme.mode_yolo, - crate::tui::app::AppMode::Plan => app.ui_theme.mode_plan, - }; - (label, color) -} - -fn format_token_count_compact(tokens: u64) -> String { - if tokens >= 1_000_000 { - format!("{:.1}M", tokens as f64 / 1_000_000.0) - } else if tokens >= 1_000 { - format!("{:.1}k", tokens as f64 / 1_000.0) - } else { - tokens.to_string() - } -} - -#[allow(dead_code)] -fn format_context_budget(used: i64, max: u32) -> String { - let max_u64 = u64::from(max); - let max_i64 = i64::from(max); - - if used > max_i64 { - return format!( - ">{}/{}", - format_token_count_compact(max_u64), - format_token_count_compact(max_u64) - ); - } - - let used_u64 = u64::try_from(used.max(0)).unwrap_or(0); - format!( - "{}/{}", - format_token_count_compact(used_u64), - format_token_count_compact(max_u64) - ) -} - -#[allow(dead_code)] -fn spans_width(spans: &[Span<'_>]) -> usize { - spans.iter().map(|span| span.content.width()).sum() -} - #[allow(dead_code)] fn transcript_scroll_percent(top: usize, visible: usize, total: usize) -> Option { if total <= visible { @@ -7462,7 +6601,7 @@ fn estimated_context_tokens(app: &App) -> Option { .ok() } -fn context_usage_snapshot(app: &App) -> Option<(i64, u32, f64)> { +pub(crate) fn context_usage_snapshot(app: &App) -> Option<(i64, u32, f64)> { let max = context_window_for_model(app.effective_model_for_budget())?; let max_i64 = i64::from(max); let reported = app @@ -7608,661 +6747,7 @@ fn history_has_live_motion(history: &[HistoryCell]) -> bool { }) } -pub(crate) fn truncate_line_to_width(text: &str, max_width: usize) -> String { - if max_width == 0 { - return String::new(); - } - if UnicodeWidthStr::width(text) <= max_width { - return text.to_string(); - } - // For very small budgets, take chars until we exceed the *display* width. - // Counting characters instead of widths (the previous behavior) overran - // the budget for any double-width grapheme and contributed to mid-character - // sidebar artifacts on resize (issue #65). - if max_width <= 3 { - let mut out = String::new(); - let mut width = 0usize; - for ch in text.chars() { - let ch_width = UnicodeWidthChar::width(ch).unwrap_or(0); - if width + ch_width > max_width { - break; - } - out.push(ch); - width += ch_width; - } - return out; - } - - let mut out = String::new(); - let mut width = 0usize; - let limit = max_width.saturating_sub(3); - for ch in text.chars() { - let ch_width = UnicodeWidthChar::width(ch).unwrap_or(0); - if width + ch_width > limit { - break; - } - out.push(ch); - width += ch_width; - } - out.push_str("..."); - out -} - -fn should_drop_loading_mouse_motion(app: &App, mouse: MouseEvent) -> bool { - if !app.is_loading { - return false; - } - - match mouse.kind { - MouseEventKind::Moved => true, - MouseEventKind::Drag(_) => { - !app.viewport.transcript_selection.dragging - && !app.viewport.transcript_scrollbar_dragging - } - _ => false, - } -} - -fn handle_mouse_event(app: &mut App, mouse: MouseEvent) -> Vec { - if app.view_stack.top_kind() == Some(ModalKind::ContextMenu) { - if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Right)) { - app.view_stack.pop(); - open_context_menu(app, mouse); - return Vec::new(); - } - return app.view_stack.handle_mouse(mouse); - } - - if !app.view_stack.is_empty() { - app.needs_redraw = true; - return app.view_stack.handle_mouse(mouse); - } - - match mouse.kind { - MouseEventKind::ScrollUp => { - let update = app.viewport.mouse_scroll.on_scroll(ScrollDirection::Up); - app.viewport.pending_scroll_delta = app - .viewport - .pending_scroll_delta - .saturating_add(update.delta_lines); - if update.delta_lines != 0 { - app.user_scrolled_during_stream = true; - app.needs_redraw = true; - } - } - MouseEventKind::ScrollDown => { - let update = app.viewport.mouse_scroll.on_scroll(ScrollDirection::Down); - app.viewport.pending_scroll_delta = app - .viewport - .pending_scroll_delta - .saturating_add(update.delta_lines); - if update.delta_lines != 0 { - app.user_scrolled_during_stream = true; - app.needs_redraw = true; - } - } - MouseEventKind::Down(MouseButton::Left) => { - app.viewport.transcript_scrollbar_dragging = false; - app.viewport.selection_autoscroll = None; - - // Click on the transcript scrollbar gutter starts a scrollbar - // drag so the visible thumb remains interactive for users who - // prefer mouse-based navigation. - if mouse_hits_transcript_scrollbar(app, mouse) { - app.viewport.transcript_scrollbar_dragging = true; - return Vec::new(); - } - - if mouse_hits_rect(mouse, app.viewport.jump_to_latest_button_area) { - app.scroll_to_bottom(); - return Vec::new(); - } - - if let Some(point) = selection_point_from_mouse(app, mouse) { - app.viewport.transcript_selection.anchor = Some(point); - app.viewport.transcript_selection.head = Some(point); - app.viewport.transcript_selection.dragging = true; - - if app.is_loading - && app.viewport.transcript_scroll.is_at_tail() - && let Some(anchor) = TranscriptScroll::anchor_for( - app.viewport.transcript_cache.line_meta(), - app.viewport.last_transcript_top, - ) - { - app.viewport.transcript_scroll = anchor; - } - } else if app.viewport.transcript_selection.is_active() { - app.viewport.transcript_selection.clear(); - } - } - MouseEventKind::Drag(MouseButton::Left) => { - if app.viewport.transcript_scrollbar_dragging { - scroll_transcript_to_mouse_row(app, mouse.row); - return Vec::new(); - } - - if app.viewport.transcript_selection.dragging { - update_selection_drag(app, mouse); - } - } - MouseEventKind::Up(MouseButton::Left) if app.viewport.transcript_scrollbar_dragging => { - app.viewport.transcript_scrollbar_dragging = false; - app.viewport.selection_autoscroll = None; - app.needs_redraw = true; - } - MouseEventKind::Up(MouseButton::Left) if app.viewport.transcript_selection.dragging => { - app.viewport.transcript_selection.dragging = false; - app.viewport.selection_autoscroll = None; - if selection_has_content(app) { - copy_active_selection(app); - } - } - MouseEventKind::Down(MouseButton::Right) => { - open_context_menu(app, mouse); - } - _ => {} - } - - Vec::new() -} - -fn mouse_hits_transcript_scrollbar(app: &App, mouse: MouseEvent) -> bool { - let Some(area) = app.viewport.last_transcript_area else { - return false; - }; - if area.width <= 1 || app.viewport.last_transcript_total <= app.viewport.last_transcript_visible - { - return false; - } - - let scrollbar_col = area.x.saturating_add(area.width.saturating_sub(1)); - mouse.column == scrollbar_col - && mouse.row >= area.y - && mouse.row < area.y.saturating_add(area.height) -} - -fn scroll_transcript_to_mouse_row(app: &mut App, row: u16) -> bool { - let Some(area) = app.viewport.last_transcript_area else { - return false; - }; - let total = app.viewport.last_transcript_total; - let visible = app.viewport.last_transcript_visible; - if area.height == 0 || total <= visible { - return false; - } - - let max_start = total.saturating_sub(visible); - if max_start == 0 { - app.scroll_to_bottom(); - return true; - } - - let max_row = usize::from(area.height.saturating_sub(1)); - let relative_row = usize::from(row.saturating_sub(area.y)).min(max_row); - let numerator = relative_row - .saturating_mul(max_start) - .saturating_add(max_row / 2); - // Round to the nearest transcript offset so short thumbs still feel - // responsive on compact terminals. - let top = numerator.checked_div(max_row).unwrap_or(0); - - app.viewport.transcript_scroll = if top >= max_start { - TranscriptScroll::to_bottom() - } else { - TranscriptScroll::at_line(top) - }; - app.viewport.pending_scroll_delta = 0; - app.user_scrolled_during_stream = !app.viewport.transcript_scroll.is_at_tail(); - app.needs_redraw = true; - true -} - -/// Cadence between auto-scroll ticks while drag-selecting past the -/// transcript edge (#1163). 30 ms ≈ 33 lines/sec, comparable to the feel -/// of a steady scroll-wheel drag. -const SELECTION_AUTOSCROLL_INTERVAL: Duration = Duration::from_millis(30); - -/// Update the transcript selection while the left button is dragging. -/// When the mouse leaves the transcript rect vertically, arm -/// `selection_autoscroll` so the main loop can advance the viewport on a -/// fixed cadence; when the mouse returns inside, disarm it. -fn update_selection_drag(app: &mut App, mouse: MouseEvent) { - if let Some(point) = selection_point_from_mouse(app, mouse) { - app.viewport.transcript_selection.head = Some(point); - app.viewport.selection_autoscroll = None; - return; - } - - let Some(area) = app.viewport.last_transcript_area else { - return; - }; - if area.height == 0 || area.width == 0 { - return; - } - - let direction = if mouse.row < area.y { - -1 - } else if mouse.row >= area.y.saturating_add(area.height) { - 1 - } else { - // Outside horizontally only — leave selection head where it is. - return; - }; - - let max_col = area.x.saturating_add(area.width.saturating_sub(1)); - let column = mouse.column.clamp(area.x, max_col); - - // Fire on the next tick immediately by setting `next_tick` to now. - app.viewport.selection_autoscroll = Some(SelectionAutoscroll { - direction, - column, - next_tick: Instant::now(), - }); - app.needs_redraw = true; -} - -/// Advance the drag-edge auto-scroll one step if its cadence has elapsed. -/// Called once per main-loop iteration. -fn tick_selection_autoscroll(app: &mut App) { - let Some(state) = app.viewport.selection_autoscroll else { - return; - }; - - if !app.viewport.transcript_selection.dragging { - app.viewport.selection_autoscroll = None; - return; - } - - let Some(area) = app.viewport.last_transcript_area else { - return; - }; - if area.height == 0 { - return; - } - - let now = Instant::now(); - if now < state.next_tick { - return; - } - - app.viewport.pending_scroll_delta = app - .viewport - .pending_scroll_delta - .saturating_add(state.direction); - app.user_scrolled_during_stream = true; - - let edge_row = if state.direction < 0 { - area.y - } else { - area.y.saturating_add(area.height.saturating_sub(1)) - }; - if let Some(point) = selection_point_from_position( - area, - state.column, - edge_row, - app.viewport.last_transcript_top, - app.viewport.last_transcript_total, - app.viewport.last_transcript_padding_top, - ) { - app.viewport.transcript_selection.head = Some(point); - } - - app.viewport.selection_autoscroll = Some(SelectionAutoscroll { - next_tick: now + SELECTION_AUTOSCROLL_INTERVAL, - ..state - }); - app.needs_redraw = true; -} - -fn mouse_hits_rect(mouse: MouseEvent, area: Option) -> bool { - let Some(area) = area else { - return false; - }; - - mouse.column >= area.x - && mouse.column < area.x.saturating_add(area.width) - && mouse.row >= area.y - && mouse.row < area.y.saturating_add(area.height) -} - -fn open_context_menu(app: &mut App, mouse: MouseEvent) { - let entries = build_context_menu_entries(app, mouse); - if entries.is_empty() { - return; - } - app.view_stack - .push(ContextMenuView::new(entries, mouse.column, mouse.row)); - app.needs_redraw = true; -} - -fn build_context_menu_entries(app: &App, mouse: MouseEvent) -> Vec { - let mut entries = Vec::new(); - - if selection_has_content(app) { - entries.push(ContextMenuEntry { - label: "Copy selection".to_string(), - description: "write selected transcript text".to_string(), - action: ContextMenuAction::CopySelection, - }); - entries.push(ContextMenuEntry { - label: "Open selection".to_string(), - description: "show selected text in pager".to_string(), - action: ContextMenuAction::OpenSelection, - }); - entries.push(ContextMenuEntry { - label: "Clear selection".to_string(), - description: String::new(), - action: ContextMenuAction::ClearSelection, - }); - } - - if let Some(filtered_cell_index) = transcript_cell_index_from_mouse(app, mouse) { - // Convert filtered index → original virtual index using the - // mapping built in ChatWidget::new. When no cells are collapsed - // this is an identity mapping. - let cell_index = app - .collapsed_cell_map - .get(filtered_cell_index) - .copied() - .unwrap_or(filtered_cell_index); - - let target = detail_target_label(app, cell_index) - .map(|label| truncate_line_to_width(&label, 28)) - .unwrap_or_else(|| "message".to_string()); - entries.push(ContextMenuEntry { - label: "Open details".to_string(), - description: target, - action: ContextMenuAction::OpenDetails { cell_index }, - }); - entries.push(ContextMenuEntry { - label: "Copy message".to_string(), - description: "write clicked transcript cell".to_string(), - action: ContextMenuAction::CopyCell { cell_index }, - }); - entries.push(ContextMenuEntry { - label: "Open in editor".to_string(), - description: "open file:line in $EDITOR".to_string(), - action: ContextMenuAction::OpenFileAtLine { cell_index }, - }); - // Hide/show cell toggle. - if app.collapsed_cells.contains(&cell_index) { - entries.push(ContextMenuEntry { - label: "Show cell".to_string(), - description: "unhide this transcript cell".to_string(), - action: ContextMenuAction::ShowCell { cell_index }, - }); - } else { - entries.push(ContextMenuEntry { - label: "Hide cell".to_string(), - description: "collapse this transcript cell".to_string(), - action: ContextMenuAction::HideCell { cell_index }, - }); - } - } - - // When cells are hidden, offer a way to show them all. - if !app.collapsed_cells.is_empty() { - let count = app.collapsed_cells.len(); - entries.push(ContextMenuEntry { - label: format!("Show hidden ({count})"), - description: "unhide all collapsed cells".to_string(), - action: ContextMenuAction::ShowAllHidden, - }); - } - - entries.push(ContextMenuEntry { - label: "Paste".to_string(), - description: "insert clipboard into composer".to_string(), - action: ContextMenuAction::Paste, - }); - entries.push(ContextMenuEntry { - label: "Command palette".to_string(), - description: "commands, skills, and tools".to_string(), - action: ContextMenuAction::OpenCommandPalette, - }); - entries.push(ContextMenuEntry { - label: "Context inspector".to_string(), - description: "active context and cache hints".to_string(), - action: ContextMenuAction::OpenContextInspector, - }); - entries.push(ContextMenuEntry { - label: "Help".to_string(), - description: "keybindings and commands".to_string(), - action: ContextMenuAction::OpenHelp, - }); - - entries -} - -fn transcript_cell_index_from_mouse(app: &App, mouse: MouseEvent) -> Option { - let point = selection_point_from_mouse(app, mouse)?; - app.viewport - .transcript_cache - .line_meta() - .get(point.line_index) - .and_then(|meta| meta.cell_line()) - .map(|(cell_index, _)| cell_index) -} - -fn handle_context_menu_action(app: &mut App, action: ContextMenuAction) { - match action { - ContextMenuAction::CopySelection => { - copy_active_selection(app); - } - ContextMenuAction::OpenSelection => { - if !open_pager_for_selection(app) { - app.status_message = Some("No selection to open".to_string()); - } - } - ContextMenuAction::ClearSelection => { - app.viewport.transcript_selection.clear(); - app.status_message = Some("Selection cleared".to_string()); - } - ContextMenuAction::CopyCell { cell_index } => { - copy_cell_to_clipboard(app, cell_index); - } - ContextMenuAction::OpenDetails { cell_index } => { - if !open_details_pager_for_cell(app, cell_index) { - app.status_message = Some("No details available for that line".to_string()); - } - } - ContextMenuAction::Paste => { - app.paste_from_clipboard(); - } - ContextMenuAction::OpenCommandPalette => { - app.view_stack - .push(CommandPaletteView::new(build_command_palette_entries( - app.ui_locale, - &app.skills_dir, - &app.workspace, - &app.mcp_config_path, - app.mcp_snapshot.as_ref(), - ))); - } - ContextMenuAction::OpenContextInspector => { - open_context_inspector(app); - } - ContextMenuAction::OpenHelp => { - app.view_stack.push(HelpView::new_for_locale(app.ui_locale)); - } - ContextMenuAction::OpenFileAtLine { cell_index } => { - let width = app - .viewport - .last_transcript_area - .map(|area| area.width) - .unwrap_or(80); - let text = history_cell_to_text( - app.cell_at_virtual_index(cell_index) - .unwrap_or(&HistoryCell::System { - content: String::new(), - }), - width, - ); - if crate::tui::history::try_open_file_at_line(&text, &app.workspace) { - app.status_message = Some("Opened file in editor".to_string()); - } else { - app.status_message = Some("No file:line pattern found in selection".to_string()); - } - } - ContextMenuAction::HideCell { cell_index } => { - app.collapsed_cells.insert(cell_index); - app.status_message = Some("Cell hidden".to_string()); - } - ContextMenuAction::ShowCell { cell_index } => { - app.collapsed_cells.remove(&cell_index); - app.status_message = Some("Cell shown".to_string()); - } - ContextMenuAction::ShowAllHidden => { - let count = app.collapsed_cells.len(); - app.collapsed_cells.clear(); - app.status_message = Some(format!("{count} hidden cell(s) restored")); - } - } - app.needs_redraw = true; -} - -fn selection_point_from_mouse(app: &App, mouse: MouseEvent) -> Option { - selection_point_from_position( - app.viewport.last_transcript_area?, - mouse.column, - mouse.row, - app.viewport.last_transcript_top, - app.viewport.last_transcript_total, - app.viewport.last_transcript_padding_top, - ) -} - -fn selection_point_from_position( - area: Rect, - column: u16, - row: u16, - transcript_top: usize, - transcript_total: usize, - padding_top: usize, -) -> Option { - if column < area.x - || column >= area.x + area.width - || row < area.y - || row >= area.y + area.height - { - return None; - } - - if transcript_total == 0 { - return None; - } - - let row = row.saturating_sub(area.y) as usize; - if row < padding_top { - return None; - } - let row = row.saturating_sub(padding_top); - - let col = column.saturating_sub(area.x) as usize; - let line_index = transcript_top - .saturating_add(row) - .min(transcript_total.saturating_sub(1)); - - Some(TranscriptSelectionPoint { - line_index, - column: col, - }) -} - -fn selection_has_content(app: &App) -> bool { - selection_to_text(app).is_some_and(|text| !text.is_empty()) -} - -/// Branches taken by the Ctrl+C key handler. The order encodes priority and is -/// the unit-tested contract for #1337 / #1367: a transcript selection always -/// wins (so users learn that Ctrl+C copies when there's something to copy); -/// otherwise an active turn is interrupted; otherwise the quit-arm flow runs. -#[derive(Debug, PartialEq, Eq)] -enum CtrlCDisposition { - CopySelection, - CancelTurn, - ConfirmExit, - ArmExit, -} - -fn ctrl_c_disposition(app: &App) -> CtrlCDisposition { - if selection_has_content(app) { - CtrlCDisposition::CopySelection - } else if app.is_loading { - CtrlCDisposition::CancelTurn - } else if app.quit_is_armed() { - CtrlCDisposition::ConfirmExit - } else { - CtrlCDisposition::ArmExit - } -} - -fn copy_active_selection(app: &mut App) { - if !app.viewport.transcript_selection.is_active() { - return; - } - if let Some(text) = selection_to_text(app).filter(|text| !text.is_empty()) { - if app.clipboard.write_text(&text).is_ok() { - app.status_message = Some("Selection copied".to_string()); - } else { - app.status_message = Some("Copy failed".to_string()); - } - } else { - app.viewport.transcript_selection.clear(); - app.status_message = Some("No selection to copy".to_string()); - } -} - -fn selection_to_text(app: &App) -> Option { - let (start, end) = app.viewport.transcript_selection.ordered_endpoints()?; - let lines = app.viewport.transcript_cache.lines(); - if lines.is_empty() { - return None; - } - let end_index = end.line_index.min(lines.len().saturating_sub(1)); - let start_index = start.line_index.min(end_index); - - let mut selected_lines = Vec::new(); - #[allow(clippy::needless_range_loop)] - for line_index in start_index..=end_index { - // Rail-prefix decorations are stored as cache metadata rather than - // detected from glyphs, so new decoration types are covered without - // changes to the copy path (#1163). - let rail_width = app.viewport.transcript_cache.rail_prefix_width(line_index); - // Convert the rendered line to plain text (strips OSC-8), then - // slice off the rail prefix so subsequent column offsets operate - // on content-only text. - let full_text = line_to_plain(&lines[line_index]); - let line_text = if rail_width > 0 { - slice_text(&full_text, rail_width, text_display_width(&full_text)) - } else { - full_text - }; - let line_width = text_display_width(&line_text); - // Selection coordinates are recorded in rendered-column space, which - // includes the visual rail prefix. Add rail_width back so the column - // window maps correctly into the rail-stripped text. - let (raw_col_start, raw_col_end) = if start_index == end_index { - (start.column, end.column) - } else if line_index == start_index { - (start.column, line_width.saturating_add(rail_width)) - } else if line_index == end_index { - (0, end.column) - } else { - (0, line_width.saturating_add(rail_width)) - }; - - let col_start = raw_col_start.saturating_sub(rail_width).min(line_width); - let col_end = raw_col_end.saturating_sub(rail_width).min(line_width); - - let slice = slice_text(&line_text, col_start, col_end); - selected_lines.push(slice); - } - Some(selected_lines.join("\n")) -} - -fn open_pager_for_selection(app: &mut App) -> bool { +pub(crate) fn open_pager_for_selection(app: &mut App) -> bool { let Some(text) = selection_to_text(app) else { return false; }; @@ -8710,7 +7195,7 @@ fn spillover_pager_section(app: &App, cell_index: usize) -> Option { )) } -fn open_details_pager_for_cell(app: &mut App, cell_index: usize) -> bool { +pub(crate) fn open_details_pager_for_cell(app: &mut App, cell_index: usize) -> bool { if let Some(detail) = app.tool_detail_record_for_cell(cell_index) { let input = serde_json::to_string_pretty(&detail.input) .unwrap_or_else(|_| detail.input.to_string()); @@ -8791,7 +7276,7 @@ fn copy_focused_cell(app: &mut App) -> bool { copy_cell_to_clipboard(app, index) } -fn copy_cell_to_clipboard(app: &mut App, cell_index: usize) -> bool { +pub(crate) fn copy_cell_to_clipboard(app: &mut App, cell_index: usize) -> bool { let Some(cell) = app.cell_at_virtual_index(cell_index) else { app.status_message = Some("No message at that line".to_string()); return false; @@ -8834,7 +7319,7 @@ fn detail_target_cell_index(app: &App) -> Option { .or_else(|| app.history.len().checked_sub(1)) } -fn selected_detail_footer_label(app: &App) -> Option { +pub(crate) fn selected_detail_footer_label(app: &App) -> Option { if app.viewport.transcript_selection.is_active() { return None; } @@ -8876,7 +7361,7 @@ fn activity_footer_target_cell_index(app: &App) -> Option { activity_target_cell_index(app) } -fn detail_target_label(app: &App, cell_index: usize) -> Option { +pub(crate) fn detail_target_label(app: &App, cell_index: usize) -> Option { if let Some(detail) = app.tool_detail_record_for_cell(cell_index) { return Some(detail.tool_name.clone()); } diff --git a/crates/tui/src/tui/ui/tests.rs b/crates/tui/src/tui/ui/tests.rs index 649ac859..deccfe34 100644 --- a/crates/tui/src/tui/ui/tests.rs +++ b/crates/tui/src/tui/ui/tests.rs @@ -8,17 +8,27 @@ use crate::tui::file_mention::{ apply_mention_menu_selection, find_file_mention_completions, partial_file_mention_at_cursor, try_autocomplete_file_mention, user_request_with_file_mentions, visible_mention_menu_entries, }; +use crate::tui::footer_ui::{ + active_tool_status_label, footer_auxiliary_spans, footer_cache_spans, footer_coherence_spans, + footer_state_label, footer_status_line_spans, format_context_budget, + format_token_count_compact, friendly_subagent_progress, render_footer_from, +}; use crate::tui::history::{ ExecCell, ExecSource, GenericToolCell, HistoryCell, ToolCell, ToolStatus, }; use crate::tui::views::{ModalView, ViewAction}; use crate::working_set::Workspace; +use crossterm::event::{KeyEvent, MouseButton, MouseEvent, MouseEventKind}; +use ratatui::text::Span; use std::collections::HashSet; use std::ffi::OsString; use std::path::PathBuf; use std::process::Command; use std::sync::MutexGuard; use std::time::{Duration, Instant}; +use unicode_width::UnicodeWidthStr; + +use crate::tui::selection::{SelectionAutoscroll, TranscriptSelectionPoint}; use tempfile::TempDir; struct ConfigPathEnvGuard { @@ -1847,6 +1857,40 @@ fn ctrl_alt_4_focuses_agents_sidebar_without_switching_modes() { assert_eq!(app.status_message.as_deref(), Some("Sidebar focus: agents")); } +#[test] +fn alt_0_restores_auto_sidebar_focus() { + let mut app = create_test_app(); + app.sidebar_focus = SidebarFocus::Hidden; + + apply_alt_0_shortcut(&mut app, KeyModifiers::ALT); + + assert_eq!(app.sidebar_focus, SidebarFocus::Auto); + assert_eq!(app.status_message.as_deref(), Some("Sidebar focus: auto")); +} + +#[test] +fn ctrl_alt_0_hides_sidebar() { + let mut app = create_test_app(); + app.sidebar_focus = SidebarFocus::Tasks; + + apply_alt_0_shortcut(&mut app, KeyModifiers::ALT | KeyModifiers::CONTROL); + + assert_eq!(app.sidebar_focus, SidebarFocus::Hidden); + assert_eq!(app.status_message.as_deref(), Some("Sidebar hidden")); +} + +#[test] +fn hidden_sidebar_focus_suppresses_sidebar_split_even_when_wide() { + let mut app = create_test_app(); + app.sidebar_width_percent = 28; + + app.sidebar_focus = SidebarFocus::Auto; + assert_eq!(sidebar_width_for_chat_area(&app, 120), Some(33)); + + app.sidebar_focus = SidebarFocus::Hidden; + assert_eq!(sidebar_width_for_chat_area(&app, 120), None); +} + fn make_subagent( id: &str, status: crate::tools::subagent::SubAgentStatus, @@ -2136,6 +2180,35 @@ fn footer_auxiliary_spans_show_cache_and_cost_when_roomy() { ); } +#[test] +fn footer_cache_low_hit_with_stable_prefix_is_not_error_colored() { + let mut app = create_test_app(); + app.session.last_prompt_tokens = Some(10_000); + app.session.last_prompt_cache_hit_tokens = Some(500); + app.session.last_prompt_cache_miss_tokens = Some(9_500); + app.prefix_stability_pct = Some(100); + app.prefix_change_count = 0; + + let spans = footer_cache_spans(&app); + + assert_eq!(spans_text(&spans), "Cache: 5.0% hit | hit 500 | miss 9500"); + assert_eq!(spans[0].style.fg, Some(palette::TEXT_MUTED)); +} + +#[test] +fn footer_cache_low_hit_with_prefix_churn_stays_error_colored() { + let mut app = create_test_app(); + app.session.last_prompt_tokens = Some(10_000); + app.session.last_prompt_cache_hit_tokens = Some(500); + app.session.last_prompt_cache_miss_tokens = Some(9_500); + app.prefix_stability_pct = Some(80); + app.prefix_change_count = 2; + + let spans = footer_cache_spans(&app); + + assert_eq!(spans[0].style.fg, Some(palette::STATUS_ERROR)); +} + #[test] fn footer_auxiliary_spans_show_tiny_positive_cost_when_roomy() { let mut app = create_test_app(); @@ -4855,6 +4928,53 @@ fn render_footer_from_with_default_items_renders_mode_and_model() { assert_eq!(spans_text(&props.cost), "<$0.0001"); } +#[test] +fn default_footer_includes_prefix_stability_before_cache() { + let items = crate::config::StatusItem::default_footer(); + let prefix = items + .iter() + .position(|item| *item == crate::config::StatusItem::PrefixStability) + .expect("default footer includes prefix stability"); + let cache = items + .iter() + .position(|item| *item == crate::config::StatusItem::Cache) + .expect("default footer includes cache"); + + assert!(prefix < cache); +} + +#[test] +fn render_footer_from_prefix_stability_item_renders_cache_slot_chip() { + let mut app = create_test_app(); + app.prefix_stability_pct = Some(100); + app.prefix_change_count = 0; + + let props = render_footer_from(&app, &[crate::config::StatusItem::PrefixStability], None); + + assert_eq!(spans_text(&props.cache), "P 100%"); +} + +#[test] +fn render_footer_from_preserves_prefix_then_cache_order() { + let mut app = create_test_app(); + app.prefix_stability_pct = Some(100); + app.prefix_change_count = 0; + app.session.last_prompt_tokens = Some(10_000); + app.session.last_prompt_cache_hit_tokens = Some(9_000); + app.session.last_prompt_cache_miss_tokens = Some(1_000); + + let props = render_footer_from( + &app, + &[ + crate::config::StatusItem::PrefixStability, + crate::config::StatusItem::Cache, + ], + None, + ); + + assert!(spans_text(&props.cache).starts_with("P 100% Cache: 90.0% hit")); +} + #[test] fn render_footer_from_with_empty_items_blanks_every_segment() { // A user who toggles every chip OFF should get a bare footer (no model diff --git a/crates/tui/src/tui/ui_text.rs b/crates/tui/src/tui/ui_text.rs index b07e4929..c437319e 100644 --- a/crates/tui/src/tui/ui_text.rs +++ b/crates/tui/src/tui/ui_text.rs @@ -1,11 +1,48 @@ //! Shared text helpers for TUI selection and clipboard workflows. use ratatui::text::{Line, Span}; -use unicode_width::UnicodeWidthChar; +use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; use crate::tui::history::HistoryCell; use crate::tui::osc8; +pub(crate) fn truncate_line_to_width(text: &str, max_width: usize) -> String { + if max_width == 0 { + return String::new(); + } + if UnicodeWidthStr::width(text) <= max_width { + return text.to_string(); + } + // For very small budgets, take chars until we exceed the *display* width. + if max_width <= 3 { + let mut out = String::new(); + let mut width = 0usize; + for ch in text.chars() { + let ch_width = UnicodeWidthChar::width(ch).unwrap_or(0); + if width + ch_width > max_width { + break; + } + out.push(ch); + width += ch_width; + } + return out; + } + + let mut out = String::new(); + let mut width = 0usize; + let limit = max_width.saturating_sub(3); + for ch in text.chars() { + let ch_width = UnicodeWidthChar::width(ch).unwrap_or(0); + if width + ch_width > limit { + break; + } + out.push(ch); + width += ch_width; + } + out.push_str("..."); + out +} + pub(super) fn history_cell_to_text(cell: &HistoryCell, width: u16) -> String { cell.transcript_lines(width) .into_iter() diff --git a/crates/tui/src/tui/views/mod.rs b/crates/tui/src/tui/views/mod.rs index 89090fa6..f5635f10 100644 --- a/crates/tui/src/tui/views/mod.rs +++ b/crates/tui/src/tui/views/mod.rs @@ -1109,7 +1109,7 @@ fn config_hint_for_key(key: &str) -> &'static str { "background_color" => "#RRGGBB | default", "default_mode" => "agent | plan | yolo", "sidebar_width" => "10..=50", - "sidebar_focus" => "auto | work | tasks | agents | context", + "sidebar_focus" => "auto | work | tasks | agents | context | hidden", "max_history" => "integer (0 allowed)", "default_model" => "deepseek-v4-pro | deepseek-v4-flash | deepseek-* | none/default", "mcp_config_path" => "path to mcp.json", diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index a5931c27..d68dc6c2 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -353,10 +353,12 @@ Common settings keys: context panel, `/cost`, `/tokens`, and long-turn notification summaries. The aliases `rmb` and `yuan` normalize to `cny`. - `default_mode` (agent, plan, yolo; legacy `normal` is accepted and normalized to `agent`) -- `sidebar_focus` (`auto`, `work`, `tasks`, `agents`, `context`; default +- `sidebar_focus` (`auto`, `work`, `tasks`, `agents`, `context`, `hidden`; default `auto`): selects the right sidebar focus. `auto` prioritizes Work, Tasks, Agents, then optional Context, and uses Work as the single quiet empty state. - Legacy `plan` and `todos` values are accepted and normalized to `work`. + `hidden` disables the right sidebar entirely so raw terminal selection cannot + cross from the transcript into sidebar borders. Legacy `plan` and `todos` + values are accepted and normalized to `work`. - `max_history` (number of submitted input history entries; cleared drafts are also kept locally for composer history search) - `default_model` (model name override) @@ -517,7 +519,7 @@ If you are upgrading from older releases: `false`. When `true`, the notification body includes the elapsed duration and the turn's cost in the configured display currency. - `tui.alternate_screen` (string, optional): `auto`, `always`, or `never`. This is retained for config compatibility, but interactive sessions now always use the TUI-owned alternate screen so host terminal scrollback cannot hijack the viewport. -- `tui.mouse_capture` (bool, optional, default `true` on non-Windows terminals when the alternate screen is active; `false` on Windows and inside JetBrains JediTerm — PyCharm/IDEA/CLion/etc. — where mouse-event escapes leak into the input stream as garbled text, see #878 / #898): enable internal mouse scrolling, transcript selection, right-click context actions, and transcript scrollbar dragging. TUI-owned drag selection copies only transcript text and keeps selection scoped to the transcript pane. Set this to `false` or run with `--no-mouse-capture` for raw terminal selection; set it to `true` or run with `--mouse-capture` to opt in anywhere it's defaulted off. On Windows, raw terminal selection may cross the right sidebar because the terminal, not the TUI, owns the selection. +- `tui.mouse_capture` (bool, optional, default `true` on non-Windows terminals and on Windows Terminal/ConEmu/Cmder when the alternate screen is active; `false` on legacy Windows console and inside JetBrains JediTerm — PyCharm/IDEA/CLion/etc. — where mouse-event escapes leak into the input stream as garbled text, see #878 / #898): enable internal mouse scrolling, transcript selection, right-click context actions, and transcript scrollbar dragging. TUI-owned drag selection copies only transcript text and keeps selection scoped to the transcript pane. Set this to `false` or run with `--no-mouse-capture` for raw terminal selection; set it to `true` or run with `--mouse-capture` to opt in anywhere it's defaulted off. On raw terminal selection, especially on legacy Windows console or when mouse capture is disabled, selection may cross the right sidebar because the terminal, not the TUI, owns the selection. - `tui.terminal_probe_timeout_ms` (int, optional, default `500`): startup terminal-mode probe timeout in milliseconds. Values are clamped to `100..=5000`; timeout emits a warning and aborts startup instead of hanging indefinitely. - `tui.osc8_links` (bool, optional, default `true`): emit OSC 8 escape sequences around URLs in transcript output so terminals that support them (iTerm2, Terminal.app 13+, Ghostty, Kitty, WezTerm, Alacritty, recent gnome-terminal/konsole) render them as Cmd+click hyperlinks. Terminals without OSC 8 support render the plain URL and ignore the escape. Set `false` for terminals that misrender the sequence; selection/clipboard output always strips the escapes. - `hooks` (optional): lifecycle hooks configuration (see `config.example.toml`). diff --git a/docs/KEYBINDINGS.md b/docs/KEYBINDINGS.md index eaf37724..0fd9ddf0 100644 --- a/docs/KEYBINDINGS.md +++ b/docs/KEYBINDINGS.md @@ -18,6 +18,8 @@ Bindings are not (yet) user-configurable — tracked for a future release (#436, | `Ctrl-L` | Refresh / clear the screen | | `Ctrl-O` | Open Activity Detail for selected/live/recent tool work, or the full reasoning timeline for thinking blocks when the composer is empty | | `Ctrl-Shift-E` / `Cmd-Shift-E` | Toggle the file-tree sidebar | +| `Alt-!` / `Alt-@` / `Alt-#` / `Alt-$` / `Alt-0` | Focus Work / Tasks / Agents / Context / Auto sidebar | +| `Ctrl-Alt-0` | Hide the right sidebar | | `Esc` | Close topmost modal · cancel slash menu · dismiss toast | ## Composer diff --git a/docs/MODES.md b/docs/MODES.md index 1e22f1d4..c8176f71 100644 --- a/docs/MODES.md +++ b/docs/MODES.md @@ -87,7 +87,7 @@ Run `deepseek --help` for the canonical list. Common flags: - `-r, --resume `: resume a saved session - `-c, --continue`: resume the most recent session in this workspace - `--max-subagents `: clamp to `1..=20` -- `--mouse-capture` / `--no-mouse-capture`: opt in or out of internal mouse scrolling, transcript selection, right-click context actions, and transcript scrollbar dragging. Mouse capture is enabled by default on non-Windows terminals so drag selection copies only transcript text and stays scoped to the transcript pane; hold Shift while dragging or use `--no-mouse-capture` for raw terminal selection. It defaults off on Windows (CMD/terminal mouse-escape spam in the prompt) and inside JetBrains JediTerm — PyCharm/IDEA/CLion/etc. — where the terminal advertises mouse support but forwards SGR mouse events as raw text (#878, #898). Use `--mouse-capture` to opt in anywhere it's defaulted off. On Windows, raw terminal selection may cross the right sidebar because the terminal, not the TUI, owns the selection. +- `--mouse-capture` / `--no-mouse-capture`: opt in or out of internal mouse scrolling, transcript selection, right-click context actions, and transcript scrollbar dragging. Mouse capture is enabled by default on non-Windows terminals and on Windows Terminal/ConEmu/Cmder so drag selection copies only transcript text and stays scoped to the transcript pane; hold Shift while dragging or use `--no-mouse-capture` for raw terminal selection. It defaults off on legacy Windows console (CMD without `WT_SESSION` / `ConEmuPID`) and inside JetBrains JediTerm — PyCharm/IDEA/CLion/etc. — where the terminal advertises mouse support but forwards SGR mouse events as raw text (#878, #898). Use `--mouse-capture` to opt in anywhere it's defaulted off. Raw terminal selection may cross the right sidebar because the terminal, not the TUI, owns the selection. - `--profile `: select config profile - `--config `: config file path - `-v, --verbose`: verbose logging diff --git a/npm/deepseek-tui/package.json b/npm/deepseek-tui/package.json index 476826a6..5295ad1b 100644 --- a/npm/deepseek-tui/package.json +++ b/npm/deepseek-tui/package.json @@ -1,7 +1,7 @@ { "name": "deepseek-tui", - "version": "0.8.35", - "deepseekBinaryVersion": "0.8.35", + "version": "0.8.36", + "deepseekBinaryVersion": "0.8.36", "description": "Install and run deepseek and deepseek-tui binaries from GitHub release artifacts.", "author": "Hmbown", "license": "MIT",