chore(release): prepare v0.8.36

Squash merge of work/v0.8.36-cache-hygiene into main.

All preflight gates passed: version-drift/check/lint/test (3073 pass, 0 fail) / CodeQL / GitGuardian / npm-smoke. Preparing the v0.8.36 release tag.
This commit is contained in:
Hunter Bown
2026-05-14 00:31:18 -05:00
committed by GitHub
parent 6b5011a722
commit d5c45d962d
44 changed files with 2258 additions and 1703 deletions
+25 -1
View File
@@ -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 `<deepseek:subagent.done>` 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
Generated
+14 -14
View File
@@ -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"
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+9 -9
View File
@@ -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
+7 -7
View File
@@ -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
+1 -1
View File
@@ -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
+8 -8
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+12 -6
View File
@@ -202,19 +202,25 @@ impl InMemoryKeyringStore {
impl KeyringStore for InMemoryKeyringStore {
fn get(&self, key: &str) -> Result<Option<String>, 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(())
}
+1 -1
View File
@@ -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
+25 -1
View File
@@ -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 `<deepseek:subagent.done>` 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
+2 -2
View File
@@ -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"
+3
View File
@@ -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,
+14 -6
View File
@@ -561,12 +561,13 @@ pub struct SearchConfig {
///
/// Order in the user's `Vec<StatusItem>` 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,
+6
View File
@@ -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<StatusItem> 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<StatusItemValue> 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,
+11 -5
View File
@@ -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",
+8 -6
View File
@@ -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 `<deepseek:subagent.done>` 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 `<deepseek:subagent.done>`, read the `summary` field first.
1. When you see `<deepseek:subagent.done>`, 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.
+170 -17
View File
@@ -74,11 +74,15 @@ pub enum RpcRequest {
#[serde(default)]
system: Option<String>,
},
/// `llm_query_batched(prompts, model=None)`
/// `llm_query_batched(prompts, model=None, dependency_mode="independent")`
LlmBatch {
prompts: Vec<String>,
#[serde(default)]
model: Option<String>,
#[serde(default)]
dependency_mode: Option<String>,
#[serde(default)]
safety_note: Option<String>,
},
/// `rlm_query(prompt, model=None)` — recursive sub-RLM (paper's `sub_RLM`).
Rlm {
@@ -86,11 +90,15 @@ pub enum RpcRequest {
#[serde(default)]
model: Option<String>,
},
/// `rlm_query_batched(prompts, model=None)`
/// `rlm_query_batched(prompts, model=None, dependency_mode="independent")`
RlmBatch {
prompts: Vec<String>,
#[serde(default)]
model: Option<String>,
#[serde(default)]
dependency_mode: Option<String>,
#[serde(default)]
safety_note: Option<String>,
},
}
@@ -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");
+82 -14
View File
@@ -151,8 +151,13 @@ impl RlmBridge {
SingleResp { text, error: None }
}
async fn dispatch_llm_batch(&self, prompts: Vec<String>, _model: Option<String>) -> BatchResp {
if let Some(resp) = batch_guard(prompts.len()) {
async fn dispatch_llm_batch(
&self,
prompts: Vec<String>,
_model: Option<String>,
dependency_mode: Option<String>,
) -> 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<String>, _model: Option<String>) -> BatchResp {
if let Some(resp) = batch_guard(prompts.len()) {
async fn dispatch_rlm_batch(
&self,
prompts: Vec<String>,
_model: Option<String>,
dependency_mode: Option<String>,
) -> 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<BatchResp> {
fn batch_guard(prompt_count: usize, dependency_mode: Option<&str>) -> Option<BatchResp> {
if prompt_count == 0 {
return Some(BatchResp { results: vec![] });
}
@@ -245,6 +255,27 @@ fn batch_guard(prompt_count: usize) -> Option<BatchResp> {
.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;
+18 -2
View File
@@ -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();
+14 -4
View File
@@ -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());
+11 -3
View File
@@ -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");
+3 -1
View File
@@ -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 {
+11 -6
View File
@@ -3264,25 +3264,30 @@ pub(crate) fn emit_parent_completion(
/// Build a `<deepseek:subagent.done>` 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!("<deepseek:subagent.done>{payload}</deepseek:subagent.done>")
}
/// Build a `<deepseek:subagent.done>` 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!("<deepseek:subagent.done>{payload}</deepseek:subagent.done>")
}
+12 -1
View File
@@ -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"
);
}
+6
View File
@@ -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]
+141
View File
@@ -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);
}
_ => {}
}
}
+1 -1
View File
@@ -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
+764
View File
@@ -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<String> {
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<String> {
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<String>,
primary_any: Option<String>,
running: usize,
completed: usize,
started_at: Option<Instant>,
}
impl ActiveToolStatusSnapshot {
fn record(&mut self, label: String, status: ToolStatus, started_at: Option<Instant>) {
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<String> {
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::<Vec<_>>().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<FooterToast>,
) -> 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<Span<'static>> = 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<Span<'static>> {
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<Span<'static>> {
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<Span<'static>> {
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<Span<'static>> {
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<Span<'static>> {
// 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<Span<'static>>> = [
&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<Span<'static>> {
// 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<Span<'static>> {
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<Span<'static>> {
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<Span<'static>> {
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()
}
+1 -1
View File
@@ -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,
},
+4 -1
View File
@@ -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;
+640
View File
@@ -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<ViewEvent> {
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<Rect>) -> 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<ContextMenuEntry> {
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<usize> {
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<TranscriptSelectionPoint> {
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<TranscriptSelectionPoint> {
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<String> {
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"))
}
+1 -1
View File
@@ -419,7 +419,7 @@ pub fn text_summary(text: &str) -> Option<String> {
let collapsed = sanitized
.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
.filter(|line: &&str| !line.is_empty())
.collect::<Vec<_>>()
.join("\n");
let trimmed = collapsed.trim();
+4 -1
View File
@@ -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()),
}
}
+56 -1571
View File
File diff suppressed because it is too large Load Diff
+120
View File
@@ -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
+38 -1
View File
@@ -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()
+1 -1
View File
@@ -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",
+5 -3
View File
@@ -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`).
+2
View File
@@ -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
+1 -1
View File
@@ -87,7 +87,7 @@ Run `deepseek --help` for the canonical list. Common flags:
- `-r, --resume <ID|PREFIX|latest>`: resume a saved session
- `-c, --continue`: resume the most recent session in this workspace
- `--max-subagents <N>`: 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 <NAME>`: select config profile
- `--config <PATH>`: config file path
- `-v, --verbose`: verbose logging
+2 -2
View File
@@ -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",