diff --git a/Cargo.lock b/Cargo.lock index 68922611..04a90b19 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2128,6 +2128,7 @@ dependencies = [ "diffy", "fs2", "futures", + "lru", "pretty_assertions", "rusqlite", "serde", diff --git a/Cargo.toml b/Cargo.toml index 8ff60d08..00bb29b9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -102,6 +102,7 @@ itertools = "0.14.0" keyring = { version = "3.6", default-features = false } lazy_static = "1" libc = "0.2.182" +lru = "0.12" multimap = "0.10.1" model2vec = "0.3.0" notify = "8.2.0" diff --git a/README.ja.md b/README.ja.md index 4fc2d2ef..3006de98 100644 --- a/README.ja.md +++ b/README.ja.md @@ -267,16 +267,6 @@ TUI/CLI を使ってください。どちらの surface も同じローカル De 変更について議論するには issue または pull request を開いてください。 -## Star 履歴 - - - - - - Star History Chart - - - ## ライセンス このプロジェクトは [MIT License](./LICENSE) のもとで公開されています。 diff --git a/README.md b/README.md index adf3b2eb..5a804704 100644 --- a/README.md +++ b/README.md @@ -280,16 +280,6 @@ Contributions are welcome while the project is still early: Open an issue or pull request to discuss changes. -## Star History - - - - - - Star History Chart - - - ## License This project is licensed under the [MIT License](./LICENSE). diff --git a/README.ru.md b/README.ru.md index c70c5f30..fd9c9076 100644 --- a/README.ru.md +++ b/README.ru.md @@ -281,16 +281,6 @@ Devo runtime. Откройте issue или pull request, чтобы обсудить изменения. -## История звезд - - - - - - Star History Chart - - - ## Лицензия Проект распространяется по [MIT License](./LICENSE). diff --git a/README.zh-Hans.md b/README.zh-Hans.md index 507c49ad..055bdcc3 100644 --- a/README.zh-Hans.md +++ b/README.zh-Hans.md @@ -256,16 +256,6 @@ provider 与 binding,把 key 存入用户级 `auth.json`,并将 `invocation_ 请打开 issue 或 pull request 讨论变更。 -## Star 历史 - - - - - - Star History Chart - - - ## 许可证 本项目采用 [MIT License](./LICENSE) 授权。 diff --git a/README.zh-Hant.md b/README.zh-Hant.md index d18d70b3..5365b29a 100644 --- a/README.zh-Hant.md +++ b/README.zh-Hant.md @@ -256,16 +256,6 @@ provider 與 binding,把 key 存入使用者級 `auth.json`,並將 `invocati 請開啟 issue 或 pull request 討論變更。 -## Star 歷史 - - - - - - Star History Chart - - - ## 授權 本專案採用 [MIT License](./LICENSE) 授權。 diff --git a/apps/desktop/packages/devo-ai-sdk/src/v2/client.ts b/apps/desktop/packages/devo-ai-sdk/src/v2/client.ts index e2b9ead8..a61976aa 100644 --- a/apps/desktop/packages/devo-ai-sdk/src/v2/client.ts +++ b/apps/desktop/packages/devo-ai-sdk/src/v2/client.ts @@ -45,10 +45,7 @@ import type { GoalStatusResult, InputItem, ThreadGoalStatus, - TurnQueueRemoveResult, - TurnQueueSteerResult, TurnStartResult, - TurnSteerResult, RequestUserInputRespondParams, WorkspaceChangeCoverage, WorkspaceChangeScope, @@ -836,8 +833,6 @@ class AcpClient { } turn = { - // User requirement: busy composer follow-ups can be queued first, then converted - // to steer from the composer status stack without creating transcript-only state. start: async (params: { sessionID: string parts: PromptPartInput[] @@ -860,37 +855,6 @@ class AcpClient { })) as TurnStartResult return { data: result } }, - steer: async (params: { - sessionID: string - expectedTurnID: string - parts: PromptPartInput[] - }) => { - const result = (await this.request("turn/steer", { - session_id: params.sessionID, - expected_turn_id: params.expectedTurnID, - input: inputItemsFromPromptParts(params.parts), - })) as TurnSteerResult - return { data: result } - }, - removeQueued: async (params: { sessionID: string; queuedInputID: string }) => { - const result = (await this.request("turn/queue/remove", { - session_id: params.sessionID, - queued_input_id: params.queuedInputID, - })) as TurnQueueRemoveResult - return { data: result } - }, - steerQueued: async (params: { - sessionID: string - expectedTurnID: string - queuedInputID: string - }) => { - const result = (await this.request("turn/queue/steer", { - session_id: params.sessionID, - expected_turn_id: params.expectedTurnID, - queued_input_id: params.queuedInputID, - })) as TurnQueueSteerResult - return { data: result } - }, } permission = { diff --git a/apps/web/content/docs/using-devo/model-picker.mdx b/apps/web/content/docs/using-devo/model-picker.mdx index 9a9a9a96..4386e6c6 100644 --- a/apps/web/content/docs/using-devo/model-picker.mdx +++ b/apps/web/content/docs/using-devo/model-picker.mdx @@ -9,8 +9,16 @@ Use `/model` to switch the active model for the current session. /model ``` -The picker opens in the bottom pane. Use `Up` and `Down` to move, `Enter` to -select, and `Esc` to close without changing the model. +The picker opens in the bottom pane and replaces the composer input area: + +- `Up` / `Down` (or `j` / `k`) move through the model list +- `Left` / `Right` cycle reasoning effort for the focused model when that model + supports it +- `Enter` applies the focused model and selected effort together +- `Esc` closes without changing the model + +When there are many saved bindings, the model list shows at most eight rows and +scrolls so the focused row stays visible. ## What The Picker Shows @@ -36,16 +44,16 @@ match either, Devo still sets the requested model name for the turn context. ## Reasoning Effort -Some models expose configurable reasoning effort. When you select one of those -models, Devo may open a second picker for reasoning effort before finalizing -the model change. +Some models expose configurable reasoning effort. For those models, the picker +shows a horizontal effort strip below the model list. Changing the focused +model updates the available options; if the previously selected effort is still +supported it is preserved, otherwise Devo falls back to that model's default. -The reasoning effort picker uses the model's configured capability and reasoning-effort -options. If the model does not expose options, Devo applies the model -immediately. +If the focused model does not expose effort options, the strip is hidden and +`Enter` applies the model alone. -After selection, Devo updates the header and sends the model and reasoning effort -selection to the runtime as turn context. +After confirmation, Devo updates the header and sends the model and reasoning +effort selection to the runtime as turn context. ## Session Scope diff --git a/apps/web/content/docs/using-devo/model-picker.zh.mdx b/apps/web/content/docs/using-devo/model-picker.zh.mdx index 64541065..041b28ba 100644 --- a/apps/web/content/docs/using-devo/model-picker.zh.mdx +++ b/apps/web/content/docs/using-devo/model-picker.zh.mdx @@ -9,7 +9,14 @@ description: 在已配置的 model bindings 之间切换。 /model ``` -Picker 会在底部 pane 打开。使用 `Up` 和 `Down` 移动,`Enter` 选择,`Esc` 关闭且不修改模型。 +Picker 会在底部 pane 打开,并替换 composer 输入区域: + +- `Up` / `Down`(或 `j` / `k`)在模型列表中移动 +- `Left` / `Right` 在当前聚焦模型支持的推理强度选项间切换 +- `Enter` 一次应用聚焦的模型与所选推理强度 +- `Esc` 关闭且不修改模型 + +当保存的 binding 较多时,模型列表最多显示 8 行,并滚动以保持聚焦行可见。 ## Picker 显示什么 @@ -29,11 +36,11 @@ Picker 来自保存的 model bindings。每个条目显示 display name,并在 ## Reasoning Effort -某些模型暴露 configurable reasoning effort。当你选择这些模型时,Devo 可能先打开第二个 picker 选择 reasoning effort,再完成模型切换。 +某些模型暴露 configurable reasoning effort。对这些模型,picker 在模型列表下方显示横向推理强度选项。切换聚焦模型时会更新可用选项;若先前选中的强度仍被支持则保留,否则回退到该模型的默认值。 -Reasoning effort picker 使用模型配置的 capability 和 reasoning-effort options。如果模型没有暴露选项,Devo 会直接应用模型。 +若聚焦模型没有暴露选项,强度行会隐藏,`Enter` 只应用模型。 -选择后,Devo 更新 header,并把 model 和 reasoning effort selection 作为 turn context 发送给 runtime。 +确认后,Devo 更新 header,并把 model 和 reasoning effort selection 作为 turn context 发送给 runtime。 ## Session Scope diff --git a/crates/cli/src/agent_command.rs b/crates/cli/src/agent_command.rs index dd7a0be0..edbc884e 100644 --- a/crates/cli/src/agent_command.rs +++ b/crates/cli/src/agent_command.rs @@ -92,6 +92,8 @@ pub(crate) async fn run_agent( reasoning_effort_selection: model_reasoning_effort_selection, permission_preset, sandbox_profile, + compaction_token_limit: app_config.compaction_token_limit, + default_collaboration_mode: app_config.default_collaboration_mode, // TODO: why do we need cwd here, maybe remove it ? cwd, }, diff --git a/crates/cli/src/prompt_command.rs b/crates/cli/src/prompt_command.rs index b2281414..56771e07 100644 --- a/crates/cli/src/prompt_command.rs +++ b/crates/cli/src/prompt_command.rs @@ -117,7 +117,7 @@ pub(crate) async fn run_prompt( session_id: session_state.id.clone(), transcript_path: String::new(), cwd: cwd.clone(), - permission_mode: Some("auto-approve".to_string()), + permission_mode: Some("yolo".to_string()), agent_id: None, agent_type: None, }, @@ -499,7 +499,7 @@ fn write_query_event_jsonl(session_id: &str, event: &QueryEvent) -> Result<()> { QueryEvent::ContextCompactionStarted => { write_jsonl(&PromptJsonlEvent::ContextCompactionStarted { session_id }) } - QueryEvent::ContextCompactionCompleted => { + QueryEvent::ContextCompactionCompleted { .. } => { write_jsonl(&PromptJsonlEvent::ContextCompactionCompleted { session_id }) } QueryEvent::ContextCompactionFailed { message } => { diff --git a/crates/client/README.md b/crates/client/README.md index 32fadf7b..153b286d 100644 --- a/crates/client/README.md +++ b/crates/client/README.md @@ -41,6 +41,7 @@ The short `ws://` listen target uses `127.0.0.1:3210`. `session_permissions_update`, `session_sandbox_profile_update`: update session metadata shown by clients. - `session_compact`, `session_fork`, `session_rollback`: manage session history + (`session_compact` returns `TurnStartResult` for the compaction turn) and derived sessions. - `agent_list`, `agent_spawn`, `agent_close`: inspect and manage background agents. diff --git a/crates/client/src/client_core.rs b/crates/client/src/client_core.rs index 5c4b2bb5..74ee9520 100644 --- a/crates/client/src/client_core.rs +++ b/crates/client/src/client_core.rs @@ -464,6 +464,13 @@ impl ServerClientCore { .await } + pub(crate) async fn session_compaction_update( + &mut self, + params: SessionCompactionUpdateParams, + ) -> Result { + self.request_devo("session/compaction/update", params).await + } + pub(crate) async fn session_sandbox_profile_update( &mut self, params: SessionSandboxProfileUpdateParams, @@ -475,10 +482,17 @@ impl ServerClientCore { pub(crate) async fn session_compact( &mut self, params: SessionCompactParams, - ) -> Result { + ) -> Result { self.request_devo("session/compact", params).await } + pub(crate) async fn session_cancel( + &mut self, + params: AcpCancelParams, + ) -> Result { + self.request(ACP_SESSION_CANCEL_METHOD, params).await + } + pub(crate) async fn goal_create( &mut self, params: GoalCreateParams, @@ -652,8 +666,46 @@ impl ServerClientCore { self.request_devo("turn/interrupt", params).await } - pub(crate) async fn turn_steer(&mut self, params: TurnSteerParams) -> Result { - self.request_devo("turn/steer", params).await + pub(crate) async fn session_queue_push( + &mut self, + params: canonical::rpc_turn::SessionQueuePushParams, + ) -> Result { + self.request_devo("session/queue/push", params).await + } + + pub(crate) async fn session_queue_list( + &mut self, + params: canonical::rpc_turn::SessionQueueListParams, + ) -> Result { + self.request_devo("session/queue/list", params).await + } + + pub(crate) async fn session_queue_update( + &mut self, + params: canonical::rpc_turn::SessionQueueUpdateParams, + ) -> Result { + self.request_devo("session/queue/update", params).await + } + + pub(crate) async fn session_queue_remove( + &mut self, + params: canonical::rpc_turn::SessionQueueRemoveParams, + ) -> Result { + self.request_devo("session/queue/remove", params).await + } + + pub(crate) async fn session_queue_steer( + &mut self, + params: canonical::rpc_turn::SessionQueueSteerParams, + ) -> Result { + self.request_devo("session/queue/steer", params).await + } + + pub(crate) async fn subscription_create( + &mut self, + params: canonical::event::SubscriptionCreateParams, + ) -> Result { + self.request_devo("subscription/create", params).await } pub(crate) async fn reference_search_start( @@ -1028,8 +1080,11 @@ fn acp_session_metadata_from_start_params( prompt_token_estimate: 0, last_query_usage: None, last_query_total_tokens: 0, + last_context_occupancy: None, status: SessionRuntimeStatus::Idle, collaboration_mode: Default::default(), + effective_context_window: None, + permission_preset: None, } } @@ -1066,8 +1121,11 @@ fn acp_session_metadata_from_session_info(session_info: &AcpSessionInfo) -> Sess prompt_token_estimate: 0, last_query_usage: None, last_query_total_tokens: 0, + last_context_occupancy: None, status: SessionRuntimeStatus::Idle, collaboration_mode: Default::default(), + effective_context_window: None, + permission_preset: None, } } diff --git a/crates/client/src/stdio.rs b/crates/client/src/stdio.rs index 85ea727d..f1a7638d 100644 --- a/crates/client/src/stdio.rs +++ b/crates/client/src/stdio.rs @@ -184,6 +184,13 @@ impl StdioServerClient { self.core.session_permissions_update(params).await } + pub async fn session_compaction_update( + &mut self, + params: SessionCompactionUpdateParams, + ) -> Result { + self.core.session_compaction_update(params).await + } + pub async fn session_sandbox_profile_update( &mut self, params: SessionSandboxProfileUpdateParams, @@ -194,10 +201,14 @@ impl StdioServerClient { pub async fn session_compact( &mut self, params: SessionCompactParams, - ) -> Result { + ) -> Result { self.core.session_compact(params).await } + pub async fn session_cancel(&mut self, params: AcpCancelParams) -> Result { + self.core.session_cancel(params).await + } + pub async fn goal_create(&mut self, params: GoalCreateParams) -> Result { self.core.goal_create(params).await } @@ -357,8 +368,46 @@ impl StdioServerClient { self.core.turn_interrupt(params).await } - pub async fn turn_steer(&mut self, params: TurnSteerParams) -> Result { - self.core.turn_steer(params).await + pub async fn session_queue_push( + &mut self, + params: canonical::rpc_turn::SessionQueuePushParams, + ) -> Result { + self.core.session_queue_push(params).await + } + + pub async fn session_queue_list( + &mut self, + params: canonical::rpc_turn::SessionQueueListParams, + ) -> Result { + self.core.session_queue_list(params).await + } + + pub async fn session_queue_update( + &mut self, + params: canonical::rpc_turn::SessionQueueUpdateParams, + ) -> Result { + self.core.session_queue_update(params).await + } + + pub async fn session_queue_remove( + &mut self, + params: canonical::rpc_turn::SessionQueueRemoveParams, + ) -> Result { + self.core.session_queue_remove(params).await + } + + pub async fn session_queue_steer( + &mut self, + params: canonical::rpc_turn::SessionQueueSteerParams, + ) -> Result { + self.core.session_queue_steer(params).await + } + + pub async fn subscription_create( + &mut self, + params: canonical::event::SubscriptionCreateParams, + ) -> Result { + self.core.subscription_create(params).await } pub async fn approval_respond(&mut self, params: ApprovalResponseParams) -> Result<()> { @@ -691,8 +740,11 @@ mod tests { prompt_token_estimate: 0, last_query_usage: None, last_query_total_tokens: 0, + last_context_occupancy: None, status: SessionRuntimeStatus::Idle, collaboration_mode: Default::default(), + effective_context_window: None, + permission_preset: None, }; assert_eq!(session, expected); @@ -783,8 +835,11 @@ mod tests { prompt_token_estimate: 0, last_query_usage: None, last_query_total_tokens: 0, + last_context_occupancy: None, status: SessionRuntimeStatus::Idle, collaboration_mode: Default::default(), + effective_context_window: None, + permission_preset: None, }] ); @@ -833,8 +888,11 @@ mod tests { prompt_token_estimate: 0, last_query_usage: None, last_query_total_tokens: 0, + last_context_occupancy: None, status: SessionRuntimeStatus::Idle, collaboration_mode: Default::default(), + effective_context_window: None, + permission_preset: None, }; let mut stdout_lines = BufReader::new(stdout).lines(); diff --git a/crates/client/src/websocket.rs b/crates/client/src/websocket.rs index db462eca..98901be1 100644 --- a/crates/client/src/websocket.rs +++ b/crates/client/src/websocket.rs @@ -166,6 +166,15 @@ impl WebSocketServerClient { .await } + pub async fn session_compaction_update( + &mut self, + params: SessionCompactionUpdateParams, + ) -> Result { + self.core + .request_devo("session/compaction/update", params) + .await + } + pub async fn session_sandbox_profile_update( &mut self, params: SessionSandboxProfileUpdateParams, @@ -178,10 +187,16 @@ impl WebSocketServerClient { pub async fn session_compact( &mut self, params: SessionCompactParams, - ) -> Result { + ) -> Result { self.core.request_devo("session/compact", params).await } + pub async fn session_cancel(&mut self, params: AcpCancelParams) -> Result { + self.core + .request(devo_protocol::ACP_SESSION_CANCEL_METHOD, params) + .await + } + pub async fn goal_create(&mut self, params: GoalCreateParams) -> Result { self.core.request_devo("goal/create", params).await } @@ -343,8 +358,46 @@ impl WebSocketServerClient { self.core.request_devo("turn/interrupt", params).await } - pub async fn turn_steer(&mut self, params: TurnSteerParams) -> Result { - self.core.request_devo("turn/steer", params).await + pub async fn session_queue_push( + &mut self, + params: canonical::rpc_turn::SessionQueuePushParams, + ) -> Result { + self.core.session_queue_push(params).await + } + + pub async fn session_queue_list( + &mut self, + params: canonical::rpc_turn::SessionQueueListParams, + ) -> Result { + self.core.session_queue_list(params).await + } + + pub async fn session_queue_update( + &mut self, + params: canonical::rpc_turn::SessionQueueUpdateParams, + ) -> Result { + self.core.session_queue_update(params).await + } + + pub async fn session_queue_remove( + &mut self, + params: canonical::rpc_turn::SessionQueueRemoveParams, + ) -> Result { + self.core.session_queue_remove(params).await + } + + pub async fn session_queue_steer( + &mut self, + params: canonical::rpc_turn::SessionQueueSteerParams, + ) -> Result { + self.core.session_queue_steer(params).await + } + + pub async fn subscription_create( + &mut self, + params: canonical::event::SubscriptionCreateParams, + ) -> Result { + self.core.subscription_create(params).await } pub async fn approval_respond(&mut self, params: ApprovalResponseParams) -> Result<()> { diff --git a/crates/config/src/app.rs b/crates/config/src/app.rs index 01f909f9..0f8e0057 100644 --- a/crates/config/src/app.rs +++ b/crates/config/src/app.rs @@ -4,6 +4,7 @@ use std::fs; use std::path::Path; use std::path::PathBuf; +use devo_protocol::CollaborationMode; use devo_protocol::PermissionPreset; use devo_protocol::ProviderModelBinding; use devo_protocol::ProviderVendor; @@ -92,6 +93,15 @@ pub struct AppConfig { pub project_root_markers: Vec, /// User-level settings remembered per project key. pub projects: BTreeMap, + /// Global absolute auto-compaction token limit. + /// + /// When set, sessions clamp this value to each model's `context_window`. + /// When unset, sessions use the model effective context window. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub compaction_token_limit: Option, + /// Default collaboration/input mode (Build or Plan) for new sessions. + #[serde(default)] + pub default_collaboration_mode: CollaborationMode, } /// Settings remembered for one project. @@ -184,6 +194,8 @@ impl Default for AppConfig { }, project_root_markers: vec![".git".into()], projects: BTreeMap::new(), + compaction_token_limit: None, + default_collaboration_mode: CollaborationMode::Build, } } } @@ -377,6 +389,65 @@ impl AppConfigStore { Ok(()) } + /// Persists the global compaction token limit and refreshes effective config. + pub fn set_compaction_token_limit(&mut self, limit: u64) -> anyhow::Result<()> { + if limit == 0 { + anyhow::bail!("compaction_token_limit must be at least 1"); + } + + let target_config_file = self.user_config_file.as_path(); + if let Some(parent) = target_config_file.parent() { + fs::create_dir_all(parent)?; + } + let mut document = read_provider_config_document(target_config_file)?; + let document = ensure_toml_table(&mut document); + let limit_i64 = i64::try_from(limit).map_err(|_| { + anyhow::anyhow!("compaction_token_limit is too large to store in config.toml") + })?; + document.insert( + "compaction_token_limit".to_string(), + toml::Value::Integer(limit_i64), + ); + + let data = toml::to_string_pretty(&document)?; + write_atomic(target_config_file, data.as_bytes())?; + + self.config = self + .loader + .load(self.workspace_root.as_deref()) + .map_err(|error| anyhow::anyhow!(error))?; + Ok(()) + } + + /// Persists the default collaboration mode and refreshes effective config. + pub fn set_default_collaboration_mode( + &mut self, + mode: CollaborationMode, + ) -> anyhow::Result<()> { + let target_config_file = self.user_config_file.as_path(); + if let Some(parent) = target_config_file.parent() { + fs::create_dir_all(parent)?; + } + let mut document = read_provider_config_document(target_config_file)?; + let document = ensure_toml_table(&mut document); + document.insert( + "default_collaboration_mode".to_string(), + toml::Value::String(match mode { + CollaborationMode::Build => "build".to_string(), + CollaborationMode::Plan => "plan".to_string(), + }), + ); + + let data = toml::to_string_pretty(&document)?; + write_atomic(target_config_file, data.as_bytes())?; + + self.config = self + .loader + .load(self.workspace_root.as_deref()) + .map_err(|error| anyhow::anyhow!(error))?; + Ok(()) + } + /// Persists a path-based skill enablement override in the user config. pub fn set_skill_enabled(&mut self, path: PathBuf, enabled: bool) -> anyhow::Result<()> { if path.as_os_str().is_empty() { diff --git a/crates/config/src/app_store_tests.rs b/crates/config/src/app_store_tests.rs index d02c2d44..00f1c503 100644 --- a/crates/config/src/app_store_tests.rs +++ b/crates/config/src/app_store_tests.rs @@ -115,3 +115,68 @@ model_reasoning_effort_selection = "medium" let _ = fs::remove_dir_all(root); } + +#[test] +fn loader_reads_compaction_token_limit_from_user_config() { + let root = unique_temp_dir("compaction-limit-load"); + let home = root.join(".devo"); + fs::create_dir_all(&home).expect("create config dir"); + fs::write( + home.join(APP_CONFIG_FILE_NAME), + "compaction_token_limit = 250000\n", + ) + .expect("write config"); + + let store = AppConfigStore::load(home, None).expect("load config"); + assert_eq!( + store.effective_config().compaction_token_limit, + Some(250_000) + ); + + let _ = fs::remove_dir_all(root); +} + +#[test] +fn set_compaction_token_limit_writes_user_config_and_refreshes() { + let root = unique_temp_dir("compaction-limit-set"); + let home = root.join(".devo"); + fs::create_dir_all(&home).expect("create config dir"); + fs::write(home.join(APP_CONFIG_FILE_NAME), "theme = \"aurora\"\n").expect("write config"); + + let mut store = AppConfigStore::load(home.clone(), None).expect("load config"); + store + .set_compaction_token_limit(250_000) + .expect("write compaction limit"); + + let config_text = fs::read_to_string(home.join(APP_CONFIG_FILE_NAME)).expect("read config"); + let document: toml::Value = toml::from_str(&config_text).expect("parse config"); + assert_eq!(document["theme"].as_str(), Some("aurora")); + assert_eq!( + document["compaction_token_limit"].as_integer(), + Some(250_000) + ); + assert_eq!( + store.effective_config().compaction_token_limit, + Some(250_000) + ); + + let _ = fs::remove_dir_all(root); +} + +#[test] +fn set_compaction_token_limit_rejects_values_above_i64_max() { + let root = unique_temp_dir("compaction-limit-overflow"); + let home = root.join(".devo"); + fs::create_dir_all(&home).expect("create config dir"); + + let mut store = AppConfigStore::load(home, None).expect("load config"); + let err = store + .set_compaction_token_limit(u64::MAX) + .expect_err("overflow must fail"); + assert!( + err.to_string().contains("too large"), + "unexpected error: {err}" + ); + + let _ = fs::remove_dir_all(root); +} diff --git a/crates/config/src/mcp.rs b/crates/config/src/mcp.rs index acdef4f4..2f002c0c 100644 --- a/crates/config/src/mcp.rs +++ b/crates/config/src/mcp.rs @@ -104,6 +104,31 @@ impl McpConfig { break; } } + + /// Returns whether two MCP configs can share a live manager/registry. + /// + /// Used by `SessionRuntimeContext::load_for_workspace` to avoid rebuilding + /// MCP state (and re-spawning lazy servers) for every workspace lookup. + /// + /// Workspace injection of `code_search` cwd is ignored while that server is + /// disabled, so process-level and workspace-level managers stay reusable. + /// When `code_search` is enabled, cwd must match or managers are not shared. + pub fn is_operationally_equivalent_to(&self, other: &Self) -> bool { + self.normalized_for_runtime_equivalence() == other.normalized_for_runtime_equivalence() + } + + fn normalized_for_runtime_equivalence(&self) -> Self { + let mut normalized = self.clone(); + for record in &mut normalized.servers { + if record.id.0 != BUNDLED_CODE_SEARCH_MCP_SERVER_ID || record.enabled { + continue; + } + if let McpTransportConfig::Stdio { cwd, .. } = &mut record.transport { + *cwd = None; + } + } + normalized + } } /// Returns the bundled, disabled-by-default code_search MCP server record. @@ -442,4 +467,26 @@ mod tests { _ => panic!("expected stdio transport"), } } + + #[test] + fn operational_equivalence_ignores_disabled_code_search_cwd() { + let mut left = McpConfig::default(); + let mut right = McpConfig::default(); + left.apply_code_search_workspace_cwd(PathBuf::from("/process-cwd")); + right.apply_code_search_workspace_cwd(PathBuf::from("/workspace-cwd")); + + assert!(left.is_operationally_equivalent_to(&right)); + } + + #[test] + fn operational_equivalence_requires_enabled_code_search_cwd_match() { + let mut left = McpConfig::default(); + let mut right = McpConfig::default(); + left.servers[0].enabled = true; + right.servers[0].enabled = true; + left.apply_code_search_workspace_cwd(PathBuf::from("/process-cwd")); + right.apply_code_search_workspace_cwd(PathBuf::from("/workspace-cwd")); + + assert!(!left.is_operationally_equivalent_to(&right)); + } } diff --git a/crates/config/src/tests.rs b/crates/config/src/tests.rs index 14f92235..42010953 100644 --- a/crates/config/src/tests.rs +++ b/crates/config/src/tests.rs @@ -3,6 +3,7 @@ use std::path::PathBuf; use std::time::SystemTime; use std::time::UNIX_EPOCH; +use devo_protocol::CollaborationMode; use devo_protocol::PermissionPreset; use pretty_assertions::assert_eq; @@ -156,6 +157,8 @@ check_interval_hours = 48 }, project_root_markers: vec![".workspace".into()], projects: BTreeMap::new(), + compaction_token_limit: None, + default_collaboration_mode: CollaborationMode::Build, } ); diff --git a/crates/core/src/context/mod.rs b/crates/core/src/context/mod.rs index 1a6de0d4..c2c47a6e 100644 --- a/crates/core/src/context/mod.rs +++ b/crates/core/src/context/mod.rs @@ -477,6 +477,26 @@ mod tests { assert_eq!(budget.should_compact(950_001), true); } + #[test] + fn session_override_sets_effective_context_window_and_auto_compact() { + let model = Model { + context_window: 200_000, + effective_context_window_percent: Some(95), + max_tokens: Some(8_192), + ..Model::default() + }; + let turn = crate::TurnConfig::new(model, None); + let budget = turn.token_budget_for_session(Some(100_000)); + assert_eq!(budget.context_window, 100_000); + assert_eq!(budget.auto_compact_token_limit, Some(100_000)); + assert!(!budget.should_compact(100_000)); + assert!(budget.should_compact(100_001)); + + let clamped = turn.token_budget_for_session(Some(500_000)); + assert_eq!(clamped.context_window, 200_000); + assert_eq!(clamped.auto_compact_token_limit, Some(200_000)); + } + #[test] fn token_budget_input_budget_saturates() { let budget = TokenBudget::new(100, 200); diff --git a/crates/core/src/conversation/legacy_projector.rs b/crates/core/src/conversation/legacy_projector.rs index 89b8368c..ba27255b 100644 --- a/crates/core/src/conversation/legacy_projector.rs +++ b/crates/core/src/conversation/legacy_projector.rs @@ -220,6 +220,7 @@ impl LegacyProjector { .iter() .map(|id| legacy_uuid(id).map(ItemId::from_legacy_uuid)) .collect::>()?, + context_occupancy: line.context_occupancy.clone(), }]), RolloutLine::MessageEditRecorded(line) => { let record = &line.record; @@ -389,6 +390,7 @@ impl LegacyProjector { mode: None, sandbox_profile: (!record.sandbox_policy.is_empty()) .then(|| record.sandbox_policy.clone()), + effective_context_window: record.effective_context_window, }, git_info, preview: record.first_user_message.clone().unwrap_or_default(), @@ -408,6 +410,8 @@ impl LegacyProjector { session_context: record.session_context.clone(), cli_version: record.cli_version.clone(), source: record.source.clone(), + collaboration_mode: record.collaboration_mode, + permission_preset: record.permission_preset, })), }]) } @@ -424,6 +428,7 @@ impl LegacyProjector { request_thinking: record.request_thinking.clone(), input_token_estimate: record.input_token_estimate, latest_query_usage: record.latest_query_usage.clone(), + context_occupancy: record.context_occupancy.clone(), stop_reason: record.stop_reason.clone(), failure_reason: record.failure_reason, })), diff --git a/crates/core/src/conversation/records.rs b/crates/core/src/conversation/records.rs index 5f78f2e8..bee20c02 100644 --- a/crates/core/src/conversation/records.rs +++ b/crates/core/src/conversation/records.rs @@ -9,7 +9,7 @@ use crate::{ TurnWorkspaceChangeRecordedRecord, TurnWorkspaceCheckpointRecordedRecord, TurnWorkspaceRestoreCompletedRecord, TurnWorkspaceRestoreStartedRecord, }; -use devo_protocol::{StopReason, TurnFailureReason}; +use devo_protocol::{CollaborationMode, PermissionPreset, StopReason, TurnFailureReason}; /// Stores persistent metadata for one session. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] @@ -58,6 +58,9 @@ pub struct SessionRecord { pub sandbox_policy: String, /// The active approval mode description for the session. pub approval_mode: String, + /// Session override for the absolute effective context window. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub effective_context_window: Option, /// The last observed aggregate token count for the session. pub tokens_used: i64, /// The first user message stored for preview or title derivation. @@ -78,6 +81,12 @@ pub struct SessionRecord { /// The latest turn context snapshot known for this session. #[serde(default, skip_serializing_if = "Option::is_none")] pub latest_turn_context: Option, + /// Session-level collaboration mode override, persisted without requiring a turn. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub collaboration_mode: Option, + /// Session-level permission preset override. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub permission_preset: Option, /// The schema version for persisted session metadata. pub schema_version: u32, } @@ -122,6 +131,12 @@ pub struct TurnRecord { /// have been performed for tools, retries, or delegated work. #[serde(default, skip_serializing_if = "Option::is_none")] pub latest_query_usage: Option, + /// Context-window occupancy snapshot after this turn's latest model query. + /// + /// Used by resume/fork/rollback to restore category occupancy at a cut + /// turn rather than copying the session tip. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub context_occupancy: Option, /// The terminal provider/model stop reason, when available. #[serde(default, skip_serializing_if = "Option::is_none")] pub stop_reason: Option, @@ -426,6 +441,9 @@ pub struct CompactionSnapshotLine { pub summary_item_id: ItemId, /// The pre-existing item ids that remain after compaction, in prompt order. pub preserved_item_ids: Vec, + /// Post-compaction context occupancy, when computed. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub context_occupancy: Option, } /// Stores an append-only rollback marker for a session rollout. @@ -564,6 +582,7 @@ mod tests { title_state: SessionTitleState::Unset, sandbox_policy: "workspace-write".into(), approval_mode: "on-request".into(), + effective_context_window: None, tokens_used: 0, first_user_message: None, archived_at: None, @@ -573,6 +592,8 @@ mod tests { parent_session_id: None, session_context: None, latest_turn_context: None, + collaboration_mode: None, + permission_preset: None, schema_version: 2, }; @@ -672,6 +693,35 @@ mod tests { assert_eq!(serialized.get("thinking"), None); } + #[test] + fn turn_record_roundtrips_context_occupancy() { + use pretty_assertions::assert_eq; + + let occupancy = devo_protocol::canonical::item::ContextOccupancy::from_category_tokens( + /*context_window_tokens*/ 100_000, /*base*/ 10_000, /*skills*/ 5_000, + /*tools_builtin*/ 20_000, /*tools_mcp*/ 15_000, /*conversation*/ 50_000, + ); + let mut turn = make_test_turn(TurnStatus::Completed); + turn.context_occupancy = Some(occupancy.clone()); + let json = serde_json::to_string(&turn).expect("serialize"); + let restored: TurnRecord = serde_json::from_str(&json).expect("deserialize"); + assert_eq!(restored.context_occupancy, Some(occupancy)); + } + + #[test] + fn turn_record_reads_legacy_record_without_context_occupancy() { + let expected = make_test_turn(TurnStatus::Completed); + let mut value = serde_json::to_value(&expected).expect("serialize value"); + value + .as_object_mut() + .expect("turn json object") + .remove("context_occupancy"); + + let restored: TurnRecord = serde_json::from_value(value).expect("deserialize legacy"); + assert_eq!(restored.context_occupancy, None); + assert_eq!(restored, expected); + } + #[test] fn turn_cannot_transition_from_completed_to_running() { // Per L3-BEH-CORE-001 §4: Completed → Running is ILLEGAL @@ -900,6 +950,7 @@ mod tests { turn_id: turn.id, summary_item_id: item.id, preserved_item_ids: vec![item.id], + context_occupancy: None, })), RolloutLine::SessionRollback(Box::new(SessionRollbackLine { timestamp: Utc::now(), @@ -1091,10 +1142,38 @@ mod tests { turn_id: TurnId::new(), summary_item_id: ItemId::new(), preserved_item_ids: vec![ItemId::new(), ItemId::new(), ItemId::new()], + context_occupancy: Some( + devo_protocol::canonical::item::ContextOccupancy::from_category_tokens( + /*context_window_tokens*/ 80_000, /*base*/ 8_000, + /*skills*/ 2_000, /*tools_builtin*/ 10_000, /*tools_mcp*/ 0, + /*conversation*/ 20_000, + ), + ), }; let json = serde_json::to_string(&snapshot).expect("serialize"); let restored: CompactionSnapshotLine = serde_json::from_str(&json).expect("deserialize"); assert_eq!(restored.preserved_item_ids.len(), 3); + assert_eq!(restored.context_occupancy, snapshot.context_occupancy); + } + + #[test] + fn compaction_snapshot_reads_legacy_without_context_occupancy() { + let snapshot = CompactionSnapshotLine { + timestamp: Utc::now(), + session_id: SessionId::new(), + turn_id: TurnId::new(), + summary_item_id: ItemId::new(), + preserved_item_ids: vec![ItemId::new()], + context_occupancy: None, + }; + let mut value = serde_json::to_value(&snapshot).expect("serialize"); + value + .as_object_mut() + .expect("snapshot object") + .remove("context_occupancy"); + let restored: CompactionSnapshotLine = + serde_json::from_value(value).expect("deserialize legacy"); + assert_eq!(restored.context_occupancy, None); } // ── Helpers ─────────────────────────────────────────────── @@ -1121,6 +1200,7 @@ mod tests { title_state: SessionTitleState::Provisional, sandbox_policy: "workspace-write".into(), approval_mode: "on-request".into(), + effective_context_window: None, tokens_used: 100, first_user_message: Some("hello".into()), archived_at: None, @@ -1130,6 +1210,8 @@ mod tests { parent_session_id: None, session_context: None, latest_turn_context: None, + collaboration_mode: None, + permission_preset: None, schema_version: 2, } } @@ -1151,6 +1233,7 @@ mod tests { input_token_estimate: Some(100), usage: None, latest_query_usage: None, + context_occupancy: None, stop_reason: None, failure_reason: None, error: None, diff --git a/crates/core/src/conversation/rollout_v2.rs b/crates/core/src/conversation/rollout_v2.rs index 010505b4..9dee9d06 100644 --- a/crates/core/src/conversation/rollout_v2.rs +++ b/crates/core/src/conversation/rollout_v2.rs @@ -40,6 +40,12 @@ pub struct SessionPersistenceExtras { pub cli_version: String, /// The session source kind, such as `cli` or `api` (audit field). pub source: String, + /// Session-level collaboration mode override (Build/Plan), when set. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub collaboration_mode: Option, + /// Session-level permission preset override, when set. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub permission_preset: Option, } /// Persistence-only extras on the v2 Turn line, same rationale as @@ -61,6 +67,9 @@ pub struct TurnPersistenceExtras { /// Provider usage of the latest model query (excludes tool/retry calls). #[serde(default, skip_serializing_if = "Option::is_none")] pub latest_query_usage: Option, + /// Context-window occupancy after the latest model query in this turn. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub context_occupancy: Option, /// The terminal provider/model stop reason, when available. #[serde(default, skip_serializing_if = "Option::is_none")] pub stop_reason: Option, @@ -137,6 +146,8 @@ pub enum RolloutLineV2 { turn_id: TurnId, summary_item_id: ItemId, preserved_item_ids: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + context_occupancy: Option, }, /// An append-only rollback marker: the retained turns/items after the /// in-memory history was rebuilt. diff --git a/crates/core/src/conversation/v2_inverse.rs b/crates/core/src/conversation/v2_inverse.rs index 4828c02c..cdea6fd4 100644 --- a/crates/core/src/conversation/v2_inverse.rs +++ b/crates/core/src/conversation/v2_inverse.rs @@ -127,6 +127,7 @@ impl V2InverseProjector { turn_id, summary_item_id, preserved_item_ids, + context_occupancy, .. } => Ok(vec![RolloutLine::CompactionSnapshot(Box::new( CompactionSnapshotLine { @@ -138,6 +139,7 @@ impl V2InverseProjector { .iter() .map(legacy_item_id) .collect::>()?, + context_occupancy: context_occupancy.clone(), }, ))]), RolloutLineV2::SessionRollback { @@ -268,6 +270,7 @@ impl V2InverseProjector { }, sandbox_policy: session.settings.sandbox_profile.clone().unwrap_or_default(), approval_mode: approval_mode.into(), + effective_context_window: session.settings.effective_context_window, tokens_used: session .usage .legacy @@ -286,6 +289,8 @@ impl V2InverseProjector { session_context: extras.and_then(|extras| extras.session_context.clone()), // Internal prefix-cache cache, not carried even in the extras. latest_turn_context: None, + collaboration_mode: extras.and_then(|extras| extras.collaboration_mode), + permission_preset: extras.and_then(|extras| extras.permission_preset), schema_version: CURRENT_SESSION_SCHEMA_VERSION, }; Ok(vec![RolloutLine::SessionMeta(Box::new(SessionMetaLine { @@ -361,6 +366,7 @@ impl V2InverseProjector { input_token_estimate: extras.and_then(|extras| extras.input_token_estimate), usage, latest_query_usage: extras.and_then(|extras| extras.latest_query_usage.clone()), + context_occupancy: extras.and_then(|extras| extras.context_occupancy.clone()), stop_reason: extras.and_then(|extras| extras.stop_reason.clone()), failure_reason: extras.and_then(|extras| extras.failure_reason), error, diff --git a/crates/core/src/history/compaction.rs b/crates/core/src/history/compaction.rs index 690aa7d6..37eade4f 100644 --- a/crates/core/src/history/compaction.rs +++ b/crates/core/src/history/compaction.rs @@ -33,6 +33,7 @@ use async_trait::async_trait; use serde::Deserialize; use serde::Serialize; use tokio::time::sleep; +use tokio_util::sync::CancellationToken; use crate::context::ContextualUserFragment; use crate::context::TokenBudget; @@ -71,6 +72,9 @@ pub enum CompactionError { /// The summarizer returned an empty response. #[error("summarizer returned empty response")] EmptyResponse, + /// Compaction was canceled by the caller. + #[error("compaction canceled")] + Canceled, /// Compaction is not possible after exhausting retries. #[error("compaction not possible after {retries} retries")] NotPossible { @@ -91,7 +95,16 @@ pub enum CompactionError { pub trait HistorySummarizer: Send + Sync { /// Send `messages` (to-compact history followed by a developer compaction /// prompt) to the model and return the generated summary text. - async fn summarize(&self, messages: Vec) -> Result; + /// + /// When `cancel_token` is set, implementations should return + /// [`CompactionError::Canceled`] promptly if it fires while the provider + /// call is in flight (typically by racing the request against + /// `cancel_token.cancelled()`). + async fn summarize( + &self, + messages: Vec, + cancel_token: Option<&CancellationToken>, + ) -> Result; } // --------------------------------------------------------------------------- @@ -176,6 +189,7 @@ pub async fn compact_history( token_info: &TokenInfo, summarizer: &dyn HistorySummarizer, config: &CompactionConfig, + cancel_token: Option<&CancellationToken>, ) -> Result { // For auto compaction, skip if already within budget. // Proactive compaction always proceeds regardless of budget. @@ -224,10 +238,15 @@ pub async fn compact_history( const MAX_TRANSIENT_RETRIES: u32 = 5; loop { + if cancel_token.is_some_and(CancellationToken::is_cancelled) { + return Err(CompactionError::Canceled); + } + let messages = summarizer_request_messages(&to_compact); - let summary = match summarizer.summarize(messages).await { + let summary = match summarizer.summarize(messages, cancel_token).await { Ok(s) => s, + Err(CompactionError::Canceled) => return Err(CompactionError::Canceled), Err(CompactionError::ContextTooLong) => { if to_compact.is_empty() { // All items were moved to preserve — nothing to compact. @@ -246,7 +265,7 @@ pub async fn compact_history( } // Exponential backoff: 2^(retries) * 100ms let delay = Duration::from_millis(100 * (1 << transient_retries)); - sleep(delay).await; + sleep_cancellable(delay, cancel_token).await?; continue; } }; @@ -265,6 +284,21 @@ pub async fn compact_history( } } +async fn sleep_cancellable( + delay: Duration, + cancel_token: Option<&CancellationToken>, +) -> Result<(), CompactionError> { + let Some(cancel_token) = cancel_token else { + sleep(delay).await; + return Ok(()); + }; + tokio::select! { + biased; + () = cancel_token.cancelled() => Err(CompactionError::Canceled), + () = sleep(delay) => Ok(()), + } +} + // --------------------------------------------------------------------------- // Internal helpers // --------------------------------------------------------------------------- @@ -576,6 +610,7 @@ mod tests { async fn summarize( &self, _messages: Vec, + _cancel_token: Option<&CancellationToken>, ) -> Result { Ok("summary".to_string()) } @@ -602,18 +637,29 @@ mod tests { kind: CompactionKind::Auto, }; - let action = compact_history(&items, &token_info, &StubSummarizer, &config) - .await - .expect("auto compaction should succeed"); + let action = compact_history( + &items, + &token_info, + &StubSummarizer, + &config, + /*cancel_token*/ None, + ) + .await + .expect("auto compaction should succeed"); let proactive_config = CompactionConfig { budget: config.budget.clone(), kind: CompactionKind::Proactive, }; - let proactive_action = - compact_history(&items, &token_info, &StubSummarizer, &proactive_config) - .await - .expect("proactive compaction should succeed"); + let proactive_action = compact_history( + &items, + &token_info, + &StubSummarizer, + &proactive_config, + /*cancel_token*/ None, + ) + .await + .expect("proactive compaction should succeed"); match (action, proactive_action) { ( @@ -647,6 +693,84 @@ mod tests { } } + /// Trace: L2-DES-AGENT-002 + /// Verifies: cancel token aborts an in-flight history summarizer. + #[tokio::test] + async fn compact_history_returns_canceled_when_token_fires() { + use std::sync::Arc; + use std::sync::atomic::AtomicBool; + use std::sync::atomic::Ordering; + + struct HangingSummarizer { + entered: Arc, + } + + #[async_trait] + impl HistorySummarizer for HangingSummarizer { + async fn summarize( + &self, + _messages: Vec, + cancel_token: Option<&CancellationToken>, + ) -> Result { + self.entered.store(true, Ordering::SeqCst); + let Some(cancel_token) = cancel_token else { + std::future::pending::<()>().await; + unreachable!("summarizer should be canceled") + }; + cancel_token.cancelled().await; + Err(CompactionError::Canceled) + } + } + + let items = vec![ + ResponseItem::Message(Message::user("first")), + ResponseItem::Message(Message::assistant_text("reply")), + ResponseItem::Message(Message::user("latest")), + ]; + let token_info = TokenInfo { + input_tokens: 10, + cached_input_tokens: 0, + output_tokens: 5, + }; + let config = CompactionConfig { + budget: TokenBudget::new(200_000, 8192), + kind: CompactionKind::Proactive, + }; + let entered = Arc::new(AtomicBool::new(false)); + let summarizer = HangingSummarizer { + entered: Arc::clone(&entered), + }; + let cancel_token = CancellationToken::new(); + let cancel_for_task = cancel_token.clone(); + let compact = tokio::spawn(async move { + compact_history( + &items, + &token_info, + &summarizer, + &config, + Some(&cancel_for_task), + ) + .await + }); + + for _ in 0..50 { + if entered.load(Ordering::SeqCst) { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + assert!( + entered.load(Ordering::SeqCst), + "summarizer should start before cancel" + ); + cancel_token.cancel(); + let result = tokio::time::timeout(Duration::from_secs(2), compact) + .await + .expect("compaction should finish after cancel") + .expect("compaction task should not panic"); + assert!(matches!(result, Err(CompactionError::Canceled))); + } + #[tokio::test] async fn proactive_compaction_summarizes_all_history_and_preserves_latest_user_suffix() { struct StubSummarizer; @@ -656,6 +780,7 @@ mod tests { async fn summarize( &self, messages: Vec, + _cancel_token: Option<&CancellationToken>, ) -> Result { assert_eq!(messages.len(), 4); let last = messages @@ -684,9 +809,15 @@ mod tests { kind: CompactionKind::Proactive, }; - let action = compact_history(&items, &token_info, &StubSummarizer, &config) - .await - .expect("proactive compaction should succeed"); + let action = compact_history( + &items, + &token_info, + &StubSummarizer, + &config, + /*cancel_token*/ None, + ) + .await + .expect("proactive compaction should succeed"); match action { CompactAction::Replaced(compacted) => { diff --git a/crates/core/src/history/summarizer.rs b/crates/core/src/history/summarizer.rs index 3e32e524..27c34f6b 100644 --- a/crates/core/src/history/summarizer.rs +++ b/crates/core/src/history/summarizer.rs @@ -3,6 +3,7 @@ use std::sync::Arc; use async_trait::async_trait; use devo_protocol::{Model, ModelRequest, RequestMessage, ResponseContent, SamplingControls}; use devo_provider::ModelProviderSDK; +use tokio_util::sync::CancellationToken; use tracing::debug; use super::compaction::{CompactionError, HistorySummarizer}; @@ -69,7 +70,11 @@ fn should_keep_summary_line(line: &str) -> bool { #[async_trait] impl HistorySummarizer for DefaultHistorySummarizer { - async fn summarize(&self, messages: Vec) -> Result { + async fn summarize( + &self, + messages: Vec, + cancel_token: Option<&CancellationToken>, + ) -> Result { let request = ModelRequest { model_slug: devo_protocol::ModelProfileKey::CatalogSlug(self.model_slug.clone()), model: self.request_model.clone(), @@ -94,7 +99,20 @@ impl HistorySummarizer for DefaultHistorySummarizer { "sending LLM compaction request" ); - let response = match self.provider.completion(request).await { + let completion = self.provider.completion(request); + let response = match cancel_token { + Some(cancel_token) => { + tokio::select! { + biased; + () = cancel_token.cancelled() => { + return Err(CompactionError::Canceled); + } + result = completion => result, + } + } + None => completion.await, + }; + let response = match response { Ok(r) => r, Err(e) => { let err_msg = e.to_string(); diff --git a/crates/core/src/permission.rs b/crates/core/src/permission.rs index b4d43607..c0616d79 100644 --- a/crates/core/src/permission.rs +++ b/crates/core/src/permission.rs @@ -488,7 +488,8 @@ impl Default for ApprovalPolicy { #[serde(rename_all = "snake_case")] pub enum PermissionMode { Default, - AutoApprove, + #[serde(alias = "auto_approve")] + Yolo, Deny, } @@ -571,7 +572,7 @@ pub enum AutoReviewerStatus { /// Authorize a tool request through the four-layer pipeline. /// -/// Layer 1 — PermissionMode override (AutoApprove/Deny) +/// Layer 1 — PermissionMode override (Yolo/Deny) /// Layer 2 — Profile evaluation against RuntimePermissionProfile /// Layer 3 — Approval cache check /// Layer 4 — Return Ask for user prompt (handled by server) @@ -583,7 +584,7 @@ pub fn authorize_tool_request( ) -> PermissionDecision { // Layer 1 — PermissionMode override match policy.permission_mode { - PermissionMode::AutoApprove => return PermissionDecision::Allow, + PermissionMode::Yolo => return PermissionDecision::Allow, PermissionMode::Deny => { return PermissionDecision::Deny { reason: "permission mode is Deny".into(), @@ -951,7 +952,7 @@ mod tests { // ── authorize_tool_request ─────────────────────────────────── #[test] - fn auto_approve_mode_allows_everything() { + fn yolo_mode_allows_everything() { let request = ToolPermissionRequest { tool_name: "shell".into(), tool_category: ToolCategory::Command, @@ -973,7 +974,7 @@ mod tests { }; let mut cache = ApprovalCache::new(); let policy = ApprovalPolicy { - permission_mode: PermissionMode::AutoApprove, + permission_mode: PermissionMode::Yolo, ..Default::default() }; diff --git a/crates/core/src/query/event.rs b/crates/core/src/query/event.rs index 0d03820d..51807aac 100644 --- a/crates/core/src/query/event.rs +++ b/crates/core/src/query/event.rs @@ -8,6 +8,7 @@ use std::sync::Arc; use futures::future::BoxFuture; use tokio_util::sync::CancellationToken; +use crate::response_item::ResponseItem; use crate::tools::ToolContent; use devo_protocol::StopReason; use devo_provider::ModelProviderSDK; @@ -20,7 +21,12 @@ pub enum QueryEvent { /// Context compaction is about to begin. ContextCompactionStarted, /// Context compaction replaced the current prompt history. - ContextCompactionCompleted, + ContextCompactionCompleted { + /// Full compacted history (summary + preserved suffix), including tool + /// pairs. Callers persist snapshots from this before Message-only + /// prompt conversion drops non-message items. + compacted_items: Vec, + }, /// Context compaction did not replace the current prompt history. ContextCompactionFailed { /// Human-readable reason the compaction did not complete. diff --git a/crates/core/src/query/mod.rs b/crates/core/src/query/mod.rs index 6b39101e..4d53e1d2 100644 --- a/crates/core/src/query/mod.rs +++ b/crates/core/src/query/mod.rs @@ -17,7 +17,8 @@ pub use event::QueryOptions; pub use event::QueryProviderRetryPhase; pub(crate) use event::emit_query_event; -pub(crate) use prompt_estimate::estimate_request_prompt_tokens; +pub use prompt_estimate::RawContextBreakdown; +pub(crate) use prompt_estimate::estimate_request_context_breakdown; pub(crate) use provider_retry::ProviderRetryDecision; pub(crate) use provider_retry::provider_retry_decision; pub(crate) use provider_retry::wait_for_provider_retry; @@ -119,14 +120,19 @@ fn hosted_tools_for_web_search( /// budget is high; keeps a tail token window. /// - [`CompactionKind::Proactive`]: forced compaction after provider /// `context_too_long`; keeps from the latest user message onward. +struct CompactionModelRequest<'a> { + provider: &'a Arc, + model_slug: &'a str, + request_model: &'a str, + max_tokens: usize, +} + async fn summarize_and_compact( session: &mut SessionState, on_event: &Option, - provider: &Arc, - model_slug: &str, - request_model: &str, - max_tokens: usize, + model: CompactionModelRequest<'_>, kind: CompactionKind, + cancel_token: Option<&CancellationToken>, ) { let items: Vec = session .prompt_source_messages() @@ -147,19 +153,19 @@ async fn summarize_and_compact( }; let summarizer = DefaultHistorySummarizer::with_models( - Arc::clone(provider), - model_slug, - request_model, - max_tokens, + Arc::clone(model.provider), + model.model_slug, + model.request_model, + model.max_tokens, ); emit_query_event(on_event, QueryEvent::ContextCompactionStarted).await; - match compact_history(&items, &token_info, &summarizer, &config).await { + match compact_history(&items, &token_info, &summarizer, &config, cancel_token).await { Ok(CompactAction::Replaced(compacted_items)) => { let new_messages: Vec = compacted_items - .into_iter() + .iter() .filter_map(|item| match item { - ResponseItem::Message(msg) => Some(msg), + ResponseItem::Message(msg) => Some(msg.clone()), _ => None, }) .collect(); @@ -169,7 +175,11 @@ async fn summarize_and_compact( .saturating_sub(new_messages.len()); info!("LLM compaction removed {removed} messages"); session.set_prompt_messages(new_messages); - emit_query_event(on_event, QueryEvent::ContextCompactionCompleted).await; + emit_query_event( + on_event, + QueryEvent::ContextCompactionCompleted { compacted_items }, + ) + .await; } Ok(CompactAction::Skipped) => { debug!("LLM compaction skipped, nothing to compact"); @@ -503,11 +513,14 @@ pub async fn query( summarize_and_compact( session, &on_event, - &compaction_provider, - &compaction_model_slug, - &compaction_request_model, - turn_config.model.max_tokens.unwrap_or(4096) as usize, + CompactionModelRequest { + provider: &compaction_provider, + model_slug: &compaction_model_slug, + request_model: &compaction_request_model, + max_tokens: turn_config.model.max_tokens.unwrap_or(4096) as usize, + }, CompactionKind::Auto, + options.cancel_token.as_ref(), ) .await; } @@ -608,7 +621,9 @@ pub async fn query( reasoning_effort: request_reasoning_effort, extra_body, }; - session.prompt_token_estimate = estimate_request_prompt_tokens(&request); + let breakdown = estimate_request_context_breakdown(&request); + session.prompt_token_estimate = breakdown.total().try_into().unwrap_or(usize::MAX); + session.raw_context_breakdown = Some(breakdown); debug!( prompt_source_messages = prompt_source_message_count, prompt_source_items = prompt_source_item_count, @@ -659,11 +674,14 @@ pub async fn query( summarize_and_compact( session, &on_event, - &compaction_provider, - &compaction_model_slug, - &compaction_request_model, - turn_config.model.max_tokens.unwrap_or(4096) as usize, + CompactionModelRequest { + provider: &compaction_provider, + model_slug: &compaction_model_slug, + request_model: &compaction_request_model, + max_tokens: turn_config.model.max_tokens.unwrap_or(4096) as usize, + }, CompactionKind::Proactive, + options.cancel_token.as_ref(), ) .await; session.turn_count -= 1; diff --git a/crates/core/src/query/prompt_estimate.rs b/crates/core/src/query/prompt_estimate.rs index 0343402d..2ffab96b 100644 --- a/crates/core/src/query/prompt_estimate.rs +++ b/crates/core/src/query/prompt_estimate.rs @@ -1,27 +1,213 @@ //! Prompt token estimation for assembled model requests. use devo_protocol::ModelRequest; +use devo_protocol::RequestContent; +use devo_protocol::RequestMessage; use devo_protocol::approx_tokens_from_byte_count; -/// Rough prompt-token estimate for a fully built [`ModelRequest`]. -/// -/// Converts serialized request bytes with the shared protocol heuristic -/// (~4 bytes/token) so query and persistence stay aligned. -pub(crate) fn estimate_request_prompt_tokens(request: &ModelRequest) -> usize { - let system_bytes = request.system.as_ref().map_or(0, String::len); - let message_bytes = request - .messages - .iter() - .map(|message| serde_json::to_string(message).map_or(0, |json| json.len())) - .sum::(); - let tool_bytes = request - .tools - .as_ref() - .map(|tools| serde_json::to_string(tools).map_or(0, |json| json.len())) - .unwrap_or(0); - let hosted_tool_bytes = - serde_json::to_string(&request.hosted_tools).map_or(0, |json| json.len()); - approx_tokens_from_byte_count(system_bytes + message_bytes + tool_bytes + hosted_tool_bytes) - .try_into() - .unwrap_or(usize::MAX) +const MCP_TOOL_PREFIX: &str = "mcp__"; + +/// Byte-heuristic category split for an assembled request (before provider +/// usage scaling). +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct RawContextBreakdown { + pub base: u64, + pub skills: u64, + pub tools_builtin: u64, + pub tools_mcp: u64, + pub conversation: u64, +} + +impl RawContextBreakdown { + pub fn total(self) -> u64 { + self.base + .saturating_add(self.skills) + .saturating_add(self.tools_builtin) + .saturating_add(self.tools_mcp) + .saturating_add(self.conversation) + } +} + +/// Classify assembled request bytes into occupancy categories. +pub(crate) fn estimate_request_context_breakdown(request: &ModelRequest) -> RawContextBreakdown { + let mut breakdown = RawContextBreakdown::default(); + + if let Some(system) = request.system.as_ref() { + breakdown.base = breakdown + .base + .saturating_add(approx_tokens_from_byte_count(system.len())); + } + + for message in &request.messages { + let bytes = serde_json::to_string(message).map_or(0, |json| json.len()); + let tokens = approx_tokens_from_byte_count(bytes); + match classify_message(message) { + MessageCategory::Base => breakdown.base = breakdown.base.saturating_add(tokens), + MessageCategory::Skills => breakdown.skills = breakdown.skills.saturating_add(tokens), + MessageCategory::Conversation => { + breakdown.conversation = breakdown.conversation.saturating_add(tokens); + } + } + } + + if let Some(tools) = request.tools.as_ref() { + for tool in tools { + let bytes = serde_json::to_string(tool).map_or(0, |json| json.len()); + let tokens = approx_tokens_from_byte_count(bytes); + if tool.name.starts_with(MCP_TOOL_PREFIX) { + breakdown.tools_mcp = breakdown.tools_mcp.saturating_add(tokens); + } else { + breakdown.tools_builtin = breakdown.tools_builtin.saturating_add(tokens); + } + } + } + + let hosted_bytes = serde_json::to_string(&request.hosted_tools).map_or(0, |json| json.len()); + breakdown.tools_builtin = breakdown + .tools_builtin + .saturating_add(approx_tokens_from_byte_count(hosted_bytes)); + + breakdown +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum MessageCategory { + Base, + Skills, + Conversation, +} + +fn classify_message(message: &RequestMessage) -> MessageCategory { + for content in &message.content { + match content { + RequestContent::Text { text } => { + if let Some(category) = classify_text(text) { + return category; + } + } + RequestContent::ToolResult { content, .. } => { + if let Some(category) = classify_text(content) { + return category; + } + } + RequestContent::Reasoning { .. } + | RequestContent::ProviderReasoning { .. } + | RequestContent::HostedToolUse { .. } + | RequestContent::ToolUse { .. } => {} + } + } + MessageCategory::Conversation +} + +fn classify_text(text: &str) -> Option { + let trimmed = text.trim_start(); + if trimmed.starts_with("") + || trimmed.starts_with("") + || trimmed.starts_with("") + || trimmed.starts_with("") + || trimmed.starts_with("") + || trimmed.starts_with("") + || trimmed.starts_with("") + || trimmed.starts_with("# AGENTS.md instructions for ") + { + return Some(MessageCategory::Base); + } + None +} + +#[cfg(test)] +mod tests { + use pretty_assertions::assert_eq; + + use super::*; + use devo_protocol::HostedToolDefinition; + use devo_protocol::ModelProfileKey; + use devo_protocol::SamplingControls; + use devo_protocol::ToolDefinition; + + fn request_with( + system: Option<&str>, + messages: Vec, + tools: Option>, + ) -> ModelRequest { + ModelRequest { + model_slug: ModelProfileKey::Generic, + model: "test".into(), + system: system.map(str::to_string), + messages, + max_tokens: 128, + tools, + hosted_tools: Vec::::new(), + sampling: SamplingControls::default(), + request_thinking: None, + reasoning_effort: None, + extra_body: None, + } + } + + fn text_message(role: &str, text: &str) -> RequestMessage { + RequestMessage { + role: role.into(), + content: vec![RequestContent::Text { text: text.into() }], + } + } + + #[test] + fn breakdown_classifies_system_skills_tools_and_conversation() { + let request = request_with( + Some("you are a coding agent"), + vec![ + text_message("user", "\nskills\n"), + text_message( + "user", + "\n /\n", + ), + text_message("user", "please fix the bug"), + text_message("assistant", "looking into it"), + ], + Some(vec![ + ToolDefinition { + name: "bash".into(), + description: "run shell".into(), + input_schema: serde_json::json!({"type": "object"}), + output_schema: None, + }, + ToolDefinition { + name: "mcp__docs__search".into(), + description: "search docs".into(), + input_schema: serde_json::json!({"type": "object"}), + output_schema: None, + }, + ]), + ); + + let breakdown = estimate_request_context_breakdown(&request); + assert!(breakdown.base > 0); + assert!(breakdown.skills > 0); + assert!(breakdown.tools_builtin > 0); + assert!(breakdown.tools_mcp > 0); + assert!(breakdown.conversation > 0); + assert!(breakdown.total() > 0); + } + + #[test] + fn agents_md_instructions_count_as_base() { + let request = request_with( + None, + vec![text_message( + "user", + "# AGENTS.md instructions for /repo\n\nrules\n", + )], + None, + ); + let breakdown = estimate_request_context_breakdown(&request); + assert!(breakdown.base > 0); + assert_eq!(breakdown.skills, 0); + assert_eq!(breakdown.conversation, 0); + } } diff --git a/crates/core/src/query/tests.rs b/crates/core/src/query/tests.rs index cce8f13a..62932785 100644 --- a/crates/core/src/query/tests.rs +++ b/crates/core/src/query/tests.rs @@ -1275,7 +1275,9 @@ fn recorded_compaction_events(events: &[QueryEvent]) -> Vec Some(RecordedCompactionEvent::Started), - QueryEvent::ContextCompactionCompleted => Some(RecordedCompactionEvent::Completed), + QueryEvent::ContextCompactionCompleted { .. } => { + Some(RecordedCompactionEvent::Completed) + } QueryEvent::ContextCompactionFailed { message } => { Some(RecordedCompactionEvent::Failed { message: message.clone(), @@ -1328,11 +1330,14 @@ async fn automatic_compaction_emits_started_then_completed_when_history_is_repla super::summarize_and_compact( &mut session, &on_event, - &provider_sdk, - "compaction-model", - "compaction-request-model", - /*max_tokens*/ 4096, + super::CompactionModelRequest { + provider: &provider_sdk, + model_slug: "compaction-model", + request_model: "compaction-request-model", + max_tokens: 4096, + }, CompactionKind::Auto, + /*cancel_token*/ None, ) .await; @@ -1370,11 +1375,14 @@ async fn automatic_compaction_emits_failed_when_compaction_is_skipped() { super::summarize_and_compact( &mut session, &on_event, - &provider_sdk, - "compaction-model", - "compaction-request-model", - /*max_tokens*/ 4096, + super::CompactionModelRequest { + provider: &provider_sdk, + model_slug: "compaction-model", + request_model: "compaction-request-model", + max_tokens: 4096, + }, CompactionKind::Auto, + /*cancel_token*/ None, ) .await; @@ -1406,11 +1414,14 @@ async fn proactive_compaction_emits_failed_when_compaction_errors() { super::summarize_and_compact( &mut session, &on_event, - &provider_sdk, - "compaction-model", - "compaction-request-model", - /*max_tokens*/ 4096, + super::CompactionModelRequest { + provider: &provider_sdk, + model_slug: "compaction-model", + request_model: "compaction-request-model", + max_tokens: 4096, + }, CompactionKind::Proactive, + /*cancel_token*/ None, ) .await; @@ -1497,7 +1508,7 @@ async fn query_retries_transient_stream_event_errors_before_content() { .filter_map(|event| match event { QueryEvent::ProviderRetryStatus(status) => Some(status.clone()), QueryEvent::ContextCompactionStarted - | QueryEvent::ContextCompactionCompleted + | QueryEvent::ContextCompactionCompleted { .. } | QueryEvent::ContextCompactionFailed { .. } | QueryEvent::TextDelta(_) | QueryEvent::ReasoningDelta(_) @@ -1587,7 +1598,7 @@ async fn query_waits_sixty_seconds_for_each_rate_limit_retry() { .filter_map(|event| match event { QueryEvent::ProviderRetryStatus(status) => Some(status.clone()), QueryEvent::ContextCompactionStarted - | QueryEvent::ContextCompactionCompleted + | QueryEvent::ContextCompactionCompleted { .. } | QueryEvent::ContextCompactionFailed { .. } | QueryEvent::TextDelta(_) | QueryEvent::ReasoningDelta(_) diff --git a/crates/core/src/session.rs b/crates/core/src/session.rs index f71a5240..e2d2306d 100644 --- a/crates/core/src/session.rs +++ b/crates/core/src/session.rs @@ -29,6 +29,11 @@ use crate::state::turn::TurnState; #[derive(Debug, Clone)] pub struct SessionConfig { pub token_budget: TokenBudget, + /// Session-scoped absolute effective context window override. + /// When set, turn starts merge this into the model-derived budget so hot + /// updates are not wiped by `TurnConfig::token_budget()`. Clamped to the + /// active model `context_window` at resolve time. + pub effective_context_window_override: Option, pub permission_mode: PermissionMode, pub permission_profile: RuntimePermissionProfile, pub agents_md: AgentsMdConfig, @@ -61,6 +66,7 @@ impl Default for SessionConfig { RuntimePermissionProfile::from_preset(PermissionPreset::Default, cwd); Self { token_budget: TokenBudget::default(), + effective_context_window_override: None, permission_mode: permission_profile.permission_mode(), permission_profile, agents_md: AgentsMdConfig::default(), @@ -125,6 +131,24 @@ impl TurnConfig { TokenBudget::for_model(&self.model) } + /// Builds the turn token budget, applying a session effective-context + /// override when present so hot updates survive turn start reassignment. + /// + /// Resolved value is `min(override, model.context_window)` and is written to + /// both `TokenBudget.context_window` and `auto_compact_token_limit`. + pub fn token_budget_for_session( + &self, + effective_context_window_override: Option, + ) -> TokenBudget { + let mut budget = self.token_budget(); + if let Some(limit) = effective_context_window_override { + let resolved = limit.min(self.model.context_window as usize).max(1); + budget.context_window = resolved; + budget.auto_compact_token_limit = Some(resolved); + } + budget + } + pub fn new(model: Model, reasoning_effort_selection: Option) -> Self { let request_model = model.slug.clone(); let reasoning_effort_selection = @@ -250,6 +274,8 @@ pub struct SessionState { pub total_cache_creation_tokens: usize, // TODO: from Anthropic Messages API, indicate how many tokens utlized to create cache. pub total_cache_read_tokens: usize, // TODO: same with `total_input_cached_tokens`. pub prompt_token_estimate: usize, + /// Latest assembled-request category estimate (before provider scaling). + pub raw_context_breakdown: Option, /// Input tokens reported by the model for the most recent turn. pub last_input_tokens: usize, /// Total context tokens reported by the model for the most recent turn. @@ -290,6 +316,7 @@ impl SessionState { total_cache_creation_tokens: 0, total_cache_read_tokens: 0, prompt_token_estimate: 0, + raw_context_breakdown: None, last_input_tokens: 0, last_turn_tokens: 0, last_turn_interrupted: false, @@ -318,6 +345,7 @@ impl SessionState { total_cache_creation_tokens: self.total_cache_creation_tokens, total_cache_read_tokens: self.total_cache_read_tokens, prompt_token_estimate: self.prompt_token_estimate, + raw_context_breakdown: self.raw_context_breakdown, last_input_tokens: self.last_input_tokens, last_turn_tokens: self.last_turn_tokens, last_turn_interrupted: self.last_turn_interrupted, @@ -393,23 +421,6 @@ impl SessionState { } } - /// Pushes a pending input to the turn queue (for execution in a future turn). - pub fn enqueue_pending_input(&self, item: PendingInputItem) { - self.pending_turn_queue - .lock() - .expect("pending turn queue mutex should not be poisoned") - .push_back(item); - } - - /// Drains all pending inputs from the turn queue. - pub fn drain_pending_turn_queue(&self) -> Vec { - let mut pending = self - .pending_turn_queue - .lock() - .expect("pending turn queue mutex should not be poisoned"); - pending.drain(..).collect() - } - /// Drains all pending inputs from the active-turn steer queue. pub fn drain_steer_input_queue(&self) -> Vec { let mut guard = self @@ -420,24 +431,22 @@ impl SessionState { } pub fn start_turn(&mut self, kind: TurnKind) { - let mut turn = TurnState::new(kind); - // Drain pending turn queue into the new turn's pending input. - let pending = self.drain_pending_turn_queue(); - turn.pending_input = pending; - self.turn_state = Some(turn); + // The session turn queue is owned by the server-side drain (canonical + // `session/queue/*` semantics): queued entries become follow-up turns + // with their own drain notification and persistence bookkeeping, so + // they must never be absorbed into a running or starting turn here. + // Turn-scoped pending input starts empty and only collects transient + // steering fragments produced during this turn (e.g. budget notices). + self.turn_state = Some(TurnState::new(kind)); } pub fn end_turn(&mut self) { - if let Some(turn) = self.turn_state.take() { - // Unconsumed pending input goes back to the turn queue (prepend to preserve order). - let mut queue = self - .pending_turn_queue - .lock() - .expect("pending turn queue mutex should not be poisoned"); - for item in turn.pending_input.into_iter().rev() { - queue.push_front(item); - } - } + // Unconsumed turn-scoped pending input expires with the turn: it is + // transient steering produced for this turn only. It must not be + // re-queued into the canonical turn queue, whose entries are owned by + // the server-side drain (a transient item would surface as a blank + // queue row and bypass the drain's persistence bookkeeping). + self.turn_state = None; // Steer inputs that arrived before the injection boundary but // were not consumed before turn end degrade back into the session turn // queue. This preserves the message; a later follow-up drain may start @@ -461,14 +470,15 @@ impl SessionState { } } - /// Merge turn-scoped pending input with both cross-thread inboxes. - /// Order: steer inbox → turn-state pending → turn queue + /// Merge turn-scoped pending input with the steer inbox. + /// Order: steer inbox → turn-state pending. The session turn queue is + /// deliberately NOT drained here: its entries become follow-up turns via + /// the server-side drain, never silent mid-turn injections. pub fn take_turn_pending_input(&mut self) -> Vec { let mut result = self.drain_steer_input_queue(); if let Some(turn) = self.turn_state.as_mut() { result.extend(turn.take_pending_input()); } - result.extend(self.drain_pending_turn_queue()); result } } @@ -672,30 +682,6 @@ mod tests { assert_ne!(s1.id, s2.id); } - #[test] - fn session_state_drains_pending_turn_queue() { - use chrono::Utc; - let state = SessionState::new(SessionConfig::default(), PathBuf::from("/tmp")); - state.enqueue_pending_input(PendingInputItem::new( - devo_protocol::PendingInputKind::UserText { - text: "first".to_string(), - }, - None, - Utc::now(), - )); - state.enqueue_pending_input(PendingInputItem::new( - devo_protocol::PendingInputKind::UserText { - text: "second".to_string(), - }, - None, - Utc::now(), - )); - - let drained = state.drain_pending_turn_queue(); - assert_eq!(drained.len(), 2); - assert!(state.drain_pending_turn_queue().is_empty()); - } - #[test] fn session_state_start_turn_creates_turn_state() { let mut state = SessionState::new(SessionConfig::default(), PathBuf::from("/tmp")); @@ -706,44 +692,50 @@ mod tests { } #[test] - fn session_state_start_turn_drains_pending_queue() { + fn session_state_start_turn_leaves_pending_queue_untouched() { use chrono::Utc; let mut state = SessionState::new(SessionConfig::default(), PathBuf::from("/tmp")); - state.enqueue_pending_input(PendingInputItem::new( - devo_protocol::PendingInputKind::UserText { - text: "queued".to_string(), - }, - None, - Utc::now(), - )); + state + .pending_turn_queue + .lock() + .expect("queue lock") + .push_back(PendingInputItem::new( + devo_protocol::PendingInputKind::UserText { + text: "queued".to_string(), + }, + None, + Utc::now(), + )); state.start_turn(TurnKind::Regular); + // Queued entries become follow-up turns via the server-side drain; + // they are never absorbed into the starting turn. let pending = state.take_turn_pending_input(); - assert_eq!(pending.len(), 1); - assert!(state.pending_turn_queue.lock().unwrap().is_empty()); + assert!(pending.is_empty()); + assert_eq!(state.pending_turn_queue.lock().unwrap().len(), 1); } #[test] - fn session_state_end_turn_moves_unconsumed_back_to_queue() { + fn session_state_end_turn_expires_unconsumed_turn_pending() { use chrono::Utc; let mut state = SessionState::new(SessionConfig::default(), PathBuf::from("/tmp")); state.start_turn(TurnKind::Regular); - // Push an item into the turn's pending input directly. + // Push a transient turn-scoped fragment (e.g. a budget notice). if let Some(turn) = state.turn_state.as_mut() { turn.push_pending_input(PendingInputItem::new( - devo_protocol::PendingInputKind::UserText { - text: "unconsumed".to_string(), - }, + devo_protocol::PendingInputKind::BudgetLimitSteering, None, Utc::now(), )); } state.end_turn(); assert!(state.turn_state.is_none()); - assert_eq!(state.pending_turn_queue.lock().unwrap().len(), 1); + // Transient fragments expire with the turn instead of leaking into + // the canonical turn queue owned by the server-side drain. + assert!(state.pending_turn_queue.lock().unwrap().is_empty()); } #[test] - fn session_state_take_turn_pending_merges_turn_and_inbox() { + fn session_state_take_turn_pending_merges_steer_and_turn_pending() { use chrono::Utc; let mut state = SessionState::new(SessionConfig::default(), PathBuf::from("/tmp")); state.start_turn(TurnKind::Regular); @@ -757,32 +749,47 @@ mod tests { Utc::now(), )); } - // Push to cross-thread inbox. - state.enqueue_pending_input(PendingInputItem::new( - devo_protocol::PendingInputKind::UserText { - text: "inbox-item".to_string(), - }, - None, - Utc::now(), - )); + // Push to the steer inbox. + state + .steer_input_queue + .lock() + .expect("steer lock") + .push_back(PendingInputItem::new( + devo_protocol::PendingInputKind::UserText { + text: "steer-item".to_string(), + }, + None, + Utc::now(), + )); let merged = state.take_turn_pending_input(); assert_eq!(merged.len(), 2); + assert!( + matches!(&merged[0].kind, devo_protocol::PendingInputKind::UserText { text } if text == "steer-item") + ); + assert!( + matches!(&merged[1].kind, devo_protocol::PendingInputKind::UserText { text } if text == "turn-item") + ); } #[test] - fn session_state_take_turn_pending_without_turn_drains_inbox_only() { + fn session_state_take_turn_pending_leaves_turn_queue_untouched() { use chrono::Utc; - let state = SessionState::new(SessionConfig::default(), PathBuf::from("/tmp")); - state.enqueue_pending_input(PendingInputItem::new( - devo_protocol::PendingInputKind::UserText { - text: "direct".to_string(), - }, - None, - Utc::now(), - )); - // No turn started — take_turn_pending_input should still drain the inbox. - let mut state_mut = state; - let items = state_mut.take_turn_pending_input(); - assert_eq!(items.len(), 1); + let mut state = SessionState::new(SessionConfig::default(), PathBuf::from("/tmp")); + state + .pending_turn_queue + .lock() + .expect("queue lock") + .push_back(PendingInputItem::new( + devo_protocol::PendingInputKind::UserText { + text: "queued".to_string(), + }, + None, + Utc::now(), + )); + // The session turn queue is drained only by the server-side follow-up + // scheduling, never by the running turn. + let items = state.take_turn_pending_input(); + assert!(items.is_empty()); + assert_eq!(state.pending_turn_queue.lock().unwrap().len(), 1); } } diff --git a/crates/core/src/tools/registry_plan.rs b/crates/core/src/tools/registry_plan.rs index 5bdee892..21aa5e6c 100644 --- a/crates/core/src/tools/registry_plan.rs +++ b/crates/core/src/tools/registry_plan.rs @@ -44,7 +44,7 @@ impl Default for ToolRegistryPlan { } } -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq, Eq)] pub struct ToolPlanConfig { pub use_shell_command: bool, pub use_unified_exec: bool, diff --git a/crates/core/src/tools/shell_exec/mod.rs b/crates/core/src/tools/shell_exec/mod.rs index e6453e39..07fea36f 100644 --- a/crates/core/src/tools/shell_exec/mod.rs +++ b/crates/core/src/tools/shell_exec/mod.rs @@ -32,7 +32,6 @@ use pipe::run_with_pipes; use pty::run_with_pty; use resolve::ResolvedShellRun; -const MAX_METADATA_LENGTH: usize = 30_000; pub(crate) const DEFAULT_TIMEOUT_MS: u64 = 120_000; pub(crate) const DEFAULT_YIELD_TIME_MS: u64 = 1_000; pub(crate) const DEFAULT_MAX_OUTPUT_TOKENS: usize = 16_000; @@ -110,17 +109,6 @@ pub(crate) async fn execute_shell_command( } } -// TODO: Preview truncation belongs on the client, not the server. Move -// `preview` (used today for short command metadata) and any remaining -// display-oriented shortening to the client so the server returns full -// stream text plus structured metadata without duplicating output. -pub(crate) fn preview(text: &str) -> String { - if text.len() <= MAX_METADATA_LENGTH { - return text.to_string(); - } - format!("{}\n\n...", &text[..MAX_METADATA_LENGTH]) -} - pub(crate) fn truncate_output(text: &str, max_output_tokens: usize) -> String { if max_output_tokens == 0 { return String::new(); diff --git a/crates/core/src/tools/shell_exec/pipe.rs b/crates/core/src/tools/shell_exec/pipe.rs index 4e772435..e0797bc4 100644 --- a/crates/core/src/tools/shell_exec/pipe.rs +++ b/crates/core/src/tools/shell_exec/pipe.rs @@ -13,7 +13,6 @@ use crate::events::ToolProgressSender; use crate::invocation::FunctionToolOutput; use super::launch::SandboxLaunchPlan; -use super::preview; use super::resolve::ResolvedShellRun; use super::truncate_output; @@ -41,7 +40,6 @@ pub(crate) async fn run_with_pipes( } = run; info!(command = %command_to_run, shell = shell.program, "executing shell command"); - let command_preview = preview(&command_to_run); let plan = match SandboxLaunchPlan::prepare_pipe( sandbox_profile.as_deref(), @@ -125,7 +123,7 @@ pub(crate) async fn run_with_pipes( Ok(FunctionToolOutput::success_with_metadata( result_text.clone(), json!({ - "command": command_preview, + "command": command_to_run, "exit": status.code(), "description": description, "cwd": workdir, diff --git a/crates/core/src/tools/shell_exec/tests.rs b/crates/core/src/tools/shell_exec/tests.rs index d0044adc..18e18dbb 100644 --- a/crates/core/src/tools/shell_exec/tests.rs +++ b/crates/core/src/tools/shell_exec/tests.rs @@ -278,7 +278,7 @@ async fn execute_shell_command_error_output_is_text_only() { assert!(matches!(result.content, ToolContent::Text(text) if text.contains("exit code 7"))); } -use super::{platform_shell_program, preview, resolve_shell, truncate_output}; +use super::{platform_shell_program, resolve_shell, truncate_output}; #[cfg(unix)] #[tokio::test] @@ -412,13 +412,6 @@ fn resolve_shell_defaults_to_platform_shell_login() { assert_eq!(spec.program, platform_shell_program(true)); } -#[test] -fn preview_truncates_long_text() { - let long = "a".repeat(30_001); - let result = preview(&long); - assert!(result.ends_with("\n\n...")); -} - #[test] fn truncate_output_handles_zero_tokens() { assert_eq!(truncate_output("text", 0), ""); diff --git a/crates/core/tests/context_limit_compaction.rs b/crates/core/tests/context_limit_compaction.rs index cf81b136..d108fc09 100644 --- a/crates/core/tests/context_limit_compaction.rs +++ b/crates/core/tests/context_limit_compaction.rs @@ -125,7 +125,7 @@ async fn context_limit_error_compacts_and_retries_query() { .iter() .filter_map(|event| match event { QueryEvent::ContextCompactionStarted => Some(CompactionEvent::Started), - QueryEvent::ContextCompactionCompleted => Some(CompactionEvent::Completed), + QueryEvent::ContextCompactionCompleted { .. } => Some(CompactionEvent::Completed), QueryEvent::ContextCompactionFailed { .. } | QueryEvent::ProviderRetryStatus(_) | QueryEvent::TextDelta(_) diff --git a/crates/core/tests/legacy_projector.rs b/crates/core/tests/legacy_projector.rs index 82dead4b..dc65f39e 100644 --- a/crates/core/tests/legacy_projector.rs +++ b/crates/core/tests/legacy_projector.rs @@ -81,6 +81,7 @@ fn session_record(n: u128) -> SessionRecord { title_state: SessionTitleState::Final(SessionTitleFinalSource::ModelGenerated), sandbox_policy: "workspace-write".into(), approval_mode: "on-request".into(), + effective_context_window: None, tokens_used: 12345, first_user_message: Some("Fix the flaky test".into()), archived_at: None, @@ -90,6 +91,8 @@ fn session_record(n: u128) -> SessionRecord { parent_session_id: None, session_context: None, latest_turn_context: None, + collaboration_mode: None, + permission_preset: None, schema_version: 2, } } @@ -118,6 +121,7 @@ fn turn_record(n: u128, session: u128) -> TurnRecord { total_tokens: Some(150), }), latest_query_usage: None, + context_occupancy: None, stop_reason: None, failure_reason: None, error: None, @@ -292,6 +296,7 @@ fn basic_session_lines() -> Vec { turn_id: turn_id(turn), summary_item_id: item_id(0xb9), preserved_item_ids: vec![item_id(0xb3), item_id(0xb4)], + context_occupancy: None, })), RolloutLine::SessionRollback(Box::new(SessionRollbackLine { timestamp: ts(32), diff --git a/crates/core/tests/v2_roundtrip.rs b/crates/core/tests/v2_roundtrip.rs index d3728632..eaab4b73 100644 --- a/crates/core/tests/v2_roundtrip.rs +++ b/crates/core/tests/v2_roundtrip.rs @@ -287,6 +287,8 @@ fn normalize_session(session: &SessionRecord) -> SessionRecord { }, archived_at: session.archived_at.map(|_| session.created_at), latest_turn_context: None, + collaboration_mode: None, + permission_preset: None, schema_version: 2, ..session.clone() } @@ -427,6 +429,7 @@ fn live_write_lines() -> Vec { title_state: SessionTitleState::Unset, sandbox_policy: "workspace-write".into(), approval_mode: "on-request".into(), + effective_context_window: None, tokens_used: 0, first_user_message: None, archived_at: None, @@ -436,6 +439,8 @@ fn live_write_lines() -> Vec { parent_session_id: None, session_context: None, latest_turn_context: None, + collaboration_mode: None, + permission_preset: None, schema_version: 2, }, })), @@ -457,6 +462,7 @@ fn live_write_lines() -> Vec { input_token_estimate: None, usage: None, latest_query_usage: None, + context_occupancy: None, stop_reason: None, failure_reason: None, error: None, @@ -574,6 +580,7 @@ fn inverse_rejects_prefixed_canonical_ids() { reasoning_effort: None, mode: None, sandbox_profile: None, + effective_context_window: None, }, git_info: None, preview: String::new(), diff --git a/crates/protocol/README.md b/crates/protocol/README.md index 85f54cea..d482e059 100644 --- a/crates/protocol/README.md +++ b/crates/protocol/README.md @@ -16,7 +16,8 @@ client-to-server ACP methods are: - `session/prompt`: submit a prompt to an active session. The JSON-RPC response returns when the turn completes (`AcpPromptResult.stopReason`). Streaming progress is delivered through `session/update` notifications during the turn. -- `session/cancel`: cancel the active session turn. +- `session/cancel`: cancel the active session turn (including manual + `/compact`, which is admitted as a `ManualCompaction` turn). Event-driven clients that need an immediate turn acknowledgement should use the Devo extension `_devo/turn/start`, which returns `TurnStartResult::Started` @@ -63,7 +64,8 @@ behavior that is not represented by the portable ACP method set. - `_devo/session/metadata/update`: update session metadata such as the active model or reasoning-effort selection. - `_devo/session/permissions/update`: update the current permission preset. -- `_devo/session/compact`: proactively compact a session context. +- `_devo/session/compact`: start a manual compaction turn (`TurnStartResult`); + keep emitting `session/compaction/*` for UI. - `_devo/session/fork`: fork a new session from an existing turn. - `_devo/session/rollback`: roll back a session to a selected user turn. diff --git a/crates/protocol/src/acp_session.rs b/crates/protocol/src/acp_session.rs index 3f3266e4..97e76651 100644 --- a/crates/protocol/src/acp_session.rs +++ b/crates/protocol/src/acp_session.rs @@ -197,8 +197,11 @@ mod tests { prompt_token_estimate: 0, last_query_usage: None, last_query_total_tokens: 0, + last_context_occupancy: None, status: SessionRuntimeStatus::Idle, collaboration_mode: Default::default(), + effective_context_window: None, + permission_preset: None, }; let info = acp_session_info_from_metadata(&session); diff --git a/crates/protocol/src/acp_ts.rs b/crates/protocol/src/acp_ts.rs index 2fcd7091..353728f1 100644 --- a/crates/protocol/src/acp_ts.rs +++ b/crates/protocol/src/acp_ts.rs @@ -9,6 +9,11 @@ use serde::Serialize; use ts_rs::Config; use ts_rs::TS; +use crate::canonical::item::ContextCategoryId; +use crate::canonical::item::ContextCategoryUsage; +use crate::canonical::item::ContextOccupancy; +use crate::canonical::rpc_admin::ContextUsageReadParams; +use crate::canonical::rpc_admin::ContextUsageReadResult; use crate::canonical::rpc_admin::McpListParams; use crate::canonical::rpc_admin::McpListResult; use crate::canonical::rpc_admin::McpServerInfo; @@ -198,7 +203,6 @@ pub fn generate_protocol_typescript() -> String { push_decl::(&cfg, &mut output); push_decl::(&cfg, &mut output); push_decl::(&cfg, &mut output); - push_decl::(&cfg, &mut output); push_decl::(&cfg, &mut output); push_decl::(&cfg, &mut output); push_decl::(&cfg, &mut output); @@ -223,12 +227,6 @@ pub fn generate_protocol_typescript() -> String { push_decl::(&cfg, &mut output); push_decl::(&cfg, &mut output); push_decl::(&cfg, &mut output); - push_decl::(&cfg, &mut output); - push_decl::(&cfg, &mut output); - push_decl::(&cfg, &mut output); - push_decl::(&cfg, &mut output); - push_decl::(&cfg, &mut output); - push_decl::(&cfg, &mut output); push_decl::(&cfg, &mut output); push_decl::(&cfg, &mut output); push_decl::(&cfg, &mut output); @@ -255,6 +253,9 @@ pub fn generate_protocol_typescript() -> String { push_decl::(&cfg, &mut output); push_decl::(&cfg, &mut output); push_decl::(&cfg, &mut output); + push_decl::(&cfg, &mut output); + push_decl::(&cfg, &mut output); + push_decl::(&cfg, &mut output); push_decl::(&cfg, &mut output); push_decl::(&cfg, &mut output); @@ -293,6 +294,11 @@ pub fn generate_protocol_typescript() -> String { push_decl::(&cfg, &mut output); push_decl::(&cfg, &mut output); push_decl::(&cfg, &mut output); + push_decl::(&cfg, &mut output); + push_decl::(&cfg, &mut output); + push_decl::(&cfg, &mut output); + push_decl::(&cfg, &mut output); + push_decl::(&cfg, &mut output); push_decl::(&cfg, &mut output); push_decl::(&cfg, &mut output); @@ -637,6 +643,9 @@ fn register_devo_protocol_schemas( schema::(schemas); schema::(schemas); schema::(schemas); + schema::(schemas); + schema::(schemas); + schema::(schemas); schema::(schemas); schema::(schemas); schema::(schemas); @@ -648,7 +657,6 @@ fn register_devo_protocol_schemas( schema::(schemas); schema::(schemas); schema::(schemas); - schema::(schemas); schema::(schemas); schema::(schemas); schema::(schemas); @@ -663,6 +671,8 @@ fn register_devo_protocol_schemas( schema::(schemas); schema::(schemas); schema::(schemas); + schema::(schemas); + schema::(schemas); schema::(schemas); schema::(schemas); schema::(schemas); @@ -686,12 +696,6 @@ fn register_devo_protocol_schemas( schema::(schemas); schema::(schemas); schema::(schemas); - schema::(schemas); - schema::(schemas); - schema::(schemas); - schema::(schemas); - schema::(schemas); - schema::(schemas); schema::(schemas); schema::(schemas); schema::(schemas); @@ -763,6 +767,14 @@ fn register_devo_protocol_schemas( methods, ClientMethod::SessionPermissionsUpdate, ); + devo_method::( + methods, + ClientMethod::SessionCompactionUpdate, + ); + devo_notification::( + methods, + "session/effective_context_window/updated", + ); devo_method::( methods, ClientMethod::SessionSandboxProfileUpdate, @@ -777,10 +789,7 @@ fn register_devo_protocol_schemas( methods, ClientMethod::SessionRollback, ); - devo_method::( - methods, - ClientMethod::SessionCompact, - ); + devo_method::(methods, ClientMethod::SessionCompact); devo_method::(methods, ClientMethod::SkillsList); devo_method::(methods, ClientMethod::SkillsChanged); devo_method::( @@ -790,6 +799,10 @@ fn register_devo_protocol_schemas( devo_method::(methods, ClientMethod::McpList); devo_method::(methods, ClientMethod::McpTools); devo_method::(methods, ClientMethod::McpSetEnabled); + devo_method::( + methods, + ClientMethod::ContextUsageRead, + ); devo_method::(methods, ClientMethod::ModelCatalog); devo_method::(methods, ClientMethod::ModelConfig); devo_method::(methods, ClientMethod::ModelConfigSet); @@ -814,15 +827,6 @@ fn register_devo_protocol_schemas( devo_method::(methods, ClientMethod::TurnStart); devo_method::(methods, ClientMethod::TurnShellCommand); devo_method::(methods, ClientMethod::TurnInterrupt); - devo_method::(methods, ClientMethod::TurnSteer); - devo_method::( - methods, - ClientMethod::TurnQueueRemove, - ); - devo_method::( - methods, - ClientMethod::TurnQueueSteer, - ); devo_method::( methods, ClientMethod::WorkspaceChangesRead, diff --git a/crates/protocol/src/canonical/event.rs b/crates/protocol/src/canonical/event.rs index c290a9ca..4326b443 100644 --- a/crates/protocol/src/canonical/event.rs +++ b/crates/protocol/src/canonical/event.rs @@ -25,7 +25,7 @@ use super::ids::SubscriptionId; use super::ids::TurnId; use super::item::ApprovalDecision; use super::item::CompactionTrigger; -use super::item::ContextUsage; +use super::item::ContextOccupancy; use super::item::ItemEnvelope; use super::item::SpawnedWorkState; use super::queue::QueueChange; @@ -222,7 +222,7 @@ pub enum ServerNotification { #[serde(rename = "context/usageUpdated")] ContextUsageUpdated { session_id: SessionId, - usage: ContextUsage, + occupancy: ContextOccupancy, }, #[serde(rename = "context/compactionStarted")] ContextCompactionStarted { diff --git a/crates/protocol/src/canonical/item.rs b/crates/protocol/src/canonical/item.rs index 82fc2eec..403a56b4 100644 --- a/crates/protocol/src/canonical/item.rs +++ b/crates/protocol/src/canonical/item.rs @@ -565,6 +565,9 @@ pub enum CompactionTrigger { /// Context-window occupancy snapshot (distinct from billing usage, see the /// usage module). `measured = false` marks parts that were not precisely /// metered. +/// +/// Used on compaction items (`before` / `after`). Prefer [`ContextOccupancy`] +/// for `context/usage/read` and `context/usageUpdated`. #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] #[serde(rename_all = "camelCase")] pub struct ContextUsage { @@ -578,12 +581,221 @@ pub struct ContextUsage { pub measured: bool, } +/// Category buckets for model-visible context occupancy. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub enum ContextCategoryId { + Base, + Skills, + ToolsBuiltin, + ToolsMcp, + Conversation, +} + +/// One category's share of the current context window occupancy. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct ContextCategoryUsage { + pub id: ContextCategoryId, + pub tokens: u64, + /// Share in basis points (0..=10_000); categories sum to 10_000 when + /// `total_tokens > 0`. + pub share_bps: u16, +} + +/// Category breakdown of how full the effective context window is. +/// +/// Distinct from billing usage: this answers "what fills the window", not +/// "how much was spent". +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct ContextOccupancy { + pub total_tokens: u64, + /// Effective context window (`context_window * percent / 100`). + pub context_window_tokens: u64, + pub categories: Vec, +} + +impl ContextOccupancy { + pub fn empty(context_window_tokens: u64) -> Self { + Self { + total_tokens: 0, + context_window_tokens, + categories: Vec::new(), + } + } + + /// Build occupancy from absolute category token counts. + /// + /// Categories are emitted in a stable order. `share_bps` uses largest- + /// remainder so shares sum to 10_000 when `total > 0`. + pub fn from_category_tokens( + context_window_tokens: u64, + base: u64, + skills: u64, + tools_builtin: u64, + tools_mcp: u64, + conversation: u64, + ) -> Self { + let parts = [ + (ContextCategoryId::Base, base), + (ContextCategoryId::Skills, skills), + (ContextCategoryId::ToolsBuiltin, tools_builtin), + (ContextCategoryId::ToolsMcp, tools_mcp), + (ContextCategoryId::Conversation, conversation), + ]; + let total_tokens = parts.iter().map(|(_, tokens)| *tokens).sum::(); + let categories = if total_tokens == 0 { + Vec::new() + } else { + let mut shares = [0u16; 5]; + let mut remainders = [0u128; 5]; + let mut assigned = 0u32; + for (idx, (_, tokens)) in parts.iter().enumerate() { + let product = (*tokens as u128).saturating_mul(10_000); + shares[idx] = u16::try_from(product.checked_div(total_tokens as u128).unwrap_or(0)) + .unwrap_or(u16::MAX); + remainders[idx] = product % (total_tokens as u128); + assigned = assigned.saturating_add(u32::from(shares[idx])); + } + let mut leftover = 10_000u32.saturating_sub(assigned); + let mut order: Vec = (0..parts.len()).collect(); + order.sort_by(|&a, &b| remainders[b].cmp(&remainders[a]).then_with(|| a.cmp(&b))); + for idx in order { + if leftover == 0 { + break; + } + if parts[idx].1 == 0 { + continue; + } + shares[idx] = shares[idx].saturating_add(1); + leftover = leftover.saturating_sub(1); + } + parts + .iter() + .enumerate() + .filter(|(_, (_, tokens))| *tokens > 0) + .map(|(idx, (id, tokens))| ContextCategoryUsage { + id: *id, + tokens: *tokens, + share_bps: shares[idx], + }) + .collect() + }; + Self { + total_tokens, + context_window_tokens, + categories, + } + } + + /// Scale raw category estimates so they sum exactly to `anchor_total`. + pub fn scale_raw_to_total( + context_window_tokens: u64, + anchor_total: u64, + base: u64, + skills: u64, + tools_builtin: u64, + tools_mcp: u64, + conversation: u64, + ) -> Self { + let raw_total = base + .saturating_add(skills) + .saturating_add(tools_builtin) + .saturating_add(tools_mcp) + .saturating_add(conversation); + if anchor_total == 0 || raw_total == 0 { + return Self::empty(context_window_tokens); + } + let parts = [base, skills, tools_builtin, tools_mcp, conversation]; + let mut scaled = [0u64; 5]; + let mut remainders = [0u128; 5]; + let mut assigned = 0u64; + for (idx, tokens) in parts.iter().enumerate() { + let product = (*tokens as u128).saturating_mul(anchor_total as u128); + scaled[idx] = (product / raw_total as u128) as u64; + remainders[idx] = product % raw_total as u128; + assigned = assigned.saturating_add(scaled[idx]); + } + let mut leftover = anchor_total.saturating_sub(assigned); + let mut order: Vec = (0..parts.len()).collect(); + order.sort_by(|&a, &b| remainders[b].cmp(&remainders[a]).then_with(|| a.cmp(&b))); + for idx in order { + if leftover == 0 { + break; + } + if parts[idx] == 0 { + continue; + } + scaled[idx] = scaled[idx].saturating_add(1); + leftover = leftover.saturating_sub(1); + } + Self::from_category_tokens( + context_window_tokens, + scaled[0], + scaled[1], + scaled[2], + scaled[3], + scaled[4], + ) + } + + pub fn tokens_for(&self, id: ContextCategoryId) -> u64 { + self.categories + .iter() + .find(|category| category.id == id) + .map(|category| category.tokens) + .unwrap_or(0) + } +} + #[cfg(test)] mod tests { use pretty_assertions::assert_eq; use super::*; + #[test] + fn context_occupancy_share_bps_sum_to_10000() { + let occupancy = ContextOccupancy::from_category_tokens( + /*context_window_tokens*/ 100_000, /*base*/ 10, /*skills*/ 20, + /*tools_builtin*/ 30, /*tools_mcp*/ 40, /*conversation*/ 0, + ); + assert_eq!(occupancy.total_tokens, 100); + let share_sum: u32 = occupancy + .categories + .iter() + .map(|category| u32::from(category.share_bps)) + .sum(); + assert_eq!(share_sum, 10_000); + assert_eq!( + occupancy + .categories + .iter() + .map(|category| category.tokens) + .sum::(), + 100 + ); + } + + #[test] + fn context_occupancy_scale_raw_matches_anchor() { + let occupancy = ContextOccupancy::scale_raw_to_total( + /*context_window_tokens*/ 100_000, /*anchor_total*/ 1_000, /*base*/ 1, + /*skills*/ 1, /*tools_builtin*/ 1, /*tools_mcp*/ 1, + /*conversation*/ 6, + ); + assert_eq!(occupancy.total_tokens, 1_000); + assert_eq!( + occupancy + .categories + .iter() + .map(|category| category.tokens) + .sum::(), + 1_000 + ); + } + #[test] fn user_message_serializes_with_camel_case_tag_and_defaults() { let item = Item::UserMessage { diff --git a/crates/protocol/src/canonical/rpc_admin.rs b/crates/protocol/src/canonical/rpc_admin.rs index 6d6ac10f..04a04410 100644 --- a/crates/protocol/src/canonical/rpc_admin.rs +++ b/crates/protocol/src/canonical/rpc_admin.rs @@ -8,7 +8,7 @@ use serde::Serialize; use ts_rs::TS; use super::ids::SessionId; -use super::item::ContextUsage; +use super::item::ContextOccupancy; use super::item::ToolSource; use super::model::PermissionProfile; @@ -216,11 +216,11 @@ pub struct ContextUsageReadParams { pub session_id: SessionId, } -/// Parts that were not precisely metered are marked `measured = false`. +/// Current context-window occupancy with category breakdown. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] #[serde(rename_all = "camelCase")] pub struct ContextUsageReadResult { - pub usage: ContextUsage, + pub occupancy: ContextOccupancy, } // ── permission/profile/* ── diff --git a/crates/protocol/src/canonical/session.rs b/crates/protocol/src/canonical/session.rs index b9724093..6cd5583c 100644 --- a/crates/protocol/src/canonical/session.rs +++ b/crates/protocol/src/canonical/session.rs @@ -136,6 +136,12 @@ pub struct SessionSettings { /// resuming the session. #[serde(default, skip_serializing_if = "Option::is_none")] pub sandbox_profile: Option, + /// Session override for the absolute effective context window (tokens). + /// When set, takes precedence over the model-derived effective window and + /// is clamped to `model.context_window` at resolve time. Also drives the + /// automatic-compaction boundary. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub effective_context_window: Option, } /// Snapshot semantics: the current value is *copied* into the record at diff --git a/crates/protocol/src/canonical/wire_projector.rs b/crates/protocol/src/canonical/wire_projector.rs index 827e5805..ec22b8b6 100644 --- a/crates/protocol/src/canonical/wire_projector.rs +++ b/crates/protocol/src/canonical/wire_projector.rs @@ -838,10 +838,13 @@ mod tests { #[test] fn typed_notification_skips_non_item_events() { - let event = ServerEvent::InputQueueUpdated(crate::InputQueueUpdatedPayload { - session_id: crate::SessionId::new(), - pending_count: 0, - pending_texts: Vec::new(), + let event = ServerEvent::ReferenceSearchUpdated(crate::ReferenceSearchSnapshot { + search_id: crate::ReferenceSearchId::new(), + query: "q".to_string(), + results: Vec::new(), + total_file_match_count: 0, + scanned_file_count: 0, + file_search_complete: false, }); assert_eq!(typed_item_notification_from_server_event(&event), None); } diff --git a/crates/protocol/src/event.rs b/crates/protocol/src/event.rs index fae9318f..606a5cd5 100644 --- a/crates/protocol/src/event.rs +++ b/crates/protocol/src/event.rs @@ -188,6 +188,12 @@ pub struct TurnUsageUpdatedPayload { pub context_window: Option, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ContextUsageUpdatedPayload { + pub session_id: SessionId, + pub occupancy: crate::canonical::item::ContextOccupancy, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct ToolCallStatusUpdatedPayload { pub session_id: SessionId, @@ -213,6 +219,13 @@ pub struct SessionStatusChangedPayload { pub status: SessionRuntimeStatus, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct SessionEffectiveContextWindowUpdatedPayload { + pub session_id: SessionId, + pub effective_context_window: u64, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct SessionCompactionFailedPayload { pub session_id: SessionId, @@ -226,19 +239,6 @@ pub struct ServerRequestResolvedPayload { pub turn_id: Option, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct InputQueueUpdatedPayload { - pub session_id: SessionId, - pub pending_count: usize, - pub pending_texts: Vec, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct SteerAcceptedPayload { - pub session_id: SessionId, - pub turn_id: TurnId, -} - #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct MessageEditRecordedPayload { pub session_id: SessionId, @@ -389,6 +389,7 @@ pub enum ServerEvent { SessionCompactionCompleted(SessionEventPayload), SessionCompactionFailed(SessionCompactionFailedPayload), SessionStatusChanged(SessionStatusChangedPayload), + SessionEffectiveContextWindowUpdated(SessionEffectiveContextWindowUpdatedPayload), SessionArchived(SessionEventPayload), SessionUnarchived(SessionEventPayload), SessionClosed(SessionEventPayload), @@ -400,12 +401,11 @@ pub enum ServerEvent { TurnPlanUpdated(TurnPlanUpdatedPayload), TurnDiffUpdated(TurnEventPayload), TurnUsageUpdated(TurnUsageUpdatedPayload), + ContextUsageUpdated(ContextUsageUpdatedPayload), TurnProviderRetryStatus(TurnProviderRetryStatusPayload), WorkspaceChangesUpdated(WorkspaceChangesUpdatedPayload), ToolCallStatusUpdated(ToolCallStatusUpdatedPayload), RequestUserInput(RequestUserInputPayload), - InputQueueUpdated(InputQueueUpdatedPayload), - SteerAccepted(SteerAcceptedPayload), MessageEditRecorded(MessageEditRecordedPayload), TurnSuperseded(TurnSupersededPayload), WorkspaceRestoreStarted(WorkspaceRestoreStartedPayload), @@ -437,6 +437,7 @@ impl ServerEvent { Self::SessionDeleted(payload) => Some(payload.session_id), Self::SessionCompactionFailed(payload) => Some(payload.session_id), Self::SessionStatusChanged(payload) => Some(payload.session_id), + Self::SessionEffectiveContextWindowUpdated(payload) => Some(payload.session_id), Self::TurnStarted(payload) | Self::TurnCompleted(payload) | Self::TurnInterrupted(payload) @@ -444,12 +445,11 @@ impl ServerEvent { Self::TurnFailed(payload) => Some(payload.session_id), Self::TurnPlanUpdated(payload) => Some(payload.session_id), Self::TurnUsageUpdated(payload) => Some(payload.session_id), + Self::ContextUsageUpdated(payload) => Some(payload.session_id), Self::TurnProviderRetryStatus(payload) => Some(payload.session_id), Self::WorkspaceChangesUpdated(payload) => Some(payload.session_id), Self::ToolCallStatusUpdated(payload) => Some(payload.session_id), Self::RequestUserInput(payload) => Some(payload.request.session_id), - Self::InputQueueUpdated(payload) => Some(payload.session_id), - Self::SteerAccepted(payload) => Some(payload.session_id), Self::MessageEditRecorded(payload) => Some(payload.session_id), Self::TurnSuperseded(payload) => Some(payload.session_id), Self::WorkspaceRestoreStarted(payload) => Some(payload.session_id), @@ -475,6 +475,9 @@ impl ServerEvent { Self::SessionCompactionCompleted(_) => "session/compaction/completed", Self::SessionCompactionFailed(_) => "session/compaction/failed", Self::SessionStatusChanged(_) => "session/status/changed", + Self::SessionEffectiveContextWindowUpdated(_) => { + "session/effective_context_window/updated" + } Self::SessionArchived(_) => "session/archived", Self::SessionUnarchived(_) => "session/unarchived", Self::SessionClosed(_) => "session/closed", @@ -486,12 +489,11 @@ impl ServerEvent { Self::TurnPlanUpdated(_) => "turn/plan/updated", Self::TurnDiffUpdated(_) => "turn/diff/updated", Self::TurnUsageUpdated(_) => "turn/usage/updated", + Self::ContextUsageUpdated(_) => "context/usageUpdated", Self::TurnProviderRetryStatus(_) => "turn/provider_retry_status", Self::WorkspaceChangesUpdated(_) => "workspace/changes/updated", Self::ToolCallStatusUpdated(_) => "tool_call/status_updated", Self::RequestUserInput(_) => "item/tool/requestUserInput", - Self::InputQueueUpdated(_) => "inputQueue/updated", - Self::SteerAccepted(_) => "steer/accepted", Self::MessageEditRecorded(_) => "message/edit/recorded", Self::TurnSuperseded(_) => "turn/superseded", Self::WorkspaceRestoreStarted(_) => "workspace_restore_started", @@ -522,12 +524,11 @@ impl ServerEvent { } Self::ItemDelta { payload, .. } => payload.context.seq = seq, Self::TurnUsageUpdated(_) + | Self::ContextUsageUpdated(_) | Self::TurnProviderRetryStatus(_) | Self::WorkspaceChangesUpdated(_) | Self::ToolCallStatusUpdated(_) | Self::RequestUserInput(_) - | Self::InputQueueUpdated(_) - | Self::SteerAccepted(_) | Self::MessageEditRecorded(_) | Self::TurnSuperseded(_) | Self::WorkspaceRestoreStarted(_) @@ -553,19 +554,6 @@ mod tests { WorkspaceChangeStats, WorkspaceChangeViewStatus, }; - #[test] - fn input_queue_updated_event_roundtrips() { - let payload = InputQueueUpdatedPayload { - session_id: SessionId::new(), - pending_count: 3, - pending_texts: vec!["first".into(), "second".into()], - }; - let json = serde_json::to_string(&payload).expect("serialize"); - let restored: InputQueueUpdatedPayload = serde_json::from_str(&json).expect("deserialize"); - assert_eq!(restored.pending_count, 3); - assert_eq!(restored.pending_texts, vec!["first", "second"]); - } - #[test] fn turn_failed_payload_serializes_error_and_accepts_legacy_shape() { let session_id = SessionId::new(); @@ -656,18 +644,6 @@ mod tests { ); } - #[test] - fn steer_accepted_event_roundtrips() { - let turn_id = TurnId::new(); - let payload = SteerAcceptedPayload { - session_id: SessionId::new(), - turn_id, - }; - let json = serde_json::to_string(&payload).expect("serialize"); - let restored: SteerAcceptedPayload = serde_json::from_str(&json).expect("deserialize"); - assert_eq!(restored.turn_id, turn_id); - } - #[test] fn message_edit_events_roundtrip_and_report_methods() { let session_id = SessionId::new(); @@ -749,27 +725,6 @@ mod tests { assert_eq!(restore_completed_event.session_id(), Some(session_id)); } - #[test] - fn server_event_input_queue_updated_method_name() { - let event = ServerEvent::InputQueueUpdated(InputQueueUpdatedPayload { - session_id: SessionId::new(), - pending_count: 0, - pending_texts: vec![], - }); - assert_eq!(event.method_name(), "inputQueue/updated"); - assert!(event.session_id().is_some()); - } - - #[test] - fn server_event_steer_accepted_method_name() { - let event = ServerEvent::SteerAccepted(SteerAcceptedPayload { - session_id: SessionId::new(), - turn_id: TurnId::new(), - }); - assert_eq!(event.method_name(), "steer/accepted"); - assert!(event.session_id().is_some()); - } - #[test] fn workspace_changes_updated_method_name() { let session_id = SessionId::new(); diff --git a/crates/protocol/src/protocol.rs b/crates/protocol/src/protocol.rs index 02c73d38..ac3f51d2 100644 --- a/crates/protocol/src/protocol.rs +++ b/crates/protocol/src/protocol.rs @@ -64,6 +64,7 @@ pub enum ClientMethod { SessionFork, SessionRollback, SessionCompact, + SessionCompactionUpdate, SkillsList, SkillsChanged, SkillsSetEnabled, @@ -79,9 +80,6 @@ pub enum ClientMethod { TurnStart, TurnShellCommand, TurnInterrupt, - TurnSteer, - TurnQueueRemove, - TurnQueueSteer, WorkspaceChangesRead, RequestUserInputRespond, SearchStart, @@ -108,6 +106,7 @@ pub enum ClientMethod { McpList, McpTools, McpSetEnabled, + ContextUsageRead, // New Native API methods (canonical types; not part of the legacy // `_devo/*` alias surface). SessionTurnsList, @@ -136,6 +135,7 @@ impl ClientMethod { Self::SessionFork => "session/fork", Self::SessionRollback => "session/rollback", Self::SessionCompact => "session/compact", + Self::SessionCompactionUpdate => "session/compaction/update", Self::SkillsList => "skills/list", Self::SkillsChanged => "skills/changed", Self::SkillsSetEnabled => "skills/set_enabled", @@ -151,9 +151,6 @@ impl ClientMethod { Self::TurnStart => "turn/start", Self::TurnShellCommand => "turn/shell_command", Self::TurnInterrupt => "turn/interrupt", - Self::TurnSteer => "turn/steer", - Self::TurnQueueRemove => "turn/queue/remove", - Self::TurnQueueSteer => "turn/queue/steer", Self::WorkspaceChangesRead => "workspace/changes/read", Self::RequestUserInputRespond => "request_user_input/respond", Self::SearchStart => "search/start", @@ -180,6 +177,7 @@ impl ClientMethod { Self::McpList => "mcp/list", Self::McpTools => "mcp/tools", Self::McpSetEnabled => "mcp/set_enabled", + Self::ContextUsageRead => "context/usage/read", Self::SessionTurnsList => "session/turns/list", Self::SessionItemsList => "session/items/list", Self::SessionRollbackPreview => "session/rollback/preview", @@ -206,6 +204,7 @@ impl ClientMethod { "session/fork" => Self::SessionFork, "session/rollback" => Self::SessionRollback, "session/compact" => Self::SessionCompact, + "session/compaction/update" => Self::SessionCompactionUpdate, "skills/list" => Self::SkillsList, "skills/changed" => Self::SkillsChanged, "skills/set_enabled" => Self::SkillsSetEnabled, @@ -221,9 +220,6 @@ impl ClientMethod { "turn/start" => Self::TurnStart, "turn/shell_command" => Self::TurnShellCommand, "turn/interrupt" => Self::TurnInterrupt, - "turn/steer" => Self::TurnSteer, - "turn/queue/remove" => Self::TurnQueueRemove, - "turn/queue/steer" => Self::TurnQueueSteer, "workspace/changes/read" => Self::WorkspaceChangesRead, "request_user_input/respond" => Self::RequestUserInputRespond, "search/start" => Self::SearchStart, @@ -250,6 +246,7 @@ impl ClientMethod { "mcp/list" => Self::McpList, "mcp/tools" => Self::McpTools, "mcp/set_enabled" => Self::McpSetEnabled, + "context/usage/read" => Self::ContextUsageRead, "session/turns/list" => Self::SessionTurnsList, "session/items/list" => Self::SessionItemsList, "session/rollback/preview" => Self::SessionRollbackPreview, @@ -774,5 +771,13 @@ mod tests { ClientMethod::SessionSandboxProfileUpdate.as_str(), "session/sandbox_profile/update" ); + assert_eq!( + ClientMethod::SessionCompactionUpdate.as_str(), + "session/compaction/update" + ); + assert_eq!( + ClientMethod::parse("session/compaction/update"), + Some(ClientMethod::SessionCompactionUpdate) + ); } } diff --git a/crates/protocol/src/session.rs b/crates/protocol/src/session.rs index e4c6ac7d..7138741d 100644 --- a/crates/protocol/src/session.rs +++ b/crates/protocol/src/session.rs @@ -15,6 +15,7 @@ use crate::SessionTitleState; use crate::TurnId; use crate::TurnUsage; use crate::parse_command::ParsedCommand; +use crate::permissions::PermissionPreset; use crate::protocol::FileChange; use crate::turn::CollaborationMode; use crate::turn::TurnMetadata; @@ -73,19 +74,39 @@ pub struct SessionMetadata { /// the latest completed-query usage rather than rolling session totals. #[serde(default, skip_serializing_if = "Option::is_none")] pub last_query_usage: Option, - /// Legacy/derived projection of [`Self::last_query_usage`] display total. + /// Compatibility scalar for current context-window occupancy. /// - /// Prefer `last_query_usage` when available. When both are set, this should - /// equal `last_query_usage.display_total_tokens()`. Provider-reported - /// `total_tokens` is used when available; otherwise this falls back to - /// `input_tokens + output_tokens`. + /// Prefer [`Self::last_query_usage`] when available for provider query + /// accounting. Prefer [`Self::last_context_occupancy`] when available for + /// window occupancy (including category breakdown). /// - /// This value is refreshed on every completed model invoke so the UI can - /// show the latest completed-query usage after each request, and it remains - /// the persisted value used when a session is resumed. While a turn is in - /// flight, the UI may temporarily fall back to the live prompt estimate - /// instead. + /// When `last_context_occupancy` is set, this should equal + /// `last_context_occupancy.total_tokens` (a derived projection, not a + /// second independent counter). When occupancy is absent (for example + /// before the first completed query, or older resume payloads), this may + /// still carry a legacy scalar from `last_query_usage.display_total_tokens()` + /// or remain `0`. + /// + /// Kept for TUI status, resume, and existing `SessionMetadata` consumers + /// until they migrate to reading `last_context_occupancy.total_tokens`. + /// While a turn is in flight, the UI may temporarily fall back to the live + /// prompt estimate instead. pub last_query_total_tokens: usize, + /// Source of truth for context-window occupancy and category shares. + /// + /// Includes `total_tokens`, effective `context_window_tokens`, and + /// category breakdown (`base`, `skills`, `toolsBuiltin`, `toolsMcp`, + /// `conversation`). New clients should use this for occupancy reads + /// (`context/usage/read`, `context/usageUpdated`) rather than inventing a + /// second total. + /// + /// Refreshed on completed turn queries (categories scaled so they sum to + /// the latest provider query display total) and immediately after + /// successful compaction (conversation bucket replaced; other buckets + /// reused). When present, [`Self::last_query_total_tokens`] should mirror + /// `total_tokens` as a compatibility projection. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_context_occupancy: Option, pub status: SessionRuntimeStatus, /// Collaboration mode restored from the latest completed turn context. /// @@ -94,6 +115,17 @@ pub struct SessionMetadata { /// composer (Plan vs Build) without waiting for the next turn start. #[serde(default)] pub collaboration_mode: CollaborationMode, + /// Session override for the absolute effective context window (tokens). + /// + /// When set, clients should treat this as the effective Settings Hub / + /// compaction-threshold value on resume (clamped to the model + /// `context_window`). Absent means use the model-derived + /// `effective_context_window()`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub effective_context_window: Option, + /// Permission preset restored from session durable metadata. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub permission_preset: Option, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] @@ -139,6 +171,7 @@ pub enum SessionHistoryItemKind { CommandExecution, Error, TurnSummary, + ContextCompaction, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] @@ -260,6 +293,8 @@ pub struct SessionMetadataUpdateParams { pub model_binding_id: Option, #[serde(default, alias = "thinking", skip_serializing_if = "Option::is_none")] pub reasoning_effort_selection: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub collaboration_mode: Option, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] @@ -272,9 +307,27 @@ pub struct SessionCompactParams { pub session_id: SessionId, } +/// Persists a global compaction token limit and hot-applies it to live sessions. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] -pub struct SessionCompactResult { - pub session: SessionMetadata, +#[serde(rename_all = "camelCase")] +pub struct SessionCompactionUpdateParams { + pub session_id: SessionId, + /// Absolute global compaction threshold in tokens. Written to user + /// `config.toml` as `compaction_token_limit`. Values above a session model's + /// `context_window` are clamped when applied to that session. + pub effective_context_window: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct SessionCompactionUpdateResult { + pub session_id: SessionId, + /// Applied absolute window for this session after clamp. + pub effective_context_window: u64, + /// Model default (`effective_context_window()`, clamped) used as recommended. + pub recommended_token_limit: u64, + /// Raw model context window used for clamp. + pub context_window_tokens: u64, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] @@ -426,8 +479,11 @@ mod tests { total_tokens: Some(21), }), last_query_total_tokens: 21, + last_context_occupancy: None, status: SessionRuntimeStatus::Idle, collaboration_mode: CollaborationMode::Plan, + effective_context_window: None, + permission_preset: None, }; let json = serde_json::to_string(&metadata).expect("serialize"); diff --git a/crates/protocol/src/slash_command.rs b/crates/protocol/src/slash_command.rs index e3c721f6..65489804 100644 --- a/crates/protocol/src/slash_command.rs +++ b/crates/protocol/src/slash_command.rs @@ -16,9 +16,9 @@ pub enum SlashCommand { Rename, Delete, Status, + Settings, Permissions, ShowReasoning, - Clear, Diff, Exit, Btw, @@ -28,7 +28,7 @@ pub enum SlashCommand { impl SlashCommand { pub fn description(self) -> &'static str { match self { - SlashCommand::Theme => "switch the UI theme", + SlashCommand::Theme => "open appearance settings to switch the UI theme", SlashCommand::Model => "choose the active model", SlashCommand::Skills => "browse available skills", SlashCommand::Mcp => "browse MCP servers", @@ -37,14 +37,14 @@ impl SlashCommand { SlashCommand::New => "start a new chat", SlashCommand::Rename => "rename the current session", SlashCommand::Delete => "delete the current session and start a new one", - SlashCommand::Status => "show current session configuration and token usage", + SlashCommand::Status => "show cwd, permissions, and context window occupancy", + SlashCommand::Settings => "open session and appearance settings", SlashCommand::Permissions => { "choose what Devo is allowed to do (also sets the OS sandbox)" } SlashCommand::ShowReasoning => { "choose how reasoning content is shown in the transcript" } - SlashCommand::Clear => "clear the current transcript", SlashCommand::Diff => "show git diff (including untracked files)", SlashCommand::Btw => { "Ask a quick side question without interrupting the main conversation" @@ -66,9 +66,9 @@ impl SlashCommand { SlashCommand::Rename => "rename", SlashCommand::Delete => "delete", SlashCommand::Status => "status", + SlashCommand::Settings => "settings", SlashCommand::Permissions => "permissions", SlashCommand::ShowReasoning => "show-reasoning", - SlashCommand::Clear => "clear", SlashCommand::Diff => "diff", SlashCommand::Btw => "btw", SlashCommand::Goal => "goal", @@ -76,6 +76,32 @@ impl SlashCommand { } } + /// Soft alternate names accepted by parsing / popup filtering. + /// + /// These are not listed as separate built-in entries; the canonical + /// [`SlashCommand::command`] remains the display and insertion name. + pub fn aliases(self) -> &'static [&'static str] { + match self { + SlashCommand::Exit => &["quit"], + SlashCommand::Theme + | SlashCommand::Model + | SlashCommand::Skills + | SlashCommand::Mcp + | SlashCommand::Compact + | SlashCommand::Resume + | SlashCommand::New + | SlashCommand::Rename + | SlashCommand::Delete + | SlashCommand::Status + | SlashCommand::Settings + | SlashCommand::Permissions + | SlashCommand::ShowReasoning + | SlashCommand::Diff + | SlashCommand::Btw + | SlashCommand::Goal => &[], + } + } + pub fn supports_inline_args(self) -> bool { matches!( self, @@ -97,9 +123,9 @@ impl SlashCommand { | SlashCommand::New | SlashCommand::Delete | SlashCommand::Status + | SlashCommand::Settings | SlashCommand::Permissions | SlashCommand::ShowReasoning - | SlashCommand::Clear | SlashCommand::Diff | SlashCommand::Exit => None, } @@ -116,6 +142,7 @@ impl SlashCommand { | SlashCommand::Delete | SlashCommand::Resume | SlashCommand::Permissions + | SlashCommand::Settings ) } @@ -136,9 +163,9 @@ impl SlashCommand { | SlashCommand::Rename | SlashCommand::Delete | SlashCommand::Status + | SlashCommand::Settings | SlashCommand::Permissions | SlashCommand::ShowReasoning - | SlashCommand::Clear | SlashCommand::Diff | SlashCommand::Exit | SlashCommand::Btw => None, @@ -160,14 +187,16 @@ impl FromStr for SlashCommand { "new" => Ok(Self::New), "rename" => Ok(Self::Rename), "delete" => Ok(Self::Delete), - "status" => Ok(Self::Status), + // `/context` remains a soft alias for the status panel. + "status" | "context" => Ok(Self::Status), + "settings" => Ok(Self::Settings), "permissions" | "approvals" => Ok(Self::Permissions), "show-reasoning" | "reasoning-view" => Ok(Self::ShowReasoning), - "clear" => Ok(Self::Clear), "diff" => Ok(Self::Diff), "btw" => Ok(Self::Btw), "goal" => Ok(Self::Goal), - "exit" => Ok(Self::Exit), + // `/quit` is a soft alias for exit. + "exit" | "quit" => Ok(Self::Exit), _ => Err(()), } } @@ -175,7 +204,6 @@ impl FromStr for SlashCommand { pub fn built_in_slash_commands() -> Vec<(&'static str, SlashCommand)> { vec![ - ("theme", SlashCommand::Theme), ("model", SlashCommand::Model), ("skills", SlashCommand::Skills), ("mcps", SlashCommand::Mcp), @@ -185,9 +213,9 @@ pub fn built_in_slash_commands() -> Vec<(&'static str, SlashCommand)> { ("rename", SlashCommand::Rename), ("delete", SlashCommand::Delete), ("status", SlashCommand::Status), + ("settings", SlashCommand::Settings), ("permissions", SlashCommand::Permissions), ("show-reasoning", SlashCommand::ShowReasoning), - ("clear", SlashCommand::Clear), ("diff", SlashCommand::Diff), ("goal", SlashCommand::Goal), ("btw", SlashCommand::Btw), @@ -258,6 +286,7 @@ mod tests { assert!(!SlashCommand::Exit.available_over_acp()); assert!(!SlashCommand::Rename.available_over_acp()); assert!(!SlashCommand::Delete.available_over_acp()); + assert!(!SlashCommand::Settings.available_over_acp()); } #[test] @@ -266,11 +295,63 @@ mod tests { assert!(!SlashCommand::Permissions.available_during_task()); assert!(!SlashCommand::Theme.available_during_task()); assert!(!SlashCommand::Delete.available_during_task()); + assert!(!SlashCommand::Settings.available_during_task()); assert!(SlashCommand::Status.available_during_task()); assert!(SlashCommand::Goal.available_during_task()); assert!(SlashCommand::Rename.available_during_task()); } + #[test] + fn theme_slash_command_is_hidden_alias_for_settings() { + assert_eq!("theme".parse::(), Ok(SlashCommand::Theme)); + assert!(!SlashCommand::Theme.available_during_task()); + assert!( + !built_in_slash_commands() + .iter() + .any(|(name, _)| *name == "theme") + ); + assert!( + built_in_slash_commands() + .iter() + .any(|(name, command)| *name == "settings" && *command == SlashCommand::Settings) + ); + } + + #[test] + fn status_slash_command_parses_and_context_is_alias() { + assert_eq!("status".parse::(), Ok(SlashCommand::Status)); + assert_eq!("context".parse::(), Ok(SlashCommand::Status)); + assert!(SlashCommand::Status.available_during_task()); + assert!(!SlashCommand::Status.available_over_acp()); + assert_eq!( + SlashCommand::Status.description(), + "show cwd, permissions, and context window occupancy" + ); + assert!( + built_in_slash_commands() + .iter() + .any(|(name, command)| *name == "status" && *command == SlashCommand::Status) + ); + assert!( + !built_in_slash_commands() + .iter() + .any(|(name, _)| *name == "context") + ); + } + + #[test] + fn settings_slash_command_parses_and_is_unavailable_during_task() { + assert_eq!( + "settings".parse::(), + Ok(SlashCommand::Settings) + ); + assert!(!SlashCommand::Settings.available_during_task()); + assert_eq!( + SlashCommand::Settings.description(), + "open session and appearance settings" + ); + } + #[test] fn rename_and_delete_slash_commands_parse_and_describe() { assert_eq!("rename".parse::(), Ok(SlashCommand::Rename)); @@ -280,4 +361,32 @@ mod tests { assert_eq!(SlashCommand::Rename.parameter_hint(), Some("")); assert_eq!(SlashCommand::Delete.parameter_hint(), None); } + + #[test] + fn exit_slash_command_parses_and_quit_is_alias() { + assert_eq!("exit".parse::(), Ok(SlashCommand::Exit)); + assert_eq!("quit".parse::(), Ok(SlashCommand::Exit)); + assert_eq!(SlashCommand::Exit.command(), "exit"); + assert_eq!(SlashCommand::Exit.aliases(), &["quit"]); + assert!( + built_in_slash_commands() + .iter() + .any(|(name, command)| *name == "exit" && *command == SlashCommand::Exit) + ); + assert!( + !built_in_slash_commands() + .iter() + .any(|(name, _)| *name == "quit") + ); + } + + #[test] + fn clear_slash_command_is_removed() { + assert_eq!("clear".parse::(), Err(())); + assert!( + !built_in_slash_commands() + .iter() + .any(|(name, _)| *name == "clear") + ); + } } diff --git a/crates/protocol/src/turn.rs b/crates/protocol/src/turn.rs index 723c45d3..c69986c8 100644 --- a/crates/protocol/src/turn.rs +++ b/crates/protocol/src/turn.rs @@ -141,10 +141,6 @@ pub enum TurnInputDisposition { Steered, } -fn default_steered_disposition() -> TurnInputDisposition { - TurnInputDisposition::Steered -} - #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] #[serde(tag = "disposition", rename_all = "snake_case")] pub enum TurnStartResult { @@ -223,45 +219,6 @@ pub struct TurnInterruptResult { pub status: TurnStatus, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] -pub struct TurnSteerParams { - pub session_id: SessionId, - pub expected_turn_id: TurnId, - pub input: Vec, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] -pub struct TurnSteerResult { - pub turn_id: TurnId, - #[serde(default = "default_steered_disposition")] - pub disposition: TurnInputDisposition, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] -pub struct TurnQueueRemoveParams { - pub session_id: SessionId, - pub queued_input_id: PendingInputId, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] -pub struct TurnQueueRemoveResult { - pub removed: bool, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] -pub struct TurnQueueSteerParams { - pub session_id: SessionId, - pub expected_turn_id: TurnId, - pub queued_input_id: PendingInputId, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] -pub struct TurnQueueSteerResult { - pub turn_id: TurnId, - #[serde(default = "default_steered_disposition")] - pub disposition: TurnInputDisposition, -} - #[derive(Debug, Clone, PartialEq, Eq, Serialize, Default, JsonSchema, TS)] #[serde(rename_all = "snake_case")] pub enum TurnKind { @@ -554,17 +511,6 @@ mod tests { assert_eq!(restored.collaboration_mode, CollaborationMode::Plan); } - #[test] - fn turn_steer_result_defaults_to_steered_disposition() { - let json = serde_json::json!({ - "turn_id": TurnId::new() - }); - - let restored: TurnSteerResult = serde_json::from_value(json).expect("deserialize"); - - assert_eq!(restored.disposition, TurnInputDisposition::Steered); - } - #[test] fn shell_command_result_keeps_plain_started_shape() { let result = ShellCommandResult { diff --git a/crates/safety/src/lib.rs b/crates/safety/src/lib.rs index 3abbeb6a..89adda67 100644 --- a/crates/safety/src/lib.rs +++ b/crates/safety/src/lib.rs @@ -16,8 +16,9 @@ use smol_str::SmolStr; /// Controls how tool permission requests are handled by the runtime. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum PermissionMode { - /// Approve every request without asking. - AutoApprove, + /// Bypass approvals entirely (never ask). Full-Access / yolo posture. + #[serde(alias = "AutoApprove")] + Yolo, /// Ask the user for confirmation on each request. Interactive, /// Deny all requests that require permission. @@ -90,7 +91,8 @@ pub struct RuntimePermissionProfile { pub writable_roots: BTreeSet, pub allow_shell_commands: bool, pub allow_network: bool, - pub auto_approve: bool, + /// When true, sensitive tools are allowed without prompts (Full-Access / yolo). + pub yolo: bool, } impl RuntimePermissionProfile { @@ -118,7 +120,7 @@ impl RuntimePermissionProfile { writable_roots, allow_shell_commands: true, allow_network: false, - auto_approve: false, + yolo: false, } } PermissionPreset::FullAccess => RuntimePermissionProfile { @@ -129,7 +131,7 @@ impl RuntimePermissionProfile { writable_roots, allow_shell_commands: true, allow_network: true, - auto_approve: true, + yolo: true, }, } } @@ -180,8 +182,8 @@ impl RuntimePermissionProfile { } pub fn permission_mode(&self) -> PermissionMode { - if self.auto_approve { - PermissionMode::AutoApprove + if self.yolo { + PermissionMode::Yolo } else { PermissionMode::Interactive } diff --git a/crates/server/Cargo.toml b/crates/server/Cargo.toml index 43100b7e..006d3df9 100644 --- a/crates/server/Cargo.toml +++ b/crates/server/Cargo.toml @@ -31,6 +31,7 @@ devo-util-shell-command = { workspace = true } diffy = { workspace = true } futures = { workspace = true } fs2 = { workspace = true } +lru = { workspace = true } rusqlite = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } @@ -50,3 +51,4 @@ uuid = { workspace = true } async-trait = { workspace = true } pretty_assertions = "1" tempfile = "3" +tokio = { workspace = true, features = ["test-util"] } diff --git a/crates/server/src/db.rs b/crates/server/src/db.rs index 423c16e6..0019ba0c 100644 --- a/crates/server/src/db.rs +++ b/crates/server/src/db.rs @@ -9,6 +9,7 @@ use chrono::{TimeZone, Utc}; use rusqlite::{Connection, params, types::Type}; use serde_json; +use devo_protocol::canonical::item::ContextOccupancy; use devo_protocol::{ PendingInputId, PendingInputItem, PendingInputKind, SessionId, SessionMetadata, SessionRuntimeStatus, SessionTitleState, @@ -56,9 +57,15 @@ pub struct SessionStats { pub total_tokens: usize, pub total_cache_creation_tokens: usize, pub total_cache_read_tokens: usize, + /// Latest **model-query** input tokens (not cumulative turn usage). + /// + /// Used for hydrate / diagnostics. Must not be confused with turn-aggregate + /// `TurnMetadata.usage`, which sums every completed leg in a multi-tool turn. pub last_input_tokens: usize, pub turn_count: usize, pub prompt_token_estimate: usize, + /// Latest context-window occupancy breakdown, when known. + pub last_context_occupancy: Option, } /// One derived event row before per-stream sequencing (08 §5). @@ -140,7 +147,8 @@ impl Database { total_cache_read_tokens INTEGER NOT NULL DEFAULT 0, last_input_tokens INTEGER NOT NULL DEFAULT 0, turn_count INTEGER NOT NULL DEFAULT 0, - prompt_token_estimate INTEGER NOT NULL DEFAULT 0 + prompt_token_estimate INTEGER NOT NULL DEFAULT 0, + last_context_occupancy TEXT ); CREATE TABLE IF NOT EXISTS pending_messages ( @@ -261,6 +269,13 @@ impl Database { ) .context("failed to backfill total_tokens column")?; } + if !table_has_column(&conn, "session_stats", "last_context_occupancy")? { + conn.execute( + "ALTER TABLE session_stats ADD COLUMN last_context_occupancy TEXT", + [], + ) + .context("failed to add last_context_occupancy column")?; + } if !sessions_has_column(&conn, "rollout_path")? { conn.execute("ALTER TABLE sessions ADD COLUMN rollout_path TEXT", []) .context("failed to add rollout_path column")?; @@ -729,12 +744,18 @@ impl Database { /// Inserts or updates session token statistics. pub fn update_stats(&self, id: &SessionId, stats: &SessionStats) -> Result<()> { + let occupancy_json = stats + .last_context_occupancy + .as_ref() + .map(serde_json::to_string) + .transpose() + .context("failed to serialize last_context_occupancy")?; let conn = self.conn.lock().expect("database mutex poisoned"); conn.execute( "INSERT INTO session_stats (session_id, total_input_tokens, total_output_tokens, total_tokens, total_cache_creation_tokens, total_cache_read_tokens, last_input_tokens, - turn_count, prompt_token_estimate) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9) + turn_count, prompt_token_estimate, last_context_occupancy) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10) ON CONFLICT(session_id) DO UPDATE SET total_input_tokens = excluded.total_input_tokens, total_output_tokens = excluded.total_output_tokens, @@ -743,7 +764,8 @@ impl Database { total_cache_read_tokens = excluded.total_cache_read_tokens, last_input_tokens = excluded.last_input_tokens, turn_count = excluded.turn_count, - prompt_token_estimate = excluded.prompt_token_estimate", + prompt_token_estimate = excluded.prompt_token_estimate, + last_context_occupancy = excluded.last_context_occupancy", params![ id.to_string(), stats.total_input_tokens as i64, @@ -754,6 +776,7 @@ impl Database { stats.last_input_tokens as i64, stats.turn_count as i64, stats.prompt_token_estimate as i64, + occupancy_json, ], ) .context("failed to update session stats")?; @@ -765,10 +788,15 @@ impl Database { let conn = self.conn.lock().expect("database mutex poisoned"); let result = conn.query_row( "SELECT total_input_tokens, total_output_tokens, total_tokens, total_cache_creation_tokens, - total_cache_read_tokens, last_input_tokens, turn_count, prompt_token_estimate + total_cache_read_tokens, last_input_tokens, turn_count, prompt_token_estimate, + last_context_occupancy FROM session_stats WHERE session_id = ?1", params![id.to_string()], |row| { + let occupancy_json: Option = row.get(8)?; + let last_context_occupancy = occupancy_json.and_then(|json| { + serde_json::from_str::(&json).ok() + }); Ok(SessionStats { total_input_tokens: row.get::<_, i64>(0)? as usize, total_output_tokens: row.get::<_, i64>(1)? as usize, @@ -778,6 +806,7 @@ impl Database { last_input_tokens: row.get::<_, i64>(5)? as usize, turn_count: row.get::<_, i64>(6)? as usize, prompt_token_estimate: row.get::<_, i64>(7)? as usize, + last_context_occupancy, }) }, ); @@ -1130,8 +1159,13 @@ fn parse_session_metadata_row( prompt_token_estimate: 0, last_query_usage: None, last_query_total_tokens: 0, + last_context_occupancy: None, status: SessionRuntimeStatus::Idle, collaboration_mode: Default::default(), + // Applied compaction window is resolved from AppConfig at session + // hydrate / create time; DB list rows do not persist it. + effective_context_window: None, + permission_preset: None, }) } @@ -1415,8 +1449,11 @@ mod tests { prompt_token_estimate: 0, last_query_usage: None, last_query_total_tokens: 0, + last_context_occupancy: None, status: SessionRuntimeStatus::Idle, collaboration_mode: Default::default(), + effective_context_window: None, + permission_preset: None, } } @@ -1514,6 +1551,7 @@ mod tests { last_input_tokens: 200, turn_count: 5, prompt_token_estimate: 800, + last_context_occupancy: None, }; db.update_stats(&meta.session_id, &stats).expect("update"); @@ -1525,6 +1563,36 @@ mod tests { assert_eq!(retrieved.turn_count, 5); } + #[test] + fn update_and_get_stats_roundtrips_context_occupancy() { + let (db, _dir) = test_db(); + let meta = sample_session("session-occupancy"); + db.upsert_session(&meta, None).expect("upsert"); + + let occupancy = ContextOccupancy::from_category_tokens( + /*context_window_tokens*/ 100_000, /*base*/ 10_000, /*skills*/ 5_000, + /*tools_builtin*/ 20_000, /*tools_mcp*/ 15_000, /*conversation*/ 50_000, + ); + let stats = SessionStats { + total_input_tokens: 1000, + total_output_tokens: 500, + total_tokens: 1500, + total_cache_creation_tokens: 0, + total_cache_read_tokens: 200, + last_input_tokens: 1000, + turn_count: 2, + prompt_token_estimate: 100_000, + last_context_occupancy: Some(occupancy.clone()), + }; + db.update_stats(&meta.session_id, &stats).expect("update"); + + let retrieved = db + .get_stats(&meta.session_id) + .expect("get") + .expect("stats row"); + assert_eq!(retrieved.last_context_occupancy, Some(occupancy)); + } + #[test] fn push_and_drain_pending() { let (db, _dir) = test_db(); diff --git a/crates/server/src/event.rs b/crates/server/src/event.rs index 6edb3a84..30a9578d 100644 --- a/crates/server/src/event.rs +++ b/crates/server/src/event.rs @@ -1,9 +1,11 @@ pub use devo_protocol::{ - ApprovalDecisionPayload, ApprovalRequestPayload, CommandExecutionPayload, EventContext, - FileChangePayload, ItemDeltaKind, ItemDeltaPayload, ItemEnvelope, ItemEventPayload, ItemKind, - MessageEditRecordedPayload, PendingServerRequestContext, RequestUserInputPayload, ServerEvent, - ServerRequestKind, ServerRequestResolvedPayload, SessionCompactionFailedPayload, - SessionEventPayload, SessionStatusChangedPayload, ToolCallPayload, ToolResultPayload, - TurnEventPayload, TurnPlanStepPayload, TurnPlanUpdatedPayload, TurnSupersededPayload, - TurnUsageUpdatedPayload, WorkspaceRestoreCompletedPayload, WorkspaceRestoreStartedPayload, + ApprovalDecisionPayload, ApprovalRequestPayload, CommandExecutionPayload, + ContextUsageUpdatedPayload, EventContext, FileChangePayload, ItemDeltaKind, ItemDeltaPayload, + ItemEnvelope, ItemEventPayload, ItemKind, MessageEditRecordedPayload, + PendingServerRequestContext, RequestUserInputPayload, ServerEvent, ServerRequestKind, + ServerRequestResolvedPayload, SessionCompactionFailedPayload, + SessionEffectiveContextWindowUpdatedPayload, SessionEventPayload, SessionStatusChangedPayload, + ToolCallPayload, ToolResultPayload, TurnEventPayload, TurnPlanStepPayload, + TurnPlanUpdatedPayload, TurnSupersededPayload, TurnUsageUpdatedPayload, + WorkspaceRestoreCompletedPayload, WorkspaceRestoreStartedPayload, }; diff --git a/crates/server/src/event_reconcile.rs b/crates/server/src/event_reconcile.rs index f3a992cd..12fadbf3 100644 --- a/crates/server/src/event_reconcile.rs +++ b/crates/server/src/event_reconcile.rs @@ -185,6 +185,7 @@ mod tests { None, None, None, + None, ); store.append_turn(&record, turn).expect("append turn"); let item = build_item_record( diff --git a/crates/server/src/execution.rs b/crates/server/src/execution.rs index a85dec4d..9b466500 100644 --- a/crates/server/src/execution.rs +++ b/crates/server/src/execution.rs @@ -1,5 +1,7 @@ +use std::collections::HashMap; use std::collections::HashSet; use std::collections::VecDeque; +use std::num::NonZeroUsize; use std::path::Path; use std::path::PathBuf; use std::sync::Arc; @@ -7,6 +9,8 @@ use std::sync::Mutex as StdMutex; use devo_core::AppConfigStore; use devo_core::ProviderVendorCatalog; +use devo_core::normalize_canonical_path; +use lru::LruCache; use tokio::sync::Mutex; use tokio::sync::oneshot; @@ -21,6 +25,7 @@ use devo_core::SkillError; #[cfg(test)] use devo_core::TurnConfig; use devo_core::TurnId; +use devo_core::TurnRecord; use devo_core::tools::ToolRegistry; use devo_protocol::ApprovalDecisionValue; use devo_protocol::PendingInputItem; @@ -39,6 +44,19 @@ use crate::session_context::ResolvedInput; use crate::session_context::SessionRuntimeContext; use crate::turn::TurnMetadata; +/// Mirrors parent-session LRU capacity so workspace contexts stay bounded. +const WORKSPACE_CONTEXT_CACHE_CAPACITY: usize = 16; + +fn workspace_context_cache(capacity: usize) -> LruCache> { + LruCache::new(NonZeroUsize::new(capacity.max(1)).expect("workspace context cache capacity")) +} + +/// Cache key for workspace contexts: canonicalize so `cwd` and `cwd/.` share one entry. +fn canonicalize_workspace_root(path: &Path) -> PathBuf { + let canonical = path.canonicalize().unwrap_or_else(|_| path.to_path_buf()); + normalize_canonical_path(canonical) +} + #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct PersistedTurnItem { pub(crate) turn_id: TurnId, @@ -128,6 +146,13 @@ pub struct ServerRuntimeDependencies { pub(crate) config_store: Arc>, /// User-level process context used before a concrete session exists. pub(crate) process_context: Arc, + /// LRU of workspace-scoped contexts (canonical cwd → context). + /// + /// Avoids rebuilding MCP/tool registry/skill catalog on every + /// `skills/list`, session cwd switch, or other `context_for_workspace` call. + /// Invalidate via [`Self::invalidate_workspace_contexts`] when user MCP, + /// skills, provider, or model config mutates. + workspace_contexts: StdMutex>>, } /// Builds an empty MCP manager for tests and bootstrap paths without servers. @@ -174,6 +199,9 @@ impl ServerRuntimeDependencies { db, config_store, process_context, + workspace_contexts: StdMutex::new(workspace_context_cache( + WORKSPACE_CONTEXT_CACHE_CAPACITY, + )), } } @@ -181,18 +209,54 @@ impl ServerRuntimeDependencies { &self, workspace_root: &Path, ) -> anyhow::Result> { + let cache_key = canonicalize_workspace_root(workspace_root); + { + let mut cache = self + .workspace_contexts + .lock() + .expect("workspace context cache mutex should not be poisoned"); + if let Some(cached) = cache.get(&cache_key) { + return Ok(Arc::clone(cached)); + } + } + let user_config_dir = self .config_store .lock() .expect("app config store mutex should not be poisoned") .user_config_dir() .to_path_buf(); - SessionRuntimeContext::load_for_workspace( + // Miss path: load workspace-merged config. MCP/registry are reused from + // `process_context` when operationally equivalent (see load_for_workspace). + let loaded = SessionRuntimeContext::load_for_workspace( user_config_dir, Some(workspace_root), &self.process_context, ) - .await + .await?; + + let mut cache = self + .workspace_contexts + .lock() + .expect("workspace context cache mutex should not be poisoned"); + // Another task may have filled the same key while we loaded. + if let Some(cached) = cache.get(&cache_key) { + return Ok(Arc::clone(cached)); + } + cache.put(cache_key, Arc::clone(&loaded)); + Ok(loaded) + } + + /// Clears the workspace context LRU so the next lookup reloads from disk. + /// + /// Call after user-global config mutations (MCP enable, skills enable, + /// provider upsert, model/config/set). Already-running sessions keep their + /// own `Arc`; only subsequent lookups are affected. + pub(crate) fn invalidate_workspace_contexts(&self) { + self.workspace_contexts + .lock() + .expect("workspace context cache mutex should not be poisoned") + .clear(); } /// Resolves the full turn configuration used by the core query loop. @@ -223,8 +287,11 @@ impl ServerRuntimeDependencies { enabled: bool, workspace_root: Option<&Path>, ) -> anyhow::Result> { - self.process_context - .set_skill_enabled(path, enabled, workspace_root) + let skills = self + .process_context + .set_skill_enabled(path, enabled, workspace_root)?; + self.invalidate_workspace_contexts(); + Ok(skills) } /// Renders turn input items and resolves any referenced skills into prompt-visible messages. @@ -263,6 +330,8 @@ pub(crate) struct RuntimeSession { pub(crate) persisted_turn_items: Vec, /// Latest compaction snapshot used to rebuild the model-facing prompt view. pub(crate) latest_compaction_snapshot: Option, + /// Completed turn records keyed by turn id (for fork/rollback occupancy cuts). + pub(crate) turn_records_by_id: HashMap, /// Shared handle to the pending-turn queue owned by `core_session`. pub(crate) pending_turn_queue: Arc>>, /// Shared handle to the active-turn steer queue owned by `core_session`. @@ -595,6 +664,122 @@ proxy_url = "http://workspace-proxy.example:8080" let _ = std::fs::remove_dir_all(workspace); } + #[tokio::test] + async fn context_for_workspace_caches_same_canonical_cwd() { + let deps = test_deps(""); + let workspace = unique_temp_dir("session-context-cache"); + std::fs::create_dir_all(&workspace).expect("create workspace"); + + let first = deps + .context_for_workspace(&workspace) + .await + .expect("load workspace context"); + let via_dot = workspace.join("."); + let second = deps + .context_for_workspace(&via_dot) + .await + .expect("load cached workspace context"); + + assert!(Arc::ptr_eq(&first, &second)); + + deps.invalidate_workspace_contexts(); + let third = deps + .context_for_workspace(&workspace) + .await + .expect("reload workspace context"); + assert!(!Arc::ptr_eq(&first, &third)); + + let _ = std::fs::remove_dir_all(workspace); + } + + #[tokio::test] + async fn context_for_workspace_reuses_mcp_manager_when_provider_configured() { + let deps = test_deps( + r#" +[defaults] +model_binding = "main" + +[providers.openrouter] +enabled = true +name = "OpenRouter" +wire_apis = ["openai_chat_completions"] + +[model_bindings.main] +enabled = true +model_slug = "catalog-slug" +provider = "openrouter" +request_model = "vendor/model-name" +invocation_method = "openai_chat_completions" +"#, + ); + let workspace = unique_temp_dir("session-context-mcp-reuse"); + std::fs::create_dir_all(&workspace).expect("create workspace"); + + let context = deps + .context_for_workspace(&workspace) + .await + .expect("load workspace context"); + + assert!(Arc::ptr_eq( + &context.mcp_manager, + &deps.process_context.mcp_manager + )); + assert!(Arc::ptr_eq( + &context.tool_registry(), + &deps.process_context.tool_registry() + )); + + let _ = std::fs::remove_dir_all(workspace); + } + + #[tokio::test] + async fn context_for_workspace_evicts_oldest_when_over_capacity() { + let deps = test_deps(""); + { + let mut cache = deps + .workspace_contexts + .lock() + .expect("workspace context cache mutex should not be poisoned"); + *cache = workspace_context_cache(2); + } + + let root = unique_temp_dir("session-context-lru"); + let workspace_a = root.join("a"); + let workspace_b = root.join("b"); + let workspace_c = root.join("c"); + for workspace in [&workspace_a, &workspace_b, &workspace_c] { + std::fs::create_dir_all(workspace).expect("create workspace"); + } + + let context_a = deps + .context_for_workspace(&workspace_a) + .await + .expect("load a"); + let _context_b = deps + .context_for_workspace(&workspace_b) + .await + .expect("load b"); + let _context_c = deps + .context_for_workspace(&workspace_c) + .await + .expect("load c"); + + let reloaded_a = deps + .context_for_workspace(&workspace_a) + .await + .expect("reload a"); + assert!(!Arc::ptr_eq(&context_a, &reloaded_a)); + assert_eq!( + deps.workspace_contexts + .lock() + .expect("workspace context cache mutex should not be poisoned") + .len(), + 2 + ); + + let _ = std::fs::remove_dir_all(root); + } + #[test] fn resolve_turn_config_preserves_catalog_slug_and_uses_binding_request_model() { let deps = test_deps( diff --git a/crates/server/src/persistence.rs b/crates/server/src/persistence.rs index 42f770d0..29918581 100644 --- a/crates/server/src/persistence.rs +++ b/crates/server/src/persistence.rs @@ -171,6 +171,7 @@ impl RolloutStore { title_state, sandbox_policy: "workspace-write".into(), approval_mode: "on-request".into(), + effective_context_window: None, tokens_used: 0, first_user_message: None, archived_at: None, @@ -180,6 +181,8 @@ impl RolloutStore { parent_session_id, session_context: None, latest_turn_context: None, + collaboration_mode: None, + permission_preset: None, schema_version: 2, } } @@ -1027,6 +1030,7 @@ struct ReplayState { latest_turn: Option, latest_turn_metadata: Option, latest_query_usage: Option, + latest_context_occupancy: Option, turn_records_by_id: HashMap, loaded_item_count: u64, next_item_seq: u64, @@ -1055,6 +1059,27 @@ struct ReplayState { last_activity_at: Option>, } +/// Auto-compact / status pressure restored on resume. +/// +/// Prefers post-compaction (or tip) occupancy so a prior large query total cannot +/// re-trigger compaction after the context was already reduced. Falls back to +/// latest-query display total, then the reconstituted prompt estimate. +fn resume_context_pressure_tokens( + occupancy: Option<&devo_protocol::canonical::item::ContextOccupancy>, + latest_query_usage: Option<&devo_protocol::TurnUsage>, + prompt_token_estimate: usize, +) -> (usize, usize) { + let last_turn_tokens = occupancy + .map(|occupancy| occupancy.total_tokens as usize) + .or_else(|| latest_query_usage.map(devo_protocol::TurnUsage::display_total_tokens)) + .unwrap_or(prompt_token_estimate); + let last_input_tokens = latest_query_usage + .map(|usage| usage.input_tokens as usize) + .or_else(|| occupancy.map(|occupancy| occupancy.total_tokens as usize)) + .unwrap_or(prompt_token_estimate); + (last_turn_tokens, last_input_tokens) +} + impl ReplayState { fn apply_line(&mut self, line: RolloutLine) -> Result<()> { match line { @@ -1105,6 +1130,9 @@ impl ReplayState { self.last_turn_tokens = 0; self.latest_query_usage = None; } + if let Some(occupancy) = turn.context_occupancy.clone() { + self.latest_context_occupancy = Some(occupancy); + } self.latest_turn_metadata = Some(turn_metadata_from_record(&turn)); self.turn_kinds_by_id.insert(turn.id, turn.kind.clone()); if let Some(session_context) = turn.session_context.clone() { @@ -1153,6 +1181,9 @@ impl ReplayState { self.session_context_recorded = true; } RolloutLine::CompactionSnapshot(line) => { + if let Some(occupancy) = line.context_occupancy.clone() { + self.latest_context_occupancy = Some(occupancy); + } self.latest_compaction_snapshot = Some(*line); } RolloutLine::MessageEditRecorded(line) => { @@ -1310,6 +1341,29 @@ impl ReplayState { if let Some(latest_turn_context) = core_session.latest_turn_context.as_ref() { core_session.collaboration_mode = latest_turn_context.collaboration_mode; } + if let Some(mode) = record.collaboration_mode { + core_session.collaboration_mode = mode; + } + if let Some(preset) = record.permission_preset { + let safety_preset = match preset { + devo_protocol::PermissionPreset::Default => devo_safety::PermissionPreset::Default, + devo_protocol::PermissionPreset::AutoReview => { + devo_safety::PermissionPreset::AutoReview + } + devo_protocol::PermissionPreset::FullAccess => { + devo_safety::PermissionPreset::FullAccess + } + }; + let profile = devo_safety::RuntimePermissionProfile::from_preset( + safety_preset, + record.cwd.clone(), + ) + .with_additional_roots(record.additional_directories.clone()); + let sandbox = Some(profile.implied_sandbox_profile().to_string()); + core_session.config.permission_mode = profile.permission_mode(); + core_session.config.permission_profile = profile; + core_session.config.sandbox_profile = sandbox; + } core_session.turn_count = self.turns_seen as usize; core_session.total_input_tokens = self.total_input_tokens; core_session.total_output_tokens = self.total_output_tokens; @@ -1325,16 +1379,13 @@ impl ReplayState { devo_protocol::approx_tokens_from_byte_count(prompt_bytes) .try_into() .unwrap_or(usize::MAX); - core_session.last_input_tokens = self - .latest_query_usage - .as_ref() - .map(|usage| usage.input_tokens as usize) - .unwrap_or(core_session.prompt_token_estimate); - core_session.last_turn_tokens = self - .latest_query_usage - .as_ref() - .map(devo_protocol::TurnUsage::display_total_tokens) - .unwrap_or(core_session.prompt_token_estimate); + let (last_turn_tokens, last_input_tokens) = resume_context_pressure_tokens( + self.latest_context_occupancy.as_ref(), + self.latest_query_usage.as_ref(), + core_session.prompt_token_estimate, + ); + core_session.last_input_tokens = last_input_tokens; + core_session.last_turn_tokens = last_turn_tokens; let pending_turn_queue = std::sync::Arc::clone(&core_session.pending_turn_queue); let steer_input_queue = std::sync::Arc::clone(&core_session.steer_input_queue); let summary_model_selection = self @@ -1387,6 +1438,24 @@ impl ReplayState { record.model_binding_id = turn_config.model_binding_id.clone(); record.reasoning_effort_selection = summary_reasoning_effort_selection.clone(); + let global_compaction_limit = runtime_context + .config_store + .lock() + .expect("app config store mutex should not be poisoned") + .effective_config() + .compaction_token_limit; + let applied_compaction_limit = crate::runtime::context_occupancy::resolved_compaction_limit( + global_compaction_limit, + &turn_config.model, + ); + // Apply before wrapping in Mutex so resume never needs to lock a + // single-owner Arc that `from_runtime_session` later unwraps. + // Prefer the global config preference; ignore legacy session overrides. + crate::runtime::context_occupancy::apply_resolved_compaction_limit( + &mut core_session.config, + applied_compaction_limit as usize, + ); + let summary = SessionMetadata { session_id: record.id, cwd: record.cwd.clone(), @@ -1413,12 +1482,20 @@ impl ReplayState { prompt_token_estimate: core_session.prompt_token_estimate, last_query_usage: self.latest_query_usage.clone(), last_query_total_tokens: self - .latest_query_usage + .latest_context_occupancy .as_ref() - .map(devo_protocol::TurnUsage::display_total_tokens) + .map(|occupancy| occupancy.total_tokens as usize) + .or_else(|| { + self.latest_query_usage + .as_ref() + .map(devo_protocol::TurnUsage::display_total_tokens) + }) .unwrap_or(0), + last_context_occupancy: self.latest_context_occupancy.clone(), status: SessionRuntimeStatus::Idle, collaboration_mode: core_session.collaboration_mode, + effective_context_window: Some(applied_compaction_limit), + permission_preset: record.permission_preset, }; let config = core_session.config.clone(); @@ -1434,6 +1511,7 @@ impl ReplayState { history_items: replayed_history_items, persisted_turn_items: replayed_persisted_turn_items, latest_compaction_snapshot: self.latest_compaction_snapshot, + turn_records_by_id: self.turn_records_by_id, pending_turn_queue, steer_input_queue, agent_tool_policy: Default::default(), @@ -1632,6 +1710,7 @@ impl ReplayState { self.last_input_tokens = 0; self.last_turn_tokens = 0; self.latest_query_usage = None; + self.latest_context_occupancy = None; for turn_id in &self.turn_order { let Some(turn) = self.turn_records_by_id.get(turn_id) else { @@ -1655,6 +1734,16 @@ impl ReplayState { self.last_turn_tokens = 0; self.latest_query_usage = None; } + if let Some(occupancy) = turn.context_occupancy.clone() { + self.latest_context_occupancy = Some(occupancy); + } + } + if let Some(occupancy) = self + .latest_compaction_snapshot + .as_ref() + .and_then(|snapshot| snapshot.context_occupancy.clone()) + { + self.latest_context_occupancy = Some(occupancy); } } @@ -2197,12 +2286,21 @@ pub(crate) fn session_metadata_from_record( prompt_token_estimate: 0, last_query_usage: None, last_query_total_tokens: 0, + last_context_occupancy: None, status: SessionRuntimeStatus::Idle, collaboration_mode: record - .latest_turn_context - .as_ref() - .map(|context| context.collaboration_mode) + .collaboration_mode + .or_else(|| { + record + .latest_turn_context + .as_ref() + .map(|context| context.collaboration_mode) + }) .unwrap_or_default(), + // Do not revive legacy session-record overrides. Applied window comes + // from AppConfig when the session is hydrated into a RuntimeSession. + effective_context_window: None, + permission_preset: record.permission_preset, } } @@ -2230,6 +2328,7 @@ pub(crate) fn build_turn_record( session_context: Option, turn_context: Option, latest_query_usage: Option, + context_occupancy: Option, ) -> TurnRecord { TurnRecord { id: turn.turn_id, @@ -2247,6 +2346,7 @@ pub(crate) fn build_turn_record( input_token_estimate: None, usage: turn.usage.clone(), latest_query_usage, + context_occupancy, stop_reason: turn.stop_reason.clone(), failure_reason: turn.failure_reason, error: None, @@ -2445,6 +2545,121 @@ mod tests { assert_eq!(titles, vec!["assistant 1", "date"]); } + #[test] + fn replay_prefers_compaction_occupancy_over_prior_turn() { + use devo_protocol::TurnUsage; + use pretty_assertions::assert_eq; + + let now = Utc.with_ymd_and_hms(2026, 7, 8, 10, 0, 0).unwrap(); + let session_id = SessionId::new(); + let turn_id = TurnId::new(); + let mut replay = ReplayState::default(); + let turn_occupancy = devo_protocol::canonical::item::ContextOccupancy::from_category_tokens( + /*context_window_tokens*/ 100_000, /*base*/ 10_000, /*skills*/ 0, + /*tools_builtin*/ 0, /*tools_mcp*/ 0, /*conversation*/ 40_000, + ); + let compact_occupancy = + devo_protocol::canonical::item::ContextOccupancy::from_category_tokens( + /*context_window_tokens*/ 100_000, /*base*/ 10_000, /*skills*/ 0, + /*tools_builtin*/ 0, /*tools_mcp*/ 0, /*conversation*/ 8_000, + ); + + replay + .apply_line(RolloutLine::Turn(Box::new(TurnLine { + timestamp: now, + turn: TurnRecord { + id: turn_id, + session_id, + sequence: 1, + started_at: now, + completed_at: Some(now), + status: TurnStatus::Completed, + kind: TurnKind::Regular, + model: "test-model".into(), + model_binding_id: None, + reasoning_effort_selection: None, + request_model: "test-model".into(), + request_thinking: None, + input_token_estimate: None, + usage: None, + latest_query_usage: Some(TurnUsage { + input_tokens: 50, + output_tokens: 5, + cache_creation_input_tokens: None, + cache_read_input_tokens: None, + reasoning_output_tokens: None, + total_tokens: Some(55), + }), + context_occupancy: Some(turn_occupancy.clone()), + stop_reason: None, + failure_reason: None, + error: None, + session_context: None, + turn_context: None, + schema_version: 4, + }, + }))) + .expect("turn"); + assert_eq!(replay.latest_context_occupancy, Some(turn_occupancy)); + replay + .apply_line(RolloutLine::CompactionSnapshot(Box::new( + CompactionSnapshotLine { + timestamp: now, + session_id, + turn_id, + summary_item_id: ItemId::new(), + preserved_item_ids: Vec::new(), + context_occupancy: Some(compact_occupancy.clone()), + }, + ))) + .expect("compaction"); + assert_eq!(replay.latest_context_occupancy, Some(compact_occupancy)); + } + + #[test] + fn resume_context_pressure_prefers_compaction_occupancy_over_large_query() { + use pretty_assertions::assert_eq; + + use devo_protocol::TurnUsage; + use devo_protocol::canonical::item::ContextOccupancy; + + let occupancy = ContextOccupancy::from_category_tokens( + /*context_window_tokens*/ 250_000, /*base*/ 10_000, /*skills*/ 0, + /*tools_builtin*/ 0, /*tools_mcp*/ 0, /*conversation*/ 40_000, + ); + let usage = TurnUsage { + input_tokens: 300_000, + output_tokens: 20_000, + cache_creation_input_tokens: None, + cache_read_input_tokens: None, + reasoning_output_tokens: None, + total_tokens: Some(320_000), + }; + + let (last_turn, last_input) = super::resume_context_pressure_tokens( + Some(&occupancy), + Some(&usage), + /*prompt_token_estimate*/ 12_000, + ); + assert_eq!(last_turn, 50_000); + assert_eq!(last_input, 300_000); + + let (last_turn, last_input) = super::resume_context_pressure_tokens( + /*occupancy*/ None, + Some(&usage), + /*prompt_token_estimate*/ 12_000, + ); + assert_eq!(last_turn, 320_000); + assert_eq!(last_input, 300_000); + + let (last_turn, last_input) = super::resume_context_pressure_tokens( + /*occupancy*/ None, /*latest_query_usage*/ None, + /*prompt_token_estimate*/ 12_000, + ); + assert_eq!(last_turn, 12_000); + assert_eq!(last_input, 12_000); + } + #[test] fn replay_preserves_latest_query_usage_when_latest_turn_has_no_usage() { use devo_protocol::TurnUsage; @@ -2480,6 +2695,7 @@ mod tests { input_token_estimate: None, usage: Some(usage.clone()), latest_query_usage: Some(usage.clone()), + context_occupancy: None, stop_reason: None, failure_reason: None, error: None, @@ -2508,6 +2724,7 @@ mod tests { input_token_estimate: None, usage: None, latest_query_usage: None, + context_occupancy: None, stop_reason: None, failure_reason: Some(devo_protocol::TurnFailureReason::MaxTurnRequests), error: None, @@ -2558,6 +2775,7 @@ mod tests { input_token_estimate: None, usage: Some(aggregate_usage), latest_query_usage: None, + context_occupancy: None, stop_reason: None, failure_reason: None, error: None, @@ -2603,6 +2821,7 @@ mod tests { input_token_estimate: None, usage: None, latest_query_usage: None, + context_occupancy: None, stop_reason: None, failure_reason: None, error: None, @@ -2691,6 +2910,7 @@ mod tests { input_token_estimate: None, usage: None, latest_query_usage: None, + context_occupancy: None, stop_reason: None, failure_reason: None, error: None, @@ -2868,8 +3088,11 @@ mod tests { prompt_token_estimate: 0, last_query_usage: None, last_query_total_tokens: 0, + last_context_occupancy: None, status: SessionRuntimeStatus::Idle, collaboration_mode: Default::default(), + effective_context_window: None, + permission_preset: None, }, None, ) @@ -3028,6 +3251,54 @@ mod tests { assert_eq!(history_items[1].title, "read output"); } + #[test] + fn replay_nameless_edit_tool_result_still_emits_edited_metadata() { + // edit is LiveOnly: start is not persisted, so resume only sees ToolResult + // with tool_name lost by the v2 canonical schema. Structured output must + // still produce Edited metadata for transcript restore. + let mut messages = Vec::new(); + let mut history_items = Vec::new(); + let mut tool_names_by_id = HashMap::new(); + + apply_turn_item( + &mut messages, + &mut history_items, + &mut tool_names_by_id, + &TurnKind::Regular, + TurnItem::ToolResult(ToolResultItem { + tool_call_id: "call-edit".to_string(), + tool_name: None, + output: serde_json::json!({ + "diff": "diff --git a/foo.txt b/foo.txt\n--- a/foo.txt\n+++ b/foo.txt\n@@ -1 +1 @@\n-old\n+new\n", + "files": [{ + "path": "foo.txt", + "kind": "update", + "diff": "--- a/foo.txt\n+++ b/foo.txt\n@@ -1 +1 @@\n-old\n+new\n", + "oldContent": "old\n", + "postContent": "new\n", + "additions": 1, + "deletions": 1 + }], + "output": "edited foo.txt" + }), + display_content: Some("edited foo.txt".to_string()), + is_error: false, + }), + ); + + assert_eq!(history_items.len(), 1); + assert_eq!(history_items[0].body, "edited foo.txt"); + let Some(devo_protocol::SessionHistoryMetadata::Edited { changes }) = + &history_items[0].metadata + else { + panic!( + "expected Edited metadata, got {:?}", + history_items[0].metadata + ); + }; + assert!(changes.contains_key(&PathBuf::from("foo.txt"))); + } + #[test] fn replay_uses_display_content_for_history_but_canonical_output_for_prompt() { let mut messages = Vec::new(); @@ -3122,6 +3393,7 @@ mod tests { turn_id: TurnId::new(), summary_item_id, preserved_item_ids: vec![preserved_item_id], + context_occupancy: None, }, ) .expect("prompt messages"); @@ -3205,6 +3477,7 @@ mod tests { title_state: SessionTitleState::Unset, sandbox_policy: "workspace-write".into(), approval_mode: "on-request".into(), + effective_context_window: None, tokens_used: 0, first_user_message: None, archived_at: None, @@ -3214,6 +3487,8 @@ mod tests { parent_session_id: None, session_context: None, latest_turn_context: None, + collaboration_mode: None, + permission_preset: None, schema_version: 2, }, }))) @@ -3237,6 +3512,7 @@ mod tests { input_token_estimate: None, usage: None, latest_query_usage: None, + context_occupancy: None, stop_reason: None, failure_reason: None, error: None, @@ -3325,6 +3601,7 @@ mod tests { title_state: SessionTitleState::Unset, sandbox_policy: "workspace-write".into(), approval_mode: "on-request".into(), + effective_context_window: None, tokens_used: 0, first_user_message: None, archived_at: None, @@ -3334,6 +3611,8 @@ mod tests { parent_session_id: None, session_context: None, latest_turn_context: None, + collaboration_mode: None, + permission_preset: None, schema_version: 2, }, }))) @@ -3367,6 +3646,7 @@ mod tests { input_token_estimate: None, usage: None, latest_query_usage: None, + context_occupancy: None, stop_reason: None, failure_reason: None, error: None, @@ -3431,6 +3711,7 @@ mod tests { title_state: SessionTitleState::Unset, sandbox_policy: "workspace-write".into(), approval_mode: "on-request".into(), + effective_context_window: None, tokens_used: 0, first_user_message: None, archived_at: None, @@ -3440,6 +3721,8 @@ mod tests { parent_session_id: None, session_context: None, latest_turn_context: None, + collaboration_mode: None, + permission_preset: None, schema_version: 2, }, }))) @@ -3473,6 +3756,7 @@ mod tests { input_token_estimate: None, usage: None, latest_query_usage: None, + context_occupancy: None, stop_reason: None, failure_reason: None, error: None, @@ -3553,7 +3837,7 @@ mod tests { .append_turn_deduped( &record, &mut session_context_recorded, - super::build_turn_record(&metadata, None, None, None), + super::build_turn_record(&metadata, None, None, None, None), Some(session_context.clone()), ) .expect("append deduped turn"); @@ -3690,7 +3974,7 @@ mod tests { .append_session_meta(&record) .expect("append session meta"); let metadata = test_turn_metadata(record.id, TurnId::new()); - let turn = super::build_turn_record(&metadata, None, None, None); + let turn = super::build_turn_record(&metadata, None, None, None, None); rollout_store .append_turn(&record, turn) .expect("append turn"); @@ -3742,7 +4026,7 @@ mod tests { .append_session_meta(&record) .expect("append session meta"); let metadata = test_turn_metadata(record.id, TurnId::new()); - let turn = super::build_turn_record(&metadata, None, None, None); + let turn = super::build_turn_record(&metadata, None, None, None, None); rollout_store .append_turn(&record, turn) .expect("append turn"); @@ -3865,7 +4149,7 @@ mod tests { store .append_turn( &record, - super::build_turn_record(&metadata, None, None, None), + super::build_turn_record(&metadata, None, None, None, None), ) .expect("append turn"); let now = Utc::now(); @@ -3949,7 +4233,7 @@ mod tests { let restarted_store = super::RolloutStore::new(dir.path().to_path_buf(), None); let metadata = test_turn_metadata(record.id, TurnId::new()); - let turn = super::build_turn_record(&metadata, None, None, None); + let turn = super::build_turn_record(&metadata, None, None, None, None); let error = restarted_store .append_turn(&record, turn) .expect_err("append onto damaged history must fail"); @@ -3987,7 +4271,7 @@ mod tests { let restarted_store = super::RolloutStore::new(dir.path().to_path_buf(), None); let metadata = test_turn_metadata(record.id, TurnId::new()); - let turn = super::build_turn_record(&metadata, None, None, None); + let turn = super::build_turn_record(&metadata, None, None, None, None); restarted_store .append_turn(&record, turn) .expect("crash tail is tolerated"); @@ -4021,7 +4305,7 @@ mod tests { &[r#"{"v":2,"kind":"nope"}"#.to_string()], ); let metadata = test_turn_metadata(record.id, TurnId::new()); - let turn = super::build_turn_record(&metadata, None, None, None); + let turn = super::build_turn_record(&metadata, None, None, None, None); rollout_store .append_turn(&record, turn) .expect("append turn"); @@ -4093,7 +4377,7 @@ mod tests { })), RolloutLine::Turn(Box::new(TurnLine { timestamp: Utc::now(), - turn: super::build_turn_record(&metadata, None, None, None), + turn: super::build_turn_record(&metadata, None, None, None, None), })), RolloutLine::Item(ItemLine { timestamp: Utc::now(), @@ -4194,7 +4478,7 @@ mod tests { .append_session_meta(&record) .expect("append session meta"); let metadata = test_turn_metadata(record.id, TurnId::new()); - let turn = super::build_turn_record(&metadata, None, None, None); + let turn = super::build_turn_record(&metadata, None, None, None, None); rollout_store .append_turn(&record, turn) .expect("append turn"); diff --git a/crates/server/src/projection.rs b/crates/server/src/projection.rs index 6aeeed3c..38789689 100644 --- a/crates/server/src/projection.rs +++ b/crates/server/src/projection.rs @@ -176,7 +176,12 @@ pub(crate) fn history_item_from_turn_item(item: &TurnItem) -> Option None, + TurnItem::ContextCompaction(TextItem { .. }) => Some(SessionHistoryItem::new( + None, + SessionHistoryItemKind::ContextCompaction, + "Context compacted".to_string(), + String::new(), + )), TurnItem::Reasoning(TextItem { text }) => Some(SessionHistoryItem::new( None, SessionHistoryItemKind::Reasoning, @@ -216,6 +221,14 @@ pub(crate) fn history_item_from_turn_item(item: &TurnItem) -> Option { + // Canonical v2 ToolResult does not carry tool_name; resume may + // still recover Edited metadata from structured file-change output. + let edited_metadata = (!*is_error) + .then(|| parse_edited_history_metadata(output)) + .flatten(); + let resolved_tool_name = tool_name + .clone() + .or_else(|| edited_metadata.is_some().then(|| "edit".to_string())); let mut item = SessionHistoryItem::new( Some(tool_call_id.clone()), if *is_error { @@ -223,15 +236,15 @@ pub(crate) fn history_item_from_turn_item(item: &TurnItem) -> Option text.clone(), other => other.to_string(), }), ); - if let Some(tool_name) = tool_name { + if let Some(tool_name) = resolved_tool_name { item = item.with_tool_io(SessionHistoryToolIo { - tool_name: tool_name.clone(), + tool_name, input: serde_json::Value::Null, output: Some(output.clone()), display_content: display_content.clone(), @@ -246,10 +259,7 @@ pub(crate) fn history_item_from_turn_item(item: &TurnItem) -> Option, - failure_reason: Option, +pub(crate) struct TerminalTurnSnapshot { + pub(crate) status: TurnStatus, + pub(crate) stop_reason: Option, + pub(crate) failure_reason: Option, } impl TerminalTurnSnapshot { - fn from_turn(turn: &TurnMetadata) -> Self { + pub(crate) fn from_turn(turn: &TurnMetadata) -> Self { Self { status: turn.status.clone(), stop_reason: turn.stop_reason.clone(), @@ -416,7 +420,7 @@ impl ServerRuntime { fn permission_mode_from_approval_policy(policy: &str) -> Option { match policy { "on-request" | "interactive" | "ask" => Some(PermissionMode::Interactive), - "never" | "auto" | "auto-approve" => Some(PermissionMode::AutoApprove), + "never" | "auto" | "auto-approve" | "yolo" => Some(PermissionMode::Yolo), "deny" => Some(PermissionMode::Deny), _ => None, } @@ -444,3 +448,13 @@ fn protocol_reviewer_from_safety( devo_safety::ApprovalsReviewer::AutoReview => devo_protocol::ApprovalsReviewer::AutoReview, } } + +pub(crate) fn protocol_preset_from_safety( + preset: devo_safety::PermissionPreset, +) -> devo_protocol::PermissionPreset { + match preset { + devo_safety::PermissionPreset::Default => devo_protocol::PermissionPreset::Default, + devo_safety::PermissionPreset::AutoReview => devo_protocol::PermissionPreset::AutoReview, + devo_safety::PermissionPreset::FullAccess => devo_protocol::PermissionPreset::FullAccess, + } +} diff --git a/crates/server/src/runtime/agents.rs b/crates/server/src/runtime/agents.rs index c6b7710c..f6256a29 100644 --- a/crates/server/src/runtime/agents.rs +++ b/crates/server/src/runtime/agents.rs @@ -167,8 +167,15 @@ impl ServerRuntime { prompt_token_estimate: core_session.prompt_token_estimate, last_query_usage: None, last_query_total_tokens: 0, + last_context_occupancy: None, status: SessionRuntimeStatus::Idle, collaboration_mode: Default::default(), + effective_context_window: parent_summary.effective_context_window.or_else(|| { + parent_config + .effective_context_window_override + .map(|limit| limit as u64) + }), + permission_preset: parent_summary.permission_preset, }; let child_session = RuntimeSession { runtime_context, @@ -182,6 +189,7 @@ impl ServerRuntime { history_items: rebuilt_history_items, persisted_turn_items: stable_items, latest_compaction_snapshot: None, + turn_records_by_id: std::collections::HashMap::new(), pending_turn_queue, steer_input_queue, agent_tool_policy: effective_tool_policy, @@ -448,7 +456,6 @@ impl ServerRuntime { "failed to persist agent follow-up pending message" ); } - self.broadcast_updated_queue(session_id).await; return Ok(active_turn); } diff --git a/crates/server/src/runtime/approval.rs b/crates/server/src/runtime/approval.rs index b4a43622..157c005e 100644 --- a/crates/server/src/runtime/approval.rs +++ b/crates/server/src/runtime/approval.rs @@ -908,7 +908,7 @@ fn policy_decision( ) -> AuthorizationDecision { use devo_protocol::canonical::item::ApprovalDecisionSource; - if profile.auto_approve { + if profile.yolo { return AuthorizationDecision::Allow { source: ApprovalDecisionSource::StaticPolicy, }; @@ -1526,7 +1526,7 @@ fn permission_mode_authorization(mode: PermissionMode) -> Option Some(AuthorizationDecision::Allow { + PermissionMode::Yolo => Some(AuthorizationDecision::Allow { source: ApprovalDecisionSource::StaticPolicy, }), PermissionMode::Deny => Some(AuthorizationDecision::Deny { @@ -1622,7 +1622,7 @@ mod tests { ); assert_eq!( permission_mode_from_approval_policy("never"), - Some(PermissionMode::AutoApprove) + Some(PermissionMode::Yolo) ); assert_eq!( permission_mode_from_approval_policy("deny"), @@ -1787,7 +1787,7 @@ mod tests { #[test] fn permission_mode_overrides_authorization_policy() { assert_eq!( - permission_mode_authorization(PermissionMode::AutoApprove), + permission_mode_authorization(PermissionMode::Yolo), Some(AuthorizationDecision::Allow { source: devo_protocol::canonical::item::ApprovalDecisionSource::StaticPolicy, }) @@ -1806,7 +1806,7 @@ mod tests { } #[test] - fn auto_approve_grants_sandbox_bypass_for_escalation() { + fn yolo_grants_sandbox_bypass_for_escalation() { let mut request = test_permission_request("shell_command"); request.requests_escalation = true; request.input = serde_json::json!({ @@ -1815,7 +1815,7 @@ mod tests { }); assert_eq!( - permission_mode_authorization(PermissionMode::AutoApprove), + permission_mode_authorization(PermissionMode::Yolo), Some(AuthorizationDecision::Allow { source: devo_protocol::canonical::item::ApprovalDecisionSource::StaticPolicy, }) @@ -1830,13 +1830,13 @@ mod tests { } #[test] - fn auto_approve_profile_grants_sandbox_bypass_for_escalation() { + fn yolo_profile_grants_sandbox_bypass_for_escalation() { let root = abs_path(&["workspace"]); let mut profile = devo_safety::RuntimePermissionProfile::from_preset( devo_safety::PermissionPreset::Default, root.clone(), ); - profile.auto_approve = true; + profile.yolo = true; let mut request = test_permission_request("shell_command"); request.requests_escalation = true; request.input = serde_json::json!({ diff --git a/crates/server/src/runtime/compaction_persist.rs b/crates/server/src/runtime/compaction_persist.rs new file mode 100644 index 00000000..82eee4f7 --- /dev/null +++ b/crates/server/src/runtime/compaction_persist.rs @@ -0,0 +1,453 @@ +//! Shared compaction summary + snapshot persistence helpers. +//! +//! Manual `/compact` and query-loop auto/proactive compaction both need to: +//! - derive preserved item ids from the compacted response suffix +//! - build a durable `ContextCompaction` summary item +//! - append a `CompactionSnapshot` so resume can rebuild `prompt_messages` + +use std::sync::Arc; + +use chrono::Utc; +use devo_core::CommandExecutionItem; +use devo_core::CompactionSnapshotLine; +use devo_core::ItemId; +use devo_core::Message; +use devo_core::ResponseItem; +use devo_core::SessionId; +use devo_core::SessionRecord; +use devo_core::TextItem; +use devo_core::ToolCallItem; +use devo_core::ToolResultItem; +use devo_core::TurnId; +use devo_core::TurnItem; +use devo_core::TurnKind; +use devo_protocol::approx_tokens_from_byte_count; +use devo_protocol::canonical::item::ContextOccupancy; + +use super::ServerRuntime; +use crate::execution::PersistedTurnItem; +use crate::persistence::RolloutStore; +use crate::persistence::build_item_record; +use crate::projection::history_item_from_turn_item; + +/// Match the compacted preserve suffix against the prompt-visible journal tail. +pub(crate) fn preserved_item_ids_from_compacted( + persisted_turn_items: &[PersistedTurnItem], + compacted_items: &[ResponseItem], +) -> Vec { + let mut normalized_persisted_items = Vec::new(); + for item in persisted_turn_items { + if !crate::persistence::prompt_visible_persisted_turn_item(item) { + continue; + } + + // The compactor returns a summary followed by the prompt-visible suffix it + // kept verbatim. Normalize persisted items into that same response shape + // without allocating a short intermediate Vec for every journal item. + match &item.turn_item { + TurnItem::UserMessage(TextItem { text }) | TurnItem::SteerInput(TextItem { text }) => { + normalized_persisted_items.push(( + item.item_id, + ResponseItem::Message(Message::user(text.clone())), + )); + } + TurnItem::AgentMessage(TextItem { text }) + | TurnItem::Plan(TextItem { text }) + | TurnItem::WebSearch(TextItem { text }) + | TurnItem::ImageGeneration(TextItem { text }) + | TurnItem::ContextCompaction(TextItem { text }) + | TurnItem::HookPrompt(TextItem { text }) => { + normalized_persisted_items.push(( + item.item_id, + ResponseItem::Message(Message::assistant_text(text.clone())), + )); + } + TurnItem::Reasoning(_) => {} + TurnItem::ToolCall(ToolCallItem { + tool_call_id, + tool_name, + input, + }) => { + normalized_persisted_items.push(( + item.item_id, + ResponseItem::ToolCall { + id: tool_call_id.clone(), + name: tool_name.clone(), + input: input.clone(), + }, + )); + } + TurnItem::ToolResult(ToolResultItem { + tool_call_id, + output, + is_error, + .. + }) => { + normalized_persisted_items.push(( + item.item_id, + ResponseItem::ToolCallOutput { + tool_use_id: tool_call_id.clone(), + content: match output { + serde_json::Value::String(text) => text.clone(), + other => other.to_string(), + }, + is_error: *is_error, + }, + )); + } + TurnItem::CommandExecution(CommandExecutionItem { + tool_call_id, + tool_name, + input, + output, + is_error, + .. + }) => { + normalized_persisted_items.push(( + item.item_id, + ResponseItem::ToolCall { + id: tool_call_id.clone(), + name: tool_name.clone(), + input: input.clone(), + }, + )); + normalized_persisted_items.push(( + item.item_id, + ResponseItem::ToolCallOutput { + tool_use_id: tool_call_id.clone(), + content: match output { + serde_json::Value::String(text) => text.clone(), + other => other.to_string(), + }, + is_error: *is_error, + }, + )); + } + TurnItem::ToolProgress(_) + | TurnItem::ApprovalRequest(_) + | TurnItem::ApprovalDecision(_) + | TurnItem::TurnSummary(_) => {} + } + } + let preserved = compacted_items.get(1..).unwrap_or(&[]); + if preserved.is_empty() { + return Vec::new(); + } + let preserved_len = preserved.len(); + if normalized_persisted_items.len() < preserved_len { + return Vec::new(); + } + let suffix = &normalized_persisted_items[normalized_persisted_items.len() - preserved_len..]; + if suffix.iter().map(|(_, item)| item).eq(preserved.iter()) { + suffix.iter().map(|(item_id, _)| *item_id).collect() + } else { + Vec::new() + } +} + +/// Build the durable summary turn item from compacted history. +pub(crate) fn summary_turn_item_from_compacted(compacted_items: &[ResponseItem]) -> TurnItem { + let summary_text = compacted_items + .first() + .and_then(|item| match item { + ResponseItem::Message(message) => { + message.content.iter().find_map(|block| match block { + devo_core::ContentBlock::Text { text } => Some(text.clone()), + devo_core::ContentBlock::Reasoning { .. } + | devo_core::ContentBlock::ProviderReasoning { .. } + | devo_core::ContentBlock::ToolUse { .. } + | devo_core::ContentBlock::HostedToolUse { .. } + | devo_core::ContentBlock::ToolResult { .. } => None, + }) + } + ResponseItem::Reason { text } => Some(text.clone()), + ResponseItem::ToolCall { .. } | ResponseItem::ToolCallOutput { .. } => None, + }) + .unwrap_or_default(); + TurnItem::ContextCompaction(TextItem { text: summary_text }) +} + +/// Construct the rollout compaction snapshot line. +pub(crate) fn build_compaction_snapshot_line( + session_id: SessionId, + turn_id: TurnId, + summary_item_id: ItemId, + preserved_item_ids: Vec, + context_occupancy: Option, +) -> CompactionSnapshotLine { + CompactionSnapshotLine { + timestamp: Utc::now(), + session_id, + turn_id, + summary_item_id, + preserved_item_ids, + context_occupancy, + } +} + +/// Inputs needed to append a compaction summary item and its snapshot. +pub(crate) struct CompactionSummaryPersist { + pub(crate) session_id: SessionId, + pub(crate) turn_id: TurnId, + pub(crate) summary_item_id: ItemId, + pub(crate) item_seq: u64, + pub(crate) summary_turn_item: TurnItem, + pub(crate) snapshot: CompactionSnapshotLine, +} + +/// Append the summary item and compaction snapshot to the durable rollout. +pub(crate) fn append_compaction_summary_and_snapshot( + rollout_store: &RolloutStore, + record: &SessionRecord, + persist: CompactionSummaryPersist, +) { + let CompactionSummaryPersist { + session_id, + turn_id, + summary_item_id, + item_seq, + summary_turn_item, + snapshot, + } = persist; + let item_record = build_item_record( + session_id, + turn_id, + summary_item_id, + item_seq, + summary_turn_item, + None, + None, + ); + if let Err(error) = rollout_store.append_item(record, item_record) { + tracing::warn!( + session_id = %session_id, + error = %error, + "failed to persist compaction summary item" + ); + } + if let Err(error) = rollout_store.append_compaction_snapshot(record, snapshot) { + tracing::warn!( + session_id = %session_id, + error = %error, + "failed to persist compaction snapshot" + ); + } +} + +/// Build the in-memory journal entry for a compaction summary item. +pub(crate) fn compaction_persisted_turn_item( + turn_id: TurnId, + turn_kind: TurnKind, + item_id: ItemId, + summary_turn_item: TurnItem, +) -> PersistedTurnItem { + PersistedTurnItem { + turn_id, + turn_kind, + item_id, + turn_item: summary_turn_item, + } +} + +impl ServerRuntime { + /// Persist an in-turn (auto/proactive) compaction summary + snapshot. + /// + /// Must not block on the session-actor mailbox: the actor is waiting on the + /// turn event stream. Mutate inline scratch under the stream lock, then write + /// rollout after releasing the lock. + pub(crate) async fn persist_in_turn_compaction( + self: &Arc, + session_id: SessionId, + turn_id: TurnId, + summary_item_id: ItemId, + compacted_items: &[ResponseItem], + ) -> Option { + let Some(stream) = self.active_stream_state(session_id).await else { + tracing::warn!( + session_id = %session_id, + turn_id = %turn_id, + "in-turn compaction persist skipped: no active stream" + ); + return None; + }; + + let spawn_stable_items = self + .active_turns + .spawn_snapshot_for_session(session_id) + .await + .map(|snapshot| snapshot.stable_items) + .unwrap_or_default(); + + let rollout = { + let mut stream = stream.lock().await; + let Some(inline) = stream.turn_inline.as_mut() else { + tracing::warn!( + session_id = %session_id, + turn_id = %turn_id, + "in-turn compaction persist skipped: no inline state" + ); + return None; + }; + if inline.turn_id != turn_id { + tracing::warn!( + session_id = %session_id, + turn_id = %turn_id, + inline_turn_id = %inline.turn_id, + "in-turn compaction persist skipped: turn mismatch" + ); + return None; + } + + let mut journal = spawn_stable_items; + journal.extend(inline.persisted_turn_items.iter().cloned()); + let preserved_item_ids = preserved_item_ids_from_compacted(&journal, compacted_items); + let summary_turn_item = summary_turn_item_from_compacted(compacted_items); + + let prompt_bytes = compacted_items + .iter() + .map(|item| serde_json::to_string(item).map_or(0, |json| json.len())) + .sum::(); + let conversation_tokens = approx_tokens_from_byte_count(prompt_bytes); + + let global = self + .deps + .config_store + .lock() + .expect("app config store mutex should not be poisoned") + .effective_config() + .compaction_token_limit; + let model = inline + .summary + .model + .as_deref() + .and_then(|slug| { + inline + .hook_context + .runtime_context + .model_catalog + .get(slug) + .or_else(|| self.deps.model_catalog.get(slug)) + }) + .or_else(|| { + inline + .summary + .model_binding_id + .as_deref() + .and_then(|binding| { + inline + .hook_context + .runtime_context + .model_catalog + .get(binding) + .or_else(|| self.deps.model_catalog.get(binding)) + }) + }); + let window = inline + .summary + .effective_context_window + .or_else(|| { + model.map(|model| { + super::context_occupancy::resolved_compaction_limit(global, model) + }) + }) + .unwrap_or(0); + let previous_occupancy = inline.summary.last_context_occupancy.clone(); + let occupancy = super::context_occupancy::occupancy_after_compaction( + window, + previous_occupancy.as_ref(), + conversation_tokens, + None, + ); + inline.summary.last_context_occupancy = Some(occupancy.clone()); + inline.summary.last_query_total_tokens = occupancy.total_tokens as usize; + inline.summary.prompt_token_estimate = + conversation_tokens.try_into().unwrap_or(usize::MAX); + + let item_seq = inline.allocate_item_seq(); + let snapshot = build_compaction_snapshot_line( + session_id, + turn_id, + summary_item_id, + preserved_item_ids, + Some(occupancy), + ); + inline.latest_compaction_snapshot = Some(snapshot.clone()); + inline + .persisted_turn_items + .push(compaction_persisted_turn_item( + turn_id, + inline.turn_kind.clone(), + summary_item_id, + summary_turn_item.clone(), + )); + if let Some(history_item) = history_item_from_turn_item(&summary_turn_item) { + inline.history_items.push(history_item); + } + + inline + .record + .clone() + .map(|record| (record, item_seq, summary_turn_item, snapshot)) + }; + + let (record, item_seq, summary_turn_item, snapshot) = rollout?; + append_compaction_summary_and_snapshot( + &self.rollout_store, + &record, + CompactionSummaryPersist { + session_id, + turn_id, + summary_item_id, + item_seq, + summary_turn_item, + snapshot, + }, + ); + Some(item_seq) + } +} + +#[cfg(test)] +mod tests { + use pretty_assertions::assert_eq; + + use super::*; + + #[test] + fn preserved_item_ids_match_complete_command_execution_pair() { + let command_item_id = ItemId::new(); + let command_input = serde_json::json!({ "cmd": "printf ok" }); + let command_output = serde_json::Value::String("ok".to_string()); + let persisted_turn_items = vec![PersistedTurnItem { + turn_id: TurnId::new(), + turn_kind: TurnKind::Regular, + item_id: command_item_id, + turn_item: TurnItem::CommandExecution(CommandExecutionItem { + tool_call_id: "call-1".to_string(), + tool_name: "exec_command".to_string(), + command: "printf ok".to_string(), + input: command_input.clone(), + output: command_output.clone(), + is_error: false, + }), + }]; + let compacted_items = vec![ + ResponseItem::Message(Message::assistant_text("summary")), + ResponseItem::ToolCall { + id: "call-1".to_string(), + name: "exec_command".to_string(), + input: command_input, + }, + ResponseItem::ToolCallOutput { + tool_use_id: "call-1".to_string(), + content: "ok".to_string(), + is_error: false, + }, + ]; + + assert_eq!( + preserved_item_ids_from_compacted(&persisted_turn_items, &compacted_items), + vec![command_item_id, command_item_id] + ); + } +} diff --git a/crates/server/src/runtime/connection.rs b/crates/server/src/runtime/connection.rs index 00d813e0..1ac0a74c 100644 --- a/crates/server/src/runtime/connection.rs +++ b/crates/server/src/runtime/connection.rs @@ -317,10 +317,13 @@ impl ServerRuntime { Some(ClientMethod::SessionMetadataUpdate) => { Some(self.handle_session_metadata_update(id?, params).await) } - // update session's permission mode, including auto-approve, default, full-access, readonly + // update session's permission mode, including yolo, default, full-access, readonly Some(ClientMethod::SessionPermissionsUpdate) => { Some(self.handle_session_permissions_update(id?, params).await) } + Some(ClientMethod::SessionCompactionUpdate) => { + Some(self.handle_session_compaction_update(id?, params).await) + } // update the session's sandbox profile for spawned commands Some(ClientMethod::SessionSandboxProfileUpdate) => Some( self.handle_session_sandbox_profile_update(id?, params) @@ -369,6 +372,9 @@ impl ServerRuntime { Some(ClientMethod::McpSetEnabled) => { Some(self.handle_mcp_set_enabled(id?, params).await) } + Some(ClientMethod::ContextUsageRead) => { + Some(self.handle_context_usage_read(id?, params).await) + } // get the model catalog, aka the configured models list Some(ClientMethod::ModelCatalog) => Some(self.handle_model_catalog(id?, params).await), Some(ClientMethod::ModelConfig) => Some(self.handle_model_config(id?, params).await), @@ -408,16 +414,6 @@ impl ServerRuntime { Some(ClientMethod::TurnInterrupt) => { Some(self.handle_turn_interrupt(id?, params).await) } - Some(ClientMethod::TurnSteer) => { - Some(self.handle_turn_steer(connection_id, id?, params).await) - } - Some(ClientMethod::TurnQueueRemove) => { - Some(self.handle_turn_queue_remove(id?, params).await) - } - Some(ClientMethod::TurnQueueSteer) => Some( - self.handle_turn_queue_steer(connection_id, id?, params) - .await, - ), Some(ClientMethod::WorkspaceChangesRead) => { Some(self.handle_workspace_changes_read(id?, params).await) } @@ -1205,6 +1201,7 @@ fn outbound_delivery_policy(event: &ServerEvent) -> OutboundDeliveryPolicy { match event { ServerEvent::ItemDelta { .. } | ServerEvent::TurnUsageUpdated(_) + | ServerEvent::ContextUsageUpdated(_) | ServerEvent::WorkspaceChangesUpdated(_) | ServerEvent::ReferenceSearchUpdated(_) | ServerEvent::CommandExecOutputDelta(_) => OutboundDeliveryPolicy::BestEffort, @@ -1214,6 +1211,7 @@ fn outbound_delivery_policy(event: &ServerEvent) -> OutboundDeliveryPolicy { | ServerEvent::SessionCompactionCompleted(_) | ServerEvent::SessionCompactionFailed(_) | ServerEvent::SessionStatusChanged(_) + | ServerEvent::SessionEffectiveContextWindowUpdated(_) | ServerEvent::SessionArchived(_) | ServerEvent::SessionUnarchived(_) | ServerEvent::SessionClosed(_) @@ -1227,8 +1225,6 @@ fn outbound_delivery_policy(event: &ServerEvent) -> OutboundDeliveryPolicy { | ServerEvent::TurnProviderRetryStatus(_) | ServerEvent::ToolCallStatusUpdated(_) | ServerEvent::RequestUserInput(_) - | ServerEvent::InputQueueUpdated(_) - | ServerEvent::SteerAccepted(_) | ServerEvent::MessageEditRecorded(_) | ServerEvent::TurnSuperseded(_) | ServerEvent::WorkspaceRestoreStarted(_) @@ -1417,6 +1413,7 @@ mod tests { use std::time::Duration; use crate::ItemDeltaPayload; + use anyhow::Context; use anyhow::Result; use async_trait::async_trait; use devo_core::AppConfigStore; @@ -2406,7 +2403,7 @@ mod tests { stop_reason: None, failure_reason: None, }; - let turn = crate::persistence::build_turn_record(&metadata, None, None, None); + let turn = crate::persistence::build_turn_record(&metadata, None, None, None, None); rollout_store .append_turn(&record, turn) .expect("append turn"); @@ -2800,7 +2797,7 @@ mod tests { stop_reason: None, failure_reason: None, }; - let turn = crate::persistence::build_turn_record(&metadata, None, None, None); + let turn = crate::persistence::build_turn_record(&metadata, None, None, None, None); store.append_turn(&record, turn).expect("append turn"); for (seq, text) in [(1u64, "one"), (2, "two")] { let item = crate::persistence::build_item_record( @@ -2883,6 +2880,182 @@ mod tests { Ok(()) } + #[tokio::test] + async fn subscription_snapshot_reads_in_memory_queue_with_1_based_positions() -> Result<()> { + use devo_protocol::canonical::event::{SnapshotData, SubscriptionCreateResult}; + use devo_protocol::canonical::rpc_turn::SessionQueuePushResult; + + let data_root = TempDir::new()?; + let open = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let runtime = build_runtime_with_provider( + data_root.path(), + Arc::new(GatedProvider { + open: Arc::clone(&open), + started: Default::default(), + }), + ); + let connection_id = initialized_connection(&runtime).await; + let session_id = start_durable_session(&runtime, connection_id, data_root.path()).await?; + + // Idle push starts a turn the gate holds open; the next two pushes + // land in the in-memory pending turn queue. + let started = history_request( + &runtime, + connection_id, + 2, + "session/queue/push", + serde_json::json!({ + "sessionId": session_id.to_string(), + "input": [{ "type": "text", "text": "first" }], + "idempotencyKey": "push-1", + }), + ) + .await; + let started: SessionQueuePushResult = + serde_json::from_value(started["result"].clone()).expect("push result"); + assert!( + matches!(started, SessionQueuePushResult::Started { .. }), + "idle push must start a turn" + ); + for (request, key, text) in [(3, "push-2", "second"), (4, "push-3", "third")] { + let pushed = history_request( + &runtime, + connection_id, + request, + "session/queue/push", + serde_json::json!({ + "sessionId": session_id.to_string(), + "input": [{ "type": "text", "text": text }], + "idempotencyKey": key, + }), + ) + .await; + let pushed: SessionQueuePushResult = + serde_json::from_value(pushed["result"].clone()).expect("push result"); + assert!( + matches!(pushed, SessionQueuePushResult::Queued { .. }), + "busy push must queue" + ); + } + + // The snapshot must mirror the in-memory queue (same source as + // session/queue/list), with 1-based positions. + let response = history_request( + &runtime, + connection_id, + 5, + "subscription/create", + serde_json::json!({ + "selectors": [{ "kind": "session", "sessionId": session_id.to_string() }], + "includeSnapshot": true, + }), + ) + .await; + let result: SubscriptionCreateResult = + serde_json::from_value(response["result"].clone()).expect("create result"); + assert_eq!(result.snapshots.len(), 1); + let SnapshotData::Session { queue, .. } = &result.snapshots[0].data else { + panic!("expected session snapshot"); + }; + assert_eq!(queue.len(), 2); + assert_eq!(queue[0].position, 1); + assert_eq!(queue[0].preview, "second"); + assert_eq!(queue[1].position, 2); + assert_eq!(queue[1].preview, "third"); + + // Let the held turn settle so teardown does not race the provider. + open.store(true, std::sync::atomic::Ordering::SeqCst); + + Ok(()) + } + + #[tokio::test] + async fn subscription_snapshot_falls_back_to_persisted_queue_for_unloaded_session() -> Result<()> + { + use devo_protocol::canonical::event::{SnapshotData, SubscriptionCreateResult}; + + let data_root = TempDir::new()?; + let runtime = build_runtime(data_root.path()); + // Rollout-only session the runtime has never resumed (no actor), so + // the snapshot must read its queue from SQLite. + let session_id = write_subscribed_rollout(&runtime).await; + // pending_items has a FK to sessions; a durable session with queued + // input always has a sessions row in production, so seed one here. + let now = Utc::now(); + runtime.deps.db.upsert_session( + &devo_protocol::SessionMetadata { + session_id, + cwd: std::path::PathBuf::from("/tmp/subscription-test"), + additional_directories: Vec::new(), + created_at: now, + updated_at: now, + last_activity_at: now, + title: Some("subscribed session".into()), + title_state: devo_protocol::SessionTitleState::Provisional, + parent_session_id: None, + agent_path: None, + agent_nickname: None, + agent_role: None, + ephemeral: false, + model: Some("test-model".into()), + model_binding_id: None, + reasoning_effort_selection: None, + reasoning_effort: None, + total_input_tokens: 0, + total_output_tokens: 0, + total_tokens: 0, + total_cache_creation_tokens: 0, + total_cache_read_tokens: 0, + prompt_token_estimate: 0, + last_query_usage: None, + last_query_total_tokens: 0, + last_context_occupancy: None, + status: devo_protocol::SessionRuntimeStatus::Unloaded, + collaboration_mode: Default::default(), + effective_context_window: None, + permission_preset: None, + }, + None, + )?; + for text in ["first queued", "second queued"] { + let item = devo_core::PendingInputItem::new( + devo_core::PendingInputKind::UserText { text: text.into() }, + None, + chrono::Utc::now(), + ); + runtime + .deps + .db + .push_pending(&session_id, crate::db::QueueType::Turn, &item)?; + } + let connection_id = initialized_connection(&runtime).await; + + let response = history_request( + &runtime, + connection_id, + 1, + "subscription/create", + serde_json::json!({ + "selectors": [{ "kind": "session", "sessionId": session_id.to_string() }], + "includeSnapshot": true, + }), + ) + .await; + let result: SubscriptionCreateResult = + serde_json::from_value(response["result"].clone()).expect("create result"); + assert_eq!(result.snapshots.len(), 1); + let SnapshotData::Session { queue, .. } = &result.snapshots[0].data else { + panic!("expected session snapshot"); + }; + assert_eq!(queue.len(), 2); + assert_eq!(queue[0].position, 1); + assert_eq!(queue[0].preview, "first queued"); + assert_eq!(queue[1].position, 2); + assert_eq!(queue[1].preview, "second queued"); + + Ok(()) + } + #[tokio::test] async fn subscription_create_future_cursor_is_expired() -> Result<()> { let data_root = TempDir::new()?; @@ -3550,8 +3723,30 @@ mod tests { let session_id = start_durable_session(&runtime, connection_id, data_root.path()).await?; let session_handle = runtime.session(session_id).await.expect("session"); let state_change_guard = session_handle.lock_state_change().await; - let compaction = - tokio::spawn(Arc::clone(&runtime).run_session_compaction(session_id, session_handle)); + let now = Utc::now(); + let turn = crate::turn::TurnMetadata { + turn_id: TurnId::new(), + session_id, + sequence: 1, + status: TurnStatus::Running, + kind: devo_core::TurnKind::ManualCompaction, + model: "test-model".to_string(), + model_binding_id: None, + reasoning_effort_selection: None, + reasoning_effort: None, + request_model: "test-model".to_string(), + request_thinking: None, + started_at: now, + completed_at: None, + usage: None, + stop_reason: None, + failure_reason: None, + }; + let compaction = tokio::spawn(Arc::clone(&runtime).run_session_compaction( + session_id, + session_handle, + turn, + )); tokio::time::sleep(Duration::from_millis(50)).await; assert!(!compaction.is_finished()); drop(state_change_guard); @@ -3559,124 +3754,772 @@ mod tests { Ok(()) } + struct TurnOkCompactHangProvider; + + #[async_trait] + impl ModelProviderSDK for TurnOkCompactHangProvider { + async fn completion(&self, _request: ModelRequest) -> Result { + std::future::pending::<()>().await; + unreachable!("hanging compaction completion should be canceled") + } + + async fn completion_stream( + &self, + _request: ModelRequest, + ) -> Result> + Send>>> + { + Ok(Box::pin(futures::stream::iter(vec![Ok( + StreamEvent::MessageDone { + response: ModelResponse { + id: "turn-ok".into(), + content: vec![devo_protocol::ResponseContent::Text("ok".into())], + stop_reason: Some(devo_protocol::StopReason::EndTurn), + usage: devo_protocol::Usage::default(), + metadata: devo_protocol::ResponseMetadata::default(), + }, + }, + )]))) + } + + fn name(&self) -> &str { + "turn-ok-compact-hang" + } + } + + /// Trace: L2-DES-AGENT-002 + /// Verifies: session/cancel (via turn interrupt) stops hanging compaction + /// and reopens admission. #[tokio::test] - async fn disconnect_wakes_concurrent_rollback_commit_waiter() -> Result<()> { + async fn session_cancel_stops_in_flight_compaction() -> Result<()> { let data_root = TempDir::new()?; - let workspace = TempDir::new()?; - let runtime = build_runtime(data_root.path()); + let runtime = + build_runtime_with_provider(data_root.path(), Arc::new(TurnOkCompactHangProvider)); let connection_id = initialized_connection(&runtime).await; - let session_id = start_durable_session(&runtime, connection_id, workspace.path()).await?; - start_turn(&runtime, connection_id, session_id, "first").await?; + let session_id = start_durable_session(&runtime, connection_id, data_root.path()).await?; + start_turn(&runtime, connection_id, session_id, "seed history").await?; for _ in 0..200 { if runtime.runtime_active_turn_id(session_id).await.is_none() { break; } tokio::time::sleep(Duration::from_millis(10)).await; } - let preview = history_request( - &runtime, - connection_id, - 350, - "session/rollback/preview", - serde_json::json!({ - "sessionId": session_id.to_string(), - "userTurnIndex": 0, - "mode": "beforeUserTurn", - }), - ) - .await; - let plan: devo_protocol::canonical::rpc_session::RestorePlan = - serde_json::from_value(preview["result"].clone())?; - let params = serde_json::json!({ - "restorePlanId": plan.restore_plan_id.as_str(), - "expectedWorkspaceVersion": plan.workspace_version, - }); - let session_handle = runtime.session(session_id).await.expect("session"); - let state_change_guard = session_handle.lock_state_change().await; - let first_runtime = Arc::clone(&runtime); - let first_params = params.clone(); - let first = tokio::spawn(async move { - first_runtime - .handle_session_rollback_commit(connection_id, serde_json::json!(351), first_params) - .await - }); - tokio::time::sleep(Duration::from_millis(20)).await; - let second_runtime = Arc::clone(&runtime); - let second = tokio::spawn(async move { - second_runtime - .handle_session_rollback_commit(connection_id, serde_json::json!(352), params) - .await - }); - tokio::time::sleep(Duration::from_millis(20)).await; - runtime.unregister_connection(connection_id).await; - drop(state_change_guard); - - let first_response = tokio::time::timeout(Duration::from_secs(5), first).await??; - let second_response = tokio::time::timeout(Duration::from_secs(5), second).await??; - assert!(first_response.get("error").is_none(), "{first_response}"); - assert_eq!( - second_response["error"]["code"], - serde_json::json!("RESTORE_PLAN_NOT_FOUND") + assert!( + runtime.runtime_active_turn_id(session_id).await.is_none(), + "seed turn should finish before compact" ); - Ok(()) - } - #[tokio::test] - async fn rollback_in_non_git_workspace_is_history_only() -> Result<()> { - use devo_protocol::canonical::rpc_session::{RestorePlan, SessionRollbackCommitResult}; + let compact_response = runtime + .handle_incoming( + connection_id, + serde_json::json!({ + "id": 200, + "method": "_devo/session/compact", + "params": { "session_id": session_id } + }), + ) + .await + .expect("session/compact response"); + assert!( + compact_response.get("error").is_none(), + "session/compact: {compact_response}" + ); + let compact_result: TurnStartResult = serde_json::from_value( + compact_response + .get("result") + .cloned() + .expect("compact result"), + )?; + let TurnStartResult::Started { turn_id, .. } = compact_result else { + panic!("expected TurnStartResult::Started, got {compact_result:?}"); + }; - let data_root = TempDir::new()?; - let workspace = TempDir::new()?; - let runtime = build_runtime(data_root.path()); - let connection_id = initialized_connection(&runtime).await; - let session_id = start_durable_session(&runtime, connection_id, workspace.path()).await?; - for text in ["first", "second"] { - start_turn(&runtime, connection_id, session_id, text).await?; - for _ in 0..200 { - if runtime.runtime_active_turn_id(session_id).await.is_none() { - break; - } - tokio::time::sleep(Duration::from_millis(10)).await; + for _ in 0..50 { + if runtime.runtime_active_turn_id(session_id).await == Some(turn_id) { + break; } - assert!(runtime.runtime_active_turn_id(session_id).await.is_none()); + tokio::time::sleep(Duration::from_millis(10)).await; } + assert_eq!( + runtime.runtime_active_turn_id(session_id).await, + Some(turn_id), + "compaction should own the active turn while summarizer hangs" + ); - let preview = history_request( - &runtime, - connection_id, - 400, - "session/rollback/preview", - serde_json::json!({ - "sessionId": session_id.to_string(), - "userTurnIndex": 1, - "mode": "beforeUserTurn", - }), - ) - .await; - let plan: RestorePlan = - serde_json::from_value(preview["result"].clone()).expect("rollback preview result"); - assert_eq!(plan.affected_files, Vec::::new()); - assert_eq!(plan.workspace_version, "history-only"); + let cancel_response = runtime + .handle_incoming( + connection_id, + serde_json::json!({ + "id": 201, + "method": "session/cancel", + "params": { "sessionId": session_id } + }), + ) + .await + .expect("session/cancel response"); + assert!( + cancel_response.get("error").is_none(), + "session/cancel: {cancel_response}" + ); - start_turn(&runtime, connection_id, session_id, "third").await?; for _ in 0..200 { if runtime.runtime_active_turn_id(session_id).await.is_none() { break; } tokio::time::sleep(Duration::from_millis(10)).await; } - assert!(runtime.runtime_active_turn_id(session_id).await.is_none()); - let stale_commit = history_request( - &runtime, - connection_id, - 401, - "session/rollback/commit", - serde_json::json!({ - "restorePlanId": plan.restore_plan_id.as_str(), - "expectedWorkspaceVersion": plan.workspace_version, - }), - ) - .await; + assert!( + runtime.runtime_active_turn_id(session_id).await.is_none(), + "cancel should clear the active compaction turn" + ); + + let idle_cancel = runtime + .handle_incoming( + connection_id, + serde_json::json!({ + "id": 202, + "method": "session/cancel", + "params": { "sessionId": session_id } + }), + ) + .await + .expect("idempotent cancel response"); + assert!( + idle_cancel.get("error").is_none(), + "idle cancel should be idempotent: {idle_cancel}" + ); + + let compact_again = runtime + .handle_incoming( + connection_id, + serde_json::json!({ + "id": 203, + "method": "_devo/session/compact", + "params": { "session_id": session_id } + }), + ) + .await + .expect("second session/compact response"); + assert!( + compact_again.get("error").is_none(), + "session should accept compact after cancel: {compact_again}" + ); + if let Some(active_turn_id) = runtime.runtime_active_turn_id(session_id).await { + let _ = runtime + .handle_incoming( + connection_id, + serde_json::json!({ + "id": 204, + "method": "_devo/turn/interrupt", + "params": { + "session_id": session_id, + "turn_id": active_turn_id, + "reason": "test cleanup" + } + }), + ) + .await; + } + Ok(()) + } + + /// Trace: L2-DES-AGENT-002 + /// Verifies: if compaction claimed active_turn then failed to record terminal + /// status, interrupt recovery still emits canceled lifecycle events. + /// + /// Deadlock note: a live compaction task holds `state_change_gate` across + /// `compact_history`. Recovery must cancel that work before a later compact + /// can admit; this test waits for the gate after recovery. + #[tokio::test] + async fn recover_orphaned_compaction_after_actor_claim() -> Result<()> { + let data_root = TempDir::new()?; + let runtime = + build_runtime_with_provider(data_root.path(), Arc::new(TurnOkCompactHangProvider)); + let connection_id = initialized_connection(&runtime).await; + let session_id = start_durable_session(&runtime, connection_id, data_root.path()).await?; + start_turn(&runtime, connection_id, session_id, "seed history").await?; + for _ in 0..200 { + if runtime.runtime_active_turn_id(session_id).await.is_none() { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + + let compact_response = runtime + .handle_incoming( + connection_id, + serde_json::json!({ + "id": 210, + "method": "_devo/session/compact", + "params": { "session_id": session_id } + }), + ) + .await + .expect("session/compact response"); + let compact_result: TurnStartResult = serde_json::from_value( + compact_response + .get("result") + .cloned() + .expect("compact result"), + )?; + let TurnStartResult::Started { turn_id, .. } = compact_result else { + panic!("expected TurnStartResult::Started, got {compact_result:?}"); + }; + for _ in 0..50 { + if runtime.runtime_active_turn_id(session_id).await == Some(turn_id) { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + assert_eq!( + runtime.runtime_active_turn_id(session_id).await, + Some(turn_id) + ); + + let session_handle = runtime.session(session_id).await.expect("session"); + // Simulate: task claimed actor active_turn, then was aborted before + // record_terminal_turn_status. Keep the abort handle so recovery can + // still abort the hanging summarizer and release state_change_gate. + assert_eq!( + session_handle.clear_active_turn_if_matches(turn_id).await, + Some(true) + ); + + let recovered = runtime + .recover_orphaned_manual_compaction_interrupt(&session_handle, session_id, turn_id) + .await + .expect("orphaned compaction should recover"); + assert_eq!(recovered.status, TurnStatus::Interrupted); + assert!( + runtime.runtime_active_turn_id(session_id).await.is_none(), + "recovery should clear runtime handles" + ); + assert!( + runtime + .recent_terminal_turn_status(turn_id) + .await + .is_some_and(|snapshot| snapshot.status == TurnStatus::Interrupted) + ); + + // Hanging compact_history held state_change_gate; wait until cancel/abort + // lets that task drop it before admitting another compact. + let gate = tokio::time::timeout(Duration::from_secs(5), session_handle.lock_state_change()) + .await + .context("timed out waiting for compaction to release state_change_gate")?; + drop(gate); + + let compact_again = runtime + .handle_incoming( + connection_id, + serde_json::json!({ + "id": 211, + "method": "_devo/session/compact", + "params": { "session_id": session_id } + }), + ) + .await + .expect("second session/compact response"); + assert!( + compact_again.get("error").is_none(), + "session should accept compact after orphan recovery: {compact_again}" + ); + if let Some(active_turn_id) = runtime.runtime_active_turn_id(session_id).await { + let _ = runtime + .handle_incoming( + connection_id, + serde_json::json!({ + "id": 212, + "method": "_devo/turn/interrupt", + "params": { + "session_id": session_id, + "turn_id": active_turn_id, + "reason": "test cleanup" + } + }), + ) + .await; + } + Ok(()) + } + + /// Turn stream is gated (the turn hangs until `stream_open`); the + /// non-stream completion used by title generation returns a valid title + /// once `completion_open`. + struct TitleCompletionStreamGatedProvider { + stream_open: Arc, + stream_started: Arc, + completion_open: Arc, + completion_requested: Arc, + } + + #[async_trait] + impl ModelProviderSDK for TitleCompletionStreamGatedProvider { + async fn completion(&self, _request: ModelRequest) -> Result { + self.completion_requested + .store(true, std::sync::atomic::Ordering::SeqCst); + while !self + .completion_open + .load(std::sync::atomic::Ordering::SeqCst) + { + tokio::time::sleep(Duration::from_millis(10)).await; + } + Ok(ModelResponse { + id: "title-response".into(), + content: vec![devo_protocol::ResponseContent::Text( + "Generated session title".into(), + )], + stop_reason: Some(devo_protocol::StopReason::EndTurn), + usage: devo_protocol::Usage::default(), + metadata: devo_protocol::ResponseMetadata::default(), + }) + } + + async fn completion_stream( + &self, + _request: ModelRequest, + ) -> Result> + Send>>> + { + self.stream_started + .store(true, std::sync::atomic::Ordering::SeqCst); + let open = Arc::clone(&self.stream_open); + Ok(Box::pin(futures::stream::unfold(false, move |done| { + let open = Arc::clone(&open); + async move { + if done { + return None; + } + let gate_open = async { + while !open.load(std::sync::atomic::Ordering::SeqCst) { + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } + }; + tokio::select! { + _ = tokio::time::sleep(std::time::Duration::from_millis(250)) => { + Some(( + Ok(StreamEvent::TextDelta { + index: 0, + text: "tick".into(), + }), + false, + )) + } + _ = gate_open => { + Some(( + Ok(StreamEvent::MessageDone { + response: ModelResponse { + id: "gated-response".into(), + content: vec![devo_protocol::ResponseContent::Text( + "done".into(), + )], + stop_reason: Some(devo_protocol::StopReason::EndTurn), + usage: devo_protocol::Usage::default(), + metadata: devo_protocol::ResponseMetadata::default(), + }, + }), + true, + )) + } + } + } + }))) + } + + fn name(&self) -> &str { + "title-completion-stream-gated" + } + } + + /// Regression: during the first turn of an untitled session, final + /// title generation takes `state_change_gate` + /// (`maybe_generate_final_title` in runtime/items.rs) and then parks on + /// the session-actor mailbox (`update_title`) while the actor is busy + /// executing the turn, so the gate stays held for the rest of the turn. + /// A `session/queue/push` in that window must still answer `Queued` + /// immediately: the busy path no longer touches the gate + /// (runtime/handlers/turn.rs). + #[tokio::test] + async fn queue_push_responds_immediately_while_title_generation_holds_gate() -> Result<()> { + let data_root = TempDir::new()?; + let provider = Arc::new(TitleCompletionStreamGatedProvider { + stream_open: Arc::new(std::sync::atomic::AtomicBool::new(false)), + stream_started: Arc::new(std::sync::atomic::AtomicBool::new(false)), + completion_open: Arc::new(std::sync::atomic::AtomicBool::new(false)), + completion_requested: Arc::new(std::sync::atomic::AtomicBool::new(false)), + }); + let stream_open = Arc::clone(&provider.stream_open); + let stream_started = Arc::clone(&provider.stream_started); + let completion_open = Arc::clone(&provider.completion_open); + let completion_requested = Arc::clone(&provider.completion_requested); + let runtime = build_runtime_with_provider(data_root.path(), provider); + let connection_id = initialized_connection(&runtime).await; + // No `title` param: final title generation runs on the first turn. + let response = runtime + .handle_incoming( + connection_id, + serde_json::json!({ + "id": 100, + "method": "session/start", + "params": { + "cwd": data_root.path(), + "ephemeral": false, + "model": "test-model" + } + }), + ) + .await + .expect("session/start response"); + let session_id = + serde_json::from_value::>(response)? + .result + .session + .session_id; + start_turn(&runtime, connection_id, session_id, "first prompt").await?; + + // The title task reaching its provider call proves it passed every + // actor mailbox round-trip; the turn is executing inside the actor. + for _ in 0..500 { + if completion_requested.load(std::sync::atomic::Ordering::SeqCst) + && stream_started.load(std::sync::atomic::Ordering::SeqCst) + { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + assert!( + completion_requested.load(std::sync::atomic::Ordering::SeqCst), + "title generation should have requested its completion" + ); + assert!( + stream_started.load(std::sync::atomic::Ordering::SeqCst), + "turn should be executing" + ); + + // Let the title model call finish: the title task now grabs + // `state_change_gate` and parks on the busy actor mailbox. + completion_open.store(true, std::sync::atomic::Ordering::SeqCst); + let session_handle = runtime.session(session_id).await.expect("session"); + let mut gate_held = false; + for _ in 0..50 { + match tokio::time::timeout( + Duration::from_millis(100), + session_handle.lock_state_change(), + ) + .await + { + Ok(guard) => { + drop(guard); + tokio::time::sleep(Duration::from_millis(50)).await; + } + Err(_) => { + gate_held = true; + break; + } + } + } + assert!( + gate_held, + "title generation should be holding state_change_gate across the actor mailbox wait" + ); + + // Fixed behavior: the busy push no longer touches + // `state_change_gate`, so it answers `Queued` immediately even + // while title generation holds the gate. + let push_runtime = Arc::clone(&runtime); + let push = tokio::spawn(async move { + push_runtime + .handle_incoming( + connection_id, + serde_json::json!({ + "id": 300, + "method": "session/queue/push", + "params": { + "sessionId": session_id.to_string(), + "input": [{ "type": "text", "text": "second prompt" }], + "idempotencyKey": "push-wedge-1" + } + }), + ) + .await + }); + let response = tokio::time::timeout(Duration::from_secs(5), push) + .await + .context("busy push must respond immediately while the gate is held")?? + .expect("push response"); + assert!(response.get("error").is_none(), "push: {response}"); + let result: devo_protocol::canonical::rpc_turn::SessionQueuePushResult = + serde_json::from_value(response["result"].clone()).expect("push result"); + assert!( + matches!( + result, + devo_protocol::canonical::rpc_turn::SessionQueuePushResult::Queued { .. } + ), + "busy push must queue: {response}" + ); + + // Cleanup: let the turn finish so the gate is released. + stream_open.store(true, std::sync::atomic::Ordering::SeqCst); + Ok(()) + } + + /// Same gate holder, same fix: a manual compaction task takes + /// `state_change_gate` across the `compact_history` provider call + /// (runtime/handlers/compaction.rs), but the compaction turn is the + /// active turn, so a `session/queue/push` takes the gate-free busy + /// path and responds immediately. + #[tokio::test] + async fn queue_push_responds_immediately_during_manual_compaction() -> Result<()> { + let data_root = TempDir::new()?; + let runtime = + build_runtime_with_provider(data_root.path(), Arc::new(TurnOkCompactHangProvider)); + let connection_id = initialized_connection(&runtime).await; + let session_id = start_durable_session(&runtime, connection_id, data_root.path()).await?; + start_turn(&runtime, connection_id, session_id, "seed history").await?; + for _ in 0..200 { + if runtime.runtime_active_turn_id(session_id).await.is_none() { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + assert!(runtime.runtime_active_turn_id(session_id).await.is_none()); + + let compact_response = runtime + .handle_incoming( + connection_id, + serde_json::json!({ + "id": 200, + "method": "_devo/session/compact", + "params": { "session_id": session_id } + }), + ) + .await + .expect("session/compact response"); + let compact_result: TurnStartResult = serde_json::from_value( + compact_response + .get("result") + .cloned() + .expect("compact result"), + )?; + let TurnStartResult::Started { + turn_id: compaction_turn_id, + .. + } = compact_result + else { + panic!("expected TurnStartResult::Started, got {compact_result:?}"); + }; + for _ in 0..50 { + if runtime.runtime_active_turn_id(session_id).await == Some(compaction_turn_id) { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + assert_eq!( + runtime.runtime_active_turn_id(session_id).await, + Some(compaction_turn_id) + ); + + // Wait until the compaction task actually holds `state_change_gate` + // inside `compact_history` (a push that slips in beforehand queues + // fine — the stall only applies while the summarizer is in flight). + let session_handle = runtime.session(session_id).await.expect("session"); + let mut gate_held = false; + for _ in 0..50 { + match tokio::time::timeout( + Duration::from_millis(100), + session_handle.lock_state_change(), + ) + .await + { + Ok(guard) => { + drop(guard); + tokio::time::sleep(Duration::from_millis(50)).await; + } + Err(_) => { + gate_held = true; + break; + } + } + } + assert!( + gate_held, + "compaction should be holding state_change_gate across compact_history" + ); + + // Fixed behavior: the compaction turn is the active turn, so the + // push takes the gate-free busy path and answers `Queued` + // immediately even while compaction holds `state_change_gate` + // across `compact_history`. + let push_runtime = Arc::clone(&runtime); + let push = tokio::spawn(async move { + push_runtime + .handle_incoming( + connection_id, + serde_json::json!({ + "id": 300, + "method": "session/queue/push", + "params": { + "sessionId": session_id.to_string(), + "input": [{ "type": "text", "text": "queued while compacting" }], + "idempotencyKey": "push-wedge-2" + } + }), + ) + .await + }); + let response = tokio::time::timeout(Duration::from_secs(5), push) + .await + .context("busy push must respond immediately during compaction")?? + .expect("push response"); + assert!(response.get("error").is_none(), "push: {response}"); + let result: devo_protocol::canonical::rpc_turn::SessionQueuePushResult = + serde_json::from_value(response["result"].clone()).expect("push result"); + assert!( + matches!( + result, + devo_protocol::canonical::rpc_turn::SessionQueuePushResult::Queued { .. } + ), + "busy push must queue: {response}" + ); + + // Cleanup: interrupt cancels the hanging summarizer. + let interrupt_response = runtime + .handle_incoming( + connection_id, + serde_json::json!({ + "id": 201, + "method": "_devo/turn/interrupt", + "params": { + "session_id": session_id, + "turn_id": compaction_turn_id, + "reason": "cleanup" + } + }), + ) + .await + .expect("turn/interrupt response"); + assert!( + interrupt_response.get("error").is_none(), + "turn/interrupt: {interrupt_response}" + ); + Ok(()) + } + + #[tokio::test] + async fn disconnect_wakes_concurrent_rollback_commit_waiter() -> Result<()> { + let data_root = TempDir::new()?; + let workspace = TempDir::new()?; + let runtime = build_runtime(data_root.path()); + let connection_id = initialized_connection(&runtime).await; + let session_id = start_durable_session(&runtime, connection_id, workspace.path()).await?; + start_turn(&runtime, connection_id, session_id, "first").await?; + for _ in 0..200 { + if runtime.runtime_active_turn_id(session_id).await.is_none() { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + let preview = history_request( + &runtime, + connection_id, + 350, + "session/rollback/preview", + serde_json::json!({ + "sessionId": session_id.to_string(), + "userTurnIndex": 0, + "mode": "beforeUserTurn", + }), + ) + .await; + let plan: devo_protocol::canonical::rpc_session::RestorePlan = + serde_json::from_value(preview["result"].clone())?; + let params = serde_json::json!({ + "restorePlanId": plan.restore_plan_id.as_str(), + "expectedWorkspaceVersion": plan.workspace_version, + }); + let session_handle = runtime.session(session_id).await.expect("session"); + let state_change_guard = session_handle.lock_state_change().await; + let first_runtime = Arc::clone(&runtime); + let first_params = params.clone(); + let first = tokio::spawn(async move { + first_runtime + .handle_session_rollback_commit(connection_id, serde_json::json!(351), first_params) + .await + }); + tokio::time::sleep(Duration::from_millis(20)).await; + let second_runtime = Arc::clone(&runtime); + let second = tokio::spawn(async move { + second_runtime + .handle_session_rollback_commit(connection_id, serde_json::json!(352), params) + .await + }); + tokio::time::sleep(Duration::from_millis(20)).await; + runtime.unregister_connection(connection_id).await; + drop(state_change_guard); + + let first_response = tokio::time::timeout(Duration::from_secs(5), first).await??; + let second_response = tokio::time::timeout(Duration::from_secs(5), second).await??; + assert!(first_response.get("error").is_none(), "{first_response}"); + assert_eq!( + second_response["error"]["code"], + serde_json::json!("RESTORE_PLAN_NOT_FOUND") + ); + Ok(()) + } + + #[tokio::test] + async fn rollback_in_non_git_workspace_is_history_only() -> Result<()> { + use devo_protocol::canonical::rpc_session::{RestorePlan, SessionRollbackCommitResult}; + + let data_root = TempDir::new()?; + let workspace = TempDir::new()?; + let runtime = build_runtime(data_root.path()); + let connection_id = initialized_connection(&runtime).await; + let session_id = start_durable_session(&runtime, connection_id, workspace.path()).await?; + for text in ["first", "second"] { + start_turn(&runtime, connection_id, session_id, text).await?; + for _ in 0..200 { + if runtime.runtime_active_turn_id(session_id).await.is_none() { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + assert!(runtime.runtime_active_turn_id(session_id).await.is_none()); + } + + let preview = history_request( + &runtime, + connection_id, + 400, + "session/rollback/preview", + serde_json::json!({ + "sessionId": session_id.to_string(), + "userTurnIndex": 1, + "mode": "beforeUserTurn", + }), + ) + .await; + let plan: RestorePlan = + serde_json::from_value(preview["result"].clone()).expect("rollback preview result"); + assert_eq!(plan.affected_files, Vec::::new()); + assert_eq!(plan.workspace_version, "history-only"); + + start_turn(&runtime, connection_id, session_id, "third").await?; + for _ in 0..200 { + if runtime.runtime_active_turn_id(session_id).await.is_none() { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + assert!(runtime.runtime_active_turn_id(session_id).await.is_none()); + let stale_commit = history_request( + &runtime, + connection_id, + 401, + "session/rollback/commit", + serde_json::json!({ + "restorePlanId": plan.restore_plan_id.as_str(), + "expectedWorkspaceVersion": plan.workspace_version, + }), + ) + .await; assert_eq!( stale_commit["error"]["code"], serde_json::json!("WORKSPACE_VERSION_CONFLICT") @@ -4132,28 +4975,46 @@ mod tests { .collect(); assert_eq!(turn_ids.len(), 2, "expected two distinct turns: {turns:?}"); - // Synchronous race: turn already over — turn/steer degrades to the - // queue instead of failing (message preserved). - let degraded = history_request( + // Canonical semantics: steering after the turn ends is an explicit + // error (nothing is silently dropped), and a fresh push on the now + // idle session starts a new turn (message preserved). + let late_steer = history_request( &runtime, connection_id, 5, - "turn/steer", + "session/queue/steer", serde_json::json!({ - "session_id": session_id.to_string(), - "expected_turn_id": turn_id.to_string(), + "sessionId": session_id.to_string(), + "queueItemId": entry.queue_item_id.as_str(), + "expectedTurnId": turn_id.to_string(), + }), + ) + .await; + assert!( + late_steer.get("error").is_some(), + "steer after turn end must fail: {late_steer}" + ); + let pushed = history_request( + &runtime, + connection_id, + 6, + "session/queue/push", + serde_json::json!({ + "sessionId": session_id.to_string(), "input": [{ "type": "text", "text": "too late" }], + "idempotencyKey": "push-late", }), ) .await; - assert_eq!( - degraded["result"]["disposition"], - serde_json::json!("queued"), - "late steer must degrade: {degraded}" + let pushed: devo_protocol::canonical::rpc_turn::SessionQueuePushResult = + serde_json::from_value(pushed["result"].clone()).expect("push result"); + assert!( + matches!( + pushed, + devo_protocol::canonical::rpc_turn::SessionQueuePushResult::Started { .. } + ), + "idle push must start a new turn: {pushed:?}" ); - let entries = queue_list(&runtime, connection_id, session_id).await; - assert_eq!(entries.len(), 1); - assert_eq!(entries[0].preview, "too late"); Ok(()) } @@ -4197,8 +5058,9 @@ mod tests { assert_eq!(entries.len(), 2); assert_eq!(entries[0].preview, "queued text"); assert_eq!(entries[1].preview, "stale steer"); - // The steer row moved into the turn queue table (the original queued - // row is consumed by the resume drain and lives in memory only). + // The steer row moved into the turn queue table; the original queued + // row stays in the database as the durable mirror of the in-memory + // queue until it is actually consumed. assert!( rebuilt .deps @@ -4210,9 +5072,89 @@ mod tests { .deps .db .list_pending(&session_id, crate::db::QueueType::Turn)?; - assert_eq!(turn_rows.len(), 1); - assert_eq!(turn_rows[0].id, steer_item.id); + assert_eq!(turn_rows.len(), 2); + assert_eq!(turn_rows[0].id, queued_item.id); + assert_eq!(turn_rows[1].id, steer_item.id); + drop(rebuilt); + + // Restart again without consuming the queue: the durable mirror must + // still hold both entries. + let restarted = build_runtime(data_root.path()); + restarted.load_persisted_sessions().await?; + let restarted_connection = initialized_connection(&restarted).await; + let entries = queue_list(&restarted, restarted_connection, session_id).await; + assert_eq!(entries.len(), 2); + assert_eq!(entries[0].preview, "queued text"); + assert_eq!(entries[1].preview, "stale steer"); + + Ok(()) + } + /// Rapid consecutive `session/queue/push` requests while a turn is active + /// must all complete: a stalled push parks an inbound permit and + /// eventually wedges the whole connection. + #[tokio::test] + async fn concurrent_queue_pushes_while_turn_active_all_respond() -> Result<()> { + let data_root = TempDir::new()?; + let open = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let started = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let runtime = build_runtime_with_provider( + data_root.path(), + Arc::new(GatedProvider { + open: Arc::clone(&open), + started: Arc::clone(&started), + }), + ); + let connection_id = initialized_connection(&runtime).await; + let session_id = start_durable_session(&runtime, connection_id, data_root.path()).await?; + start_turn(&runtime, connection_id, session_id, "hold the turn open").await?; + for _ in 0..200 { + if started.load(std::sync::atomic::Ordering::SeqCst) { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + assert!( + started.load(std::sync::atomic::Ordering::SeqCst), + "provider stream should have started" + ); + + const PUSH_COUNT: usize = 8; + let mut tasks = Vec::new(); + for index in 0..PUSH_COUNT { + let runtime = Arc::clone(&runtime); + tasks.push(tokio::spawn(async move { + runtime + .handle_incoming( + connection_id, + serde_json::json!({ + "id": 500 + index as u64, + "method": "session/queue/push", + "params": { + "sessionId": session_id.to_string(), + "input": [{ "type": "text", "text": format!("queued {index}") }], + "idempotencyKey": format!("push-{index}"), + }, + }), + ) + .await + })); + } + for (index, task) in tasks.into_iter().enumerate() { + let response = tokio::time::timeout(Duration::from_secs(10), task) + .await + .with_context(|| format!("queue push {index} did not respond in time"))? + .context("queue push task panicked")? + .expect("queue push response"); + assert!( + response.get("error").is_none(), + "queue push {index} failed: {response}" + ); + } + let entries = queue_list(&runtime, connection_id, session_id).await; + assert_eq!(entries.len(), PUSH_COUNT); + + open.store(true, std::sync::atomic::Ordering::SeqCst); Ok(()) } } diff --git a/crates/server/src/runtime/context_occupancy.rs b/crates/server/src/runtime/context_occupancy.rs new file mode 100644 index 00000000..b028c1d9 --- /dev/null +++ b/crates/server/src/runtime/context_occupancy.rs @@ -0,0 +1,110 @@ +//! Helpers for context-window occupancy snapshots. + +use devo_core::RawContextBreakdown; +use devo_protocol::Model; +use devo_protocol::canonical::item::ContextCategoryId; +use devo_protocol::canonical::item::ContextOccupancy; + +/// Resolve the applied compaction / effective-context limit for a model. +/// +/// When a global `compaction_token_limit` is set, clamp it to the model's hard +/// `context_window`. Otherwise use the model's effective context window. +pub(crate) fn resolved_compaction_limit(global: Option, model: &Model) -> u64 { + let model_window = u64::from(model.context_window.max(1)); + let model_effective = u64::from(model.effective_context_window()) + .min(model_window) + .max(1); + match global.filter(|limit| *limit > 0) { + Some(limit) => limit.min(model_window).max(1), + None => model_effective, + } +} + +/// Apply an absolute compaction limit onto session token-budget fields. +pub(crate) fn apply_resolved_compaction_limit(config: &mut devo_core::SessionConfig, limit: usize) { + config.effective_context_window_override = Some(limit); + config.token_budget.context_window = limit; + config.token_budget.auto_compact_token_limit = Some(limit); +} + +pub(crate) fn occupancy_from_raw( + context_window_tokens: u64, + raw: RawContextBreakdown, + anchor_total: u64, +) -> ContextOccupancy { + ContextOccupancy::scale_raw_to_total( + context_window_tokens, + anchor_total, + raw.base, + raw.skills, + raw.tools_builtin, + raw.tools_mcp, + raw.conversation, + ) +} + +pub(crate) fn occupancy_after_compaction( + context_window_tokens: u64, + previous: Option<&ContextOccupancy>, + conversation_tokens: u64, + fallback_raw: Option, +) -> ContextOccupancy { + let (base, skills, tools_builtin, tools_mcp) = if let Some(previous) = previous { + ( + previous.tokens_for(ContextCategoryId::Base), + previous.tokens_for(ContextCategoryId::Skills), + previous.tokens_for(ContextCategoryId::ToolsBuiltin), + previous.tokens_for(ContextCategoryId::ToolsMcp), + ) + } else if let Some(raw) = fallback_raw { + (raw.base, raw.skills, raw.tools_builtin, raw.tools_mcp) + } else { + (0, 0, 0, 0) + }; + ContextOccupancy::from_category_tokens( + context_window_tokens, + base, + skills, + tools_builtin, + tools_mcp, + conversation_tokens, + ) +} + +#[cfg(test)] +mod tests { + use pretty_assertions::assert_eq; + + use super::*; + + fn sample_model(context_window: u32, percent: u8) -> Model { + Model { + slug: "test-model".to_string(), + context_window, + effective_context_window_percent: Some(percent), + ..Model::default() + } + } + + #[test] + fn resolved_compaction_limit_uses_model_effective_when_global_unset() { + let model = sample_model(/*context_window*/ 200_000, /*percent*/ 95); + assert_eq!(resolved_compaction_limit(/*global*/ None, &model), 190_000); + } + + #[test] + fn resolved_compaction_limit_clamps_global_to_model_window() { + let model = sample_model(/*context_window*/ 200_000, /*percent*/ 95); + assert_eq!(resolved_compaction_limit(Some(250_000), &model), 200_000); + assert_eq!(resolved_compaction_limit(Some(100_000), &model), 100_000); + } + + #[test] + fn apply_resolved_compaction_limit_updates_token_budget() { + let mut config = devo_core::SessionConfig::default(); + apply_resolved_compaction_limit(&mut config, /*limit*/ 250_000); + assert_eq!(config.effective_context_window_override, Some(250_000)); + assert_eq!(config.token_budget.context_window, 250_000); + assert_eq!(config.token_budget.auto_compact_token_limit, Some(250_000)); + } +} diff --git a/crates/server/src/runtime/context_usage.rs b/crates/server/src/runtime/context_usage.rs new file mode 100644 index 00000000..a253a8a1 --- /dev/null +++ b/crates/server/src/runtime/context_usage.rs @@ -0,0 +1,80 @@ +//! `context/usage/read` RPC handler. + +use devo_core::SessionId; +use devo_protocol::SuccessResponse; +use devo_protocol::canonical::item::ContextOccupancy; +use devo_protocol::canonical::rpc_admin::ContextUsageReadParams; +use devo_protocol::canonical::rpc_admin::ContextUsageReadResult; + +use super::ServerRuntime; +use crate::ProtocolErrorCode; + +impl ServerRuntime { + pub(super) async fn handle_context_usage_read( + &self, + request_id: serde_json::Value, + params: serde_json::Value, + ) -> serde_json::Value { + let params = match serde_json::from_value::(params) { + Ok(params) => params, + Err(error) => { + return self.error_response( + request_id, + ProtocolErrorCode::InvalidParams, + format!("invalid context/usage/read params: {error}"), + ); + } + }; + + let Ok(session_id) = SessionId::try_from(params.session_id.as_str()) else { + return self.error_response( + request_id, + ProtocolErrorCode::InvalidParams, + format!("invalid session id: {}", params.session_id), + ); + }; + + let Some(summary) = self.session_summary_snapshot(session_id).await else { + return self.error_response( + request_id, + ProtocolErrorCode::SessionNotFound, + format!("session not found: {session_id}"), + ); + }; + + let occupancy = if let Some(occupancy) = summary.last_context_occupancy { + occupancy + } else { + let global = self + .deps + .config_store + .lock() + .expect("app config store mutex should not be poisoned") + .effective_config() + .compaction_token_limit; + let model = summary + .model + .as_deref() + .and_then(|slug| self.deps.model_catalog.get(slug)) + .or_else(|| { + summary + .model_binding_id + .as_deref() + .and_then(|binding| self.deps.model_catalog.get(binding)) + }); + let window = match model { + Some(model) => { + crate::runtime::context_occupancy::resolved_compaction_limit(global, model) + } + None => global.unwrap_or(0), + }; + ContextOccupancy::empty(window) + }; + + serde_json::to_value(SuccessResponse { + id: request_id, + result: ContextUsageReadResult { occupancy }, + }) + .expect("serialize context/usage/read response") + } +} diff --git a/crates/server/src/runtime/handlers/acp/history.rs b/crates/server/src/runtime/handlers/acp/history.rs index 565491cd..dc3b24d4 100644 --- a/crates/server/src/runtime/handlers/acp/history.rs +++ b/crates/server/src/runtime/handlers/acp/history.rs @@ -104,6 +104,7 @@ pub(super) fn acp_update_from_history_item( meta: Some(meta), }) } + SessionHistoryItemKind::ContextCompaction => None, } } diff --git a/crates/server/src/runtime/handlers/acp/prompt.rs b/crates/server/src/runtime/handlers/acp/prompt.rs index b22608af..12fc3e98 100644 --- a/crates/server/src/runtime/handlers/acp/prompt.rs +++ b/crates/server/src/runtime/handlers/acp/prompt.rs @@ -128,7 +128,18 @@ impl ServerRuntime { None } - /// Handles ACP `session/cancel` by interrupting the active turn on the session. + /// Handles ACP `session/cancel`. + /// + /// Design intent: `session/cancel` is the session-scoped "stop current + /// work" primitive. Prefer it over adding per-feature cancel RPCs. + /// + /// Manual `/compact` is admitted as an active `ManualCompaction` turn, so + /// cancel routes through `turn/interrupt` like any other active turn (and + /// dual-emits `session/compaction/failed` with `"compaction canceled"`). + /// Idle sessions are an idempotent no-op. + /// + /// Turn-scoped automatic compaction still rides the turn cancel token + /// under `turn/interrupt`. pub(crate) async fn handle_acp_session_cancel(self: &Arc, params: serde_json::Value) { let params: AcpCancelParams = match serde_json::from_value(params) { Ok(params) => params, @@ -137,25 +148,29 @@ impl ServerRuntime { return; } }; - let Some(turn_id) = self.runtime_active_turn_id(params.session_id).await else { - tracing::debug!(session_id = %params.session_id, "session/cancel had no active turn"); + if let Some(turn_id) = self.runtime_active_turn_id(params.session_id).await { + self.signal_active_turn_interrupt(params.session_id).await; + let runtime = Arc::clone(self); + tokio::spawn(async move { + let _ = runtime + .handle_turn_interrupt( + serde_json::Value::Null, + serde_json::to_value(TurnInterruptParams { + session_id: params.session_id, + turn_id, + reason: Some("cancelled by ACP client".to_string()), + }) + .expect("serialize turn interrupt params"), + ) + .await; + }); return; - }; - self.signal_active_turn_interrupt(params.session_id).await; - let runtime = Arc::clone(self); - tokio::spawn(async move { - let _ = runtime - .handle_turn_interrupt( - serde_json::Value::Null, - serde_json::to_value(TurnInterruptParams { - session_id: params.session_id, - turn_id, - reason: Some("cancelled by ACP client".to_string()), - }) - .expect("serialize turn interrupt params"), - ) - .await; - }); + } + + tracing::debug!( + session_id = %params.session_id, + "session/cancel had no active turn" + ); } async fn session_has_active_turn(&self, session_id: SessionId) -> bool { diff --git a/crates/server/src/runtime/handlers/acp_config_options.rs b/crates/server/src/runtime/handlers/acp_config_options.rs index f4aed0ca..3dfcd3f5 100644 --- a/crates/server/src/runtime/handlers/acp_config_options.rs +++ b/crates/server/src/runtime/handlers/acp_config_options.rs @@ -105,6 +105,7 @@ impl ServerRuntime { Some(turn_config.model.slug.clone()), turn_config.model_binding_id.clone(), turn_config.reasoning_effort_selection.clone(), + None, ) .await .ok_or_else(|| { @@ -172,6 +173,7 @@ impl ServerRuntime { Some(turn_config.model.slug.clone()), turn_config.model_binding_id.clone(), turn_config.reasoning_effort_selection.clone(), + None, ) .await .ok_or_else(|| { @@ -358,17 +360,17 @@ fn acp_mode_config_option_for_session(config: &SessionConfig) -> AcpSessionConfi [ ( PermissionPreset::Default, - "Default", - "Workspace sandbox; read/edit/run in workspace. Ask before network or outside-workspace edits.", + "Ask for approval", + "Workspace sandbox; read/edit/run in workspace. You approve sensitive tools.", ), ( PermissionPreset::AutoReview, - "Auto-review", - "Workspace sandbox with default permissions; eligible approvals go through auto-review first.", + "Approve for me", + "Same sandbox as Ask for approval. An AI reviewer may approve low-risk tools; uncertain ones still ask you.", ), ( PermissionPreset::FullAccess, - "Full Access", + "Full-Access", "No OS sandbox and no approval prompts; use with caution.", ), ] diff --git a/crates/server/src/runtime/handlers/acp_slash_commands.rs b/crates/server/src/runtime/handlers/acp_slash_commands.rs index 28b1fe44..5c543bc3 100644 --- a/crates/server/src/runtime/handlers/acp_slash_commands.rs +++ b/crates/server/src/runtime/handlers/acp_slash_commands.rs @@ -27,7 +27,6 @@ use crate::AcpStopReason; use crate::CollaborationMode; use crate::InputItem; use crate::SessionCompactParams; -use crate::SessionCompactResult; use crate::SuccessResponse; use crate::TurnExecutionMode; use crate::TurnStartParams; @@ -108,9 +107,9 @@ impl ServerRuntime { | SlashCommand::Rename | SlashCommand::Delete | SlashCommand::Status + | SlashCommand::Settings | SlashCommand::Permissions | SlashCommand::ShowReasoning - | SlashCommand::Clear | SlashCommand::Diff | SlashCommand::Exit | SlashCommand::Btw => { @@ -146,9 +145,9 @@ impl ServerRuntime { .expect("serialize session compact params"), ) .await; - let Ok(_response) = serde_json::from_value::>( - legacy_response.clone(), - ) else { + let Ok(_response) = + serde_json::from_value::>(legacy_response.clone()) + else { return AcpSlashCommandPromptResult::Response(legacy_error_to_acp( request_id, legacy_response, diff --git a/crates/server/src/runtime/handlers/compaction.rs b/crates/server/src/runtime/handlers/compaction.rs index c65542fc..034d2281 100644 --- a/crates/server/src/runtime/handlers/compaction.rs +++ b/crates/server/src/runtime/handlers/compaction.rs @@ -1,5 +1,15 @@ use super::super::*; +use std::panic::AssertUnwindSafe; + +use devo_protocol::TurnFailedPayload; use devo_protocol::approx_tokens_from_byte_count; +use futures::FutureExt; + +enum CompactionTurnOutcome { + Skipped, + Failed { message: String }, + Canceled, +} impl ServerRuntime { pub(crate) async fn handle_session_compact( @@ -18,18 +28,16 @@ impl ServerRuntime { } }; - let session_handle = match self.session(params.session_id).await { - Some(session) => session, - None => { - return self.error_response( - request_id, - ProtocolErrorCode::SessionNotFound, - "session does not exist", - ); - } + let Some(session_handle) = self.session(params.session_id).await else { + return self.error_response( + request_id, + ProtocolErrorCode::SessionNotFound, + "session does not exist", + ); }; - let Some(summary) = session_handle.summary().await else { + let _state_change_guard = session_handle.lock_state_change().await; + let Some(reservation) = session_handle.turn_reservation_snapshot().await else { return self.error_response( request_id, ProtocolErrorCode::SessionNotFound, @@ -37,25 +45,201 @@ impl ServerRuntime { ); }; + let requested_model = session_model_selection(&reservation.summary); + let requested_reasoning_effort_selection = + reservation.summary.reasoning_effort_selection.clone(); + let turn_config = reservation + .runtime_context + .resolve_turn_config(requested_model, requested_reasoning_effort_selection); + let resolved_request = turn_config + .model + .resolve_reasoning_effort_selection(turn_config.reasoning_effort_selection.as_deref()); + let request_model = turn_config.provider_request_model(&resolved_request.request_model); + let now = Utc::now(); + let turn = TurnMetadata { + turn_id: TurnId::new(), + session_id: params.session_id, + sequence: reservation + .latest_turn + .as_ref() + .map_or(1, |turn| turn.sequence + 1), + status: TurnStatus::Running, + kind: devo_core::TurnKind::ManualCompaction, + model: turn_config.model.slug.clone(), + model_binding_id: turn_config.model_binding_id.clone(), + reasoning_effort_selection: turn_config.reasoning_effort_selection.clone(), + reasoning_effort: resolved_request.effective_reasoning_effort, + request_model, + request_thinking: resolved_request.request_thinking, + started_at: now, + completed_at: None, + usage: None, + stop_reason: None, + failure_reason: None, + }; + + if !session_handle + .try_begin_active_turn(turn.clone(), turn_config) + .await + .unwrap_or(false) + { + return self.error_response( + request_id, + ProtocolErrorCode::TurnAlreadyRunning, + "cannot compact while a turn is active or queued", + ); + } + + if let Some(persistence) = session_handle.turn_persistence_snapshot().await + && persistence.record.is_some() + && let Err(error) = self + .persist_turn_line_deduped(params.session_id, &turn) + .await + { + let _ = session_handle + .clear_active_turn_if_matches(turn.turn_id) + .await; + return self.error_response( + request_id, + ProtocolErrorCode::InternalError, + format!("failed to persist compaction turn start: {error}"), + ); + } + let runtime = Arc::clone(self); - tokio::spawn(async move { - if let Err(panic) = - AssertUnwindSafe(runtime.run_session_compaction(params.session_id, session_handle)) - .catch_unwind() - .await - { - tracing::error!( - session_id = %params.session_id, - panic = ?panic, - "session compaction task panicked" - ); - } - }); - tracing::info!(session_id = %params.session_id, "accepted async session compaction request"); + let session_id = params.session_id; + let turn_for_task = turn.clone(); + let session_handle_for_task = session_handle.clone(); + self.spawn_active_turn_task( + session_id, + turn.clone(), + /*connection_id*/ None, + async move { + let runtime_for_panic = Arc::clone(&runtime); + let session_handle_for_panic = session_handle_for_task.clone(); + let turn_for_panic = turn_for_task.clone(); + if let Err(panic) = AssertUnwindSafe(runtime.run_session_compaction( + session_id, + session_handle_for_task, + turn_for_task, + )) + .catch_unwind() + .await + { + tracing::error!( + session_id = %session_id, + turn_id = %turn_for_panic.turn_id, + panic = ?panic, + "session compaction task panicked" + ); + runtime_for_panic + .finalize_manual_compaction_turn( + &session_handle_for_panic, + session_id, + turn_for_panic.clone(), + CompactionTurnOutcome::Failed { + message: "compaction failed: panicked".to_string(), + }, + ) + .await; + // If the panic happened after claim, finalize is a no-op — still + // recover so the session is not left without terminal events. + if runtime_for_panic + .recent_terminal_turn_status(turn_for_panic.turn_id) + .await + .is_none() + { + let _ = runtime_for_panic + .recover_orphaned_manual_compaction_interrupt( + &session_handle_for_panic, + session_id, + turn_for_panic.turn_id, + ) + .await; + if runtime_for_panic + .recent_terminal_turn_status(turn_for_panic.turn_id) + .await + .is_none() + { + // Actor claim may have cleared runtime metadata too; force + // a Failed terminal so admission reopens. + let mut failed = turn_for_panic; + failed.status = TurnStatus::Failed; + failed.completed_at = Some(Utc::now()); + session_handle_for_panic + .set_session_idle(Some(failed.clone())) + .await; + runtime_for_panic + .clear_active_turn_runtime_handles(session_id) + .await; + runtime_for_panic + .broadcast_event(ServerEvent::SessionCompactionFailed( + SessionCompactionFailedPayload { + session_id, + message: "compaction failed: panicked".to_string(), + }, + )) + .await; + runtime_for_panic + .broadcast_event(ServerEvent::TurnFailed(TurnFailedPayload { + session_id, + turn: failed.clone(), + error: None, + })) + .await; + runtime_for_panic + .broadcast_event(ServerEvent::TurnCompleted(TurnEventPayload { + session_id, + turn: failed.clone(), + })) + .await; + runtime_for_panic + .broadcast_event(ServerEvent::SessionStatusChanged( + SessionStatusChangedPayload { + session_id, + status: SessionRuntimeStatus::Idle, + }, + )) + .await; + runtime_for_panic + .record_terminal_turn_status( + failed.turn_id, + TerminalTurnSnapshot::from_turn(&failed), + ) + .await; + } + } + } + }, + ) + .await; + + tracing::info!( + session_id = %session_id, + turn_id = %turn.turn_id, + sequence = turn.sequence, + "started manual compaction turn" + ); + self.broadcast_event(ServerEvent::SessionStatusChanged( + SessionStatusChangedPayload { + session_id, + status: SessionRuntimeStatus::ActiveTurn, + }, + )) + .await; + self.broadcast_event(ServerEvent::TurnStarted(TurnEventPayload { + session_id, + turn: turn.clone(), + })) + .await; serde_json::to_value(SuccessResponse { id: request_id, - result: SessionCompactResult { session: summary }, + result: TurnStartResult::Started { + turn_id: turn.turn_id, + status: turn.status.clone(), + accepted_at: now, + }, }) .expect("serialize session/compact response") } @@ -64,9 +248,23 @@ impl ServerRuntime { self: Arc, session_id: SessionId, session_handle: crate::runtime::session_actor::SessionHandle, + turn: TurnMetadata, ) { - tracing::info!(session_id = %session_id, "session compaction task started"); + tracing::info!( + session_id = %session_id, + turn_id = %turn.turn_id, + "session compaction task started" + ); let Some(started_summary) = session_handle.summary().await else { + self.finalize_manual_compaction_turn( + &session_handle, + session_id, + turn, + CompactionTurnOutcome::Failed { + message: "compaction failed: session unavailable".to_string(), + }, + ) + .await; return; }; self.broadcast_event(ServerEvent::SessionCompactionStarted(SessionEventPayload { @@ -83,6 +281,12 @@ impl ServerRuntime { ) .await; + let cancel_token = self + .active_turns + .cancel_token(session_id) + .await + .unwrap_or_else(CancellationToken::new); + // Compaction computes a replacement from a history snapshot. Keep the // session mutation gate for the whole summarize-and-apply operation so // rollback, turn admission, and metadata edits cannot make that @@ -91,12 +295,15 @@ impl ServerRuntime { let result = { let Some(runtime_session) = session_handle.export_runtime_session().await else { tracing::warn!(session_id = %session_id, "session compaction failed: session unavailable"); - self.broadcast_event(ServerEvent::SessionCompactionFailed( - SessionCompactionFailedPayload { - session_id, + drop(state_change_guard); + self.finalize_manual_compaction_turn( + &session_handle, + session_id, + turn, + CompactionTurnOutcome::Failed { message: "compaction failed: session unavailable".to_string(), }, - )) + ) .await; return; }; @@ -132,6 +339,7 @@ impl ServerRuntime { tracing::debug!( session_id = %session_id, + turn_id = %turn.turn_id, model = %model_slug, request_model = %request_model, item_count = items.len(), @@ -145,7 +353,7 @@ impl ServerRuntime { .runtime_context .provider_for_route(turn_config.provider_route.clone()), session_id, - None, + Some(turn.turn_id), devo_protocol::canonical::usage::UsagePurpose::Compaction, ); let summarizer = DefaultHistorySummarizer::with_models( @@ -162,16 +370,77 @@ impl ServerRuntime { kind: CompactionKind::Proactive, }; - compact_history(&items, &token_info, &summarizer, &config).await + // Drop the core_session lock before the long summarizer await. + drop(core_session); + drop(runtime_session); + + compact_history( + &items, + &token_info, + &summarizer, + &config, + Some(&cancel_token), + ) + .await }; + // Summarize is done: detach abort so interrupt cannot kill mid-terminalize. + // Cancel token still works for any remaining cooperative checks. + self.detach_active_turn_abort(session_id).await; + match result { + Err(devo_core::history::compaction::CompactionError::Canceled) => { + drop(state_change_guard); + tracing::info!( + session_id = %session_id, + turn_id = %turn.turn_id, + "session compaction canceled" + ); + self.finalize_manual_compaction_turn( + &session_handle, + session_id, + turn, + CompactionTurnOutcome::Canceled, + ) + .await; + } Ok(CompactAction::Replaced(compacted_items)) => { + if cancel_token.is_cancelled() { + drop(state_change_guard); + self.finalize_manual_compaction_turn( + &session_handle, + session_id, + turn, + CompactionTurnOutcome::Canceled, + ) + .await; + return; + } let Some(mut runtime_session) = session_handle.export_runtime_session().await else { + drop(state_change_guard); + self.finalize_manual_compaction_turn( + &session_handle, + session_id, + turn, + CompactionTurnOutcome::Failed { + message: "compaction failed: session unavailable".to_string(), + }, + ) + .await; return; }; - let preserved_item_ids = Self::preserved_item_ids_from_compacted( + // Claim terminalization before mutating history so an interrupt that + // already took `active_turn` cannot race with replace_state. + if session_handle + .clear_active_turn_if_matches(turn.turn_id) + .await + != Some(true) + { + drop(state_change_guard); + return; + } + let preserved_item_ids = preserved_item_ids_from_compacted( &runtime_session.persisted_turn_items, &compacted_items, ); @@ -191,7 +460,10 @@ impl ServerRuntime { compacted_total_cache_creation_tokens, compacted_total_cache_read_tokens, compacted_prompt_token_estimate, + compacted_occupancy, ) = { + let previous_occupancy = + runtime_session.summary.last_context_occupancy.clone(); let mut core_session = runtime_session.core_session.lock().await; core_session.set_prompt_messages(new_messages); let prompt_bytes = core_session @@ -201,11 +473,63 @@ impl ServerRuntime { serde_json::to_string(message).map_or(0, |json| json.len()) }) .sum::(); + let conversation_tokens = approx_tokens_from_byte_count(prompt_bytes); let compacted_prompt_token_estimate = - approx_tokens_from_byte_count(prompt_bytes) - .try_into() - .unwrap_or(usize::MAX); + conversation_tokens.try_into().unwrap_or(usize::MAX); core_session.prompt_token_estimate = compacted_prompt_token_estimate; + let global = self + .deps + .config_store + .lock() + .expect("app config store mutex should not be poisoned") + .effective_config() + .compaction_token_limit; + let model = runtime_session + .summary + .model + .as_deref() + .and_then(|slug| { + runtime_session + .runtime_context + .model_catalog + .get(slug) + .or_else(|| self.deps.model_catalog.get(slug)) + }) + .or_else(|| { + runtime_session + .summary + .model_binding_id + .as_deref() + .and_then(|binding| { + runtime_session + .runtime_context + .model_catalog + .get(binding) + .or_else(|| self.deps.model_catalog.get(binding)) + }) + }); + let window = runtime_session + .summary + .effective_context_window + .or_else(|| { + model.map(|model| { + super::super::context_occupancy::resolved_compaction_limit( + global, model, + ) + }) + }) + .unwrap_or(0); + let occupancy = super::super::context_occupancy::occupancy_after_compaction( + window, + previous_occupancy.as_ref(), + conversation_tokens, + core_session.raw_context_breakdown, + ); + // Keep auto-compact pressure on the post-compact tip so + // resume / next query do not re-trigger from pre-compact + // latest-query totals. + core_session.last_turn_tokens = occupancy.total_tokens as usize; + core_session.last_input_tokens = compacted_prompt_token_estimate; ( core_session.total_input_tokens, core_session.total_output_tokens, @@ -213,6 +537,7 @@ impl ServerRuntime { core_session.total_cache_creation_tokens, core_session.total_cache_read_tokens, compacted_prompt_token_estimate, + occupancy, ) }; runtime_session.summary.total_input_tokens = compacted_total_input_tokens; @@ -223,135 +548,124 @@ impl ServerRuntime { runtime_session.summary.total_cache_read_tokens = compacted_total_cache_read_tokens; runtime_session.summary.prompt_token_estimate = compacted_prompt_token_estimate; + runtime_session.summary.last_query_total_tokens = + compacted_occupancy.total_tokens as usize; + runtime_session.summary.last_context_occupancy = + Some(compacted_occupancy.clone()); } - if let Some(turn_id) = runtime_session - .latest_turn - .as_ref() - .map(|t| t.turn_id) - .or_else(|| runtime_session.active_turn.as_ref().map(|t| t.turn_id)) - { - let item_id = devo_core::ItemId::new(); - let item_seq = runtime_session.next_item_seq; - runtime_session.loaded_item_count += 1; - runtime_session.next_item_seq += 1; - - let payload = serde_json::json!({ "title": "Context Compaction" }); - self.broadcast_event(ServerEvent::ItemStarted(ItemEventPayload { - context: EventContext { - session_id, - turn_id: Some(turn_id), - item_id: Some(item_id), - seq: item_seq, - item_seq: Some(item_seq), - }, - item: ItemEnvelope { - item_id, - item_kind: ItemKind::ContextCompaction, - payload: payload.clone(), - }, - })) - .await; - - self.broadcast_event(ServerEvent::ItemCompleted(ItemEventPayload { - context: EventContext { - session_id, - turn_id: Some(turn_id), - item_id: Some(item_id), - seq: item_seq, - item_seq: Some(item_seq), - }, - item: ItemEnvelope { - item_id, - item_kind: ItemKind::ContextCompaction, - payload, - }, - })) - .await; - - let summary_turn_item = - Self::summary_turn_item_from_compacted(&compacted_items); - let compact_summary = match &summary_turn_item { - TurnItem::ContextCompaction(TextItem { text }) => text.clone(), - _ => String::new(), + if !runtime_session.summary.ephemeral { + let stats = crate::db::SessionStats { + total_input_tokens: runtime_session.summary.total_input_tokens, + total_output_tokens: runtime_session.summary.total_output_tokens, + total_tokens: runtime_session.summary.total_tokens, + total_cache_creation_tokens: runtime_session + .summary + .total_cache_creation_tokens, + total_cache_read_tokens: runtime_session.summary.total_cache_read_tokens, + last_input_tokens: runtime_session.summary.prompt_token_estimate, + turn_count: runtime_session.summary.updated_at.timestamp() as usize, + prompt_token_estimate: runtime_session.summary.prompt_token_estimate, + last_context_occupancy: runtime_session + .summary + .last_context_occupancy + .clone(), }; - if let Some(record) = runtime_session.record.clone() { - runtime_session.latest_compaction_snapshot = - Some(devo_core::CompactionSnapshotLine { - timestamp: Utc::now(), - session_id, - turn_id, - summary_item_id: item_id, - preserved_item_ids: preserved_item_ids.clone(), - }); - runtime_session.persisted_turn_items.push( - crate::execution::PersistedTurnItem { - turn_id, - turn_kind: devo_core::TurnKind::ManualCompaction, - item_id, - turn_item: summary_turn_item.clone(), - }, + if let Err(err) = self.deps.db.update_stats(&session_id, &stats) { + tracing::warn!( + session_id = %session_id, + error = %err, + "failed to persist compaction token stats to database" ); + } + } - let item_record = crate::persistence::build_item_record( - session_id, + let turn_id = turn.turn_id; + let item_id = devo_core::ItemId::new(); + let item_seq = runtime_session.next_item_seq; + runtime_session.loaded_item_count += 1; + runtime_session.next_item_seq += 1; + + let payload = serde_json::json!({ "title": "Context Compaction" }); + self.broadcast_event(ServerEvent::ItemStarted(ItemEventPayload { + context: EventContext { + session_id, + turn_id: Some(turn_id), + item_id: Some(item_id), + seq: item_seq, + item_seq: Some(item_seq), + }, + item: ItemEnvelope { + item_id, + item_kind: ItemKind::ContextCompaction, + payload: payload.clone(), + }, + })) + .await; + + self.broadcast_event(ServerEvent::ItemCompleted(ItemEventPayload { + context: EventContext { + session_id, + turn_id: Some(turn_id), + item_id: Some(item_id), + seq: item_seq, + item_seq: Some(item_seq), + }, + item: ItemEnvelope { + item_id, + item_kind: ItemKind::ContextCompaction, + payload, + }, + })) + .await; + + let summary_turn_item = summary_turn_item_from_compacted(&compacted_items); + let compact_summary = match &summary_turn_item { + TurnItem::ContextCompaction(TextItem { text }) => text.clone(), + _ => String::new(), + }; + if let Some(record) = runtime_session.record.clone() { + let snapshot = build_compaction_snapshot_line( + session_id, + turn_id, + item_id, + preserved_item_ids.clone(), + runtime_session.summary.last_context_occupancy.clone(), + ); + runtime_session.latest_compaction_snapshot = Some(snapshot.clone()); + runtime_session + .persisted_turn_items + .push(compaction_persisted_turn_item( turn_id, + devo_core::TurnKind::ManualCompaction, item_id, + summary_turn_item.clone(), + )); + if let Some(history_item) = + crate::projection::history_item_from_turn_item(&summary_turn_item) + { + runtime_session.history_items.push(history_item); + } + append_compaction_summary_and_snapshot( + &self.rollout_store, + &record, + CompactionSummaryPersist { + session_id, + turn_id, + summary_item_id: item_id, item_seq, summary_turn_item, - None, - None, - ); - if let Err(error) = self.rollout_store.append_item(&record, item_record) { - tracing::warn!( - session_id = %session_id, - error = %error, - "failed to persist compaction summary item" - ); - } - if let Err(error) = self.rollout_store.append_compaction_snapshot( - &record, - runtime_session - .latest_compaction_snapshot - .clone() - .expect("compaction snapshot should be set"), - ) { - tracing::warn!( - session_id = %session_id, - error = %error, - "failed to persist compaction snapshot" - ); - } - } - let summary = runtime_session.summary.clone(); - session_handle - .replace_state( - crate::runtime::session_actor::SessionActorState::from_runtime_session( - runtime_session, - ), - ) - .await; - drop(state_change_guard); - self.run_session_hook( - session_id, - devo_core::HookEvent::PostCompact, - serde_json::Map::from_iter([ - ("trigger".to_string(), serde_json::json!("manual")), - ( - "compact_summary".to_string(), - serde_json::Value::String(compact_summary), - ), - ]), - ) - .await; - tracing::info!(session_id = %session_id, "session compaction completed with replacement"); - self.broadcast_event(ServerEvent::SessionCompactionCompleted( - SessionEventPayload { session: summary }, - )) - .await; - return; + snapshot, + }, + ); } + let mut completed_turn = turn.clone(); + completed_turn.status = TurnStatus::Completed; + completed_turn.completed_at = Some(Utc::now()); + runtime_session.active_turn = None; + runtime_session.latest_turn = Some(completed_turn.clone()); + runtime_session.summary.status = SessionRuntimeStatus::Idle; let summary = runtime_session.summary.clone(); session_handle .replace_state( @@ -361,172 +675,245 @@ impl ServerRuntime { ) .await; drop(state_change_guard); - tracing::info!(session_id = %session_id, "session compaction completed with replacement"); + self.clear_active_turn_runtime_handles(session_id).await; + if let Some(persistence) = session_handle.turn_persistence_snapshot().await + && persistence.record.is_some() + && let Err(error) = self + .persist_turn_line_deduped(session_id, &completed_turn) + .await + { + tracing::warn!( + session_id = %session_id, + turn_id = %completed_turn.turn_id, + error = %error, + "failed to persist compaction turn completion" + ); + } + self.run_session_hook( + session_id, + devo_core::HookEvent::PostCompact, + serde_json::Map::from_iter([ + ("trigger".to_string(), serde_json::json!("manual")), + ( + "compact_summary".to_string(), + serde_json::Value::String(compact_summary), + ), + ]), + ) + .await; + tracing::info!( + session_id = %session_id, + turn_id = %completed_turn.turn_id, + "session compaction completed with replacement" + ); + if let Some(occupancy) = summary.last_context_occupancy.clone() { + self.broadcast_event(ServerEvent::ContextUsageUpdated( + crate::ContextUsageUpdatedPayload { + session_id, + occupancy, + }, + )) + .await; + } self.broadcast_event(ServerEvent::SessionCompactionCompleted( SessionEventPayload { session: summary }, )) .await; + self.broadcast_event(ServerEvent::TurnCompleted(TurnEventPayload { + session_id, + turn: completed_turn.clone(), + })) + .await; + self.broadcast_event(ServerEvent::SessionStatusChanged( + SessionStatusChangedPayload { + session_id, + status: SessionRuntimeStatus::Idle, + }, + )) + .await; + self.record_terminal_turn_status( + completed_turn.turn_id, + TerminalTurnSnapshot::from_turn(&completed_turn), + ) + .await; } Ok(CompactAction::Skipped) => { - let Some(summary) = session_handle.summary().await else { - return; - }; drop(state_change_guard); - tracing::info!(session_id = %session_id, "session compaction completed without replacement"); - self.broadcast_event(ServerEvent::SessionCompactionCompleted( - SessionEventPayload { session: summary }, - )) + tracing::info!( + session_id = %session_id, + turn_id = %turn.turn_id, + "session compaction completed without replacement" + ); + self.finalize_manual_compaction_turn( + &session_handle, + session_id, + turn, + CompactionTurnOutcome::Skipped, + ) .await; } Err(error) => { drop(state_change_guard); - tracing::warn!(session_id = %session_id, error = %error, "session compaction failed"); - self.broadcast_event(ServerEvent::SessionCompactionFailed( - SessionCompactionFailedPayload { - session_id, + tracing::warn!( + session_id = %session_id, + turn_id = %turn.turn_id, + error = %error, + "session compaction failed" + ); + self.finalize_manual_compaction_turn( + &session_handle, + session_id, + turn, + CompactionTurnOutcome::Failed { message: format!("compaction failed: {error}"), }, - )) + ) .await; } } } - fn preserved_item_ids_from_compacted( - persisted_turn_items: &[crate::execution::PersistedTurnItem], - compacted_items: &[ResponseItem], - ) -> Vec { - let mut normalized_persisted_items = Vec::new(); - for item in persisted_turn_items { - if !crate::persistence::prompt_visible_persisted_turn_item(item) { - continue; - } + /// Terminalize a manual compaction turn when the task still owns `active_turn`. + /// + /// If interrupt already claimed the turn via `interrupt_active_turn`, this is a + /// no-op so we do not double-emit terminal events. + async fn finalize_manual_compaction_turn( + self: &Arc, + session_handle: &crate::runtime::session_actor::SessionHandle, + session_id: SessionId, + mut turn: TurnMetadata, + outcome: CompactionTurnOutcome, + ) { + // Ensure interrupt abort cannot drop us between claim and event emit. + self.detach_active_turn_abort(session_id).await; - // The compactor returns a summary followed by the prompt-visible suffix it - // kept verbatim. Normalize persisted items into that same response shape - // without allocating a short intermediate Vec for every journal item. - match &item.turn_item { - TurnItem::UserMessage(TextItem { text }) - | TurnItem::SteerInput(TextItem { text }) => { - normalized_persisted_items.push(( - item.item_id, - ResponseItem::Message(Message::user(text.clone())), - )); - } - TurnItem::AgentMessage(TextItem { text }) - | TurnItem::Plan(TextItem { text }) - | TurnItem::WebSearch(TextItem { text }) - | TurnItem::ImageGeneration(TextItem { text }) - | TurnItem::ContextCompaction(TextItem { text }) - | TurnItem::HookPrompt(TextItem { text }) => { - normalized_persisted_items.push(( - item.item_id, - ResponseItem::Message(Message::assistant_text(text.clone())), - )); - } - TurnItem::Reasoning(_) => {} - TurnItem::ToolCall(ToolCallItem { - tool_call_id, - tool_name, - input, - }) => { - normalized_persisted_items.push(( - item.item_id, - ResponseItem::ToolCall { - id: tool_call_id.clone(), - name: tool_name.clone(), - input: input.clone(), - }, - )); - } - TurnItem::ToolResult(ToolResultItem { - tool_call_id, - output, - is_error, - .. - }) => { - normalized_persisted_items.push(( - item.item_id, - ResponseItem::ToolCallOutput { - tool_use_id: tool_call_id.clone(), - content: match output { - serde_json::Value::String(text) => text.clone(), - other => other.to_string(), - }, - is_error: *is_error, - }, - )); - } - TurnItem::CommandExecution(CommandExecutionItem { - tool_call_id, - tool_name, - input, - output, - is_error, - .. - }) => { - normalized_persisted_items.push(( - item.item_id, - ResponseItem::ToolCall { - id: tool_call_id.clone(), - name: tool_name.clone(), - input: input.clone(), + let now = Utc::now(); + turn.completed_at = Some(now); + turn.status = match &outcome { + CompactionTurnOutcome::Skipped => TurnStatus::Completed, + CompactionTurnOutcome::Failed { .. } => TurnStatus::Failed, + CompactionTurnOutcome::Canceled => TurnStatus::Interrupted, + }; + + // Atomic claim: interrupt may have already taken `active_turn`. + if session_handle + .clear_active_turn_if_matches(turn.turn_id) + .await + != Some(true) + { + return; + } + session_handle.set_session_idle(Some(turn.clone())).await; + self.clear_active_turn_runtime_handles(session_id).await; + + if let Some(persistence) = session_handle.turn_persistence_snapshot().await + && persistence.record.is_some() + && let Err(error) = self.persist_turn_line_deduped(session_id, &turn).await + { + tracing::warn!( + session_id = %session_id, + turn_id = %turn.turn_id, + error = %error, + "failed to persist compaction turn terminal line" + ); + } + + match outcome { + CompactionTurnOutcome::Skipped => { + let Some(summary) = session_handle.summary().await else { + tracing::warn!( + session_id = %session_id, + turn_id = %turn.turn_id, + "compaction skipped but session summary unavailable" + ); + self.broadcast_event(ServerEvent::TurnCompleted(TurnEventPayload { + session_id, + turn: turn.clone(), + })) + .await; + self.broadcast_event(ServerEvent::SessionStatusChanged( + SessionStatusChangedPayload { + session_id, + status: SessionRuntimeStatus::Idle, }, - )); - normalized_persisted_items.push(( - item.item_id, - ResponseItem::ToolCallOutput { - tool_use_id: tool_call_id.clone(), - content: match output { - serde_json::Value::String(text) => text.clone(), - other => other.to_string(), - }, - is_error: *is_error, + )) + .await; + self.record_terminal_turn_status( + turn.turn_id, + TerminalTurnSnapshot::from_turn(&turn), + ) + .await; + return; + }; + if let Some(occupancy) = summary.last_context_occupancy.clone() { + self.broadcast_event(ServerEvent::ContextUsageUpdated( + crate::ContextUsageUpdatedPayload { + session_id, + occupancy, }, - )); + )) + .await; } - TurnItem::ToolProgress(_) - | TurnItem::ApprovalRequest(_) - | TurnItem::ApprovalDecision(_) - | TurnItem::TurnSummary(_) => {} + self.broadcast_event(ServerEvent::SessionCompactionCompleted( + SessionEventPayload { session: summary }, + )) + .await; + self.broadcast_event(ServerEvent::TurnCompleted(TurnEventPayload { + session_id, + turn: turn.clone(), + })) + .await; + } + CompactionTurnOutcome::Failed { message } => { + self.broadcast_event(ServerEvent::SessionCompactionFailed( + SessionCompactionFailedPayload { + session_id, + message, + }, + )) + .await; + self.broadcast_event(ServerEvent::TurnFailed(TurnFailedPayload { + session_id, + turn: turn.clone(), + error: None, + })) + .await; + self.broadcast_event(ServerEvent::TurnCompleted(TurnEventPayload { + session_id, + turn: turn.clone(), + })) + .await; + } + CompactionTurnOutcome::Canceled => { + self.broadcast_event(ServerEvent::SessionCompactionFailed( + SessionCompactionFailedPayload { + session_id, + message: "compaction canceled".to_string(), + }, + )) + .await; + self.broadcast_event(ServerEvent::TurnInterrupted(TurnEventPayload { + session_id, + turn: turn.clone(), + })) + .await; + self.broadcast_event(ServerEvent::TurnCompleted(TurnEventPayload { + session_id, + turn: turn.clone(), + })) + .await; } } - let preserved = compacted_items.get(1..).unwrap_or(&[]); - if preserved.is_empty() { - return Vec::new(); - } - let preserved_len = preserved.len(); - if normalized_persisted_items.len() < preserved_len { - return Vec::new(); - } - let suffix = - &normalized_persisted_items[normalized_persisted_items.len() - preserved_len..]; - if suffix.iter().map(|(_, item)| item).eq(preserved.iter()) { - suffix.iter().map(|(item_id, _)| *item_id).collect() - } else { - Vec::new() - } - } - fn summary_turn_item_from_compacted(compacted_items: &[ResponseItem]) -> TurnItem { - let summary_text = compacted_items - .first() - .and_then(|item| match item { - ResponseItem::Message(message) => { - message.content.iter().find_map(|block| match block { - devo_core::ContentBlock::Text { text } => Some(text.clone()), - devo_core::ContentBlock::Reasoning { .. } - | devo_core::ContentBlock::ProviderReasoning { .. } - | devo_core::ContentBlock::ToolUse { .. } - | devo_core::ContentBlock::HostedToolUse { .. } - | devo_core::ContentBlock::ToolResult { .. } => None, - }) - } - ResponseItem::Reason { text } => Some(text.clone()), - ResponseItem::ToolCall { .. } | ResponseItem::ToolCallOutput { .. } => None, - }) - .unwrap_or_default(); - TurnItem::ContextCompaction(TextItem { text: summary_text }) + self.broadcast_event(ServerEvent::SessionStatusChanged( + SessionStatusChangedPayload { + session_id, + status: SessionRuntimeStatus::Idle, + }, + )) + .await; + self.record_terminal_turn_status(turn.turn_id, TerminalTurnSnapshot::from_turn(&turn)) + .await; } } @@ -535,6 +922,7 @@ mod tests { use pretty_assertions::assert_eq; use super::*; + use devo_core::CommandExecutionItem; #[test] fn preserved_item_ids_match_complete_command_execution_pair() { @@ -569,10 +957,7 @@ mod tests { ]; assert_eq!( - ServerRuntime::preserved_item_ids_from_compacted( - &persisted_turn_items, - &compacted_items - ), + preserved_item_ids_from_compacted(&persisted_turn_items, &compacted_items), vec![command_item_id, command_item_id] ); } diff --git a/crates/server/src/runtime/handlers/queue.rs b/crates/server/src/runtime/handlers/queue.rs index feee815d..831c0af3 100644 --- a/crates/server/src/runtime/handlers/queue.rs +++ b/crates/server/src/runtime/handlers/queue.rs @@ -407,15 +407,6 @@ impl ServerRuntime { None, ) .await; - // The legacy queue broadcast goes through the session actor's - // mailbox, which a busy actor may not service until the turn ends — - // fire and forget, exactly like turn/start's queued path. - { - let runtime = Arc::clone(self); - tokio::spawn(async move { - runtime.broadcast_updated_queue(legacy_session_id).await; - }); - } serde_json::to_value(SuccessResponse { id: request_id, result: SessionQueueUpdateResult { entry }, @@ -512,15 +503,6 @@ impl ServerRuntime { None, ) .await; - // The legacy queue broadcast goes through the session actor's - // mailbox, which a busy actor may not service until the turn ends — - // fire and forget, exactly like turn/start's queued path. - { - let runtime = Arc::clone(self); - tokio::spawn(async move { - runtime.broadcast_updated_queue(legacy_session_id).await; - }); - } serde_json::to_value(SuccessResponse { id: request_id, result: SessionQueueRemoveResult {}, @@ -684,15 +666,6 @@ impl ServerRuntime { None, ) .await; - // The legacy queue broadcast goes through the session actor's - // mailbox, which a busy actor may not service until the turn ends — - // fire and forget, exactly like turn/start's queued path. - { - let runtime = Arc::clone(self); - tokio::spawn(async move { - runtime.broadcast_updated_queue(legacy_session_id).await; - }); - } self.emit_to_connection( connection_id, "serverRequest/resolved", @@ -713,9 +686,7 @@ impl ServerRuntime { } /// Broadcasts one canonical `queue/updated` notification to connections - /// subscribed to this session via the new subscription API. The legacy - /// `inputQueue/updated` broadcast stays on its own path - /// (`broadcast_updated_queue`) — TUI is unaffected. + /// subscribed to this session via the new subscription API. pub(crate) async fn broadcast_queue_updated( &self, session_id: SessionId, @@ -825,7 +796,10 @@ pub(crate) fn legacy_input_items(input: &[UserInput]) -> Result, UserInput::Text { text } => InputItem::Text { text: text.clone() }, UserInput::Skill { name } => InputItem::Skill { name: name.clone(), - path: std::path::PathBuf::from(name), + // Canonical skill input carries no path; an empty path keeps + // resolution name-based (a non-empty path would switch + // `find_skill` to exact path matching and never match). + path: std::path::PathBuf::new(), }, UserInput::LocalImage { path, .. } => InputItem::LocalImage { path: path.clone() }, UserInput::Mention { uri } => InputItem::Mention { diff --git a/crates/server/src/runtime/handlers/session.rs b/crates/server/src/runtime/handlers/session.rs index d41fc969..195ed81f 100644 --- a/crates/server/src/runtime/handlers/session.rs +++ b/crates/server/src/runtime/handlers/session.rs @@ -9,6 +9,41 @@ pub(crate) struct RuntimeSessionTurnCutOptions { pub(crate) created_at: chrono::DateTime, } +/// Resolve occupancy and latest-query usage for a history cut. +/// +/// Prefers a compaction snapshot that still applies to the kept turns; otherwise +/// uses the last kept turn's stored occupancy / query usage. +pub(crate) fn resolve_cut_occupancy_and_usage( + kept_turn_ids: &std::collections::HashSet, + last_kept_turn_id: Option, + turn_records_by_id: &std::collections::HashMap, + latest_compaction_snapshot: Option<&devo_core::CompactionSnapshotLine>, +) -> ( + Option, + Option, + Option, +) { + let cut_turn_record = + last_kept_turn_id.and_then(|turn_id| turn_records_by_id.get(&turn_id).cloned()); + let applicable_compaction = latest_compaction_snapshot + .filter(|snapshot| kept_turn_ids.contains(&snapshot.turn_id)) + .cloned(); + let occupancy = applicable_compaction + .as_ref() + .and_then(|snapshot| snapshot.context_occupancy.clone()) + .or_else(|| { + cut_turn_record + .as_ref() + .and_then(|turn| turn.context_occupancy.clone()) + }); + let latest_query_usage = cut_turn_record.as_ref().and_then(|turn| { + turn.latest_query_usage + .clone() + .or_else(|| turn.usage.clone()) + }); + (occupancy, latest_query_usage, applicable_compaction) +} + pub(crate) enum RuntimeSessionToolRegistryUpdate { KeepCurrent, ReplaceIfCwdMatches { @@ -88,9 +123,24 @@ impl ServerRuntime { prompt_token_estimate: 0, last_query_usage: None, last_query_total_tokens: 0, + last_context_occupancy: None, status: SessionRuntimeStatus::Idle, collaboration_mode: Default::default(), + effective_context_window: None, + permission_preset: None, }; + let global_compaction_limit = runtime_context + .config_store + .lock() + .expect("app config store mutex should not be poisoned") + .effective_config() + .compaction_token_limit; + let applied_compaction_limit = crate::runtime::context_occupancy::resolved_compaction_limit( + global_compaction_limit, + &initial_turn_config.model, + ); + let mut summary = summary; + summary.effective_context_window = Some(applied_compaction_limit); if let Some(record) = &record && let Err(error) = self.rollout_store.append_session_meta(record) { @@ -100,11 +150,15 @@ impl ServerRuntime { format!("failed to persist session metadata: {error}"), ); } - let core_session = runtime_context.new_session_state( + let mut core_session = runtime_context.new_session_state( session_id, params.cwd.clone(), params.additional_directories.clone(), ); + crate::runtime::context_occupancy::apply_resolved_compaction_limit( + &mut core_session.config, + applied_compaction_limit as usize, + ); let config = core_session.config.clone(); let pending_turn_queue = Arc::clone(&core_session.pending_turn_queue); let steer_input_queue = Arc::clone(&core_session.steer_input_queue); @@ -124,6 +178,7 @@ impl ServerRuntime { history_items: Vec::new(), persisted_turn_items: Vec::new(), latest_compaction_snapshot: None, + turn_records_by_id: std::collections::HashMap::new(), pending_turn_queue, steer_input_queue, agent_tool_policy: Default::default(), @@ -247,6 +302,7 @@ impl ServerRuntime { params.model.clone(), params.model_binding_id.clone(), params.reasoning_effort_selection.clone(), + params.collaboration_mode, ) .await else { @@ -332,6 +388,15 @@ impl ServerRuntime { "session does not exist", ); } + if let Some(record) = session_handle.record().await.flatten() + && let Err(error) = self.rollout_store.append_session_meta(&record) + { + return self.error_response( + request_id, + ProtocolErrorCode::InternalError, + format!("failed to persist session permissions update: {error}"), + ); + } serde_json::to_value(SuccessResponse { id: request_id, @@ -344,6 +409,161 @@ impl ServerRuntime { .expect("serialize session/permissions/update response") } + pub(crate) async fn handle_session_compaction_update( + &self, + request_id: serde_json::Value, + params: serde_json::Value, + ) -> serde_json::Value { + let params: SessionCompactionUpdateParams = match serde_json::from_value(params) { + Ok(params) => params, + Err(error) => { + return self.error_response( + request_id, + ProtocolErrorCode::InvalidParams, + format!("invalid session/compaction/update params: {error}"), + ); + } + }; + if params.effective_context_window == 0 { + return self.error_response( + request_id, + ProtocolErrorCode::InvalidParams, + "effectiveContextWindow must be at least 1", + ); + } + let Some(session_handle) = self.session(params.session_id).await else { + return self.error_response( + request_id, + ProtocolErrorCode::SessionNotFound, + "session does not exist", + ); + }; + let _state_change_guard = session_handle.lock_state_change().await; + let Some(summary) = session_handle.summary().await else { + return self.error_response( + request_id, + ProtocolErrorCode::SessionNotFound, + "session does not exist", + ); + }; + let model = { + let runtime_context = session_handle.runtime_context().await; + let catalog = runtime_context + .as_ref() + .map(|context| Arc::clone(&context.model_catalog)); + summary + .model + .as_deref() + .and_then(|slug| { + catalog + .as_ref() + .and_then(|catalog| catalog.get(slug).cloned()) + .or_else(|| self.deps.model_catalog.get(slug).cloned()) + }) + .or_else(|| { + summary.model_binding_id.as_deref().and_then(|binding| { + catalog + .as_ref() + .and_then(|catalog| catalog.get(binding).cloned()) + .or_else(|| self.deps.model_catalog.get(binding).cloned()) + }) + }) + }; + let Some(model) = model else { + return self.error_response( + request_id, + ProtocolErrorCode::InvalidParams, + "session model is unavailable for compaction update", + ); + }; + let context_window_tokens = u64::from(model.context_window.max(1)); + let recommended_token_limit = + u64::from(model.effective_context_window()).min(context_window_tokens); + + { + let mut store = self + .deps + .config_store + .lock() + .expect("app config store mutex should not be poisoned"); + if let Err(error) = store.set_compaction_token_limit(params.effective_context_window) { + return self.error_response( + request_id, + ProtocolErrorCode::InternalError, + format!("failed to persist compaction_token_limit: {error}"), + ); + } + } + self.deps.invalidate_workspace_contexts(); + + let global = Some(params.effective_context_window); + let handles = self.list_session_handles().await; + for handle in handles { + let Some(session_summary) = handle.summary().await else { + continue; + }; + let runtime_context = handle.runtime_context().await; + let catalog = runtime_context + .as_ref() + .map(|context| Arc::clone(&context.model_catalog)); + let Some(session_model) = session_summary + .model + .as_deref() + .and_then(|slug| { + catalog + .as_ref() + .and_then(|catalog| catalog.get(slug).cloned()) + .or_else(|| self.deps.model_catalog.get(slug).cloned()) + }) + .or_else(|| { + session_summary + .model_binding_id + .as_deref() + .and_then(|binding| { + catalog + .as_ref() + .and_then(|catalog| catalog.get(binding).cloned()) + .or_else(|| self.deps.model_catalog.get(binding).cloned()) + }) + }) + else { + continue; + }; + let applied = crate::runtime::context_occupancy::resolved_compaction_limit( + global, + &session_model, + ); + match handle + .apply_effective_context_window(applied as usize) + .await + { + Some(Ok(())) => { + self.broadcast_event(ServerEvent::SessionEffectiveContextWindowUpdated( + SessionEffectiveContextWindowUpdatedPayload { + session_id: session_summary.session_id, + effective_context_window: applied, + }, + )) + .await; + } + Some(Err(_)) | None => continue, + } + } + + let applied = crate::runtime::context_occupancy::resolved_compaction_limit(global, &model); + + serde_json::to_value(SuccessResponse { + id: request_id, + result: SessionCompactionUpdateResult { + session_id: params.session_id, + effective_context_window: applied, + recommended_token_limit, + context_window_tokens, + }, + }) + .expect("serialize session/compaction/update response") + } + pub(crate) async fn handle_session_sandbox_profile_update( &self, request_id: serde_json::Value, @@ -711,6 +931,26 @@ impl ServerRuntime { "failed to persist forked session metadata to database" ); } + if !summary.ephemeral { + let stats = crate::db::SessionStats { + total_input_tokens: 0, + total_output_tokens: 0, + total_tokens: 0, + total_cache_creation_tokens: 0, + total_cache_read_tokens: 0, + last_input_tokens: 0, + turn_count: 0, + prompt_token_estimate: summary.prompt_token_estimate, + last_context_occupancy: summary.last_context_occupancy.clone(), + }; + if let Err(err) = self.deps.db.update_stats(&forked_id, &stats) { + tracing::warn!( + session_id = %forked_id, + error = %err, + "failed to persist forked session token stats to database" + ); + } + } tracing::info!( connection_id, source_session_id = %params.session_id, @@ -830,6 +1070,26 @@ impl ServerRuntime { let latest_turn = rebuilt.latest_turn.clone(); let loaded_item_count = rebuilt.loaded_item_count; let history_items = rebuilt.history_items.clone(); + if !summary.ephemeral { + let stats = crate::db::SessionStats { + total_input_tokens: 0, + total_output_tokens: 0, + total_tokens: 0, + total_cache_creation_tokens: 0, + total_cache_read_tokens: 0, + last_input_tokens: 0, + turn_count: 0, + prompt_token_estimate: summary.prompt_token_estimate, + last_context_occupancy: summary.last_context_occupancy.clone(), + }; + if let Err(err) = self.deps.db.update_stats(¶ms.session_id, &stats) { + tracing::warn!( + session_id = %params.session_id, + error = %err, + "failed to persist rolled-back session token stats to database" + ); + } + } session_handle .replace_state(SessionActorState::from_runtime_session(rebuilt)) .await; @@ -887,12 +1147,14 @@ impl ServerRuntime { core_session.session_context = source_core_session.session_context.clone(); core_session.collaboration_mode = source_core_session.collaboration_mode; core_session.latest_turn_context = None; - core_session.total_input_tokens = source_core_session.total_input_tokens; - core_session.total_output_tokens = source_core_session.total_output_tokens; - core_session.total_cache_creation_tokens = source_core_session.total_cache_creation_tokens; - core_session.total_cache_read_tokens = source_core_session.total_cache_read_tokens; - core_session.last_input_tokens = source_core_session.last_input_tokens; - core_session.last_turn_tokens = source_core_session.last_turn_tokens; + // Fork/rollback starts a new cumulative ledger at the cut point. + core_session.total_input_tokens = 0; + core_session.total_output_tokens = 0; + core_session.total_tokens = 0; + core_session.total_cache_creation_tokens = 0; + core_session.total_cache_read_tokens = 0; + core_session.last_input_tokens = 0; + core_session.last_turn_tokens = 0; let mut rebuilt_history_items = Vec::new(); let mut rebuilt_messages = Vec::new(); @@ -913,7 +1175,29 @@ impl ServerRuntime { .filter(|item| matches!(item.turn_item, TurnItem::UserMessage(_))) .count(); - let latest_turn = if let Some(last_turn_id) = kept_items.last().map(|item| item.turn_id) { + let last_kept_turn_id = kept_items.last().map(|item| item.turn_id); + let kept_turn_ids: HashSet<_> = kept_items.iter().map(|item| item.turn_id).collect(); + let (cut_occupancy, latest_query_usage, applicable_compaction) = + resolve_cut_occupancy_and_usage( + &kept_turn_ids, + last_kept_turn_id, + &source.turn_records_by_id, + source.latest_compaction_snapshot.as_ref(), + ); + let cut_turn_record = + last_kept_turn_id.and_then(|turn_id| source.turn_records_by_id.get(&turn_id).cloned()); + let prompt_token_estimate = cut_occupancy + .as_ref() + .map(|occupancy| occupancy.total_tokens as usize) + .or_else(|| { + latest_query_usage + .as_ref() + .map(devo_protocol::TurnUsage::display_total_tokens) + }) + .unwrap_or(0); + core_session.prompt_token_estimate = prompt_token_estimate; + + let latest_turn = if let Some(last_turn_id) = last_kept_turn_id { source .latest_turn .clone() @@ -958,7 +1242,7 @@ impl ServerRuntime { request_thinking: source.summary.reasoning_effort_selection.clone(), started_at: source.summary.created_at, completed_at: Some(source.summary.updated_at), - usage: None, + usage: cut_turn_record.as_ref().and_then(|turn| turn.usage.clone()), stop_reason: None, failure_reason: None, }) @@ -967,11 +1251,6 @@ impl ServerRuntime { None }; - let latest_query_usage = source - .summary - .last_query_usage - .clone() - .or_else(|| latest_turn.as_ref().and_then(|turn| turn.usage.clone())); let updated_at = Utc::now(); let summary = crate::SessionMetadata { session_id, @@ -991,22 +1270,37 @@ impl ServerRuntime { model_binding_id: source.summary.model_binding_id.clone(), reasoning_effort_selection: source.summary.reasoning_effort_selection.clone(), reasoning_effort: source.summary.reasoning_effort, - total_input_tokens: source_core_session.total_input_tokens, - total_output_tokens: source_core_session.total_output_tokens, - total_tokens: source_core_session.total_tokens, - total_cache_creation_tokens: source_core_session.total_cache_creation_tokens, - total_cache_read_tokens: source_core_session.total_cache_read_tokens, - prompt_token_estimate: source_core_session.prompt_token_estimate, + total_input_tokens: 0, + total_output_tokens: 0, + total_tokens: 0, + total_cache_creation_tokens: 0, + total_cache_read_tokens: 0, + prompt_token_estimate, last_query_usage: latest_query_usage.clone(), - last_query_total_tokens: latest_query_usage + last_query_total_tokens: cut_occupancy .as_ref() - .map(devo_protocol::TurnUsage::display_total_tokens) + .map(|occupancy| occupancy.total_tokens as usize) + .or_else(|| { + latest_query_usage + .as_ref() + .map(devo_protocol::TurnUsage::display_total_tokens) + }) .unwrap_or(0), + last_context_occupancy: cut_occupancy.clone(), status: SessionRuntimeStatus::Idle, collaboration_mode: core_session.collaboration_mode, + effective_context_window: None, + permission_preset: None, }; drop(source_core_session); + let turn_records_by_id = source + .turn_records_by_id + .iter() + .filter(|(turn_id, _)| kept_turn_ids.contains(turn_id)) + .map(|(turn_id, record)| (*turn_id, record.clone())) + .collect(); + core_session.pending_turn_queue = Arc::clone(&source.pending_turn_queue); core_session.steer_input_queue = Arc::clone(&source.steer_input_queue); let config = core_session.config.clone(); @@ -1023,7 +1317,8 @@ impl ServerRuntime { loaded_item_count: u64::try_from(kept_items.len()).unwrap_or(u64::MAX), history_items: rebuilt_history_items, persisted_turn_items: kept_items, - latest_compaction_snapshot: None, + latest_compaction_snapshot: applicable_compaction, + turn_records_by_id, pending_turn_queue, steer_input_queue, agent_tool_policy: source.agent_tool_policy, @@ -1189,4 +1484,142 @@ mod tests { assert_eq!(kept, Vec::new()); } + + #[test] + fn cut_occupancy_uses_cut_turn_not_tip() { + use pretty_assertions::assert_eq; + + let turn_a = TurnId::new(); + let turn_b = TurnId::new(); + let occupancy_a = devo_protocol::canonical::item::ContextOccupancy::from_category_tokens( + /*context_window_tokens*/ 100_000, /*base*/ 10_000, /*skills*/ 0, + /*tools_builtin*/ 0, /*tools_mcp*/ 0, /*conversation*/ 20_000, + ); + let occupancy_b = devo_protocol::canonical::item::ContextOccupancy::from_category_tokens( + /*context_window_tokens*/ 100_000, /*base*/ 10_000, /*skills*/ 0, + /*tools_builtin*/ 0, /*tools_mcp*/ 0, /*conversation*/ 80_000, + ); + let mut records = std::collections::HashMap::new(); + records.insert( + turn_a, + devo_core::TurnRecord { + id: turn_a, + session_id: SessionId::new(), + sequence: 1, + started_at: Utc::now(), + completed_at: Some(Utc::now()), + status: TurnStatus::Completed, + kind: devo_core::TurnKind::Regular, + model: "m".into(), + model_binding_id: None, + reasoning_effort_selection: None, + request_model: "m".into(), + request_thinking: None, + input_token_estimate: None, + usage: Some(devo_protocol::TurnUsage { + input_tokens: 30, + output_tokens: 5, + cache_creation_input_tokens: None, + cache_read_input_tokens: None, + reasoning_output_tokens: None, + total_tokens: Some(35), + }), + latest_query_usage: Some(devo_protocol::TurnUsage { + input_tokens: 30, + output_tokens: 5, + cache_creation_input_tokens: None, + cache_read_input_tokens: None, + reasoning_output_tokens: None, + total_tokens: Some(35), + }), + context_occupancy: Some(occupancy_a.clone()), + stop_reason: None, + failure_reason: None, + error: None, + session_context: None, + turn_context: None, + schema_version: 4, + }, + ); + records.insert( + turn_b, + devo_core::TurnRecord { + id: turn_b, + session_id: SessionId::new(), + sequence: 2, + started_at: Utc::now(), + completed_at: Some(Utc::now()), + status: TurnStatus::Completed, + kind: devo_core::TurnKind::Regular, + model: "m".into(), + model_binding_id: None, + reasoning_effort_selection: None, + request_model: "m".into(), + request_thinking: None, + input_token_estimate: None, + usage: None, + latest_query_usage: Some(devo_protocol::TurnUsage { + input_tokens: 90, + output_tokens: 10, + cache_creation_input_tokens: None, + cache_read_input_tokens: None, + reasoning_output_tokens: None, + total_tokens: Some(100), + }), + context_occupancy: Some(occupancy_b.clone()), + stop_reason: None, + failure_reason: None, + error: None, + session_context: None, + turn_context: None, + schema_version: 4, + }, + ); + + let kept = [turn_a].into_iter().collect(); + let (occupancy, usage, compact) = + resolve_cut_occupancy_and_usage(&kept, Some(turn_a), &records, None); + assert_eq!(occupancy, Some(occupancy_a.clone())); + assert_eq!(usage.map(|u| u.input_tokens), Some(30)); + assert!(compact.is_none()); + + // Compaction on a discarded tip turn must not override the cut turn. + let tip_only_snapshot = devo_core::CompactionSnapshotLine { + timestamp: Utc::now(), + session_id: SessionId::new(), + turn_id: turn_b, + summary_item_id: ItemId::new(), + preserved_item_ids: Vec::new(), + context_occupancy: Some(occupancy_b.clone()), + }; + let (occupancy, usage, compact) = resolve_cut_occupancy_and_usage( + &kept, + Some(turn_a), + &records, + Some(&tip_only_snapshot), + ); + assert_eq!(occupancy, Some(occupancy_a)); + assert_eq!(usage.map(|u| u.input_tokens), Some(30)); + assert!(compact.is_none()); + + let kept_all = [turn_a, turn_b].into_iter().collect(); + let compact_occupancy = + devo_protocol::canonical::item::ContextOccupancy::from_category_tokens( + /*context_window_tokens*/ 100_000, /*base*/ 10_000, /*skills*/ 0, + /*tools_builtin*/ 0, /*tools_mcp*/ 0, /*conversation*/ 5_000, + ); + let snapshot = devo_core::CompactionSnapshotLine { + timestamp: Utc::now(), + session_id: SessionId::new(), + turn_id: turn_b, + summary_item_id: ItemId::new(), + preserved_item_ids: Vec::new(), + context_occupancy: Some(compact_occupancy.clone()), + }; + let (occupancy, usage, compact) = + resolve_cut_occupancy_and_usage(&kept_all, Some(turn_b), &records, Some(&snapshot)); + assert_eq!(occupancy, Some(compact_occupancy)); + assert_eq!(usage.map(|u| u.input_tokens), Some(90)); + assert_eq!(compact.as_ref().map(|s| s.turn_id), Some(turn_b)); + } } diff --git a/crates/server/src/runtime/handlers/subscription.rs b/crates/server/src/runtime/handlers/subscription.rs index d71bf3e8..06cc97df 100644 --- a/crates/server/src/runtime/handlers/subscription.rs +++ b/crates/server/src/runtime/handlers/subscription.rs @@ -29,6 +29,7 @@ use devo_protocol::canonical::turn::TurnStatus; use uuid::Uuid; use super::super::*; +use super::queue::canonical_queue_entries; use crate::db::QueueType; /// One server-side subscription record (registry entry, 08 §4). @@ -470,7 +471,7 @@ impl ServerRuntime { let stream_id = selector_stream_id(selector); match selector { StreamSelector::Session { session_id } => { - let Some(rollout_path) = self.resolve_rollout_path(session_id).await else { + let Some(rollout_path) = self.snapshot_rollout_path(session_id).await else { return Ok(None); }; let history = devo_core::read_canonical_history(&rollout_path) @@ -486,7 +487,8 @@ impl ServerRuntime { .cloned() .map(Box::new); let queue = self - .queue_entries(session_id) + .snapshot_queue_entries(session_id) + .await .map_err(|error| format!("failed to read session queue: {error}"))?; Ok(Some(StreamSnapshot { stream_id, @@ -538,6 +540,67 @@ impl ServerRuntime { Ok(sessions) } + /// Mailbox-free rollout-path resolution for the subscription critical + /// section. `handle_subscription_create` holds the `connections` lock + /// across `build_snapshot`, and the session actor may be parked + /// broadcasting into that same lock (`broadcast_event` takes it on every + /// turn event), so waiting on the actor mailbox here — as + /// `resolve_rollout_path` does — is an ABBA deadlock. The in-flight + /// turn's inline record is read under `try_lock` only: on contention we + /// fall through to the persisted sources (SQLite index, then a rollout + /// store scan) rather than risk a lock-ordering inversion with + /// turn-event persistence. + async fn snapshot_rollout_path( + &self, + session_id: &CanonicalSessionId, + ) -> Option { + let legacy_id = SessionId::try_from(session_id.as_str()).ok()?; + if let Some(stream) = self.active_stream_state(legacy_id).await + && let Ok(stream) = stream.try_lock() + && let Some(inline) = stream.turn_inline.as_ref() + && let Some(record) = inline.record.clone() + { + return Some(record.rollout_path); + } + if let Ok(Some(index)) = self.deps.db.get_session_index(&legacy_id) + && let Some(path) = index.rollout_path + { + return Some(path); + } + self.rollout_store + .find_rollout_by_session_id(&legacy_id) + .ok() + .flatten() + } + + /// Queue source for session snapshots: the session's in-memory turn + /// queue while a turn is active (same source and 1-based positions as + /// `session/queue/list` and `queue/updated`, and the only source for + /// ephemeral sessions that skip DB writes). Falls back to the persisted + /// SQLite queue otherwise (idle or never-resumed sessions; the DB mirror + /// is written through on every mutation for durable sessions). + /// + /// Locking: `build_snapshot` runs inside the `connections` critical + /// section, so this must never wait on the session-actor mailbox — the + /// actor may be parked broadcasting into that same lock. The spawn + /// snapshot comes from the runtime registry only (mailbox-free), and the + /// queue std::Mutex is held only for the entry build. + async fn snapshot_queue_entries( + &self, + session_id: &CanonicalSessionId, + ) -> anyhow::Result> { + let legacy_id = SessionId::try_from(session_id.as_str()) + .map_err(|error| anyhow::anyhow!("invalid session id: {error}"))?; + if let Some(spawn) = self.active_spawn_snapshot_for_session(legacy_id).await { + let queue = spawn + .pending_turn_queue + .lock() + .expect("pending turn queue mutex should not be poisoned"); + return Ok(canonical_queue_entries(&queue)); + } + self.queue_entries(session_id) + } + fn queue_entries(&self, session_id: &CanonicalSessionId) -> anyhow::Result> { let legacy_id = SessionId::try_from(session_id.as_str()) .map_err(|error| anyhow::anyhow!("invalid session id: {error}"))?; @@ -549,7 +612,9 @@ impl ServerRuntime { let (input, preview) = queue_entry_content(&item); QueueEntry { queue_item_id: QueueItemId::from_legacy_uuid(Uuid::from(item.id)), - position: index as u32, + // 1-based, matching `canonical_queue_entries` (in-memory + // path) and `session/queue/list`. + position: (index + 1) as u32, input, preview, enqueued_at: item.created_at, diff --git a/crates/server/src/runtime/handlers/turn.rs b/crates/server/src/runtime/handlers/turn.rs index f780c349..e1c1554e 100644 --- a/crates/server/src/runtime/handlers/turn.rs +++ b/crates/server/src/runtime/handlers/turn.rs @@ -1,9 +1,4 @@ use super::super::*; -use crate::TurnInputDisposition; -use crate::TurnQueueRemoveParams; -use crate::TurnQueueRemoveResult; -use crate::TurnQueueSteerParams; -use crate::TurnQueueSteerResult; fn pending_turn_metadata( collaboration_mode: devo_protocol::CollaborationMode, @@ -83,8 +78,12 @@ impl ServerRuntime { "session does not exist", ); }; - let state_change_guard = session_handle.lock_state_change().await; - let Some(reservation) = self + // A busy session needs no state-change gate to enqueue: the queue + // mutex is the serialization point for queue ops, and the gate can + // be held for the rest of a turn (final title generation parking + // on the busy actor mailbox) or across a compaction provider call, + // which would park every push behind it without responding. + let Some(mut reservation) = self .session_turn_reservation_snapshot(params.session_id) .await else { @@ -94,6 +93,26 @@ impl ServerRuntime { "session does not exist", ); }; + // Only admitting a new turn must serialize against rollback, + // message edit, and compaction via the gate. Re-read the + // reservation under the gate: a turn may have started meanwhile. + let state_change_guard = if reservation.active_turn.is_none() { + let guard = session_handle.lock_state_change().await; + let Some(fresh) = self + .session_turn_reservation_snapshot(params.session_id) + .await + else { + return self.error_response( + request_id, + ProtocolErrorCode::SessionNotFound, + "session does not exist", + ); + }; + reservation = fresh; + Some(guard) + } else { + None + }; let workspace_root = params .cwd .clone() @@ -249,9 +268,13 @@ impl ServerRuntime { ); } let sid = params.session_id; + // The gate-free enqueue can race the post-turn drain: if the + // active turn ended between the snapshot and this push and the + // followup chain already found an empty queue, the entry would + // strand. Kick an idle-only drain; it no-ops otherwise. let runtime = Arc::clone(self); tokio::spawn(async move { - runtime.broadcast_updated_queue(sid).await; + runtime.drain_queue_if_idle(sid).await; }); return serde_json::to_value(SuccessResponse { id: request_id, @@ -357,14 +380,6 @@ impl ServerRuntime { ); } - self.broadcast_event(ServerEvent::InputQueueUpdated( - devo_core::InputQueueUpdatedPayload { - session_id: params.session_id, - pending_count: 0, - pending_texts: vec![], - }, - )) - .await; let runtime = Arc::clone(self); let turn_for_task = turn.clone(); let display_input_for_task = display_input.clone(); @@ -599,441 +614,6 @@ impl ServerRuntime { .expect("serialize turn/shell_command response") } - pub(crate) async fn handle_turn_steer( - &self, - connection_id: u64, - request_id: serde_json::Value, - params: serde_json::Value, - ) -> serde_json::Value { - let params: TurnSteerParams = match serde_json::from_value(params) { - Ok(params) => params, - Err(error) => { - return self.error_response( - request_id, - ProtocolErrorCode::InvalidParams, - format!("invalid turn/steer params: {error}"), - ); - } - }; - if params.input.is_empty() { - return self.error_response( - request_id, - ProtocolErrorCode::EmptyInput, - "turn steer input is empty", - ); - } - let Some(display_input) = render_input_items(¶ms.input) else { - return self.error_response( - request_id, - ProtocolErrorCode::EmptyInput, - "turn steer input is empty", - ); - }; - let Some(_session_handle) = self.session(params.session_id).await else { - return self.error_response( - request_id, - ProtocolErrorCode::SessionNotFound, - "session does not exist", - ); - }; - let Some(reservation) = self - .session_turn_reservation_snapshot(params.session_id) - .await - else { - return self.error_response( - request_id, - ProtocolErrorCode::SessionNotFound, - "session does not exist", - ); - }; - let workspace_root = reservation.summary.cwd.clone(); - let runtime_context = reservation.runtime_context; - let resolved_input = match runtime_context - .resolve_input_items(¶ms.input, Some(workspace_root.as_path())) - { - Ok(Some(resolved_input)) => resolved_input, - Ok(None) => { - return self.error_response( - request_id, - ProtocolErrorCode::EmptyInput, - "turn steer input is empty", - ); - } - Err(error) => { - let code = match error { - devo_core::SkillError::SkillNotFound { .. } - | devo_core::SkillError::AmbiguousSkillName { .. } - | devo_core::SkillError::SkillDisabled { .. } => { - ProtocolErrorCode::InvalidParams - } - devo_core::SkillError::SkillParseFailed { .. } - | devo_core::SkillError::SkillRootUnavailable { .. } - | devo_core::SkillError::DuplicateSkillId { .. } => { - ProtocolErrorCode::InternalError - } - }; - return self.error_response( - request_id, - code, - format!("failed to resolve turn steer input: {error}"), - ); - } - }; - - // The turn having just ended is the ONLY race that degrades (01 - // §4.3): the input must not be lost, so it lands in the session - // queue instead of erroring. Genuine steerability violations - // (wrong turn, wrong kind) still error below. - if reservation.active_turn.is_none() { - let item = devo_core::PendingInputItem::new( - devo_core::PendingInputKind::UserInput { - input: params.input.clone(), - display_text: display_input.clone(), - prompt_text: resolved_input.prompt_text, - prompt_messages: resolved_input.prompt_messages, - }, - None, - chrono::Utc::now(), - ); - reservation - .pending_turn_queue - .lock() - .expect("pending turn queue mutex should not be poisoned") - .push_back(item.clone()); - if !reservation.ephemeral - && let Err(err) = - self.deps - .db - .push_pending(¶ms.session_id, QueueType::Turn, &item) - { - tracing::warn!( - session_id = %params.session_id, - error = %err, - "failed to persist degraded steer input to database" - ); - } - self.broadcast_queue_updated( - params.session_id, - devo_protocol::canonical::queue::QueueChange::Added, - devo_protocol::canonical::ids::QueueItemId::from_legacy_uuid(uuid::Uuid::from( - item.id, - )), - None, - ) - .await; - self.broadcast_updated_queue(params.session_id).await; - return serde_json::to_value(SuccessResponse { - id: request_id, - result: TurnSteerResult { - turn_id: params.expected_turn_id, - disposition: TurnInputDisposition::Queued, - }, - }) - .expect("serialize turn/steer degraded response"); - } - let active_turn = reservation.active_turn.as_ref().expect("checked above"); - let turn_id = active_turn.turn_id; - if turn_id != params.expected_turn_id { - return self.error_response( - request_id, - ProtocolErrorCode::ExpectedTurnMismatch, - "active turn did not match expectedTurnId", - ); - } - if active_turn.kind != devo_core::TurnKind::Regular { - return self.error_response( - request_id, - ProtocolErrorCode::ActiveTurnNotSteerable, - "cannot steer a non-regular turn", - ); - } - - self.emit_turn_item( - params.session_id, - turn_id, - ItemKind::UserMessage, - TurnItem::SteerInput(TextItem { - text: display_input.clone(), - }), - serde_json::json!({ "title": "You", "text": display_input.clone() }), - ) - .await; - let item = devo_core::PendingInputItem::new( - devo_core::PendingInputKind::UserInput { - input: params.input.clone(), - display_text: display_input, - prompt_text: resolved_input.prompt_text, - prompt_messages: resolved_input.prompt_messages, - }, - None, - chrono::Utc::now(), - ); - reservation - .steer_input_queue - .lock() - .expect("steer input queue mutex should not be poisoned") - .push_back(item.clone()); - - if !reservation.ephemeral - && let Err(err) = self - .deps - .db - .push_pending(¶ms.session_id, QueueType::Steer, &item) - { - tracing::warn!( - session_id = %params.session_id, - error = %err, - "failed to persist steer input to database" - ); - } - - self.emit_to_connection( - connection_id, - "serverRequest/resolved", - ServerEvent::ServerRequestResolved(ServerRequestResolvedPayload { - session_id: params.session_id, - request_id: "steer-accepted".into(), - turn_id: Some(turn_id), - }), - ) - .await; - tracing::info!( - connection_id, - session_id = %params.session_id, - turn_id = %turn_id, - input_items = params.input.len(), - "accepted turn steer request" - ); - serde_json::to_value(SuccessResponse { - id: request_id, - result: TurnSteerResult { - turn_id, - disposition: TurnInputDisposition::Steered, - }, - }) - .expect("serialize turn/steer response") - } - - pub(crate) async fn handle_turn_queue_remove( - &self, - request_id: serde_json::Value, - params: serde_json::Value, - ) -> serde_json::Value { - let params: TurnQueueRemoveParams = match serde_json::from_value(params) { - Ok(params) => params, - Err(error) => { - return self.error_response( - request_id, - ProtocolErrorCode::InvalidParams, - format!("invalid turn/queue/remove params: {error}"), - ); - } - }; - let Some(session_handle) = self.session(params.session_id).await else { - return self.error_response( - request_id, - ProtocolErrorCode::SessionNotFound, - "session does not exist", - ); - }; - let Some(reservation) = self - .session_turn_reservation_snapshot(params.session_id) - .await - else { - return self.error_response( - request_id, - ProtocolErrorCode::SessionNotFound, - "session does not exist", - ); - }; - let is_ephemeral = reservation.ephemeral; - let removed = session_handle - .remove_queued_turn_input(params.queued_input_id) - .await - .unwrap_or(false); - if removed - && !is_ephemeral - && let Err(error) = self.deps.db.remove_pending_by_id( - ¶ms.session_id, - QueueType::Turn, - ¶ms.queued_input_id, - ) - { - tracing::warn!( - session_id = %params.session_id, - queued_input_id = %params.queued_input_id, - error = %error, - "failed to remove pending turn message from database" - ); - } - if removed { - self.broadcast_updated_queue(params.session_id).await; - } - serde_json::to_value(SuccessResponse { - id: request_id, - result: TurnQueueRemoveResult { removed }, - }) - .expect("serialize turn/queue/remove response") - } - - pub(crate) async fn handle_turn_queue_steer( - &self, - connection_id: u64, - request_id: serde_json::Value, - params: serde_json::Value, - ) -> serde_json::Value { - let params: TurnQueueSteerParams = match serde_json::from_value(params) { - Ok(params) => params, - Err(error) => { - return self.error_response( - request_id, - ProtocolErrorCode::InvalidParams, - format!("invalid turn/queue/steer params: {error}"), - ); - } - }; - let Some(_session_handle) = self.session(params.session_id).await else { - return self.error_response( - request_id, - ProtocolErrorCode::SessionNotFound, - "session does not exist", - ); - }; - let Some(reservation) = self - .session_turn_reservation_snapshot(params.session_id) - .await - else { - return self.error_response( - request_id, - ProtocolErrorCode::SessionNotFound, - "session does not exist", - ); - }; - let Some(active_turn) = reservation.active_turn.as_ref() else { - // The turn just ended (01 §4.3): the queued entry stays queued — - // the message is never lost. - return serde_json::to_value(SuccessResponse { - id: request_id, - result: TurnQueueSteerResult { - turn_id: params.expected_turn_id, - disposition: TurnInputDisposition::Queued, - }, - }) - .expect("serialize turn/queue/steer degraded response"); - }; - if active_turn.turn_id != params.expected_turn_id { - return self.error_response( - request_id, - ProtocolErrorCode::ExpectedTurnMismatch, - "active turn did not match expectedTurnId", - ); - } - if active_turn.kind != devo_core::TurnKind::Regular { - return self.error_response( - request_id, - ProtocolErrorCode::ActiveTurnNotSteerable, - "cannot steer a non-regular turn", - ); - } - let turn_id = active_turn.turn_id; - let is_ephemeral = reservation.ephemeral; - let queued = { - let mut queue = reservation - .pending_turn_queue - .lock() - .expect("pending turn queue mutex should not be poisoned"); - let Some(index) = queue - .iter() - .position(|item| item.id == params.queued_input_id) - else { - return self.error_response( - request_id, - ProtocolErrorCode::InvalidParams, - "queued input does not exist or cannot be steered", - ); - }; - let display_input = match &queue[index].kind { - devo_core::PendingInputKind::UserText { text } => text.clone(), - devo_core::PendingInputKind::UserInput { display_text, .. } => display_text.clone(), - _ => { - return self.error_response( - request_id, - ProtocolErrorCode::InvalidParams, - "queued input does not exist or cannot be steered", - ); - } - }; - let item = queue - .remove(index) - .expect("queued item index should remain valid"); - (display_input, item) - }; - let (display_input, item) = queued; - - reservation - .steer_input_queue - .lock() - .expect("steer input queue mutex should not be poisoned") - .push_back(item.clone()); - - if !is_ephemeral { - if let Err(error) = self.deps.db.remove_pending_by_id( - ¶ms.session_id, - QueueType::Turn, - ¶ms.queued_input_id, - ) { - tracing::warn!( - session_id = %params.session_id, - queued_input_id = %params.queued_input_id, - error = %error, - "failed to remove steered queued message from database" - ); - } - if let Err(error) = - self.deps - .db - .push_pending(¶ms.session_id, QueueType::Steer, &item) - { - tracing::warn!( - session_id = %params.session_id, - queued_input_id = %params.queued_input_id, - error = %error, - "failed to persist steered queued message to database" - ); - } - } - - self.emit_turn_item( - params.session_id, - turn_id, - ItemKind::UserMessage, - TurnItem::SteerInput(TextItem { - text: display_input.clone(), - }), - serde_json::json!({ "title": "You", "text": display_input }), - ) - .await; - self.broadcast_updated_queue(params.session_id).await; - self.emit_to_connection( - connection_id, - "serverRequest/resolved", - ServerEvent::ServerRequestResolved(ServerRequestResolvedPayload { - session_id: params.session_id, - request_id: "queued-steer-accepted".into(), - turn_id: Some(turn_id), - }), - ) - .await; - serde_json::to_value(SuccessResponse { - id: request_id, - result: TurnQueueSteerResult { - turn_id, - disposition: TurnInputDisposition::Steered, - }, - }) - .expect("serialize turn/queue/steer response") - } - pub(crate) async fn handle_events_subscribe( &self, connection_id: u64, diff --git a/crates/server/src/runtime/handlers/turn_interrupt.rs b/crates/server/src/runtime/handlers/turn_interrupt.rs index 3fb05840..00670240 100644 --- a/crates/server/src/runtime/handlers/turn_interrupt.rs +++ b/crates/server/src/runtime/handlers/turn_interrupt.rs @@ -51,6 +51,7 @@ impl ServerRuntime { .await; return self.turn_interrupt_success(request_id, params.turn_id, snapshot.status); } + eprintln!("dbg interrupt: before signal_active_turn_interrupt"); // Cancel before any session-actor mailbox round-trip: the actor may be blocked // waiting for a permission response and cannot process commands until cancelled. @@ -58,11 +59,13 @@ impl ServerRuntime { // `interrupt_child_runtime_work` for why removing here races with // `run_turn_model_query` fetching the same token. self.signal_active_turn_interrupt(params.session_id).await; + eprintln!("dbg interrupt: after signal"); let removed_len = self .session_interactive .clear_pending_user_inputs_for_turn(params.session_id, params.turn_id) .await; + eprintln!("dbg interrupt: after clear_pending"); if removed_len > 0 { tracing::info!( session_id = %params.session_id, @@ -75,10 +78,12 @@ impl ServerRuntime { Arc::clone(self) .interrupt_all_child_agents(params.session_id) .await; + eprintln!("dbg interrupt: after child agents"); // Out-of-actor turns (research): actor is free, so we can claim active_turn. // In-actor turns: finalize already cleared it; fall through to terminal wait. if let Some(interrupted_turn) = session_handle.interrupt_active_turn().await.flatten() { + eprintln!("dbg interrupt: claimed active turn"); if interrupted_turn.turn_id != params.turn_id { return self.error_response( request_id, @@ -95,6 +100,7 @@ impl ServerRuntime { ) .await; } + eprintln!("dbg interrupt: no claim, waiting for terminal"); let snapshot = match tokio::time::timeout(TURN_INTERRUPT_TERMINAL_TIMEOUT, terminal_rx).await { @@ -102,6 +108,19 @@ impl ServerRuntime { Ok(Err(_)) | Err(_) => { if let Some(snapshot) = self.recent_terminal_turn_status(params.turn_id).await { snapshot + } else if let Some(orphaned) = self + .recover_orphaned_manual_compaction_interrupt( + &session_handle, + params.session_id, + params.turn_id, + ) + .await + { + return self.turn_interrupt_success( + request_id, + params.turn_id, + orphaned.status, + ); } else { return self.error_response( request_id, @@ -122,6 +141,72 @@ impl ServerRuntime { self.turn_interrupt_success(request_id, params.turn_id, snapshot.status) } + /// Safety net when interrupt abort raced past a compaction task that already + /// cleared `active_turn` but never recorded a terminal status. + pub(crate) async fn recover_orphaned_manual_compaction_interrupt( + self: &Arc, + session_handle: &crate::runtime::session_actor::SessionHandle, + session_id: SessionId, + turn_id: TurnId, + ) -> Option { + let meta = self.active_turns.active_turn_metadata(session_id).await?; + if meta.turn_id != turn_id || meta.kind != devo_core::TurnKind::ManualCompaction { + return None; + } + if let Some(snapshot) = self.recent_terminal_turn_status(turn_id).await { + return Some(snapshot); + } + + // Wake any compaction task still holding `state_change_gate` inside + // `compact_history` so admission can proceed after recovery. + if let Some(cancel_token) = self.active_turns.cancel_token(session_id).await { + cancel_token.cancel(); + } + self.active_turns.abort_task(session_id).await; + + let mut interrupted_turn = meta; + interrupted_turn.status = TurnStatus::Interrupted; + interrupted_turn.completed_at = Some(Utc::now()); + session_handle + .set_session_idle(Some(interrupted_turn.clone())) + .await; + self.clear_active_turn_runtime_handles(session_id).await; + + tracing::warn!( + session_id = %session_id, + turn_id = %turn_id, + "recovered orphaned manual compaction interrupt" + ); + self.broadcast_event(ServerEvent::SessionCompactionFailed( + SessionCompactionFailedPayload { + session_id, + message: "compaction canceled".to_string(), + }, + )) + .await; + self.broadcast_event(ServerEvent::TurnInterrupted(TurnEventPayload { + session_id, + turn: interrupted_turn.clone(), + })) + .await; + self.broadcast_event(ServerEvent::TurnCompleted(TurnEventPayload { + session_id, + turn: interrupted_turn.clone(), + })) + .await; + self.broadcast_event(ServerEvent::SessionStatusChanged( + SessionStatusChangedPayload { + session_id, + status: SessionRuntimeStatus::Idle, + }, + )) + .await; + let snapshot = TerminalTurnSnapshot::from_turn(&interrupted_turn); + self.record_terminal_turn_status(turn_id, snapshot.clone()) + .await; + Some(snapshot) + } + async fn finalize_claimed_interrupted_turn( self: &Arc, request_id: serde_json::Value, @@ -129,9 +214,12 @@ impl ServerRuntime { session_id: SessionId, interrupted_turn: TurnMetadata, ) -> serde_json::Value { + eprintln!("dbg finalize: before clear_active_turn_runtime_handles"); self.clear_active_turn_runtime_handles(session_id).await; + eprintln!("dbg finalize: before take_deferred_items"); let deferred = session_handle.take_deferred_items().await; + eprintln!("dbg finalize: after take_deferred_items"); if let Some((item_id, item_seq, text)) = deferred.assistant && !text.trim().is_empty() { @@ -158,6 +246,7 @@ impl ServerRuntime { ) .await; } + eprintln!("dbg finalize: before turn_persistence_snapshot"); if let Some(persistence) = session_handle.turn_persistence_snapshot().await && persistence.record.is_some() && let Err(error) = self @@ -170,6 +259,7 @@ impl ServerRuntime { format!("failed to persist interrupted turn: {error}"), ); } + eprintln!("dbg finalize: after persist"); tracing::info!( session_id = %session_id, @@ -179,6 +269,18 @@ impl ServerRuntime { ); self.finalize_turn_workspace_changes(session_id, &interrupted_turn) .await; + eprintln!("dbg finalize: after workspace changes"); + if interrupted_turn.kind == devo_core::TurnKind::ManualCompaction { + // Manual compact dual-emits compaction lifecycle for existing UI; + // abort may drop the compaction task before it can emit this itself. + self.broadcast_event(ServerEvent::SessionCompactionFailed( + SessionCompactionFailedPayload { + session_id, + message: "compaction canceled".to_string(), + }, + )) + .await; + } self.broadcast_event(ServerEvent::TurnInterrupted(TurnEventPayload { session_id, turn: interrupted_turn.clone(), diff --git a/crates/server/src/runtime/hooks.rs b/crates/server/src/runtime/hooks.rs index 4ab40687..36981ae4 100644 --- a/crates/server/src/runtime/hooks.rs +++ b/crates/server/src/runtime/hooks.rs @@ -169,7 +169,7 @@ impl ServerRuntime { pub(crate) fn permission_mode_label(mode: devo_safety::PermissionMode) -> String { match mode { - devo_safety::PermissionMode::AutoApprove => "auto-approve", + devo_safety::PermissionMode::Yolo => "yolo", devo_safety::PermissionMode::Interactive => "interactive", devo_safety::PermissionMode::Deny => "deny", } diff --git a/crates/server/src/runtime/lifecycle.rs b/crates/server/src/runtime/lifecycle.rs index 07c1b0a2..ab71038b 100644 --- a/crates/server/src/runtime/lifecycle.rs +++ b/crates/server/src/runtime/lifecycle.rs @@ -62,9 +62,21 @@ impl ServerRuntime { core.total_tokens = stats.total_tokens; core.total_cache_creation_tokens = stats.total_cache_creation_tokens; core.total_cache_read_tokens = stats.total_cache_read_tokens; - core.last_input_tokens = stats.last_input_tokens; - core.last_turn_tokens = core.last_turn_tokens.max(stats.last_input_tokens); core.prompt_token_estimate = stats.prompt_token_estimate; + // Align auto-compact pressure with UI / occupancy tip. Never + // inflate `last_turn_tokens` with turn-cumulative DB values — + // `last_input_tokens` is latest-query input only. + if let Some(occupancy) = stats.last_context_occupancy.as_ref() { + core.last_turn_tokens = occupancy.total_tokens as usize; + } + if stats.last_input_tokens > 0 { + core.last_input_tokens = stats.last_input_tokens; + } + } + if let Some(occupancy) = stats.last_context_occupancy { + runtime_session.summary.last_query_total_tokens = + occupancy.total_tokens as usize; + runtime_session.summary.last_context_occupancy = Some(occupancy); } tracing::debug!( session_id = %session_id, @@ -83,6 +95,7 @@ impl ServerRuntime { last_input_tokens: 0, turn_count: 0, prompt_token_estimate: runtime_session.summary.prompt_token_estimate, + last_context_occupancy: runtime_session.summary.last_context_occupancy.clone(), }; if let Err(err) = self.deps.db.update_stats(&session_id, &stats) { tracing::warn!( @@ -101,10 +114,13 @@ impl ServerRuntime { } } + // Restore the turn queue non-destructively: SQLite stays the durable + // mirror until items are consumed or removed, so a restart before the + // queue drains does not lose pending input. match self .deps .db - .drain_pending(&session_id, crate::db::QueueType::Turn) + .list_pending(&session_id, crate::db::QueueType::Turn) { Ok(items) => { if !items.is_empty() { diff --git a/crates/server/src/runtime/mcp.rs b/crates/server/src/runtime/mcp.rs index fb025c58..b8ea83f7 100644 --- a/crates/server/src/runtime/mcp.rs +++ b/crates/server/src/runtime/mcp.rs @@ -201,6 +201,7 @@ impl ServerRuntime { ); } } + self.deps.invalidate_workspace_contexts(); let server_id = McpServerId(name.to_string()); let process_context = &self.deps.process_context; diff --git a/crates/server/src/runtime/model_api.rs b/crates/server/src/runtime/model_api.rs index 614896a4..66283102 100644 --- a/crates/server/src/runtime/model_api.rs +++ b/crates/server/src/runtime/model_api.rs @@ -178,6 +178,7 @@ impl ServerRuntime { ); } } + self.deps.invalidate_workspace_contexts(); let config_options = self.acp_model_config_options_for_context(&runtime_context); serde_json::to_value(SuccessResponse { diff --git a/crates/server/src/runtime/provider_vendor_api.rs b/crates/server/src/runtime/provider_vendor_api.rs index 0d6d69a3..774cdf73 100644 --- a/crates/server/src/runtime/provider_vendor_api.rs +++ b/crates/server/src/runtime/provider_vendor_api.rs @@ -129,6 +129,8 @@ impl ServerRuntime { ); } }; + drop(store); + self.deps.invalidate_workspace_contexts(); serde_json::to_value(SuccessResponse { id: request_id, diff --git a/crates/server/src/runtime/session_actor/actor_loop.rs b/crates/server/src/runtime/session_actor/actor_loop.rs index 153f681e..47dd390d 100644 --- a/crates/server/src/runtime/session_actor/actor_loop.rs +++ b/crates/server/src/runtime/session_actor/actor_loop.rs @@ -20,6 +20,7 @@ use super::state::SessionActorState; use super::turn::execute_turn_in_actor; use crate::SessionRuntimeStatus; use crate::persistence::build_turn_record; +use crate::runtime::protocol_preset_from_safety; use crate::runtime::session_model_selection; pub(super) async fn run_session_actor( @@ -143,21 +144,17 @@ pub(super) async fn run_session_actor( .pending_turn_queue .lock() .expect("pending turn queue mutex should not be poisoned"); - let pending_texts: Vec = queue + let pending_count = queue .iter() - .filter_map(|item| match &item.kind { - devo_core::PendingInputKind::UserText { text } => Some(text.clone()), - devo_core::PendingInputKind::UserInput { display_text, .. } => { - Some(display_text.clone()) - } - _ => None, + .filter(|item| { + matches!( + &item.kind, + devo_core::PendingInputKind::UserText { .. } + | devo_core::PendingInputKind::UserInput { .. } + ) }) - .collect(); - let pending_count = pending_texts.len(); - let _ = reply.send(PendingQueueSnapshot { - pending_count, - pending_texts, - }); + .count(); + let _ = reply.send(PendingQueueSnapshot { pending_count }); } SessionCommand::PopQueuedTurnInput { require_idle_session, @@ -181,18 +178,6 @@ pub(super) async fn run_session_actor( .expect("pending turn queue mutex should not be poisoned") .push_back(item); } - SessionCommand::RemoveQueuedTurnInput { - queued_input_id, - reply, - } => { - let mut queue = state - .pending_turn_queue - .lock() - .expect("pending turn queue mutex should not be poisoned"); - let before = queue.len(); - queue.retain(|item| item.id != queued_input_id); - let _ = reply.send(queue.len() != before); - } SessionCommand::GetActiveTurnId { reply } => { let _ = reply.send(state.active_turn.as_ref().map(|turn| turn.turn_id)); } @@ -361,6 +346,7 @@ pub(super) async fn run_session_actor( } SessionCommand::UpdateCorePermissionMode { permission_mode } => { state.core.config.permission_mode = permission_mode; + state.config.permission_mode = permission_mode; } SessionCommand::UpdateRecordRolloutPath { rollout_path } => { if let Some(record) = state.record.as_mut() { @@ -408,17 +394,34 @@ pub(super) async fn run_session_actor( model, model_binding_id, reasoning_effort_selection, + collaboration_mode, reply, } => { let updated_at = Utc::now(); - state.summary.model = model.clone(); - state.summary.model_binding_id = model_binding_id.clone(); - state.summary.reasoning_effort_selection = reasoning_effort_selection.clone(); + // Mode-only updates omit model fields as null; do not wipe them. + let mode_only_update = model.is_none() + && model_binding_id.is_none() + && reasoning_effort_selection.is_none() + && collaboration_mode.is_some(); + if !mode_only_update { + state.summary.model = model.clone(); + state.summary.model_binding_id = model_binding_id.clone(); + state.summary.reasoning_effort_selection = reasoning_effort_selection.clone(); + } state.summary.updated_at = updated_at; + if let Some(mode) = collaboration_mode { + state.core.collaboration_mode = mode; + state.summary.collaboration_mode = mode; + } if let Some(record) = state.record.as_mut() { - record.model = model; - record.model_binding_id = model_binding_id; - record.reasoning_effort_selection = reasoning_effort_selection; + if !mode_only_update { + record.model = model; + record.model_binding_id = model_binding_id; + record.reasoning_effort_selection = reasoning_effort_selection; + } + if let Some(mode) = collaboration_mode { + record.collaboration_mode = Some(mode); + } record.updated_at = updated_at; } let _ = reply.send(state.summary.clone()); @@ -429,12 +432,32 @@ pub(super) async fn run_session_actor( state.core.config.permission_profile = profile.clone(); state.core.config.sandbox_profile = sandbox.clone(); state.config.permission_mode = profile.permission_mode(); - state.config.permission_profile = profile; + state.config.permission_profile = profile.clone(); state.config.sandbox_profile = sandbox; state.session_approval_cache = crate::execution::ApprovalGrantCache::default(); state.turn_approval_cache = crate::execution::ApprovalGrantCache::default(); + let preset = protocol_preset_from_safety(profile.preset); + state.summary.permission_preset = Some(preset); + let updated_at = Utc::now(); + state.summary.updated_at = updated_at; + if let Some(record) = state.record.as_mut() { + record.permission_preset = Some(preset); + record.updated_at = updated_at; + } let _ = reply.send(()); } + SessionCommand::ApplyEffectiveContextWindow { limit, reply } => { + state.core.config.effective_context_window_override = Some(limit); + state.core.config.token_budget.context_window = limit; + state.core.config.token_budget.auto_compact_token_limit = Some(limit); + state.config.effective_context_window_override = Some(limit); + state.config.token_budget.context_window = limit; + state.config.token_budget.auto_compact_token_limit = Some(limit); + // Applied window is session-local runtime state derived from the + // global config preference; do not persist as a session override. + state.summary.effective_context_window = Some(limit as u64); + let _ = reply.send(Ok(())); + } SessionCommand::ApplySandboxProfile { profile, reply } => { // Validation only; approval caches are intentionally preserved: // the sandbox profile does not widen tool permissions. @@ -546,6 +569,7 @@ pub(super) async fn run_session_actor( None, state.core.latest_turn_context.clone(), None, + None, ), state.core.session_context.clone(), ) diff --git a/crates/server/src/runtime/session_actor/commands.rs b/crates/server/src/runtime/session_actor/commands.rs index 66543163..cc7a0bea 100644 --- a/crates/server/src/runtime/session_actor/commands.rs +++ b/crates/server/src/runtime/session_actor/commands.rs @@ -70,10 +70,6 @@ pub(crate) enum SessionCommand { EnqueuePendingTurnInput { item: PendingInputItem, }, - RemoveQueuedTurnInput { - queued_input_id: devo_core::PendingInputId, - reply: oneshot::Sender, - }, GetActiveTurnId { reply: oneshot::Sender>, }, @@ -164,12 +160,17 @@ pub(crate) enum SessionCommand { model: Option, model_binding_id: Option, reasoning_effort_selection: Option, + collaboration_mode: Option, reply: oneshot::Sender, }, ApplyPermissionProfile { profile: devo_safety::RuntimePermissionProfile, reply: oneshot::Sender<()>, }, + ApplyEffectiveContextWindow { + limit: usize, + reply: oneshot::Sender>, + }, ApplySandboxProfile { profile: String, reply: oneshot::Sender>, diff --git a/crates/server/src/runtime/session_actor/handle.rs b/crates/server/src/runtime/session_actor/handle.rs index 596db8b7..5434bb2a 100644 --- a/crates/server/src/runtime/session_actor/handle.rs +++ b/crates/server/src/runtime/session_actor/handle.rs @@ -455,23 +455,6 @@ impl SessionHandle { .await; } - pub(crate) async fn remove_queued_turn_input( - &self, - queued_input_id: devo_core::PendingInputId, - ) -> Option { - let (reply_tx, reply_rx) = oneshot::channel(); - if !self - .send(SessionCommand::RemoveQueuedTurnInput { - queued_input_id, - reply: reply_tx, - }) - .await - { - return None; - } - reply_rx.await.ok() - } - pub(crate) async fn activate_queued_turn(&self, turn: TurnMetadata, turn_config: TurnConfig) { let _ = self .send(SessionCommand::ActivateQueuedTurn { turn, turn_config }) @@ -567,6 +550,7 @@ impl SessionHandle { model: Option, model_binding_id: Option, reasoning_effort_selection: Option, + collaboration_mode: Option, ) -> Option { let (reply_tx, reply_rx) = oneshot::channel(); if !self @@ -574,6 +558,7 @@ impl SessionHandle { model, model_binding_id, reasoning_effort_selection, + collaboration_mode, reply: reply_tx, }) .await @@ -600,6 +585,24 @@ impl SessionHandle { reply_rx.await.is_ok() } + /// Hot-updates the session auto-compaction token limit. + pub(crate) async fn apply_effective_context_window( + &self, + limit: usize, + ) -> Option> { + let (reply_tx, reply_rx) = oneshot::channel(); + if !self + .send(SessionCommand::ApplyEffectiveContextWindow { + limit, + reply: reply_tx, + }) + .await + { + return None; + } + reply_rx.await.ok() + } + /// Applies a new sandbox profile to the session. Returns `None` when the /// session actor is gone, `Some(Err(..))` when the profile name is invalid /// (state is left unchanged), and `Some(Ok(name))` with the canonical diff --git a/crates/server/src/runtime/session_actor/snapshots.rs b/crates/server/src/runtime/session_actor/snapshots.rs index 3ec6552c..8a0c7f08 100644 --- a/crates/server/src/runtime/session_actor/snapshots.rs +++ b/crates/server/src/runtime/session_actor/snapshots.rs @@ -68,7 +68,6 @@ pub(crate) struct TitleGenerationContext { #[derive(Clone, Default)] pub(crate) struct PendingQueueSnapshot { pub(crate) pending_count: usize, - pub(crate) pending_texts: Vec, } /// Fields returned by session/resume without locking the actor mailbox. diff --git a/crates/server/src/runtime/session_actor/state.rs b/crates/server/src/runtime/session_actor/state.rs index 4bb6e147..c9f5c92d 100644 --- a/crates/server/src/runtime/session_actor/state.rs +++ b/crates/server/src/runtime/session_actor/state.rs @@ -1,3 +1,4 @@ +use std::collections::HashMap; use std::collections::VecDeque; use std::sync::Arc; use std::sync::Mutex as StdMutex; @@ -6,6 +7,7 @@ use devo_core::SessionConfig; use devo_core::SessionRecord; use devo_core::SessionState; use devo_core::TurnId; +use devo_core::TurnRecord; use devo_core::tools::ToolRegistry; use devo_protocol::SessionId; @@ -79,6 +81,7 @@ pub(crate) struct SessionActorState { pub(crate) history_items: Vec, pub(crate) persisted_turn_items: Vec, pub(crate) latest_compaction_snapshot: Option, + pub(crate) turn_records_by_id: HashMap, pub(crate) pending_turn_queue: Arc>>, pub(crate) steer_input_queue: Arc>>, pub(crate) agent_tool_policy: devo_protocol::AgentToolPolicy, @@ -161,6 +164,7 @@ impl SessionActorState { history_items: session.history_items, persisted_turn_items: session.persisted_turn_items, latest_compaction_snapshot: session.latest_compaction_snapshot, + turn_records_by_id: session.turn_records_by_id, pending_turn_queue: session.pending_turn_queue, steer_input_queue: session.steer_input_queue, agent_tool_policy: session.agent_tool_policy, @@ -191,6 +195,7 @@ impl SessionActorState { history_items: self.history_items.clone(), persisted_turn_items: self.persisted_turn_items.clone(), latest_compaction_snapshot: self.latest_compaction_snapshot.clone(), + turn_records_by_id: self.turn_records_by_id.clone(), pending_turn_queue: Arc::clone(&self.pending_turn_queue), steer_input_queue: Arc::clone(&self.steer_input_queue), agent_tool_policy: self.agent_tool_policy, diff --git a/crates/server/src/runtime/session_actor/turn.rs b/crates/server/src/runtime/session_actor/turn.rs index 402cde96..a8c19e4f 100644 --- a/crates/server/src/runtime/session_actor/turn.rs +++ b/crates/server/src/runtime/session_actor/turn.rs @@ -33,6 +33,7 @@ pub(super) async fn execute_turn_in_actor( } = request; let spawn_snapshot = Arc::new(state.spawn_snapshot()); + eprintln!("dbg turn: register snapshot"); runtime .register_turn_spawn_snapshot(session_id, turn.turn_id, Arc::clone(&spawn_snapshot)) .await; @@ -41,10 +42,12 @@ pub(super) async fn execute_turn_in_actor( let mut stream = state.stream.lock().await; stream.turn_inline = Some(super::turn_inline::TurnInlineState::new(state, &turn)); } + eprintln!("dbg turn: register stream"); runtime .register_active_stream(session_id, Arc::clone(&state.stream)) .await; + eprintln!("dbg turn: prepare"); runtime .prepare_turn_execution_for_actor( state, @@ -53,6 +56,7 @@ pub(super) async fn execute_turn_in_actor( input_mode.emits_user_message(), ) .await; + eprintln!("dbg turn: prepared"); let (event_tx, event_rx) = mpsc::channel(QUERY_EVENT_CHANNEL_CAPACITY); let event_tool_registry = runtime.tool_registry_for_actor_state(state); @@ -86,6 +90,7 @@ pub(super) async fn execute_turn_in_actor( event_rx, ); + eprintln!("dbg turn: query start"); let query_outcome = runtime .run_turn_model_query(TurnModelQueryParams { state, @@ -99,7 +104,9 @@ pub(super) async fn execute_turn_in_actor( event_tx, }) .await; + eprintln!("dbg turn: query done"); let event_summary = event_task.await.ok(); + eprintln!("dbg turn: event task done"); let turn_id = turn.turn_id; runtime diff --git a/crates/server/src/runtime/session_actor/turn_inline.rs b/crates/server/src/runtime/session_actor/turn_inline.rs index e56974ba..7070ae53 100644 --- a/crates/server/src/runtime/session_actor/turn_inline.rs +++ b/crates/server/src/runtime/session_actor/turn_inline.rs @@ -1,5 +1,6 @@ use std::sync::Arc; +use devo_core::CompactionSnapshotLine; use devo_core::SessionRecord; use devo_core::TurnId; use devo_core::TurnKind; @@ -33,6 +34,7 @@ pub(crate) struct TurnInlineState { pub(crate) active_turn_usage: Option, pub(crate) collaboration_mode: CollaborationMode, pub(crate) hook_context: HookContextSnapshot, + pub(crate) latest_compaction_snapshot: Option, } impl TurnInlineState { @@ -59,6 +61,7 @@ impl TurnInlineState { summary: state.summary.clone(), config: state.config.clone(), }, + latest_compaction_snapshot: None, } } @@ -88,6 +91,9 @@ impl TurnInlineState { state.core.total_tokens = self.summary.total_tokens; state.core.total_cache_creation_tokens = self.summary.total_cache_creation_tokens; state.core.total_cache_read_tokens = self.summary.total_cache_read_tokens; + if let Some(snapshot) = self.latest_compaction_snapshot { + state.latest_compaction_snapshot = Some(snapshot); + } if let Some(active_turn) = state.active_turn.as_mut() && active_turn.turn_id == self.turn_id { diff --git a/crates/server/src/runtime/skills.rs b/crates/server/src/runtime/skills.rs index b50a30db..785c815a 100644 --- a/crates/server/src/runtime/skills.rs +++ b/crates/server/src/runtime/skills.rs @@ -22,6 +22,8 @@ impl ServerRuntime { }; let skills = match params.cwd.as_deref() { + // With cwd: workspace context (LRU-cached). Skill discovery uses that + // catalog so project skill overlays apply. Some(cwd) => match self.deps.context_for_workspace(cwd).await { Ok(runtime_context) => { runtime_context.discover_skills(Some(cwd), params.force_reload) diff --git a/crates/server/src/runtime/turn_exec/context_compaction.rs b/crates/server/src/runtime/turn_exec/context_compaction.rs index 492f9331..f7905fe1 100644 --- a/crates/server/src/runtime/turn_exec/context_compaction.rs +++ b/crates/server/src/runtime/turn_exec/context_compaction.rs @@ -1,5 +1,6 @@ use std::sync::Arc; +use devo_core::ResponseItem; use devo_core::{ItemId, SessionId, TurnId}; use super::super::ServerRuntime; @@ -41,12 +42,16 @@ impl ContextCompactionLifecycle { runtime: &Arc, session_id: SessionId, turn_id: TurnId, + compacted_items: Vec, ) { let Some(item_id) = self.item_id.take() else { return; }; + let item_seq = runtime + .persist_in_turn_compaction(session_id, turn_id, item_id, &compacted_items) + .await; runtime - .broadcast_event(completed_event(session_id, turn_id, item_id)) + .broadcast_event(completed_event(session_id, turn_id, item_id, item_seq)) .await; } @@ -100,6 +105,7 @@ pub(super) fn started_event( session_id, turn_id, item_id, + None, ServerEvent::ItemStarted, serde_json::json!({ "title": "Compaction started" }), ) @@ -109,11 +115,13 @@ pub(super) fn completed_event( session_id: SessionId, turn_id: TurnId, item_id: ItemId, + item_seq: Option, ) -> ServerEvent { item_event( session_id, turn_id, item_id, + item_seq, ServerEvent::ItemCompleted, serde_json::json!({ "title": "Context compacted" }), ) @@ -130,6 +138,7 @@ pub(super) fn failed_events( session_id, turn_id, item_id, + None, ServerEvent::ItemCompleted, serde_json::json!({ "title": "Compaction failed", @@ -148,6 +157,7 @@ fn item_event( session_id: SessionId, turn_id: TurnId, item_id: ItemId, + item_seq: Option, wrap: impl FnOnce(ItemEventPayload) -> ServerEvent, payload: serde_json::Value, ) -> ServerEvent { @@ -156,8 +166,8 @@ fn item_event( session_id, turn_id: Some(turn_id), item_id: Some(item_id), - seq: 0, - item_seq: None, + seq: item_seq.unwrap_or(0), + item_seq, }, item: ItemEnvelope { item_id, diff --git a/crates/server/src/runtime/turn_exec/event_stream.rs b/crates/server/src/runtime/turn_exec/event_stream.rs index c9de5487..b426e2dc 100644 --- a/crates/server/src/runtime/turn_exec/event_stream.rs +++ b/crates/server/src/runtime/turn_exec/event_stream.rs @@ -118,9 +118,14 @@ pub(crate) fn spawn_turn_event_stream( .start(&runtime, session_id, turn_for_events.turn_id) .await; } - devo_core::QueryEvent::ContextCompactionCompleted => { + devo_core::QueryEvent::ContextCompactionCompleted { compacted_items } => { context_compaction - .complete(&runtime, session_id, turn_for_events.turn_id) + .complete( + &runtime, + session_id, + turn_for_events.turn_id, + compacted_items, + ) .await; } devo_core::QueryEvent::ContextCompactionFailed { message } => { diff --git a/crates/server/src/runtime/turn_exec/finalize.rs b/crates/server/src/runtime/turn_exec/finalize.rs index 9521a901..f8ab4fe8 100644 --- a/crates/server/src/runtime/turn_exec/finalize.rs +++ b/crates/server/src/runtime/turn_exec/finalize.rs @@ -230,11 +230,27 @@ impl ServerRuntime { state.summary.total_cache_creation_tokens = session_total_cache_creation_tokens; state.summary.total_cache_read_tokens = session_total_cache_read_tokens; state.summary.prompt_token_estimate = session_prompt_token_estimate; + let last_input_for_stats = latest_query_usage + .as_ref() + .map(|usage| usage.input_tokens as usize) + .unwrap_or(session_last_input_tokens); if let Some(usage) = latest_query_usage { // Context length uses latest-query display total, not session // cumulative total_input/output/tokens. state.summary.last_query_usage = Some(usage.clone()); state.summary.last_query_total_tokens = usage.display_total_tokens(); + state.core.last_input_tokens = usage.input_tokens as usize; + state.core.last_turn_tokens = usage.display_total_tokens(); + if let Some(raw) = state.core.raw_context_breakdown { + let window = effective_context_window_tokens(state, self); + let occupancy = super::super::context_occupancy::occupancy_from_raw( + window, + raw, + usage.display_total_tokens() as u64, + ); + state.core.last_turn_tokens = occupancy.total_tokens as usize; + state.summary.last_context_occupancy = Some(occupancy); + } } state.core.total_input_tokens = session_total_input_tokens; state.core.total_output_tokens = session_total_output_tokens; @@ -248,13 +264,12 @@ impl ServerRuntime { total_tokens: session_total_tokens, total_cache_creation_tokens: session_total_cache_creation_tokens, total_cache_read_tokens: session_total_cache_read_tokens, - last_input_tokens: final_turn - .usage - .as_ref() - .map(|usage| usage.input_tokens as usize) - .unwrap_or(session_last_input_tokens), + // Persist latest-query input only — turn-aggregate usage would + // inflate auto-compact after resume via hydrate. + last_input_tokens: last_input_for_stats, turn_count: state.summary.updated_at.timestamp() as usize, prompt_token_estimate: session_prompt_token_estimate, + last_context_occupancy: state.summary.last_context_occupancy.clone(), }; if let Err(err) = self.deps.db.update_stats(&session_id, &stats) { tracing::warn!( @@ -344,9 +359,6 @@ impl ServerRuntime { ) .await; } - if !leftover.is_empty() { - self.broadcast_updated_queue(session_id).await; - } } async fn append_terminal_turn_record( @@ -360,8 +372,17 @@ impl ServerRuntime { let record = state.record.clone(); let turn_context = state.core.latest_turn_context.clone(); let session_context = state.core.session_context.clone(); - let mut turn_record = build_turn_record(final_turn, None, turn_context, latest_query_usage); + let mut turn_record = build_turn_record( + final_turn, + None, + turn_context, + latest_query_usage, + state.summary.last_context_occupancy.clone(), + ); turn_record.error = terminal_error; + state + .turn_records_by_id + .insert(turn_record.id, turn_record.clone()); if let Some(record) = record && let Err(error) = self.rollout_store.append_turn_deduped( &record, @@ -438,7 +459,54 @@ impl ServerRuntime { }, )) .await; + if let Some(occupancy) = state.summary.last_context_occupancy.clone() { + self.broadcast_event(crate::ServerEvent::ContextUsageUpdated( + crate::ContextUsageUpdatedPayload { + session_id, + occupancy, + }, + )) + .await; + } + } +} + +fn effective_context_window_tokens(state: &SessionActorState, runtime: &ServerRuntime) -> u64 { + let model = state + .core + .latest_turn_context + .as_ref() + .map(|context| &context.model) + .or_else(|| { + state + .summary + .model + .as_deref() + .and_then(|slug| runtime.deps.model_catalog.get(slug)) + }); + let Some(model) = model else { + return state + .core + .config + .effective_context_window_override + .or(state.core.config.token_budget.auto_compact_token_limit) + .map(|limit| limit as u64) + .unwrap_or(0); + }; + let global = runtime + .deps + .config_store + .lock() + .expect("app config store mutex should not be poisoned") + .effective_config() + .compaction_token_limit; + // Live session override (from a hot global apply) wins; otherwise resolve + // from the global preference / model default. + if let Some(limit) = state.core.config.effective_context_window_override { + let model_window = u64::from(model.context_window.max(1)); + return (limit as u64).min(model_window).max(1); } + crate::runtime::context_occupancy::resolved_compaction_limit(global, model) } fn append_terminal_history_items( diff --git a/crates/server/src/runtime/turn_exec/followup.rs b/crates/server/src/runtime/turn_exec/followup.rs index fc24eef0..79cb681a 100644 --- a/crates/server/src/runtime/turn_exec/followup.rs +++ b/crates/server/src/runtime/turn_exec/followup.rs @@ -6,6 +6,17 @@ use devo_core::{SessionId, TurnId, TurnStatus}; use super::super::*; use super::types::{ExecuteTurnRequest, QueuedTurnInput}; +/// Whether a queued-input drain may pop while a turn is still active. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(in crate::runtime) enum QueueDrainPolicy { + /// Pop regardless of active-turn state (post-turn chain, interrupt + /// recovery): the caller knows the turn is winding down. + Always, + /// Pop only when no turn is active, so a race-recovery kick can never + /// double-admit a turn. + OnlyWhenIdle, +} + impl ServerRuntime { /// Pop the first queued input and start a new turn in a background task. /// Used from the interrupt handler where the calling function must return @@ -13,18 +24,51 @@ impl ServerRuntime { pub(in crate::runtime) async fn spawn_next_turn_from_queue( self: &Arc, session_id: SessionId, + ) -> bool { + self.spawn_next_turn_from_queue_with_policy(session_id, QueueDrainPolicy::Always) + .await + } + + /// Starts a queued turn only when the session is idle. Gate-free busy + /// enqueues (`handle_turn_start_with_queue_policy`) can race the + /// post-turn drain: if the active turn ended between the pusher's + /// snapshot and the enqueue and the followup chain already found an + /// empty queue, this kick is what keeps the entry from stranding. + pub(in crate::runtime) async fn drain_queue_if_idle(self: &Arc, session_id: SessionId) { + let Some(reservation) = self.session_turn_reservation_snapshot(session_id).await else { + return; + }; + if reservation.active_turn.is_some() + || reservation + .pending_turn_queue + .lock() + .expect("pending turn queue mutex should not be poisoned") + .is_empty() + { + return; + } + self.spawn_next_turn_from_queue_with_policy(session_id, QueueDrainPolicy::OnlyWhenIdle) + .await; + } + + async fn spawn_next_turn_from_queue_with_policy( + self: &Arc, + session_id: SessionId, + policy: QueueDrainPolicy, ) -> bool { let Some(session_handle) = self.session(session_id).await else { return false; }; let state_change_guard = session_handle.lock_state_change().await; let Some(queued) = self - .pop_next_queued_turn_input(session_id, /*require_idle_session*/ false) + .pop_next_queued_turn_input( + session_id, + /*require_idle_session*/ matches!(policy, QueueDrainPolicy::OnlyWhenIdle), + ) .await else { return false; }; - self.broadcast_updated_queue(session_id).await; let Some((turn, turn_config)) = self.prepare_queued_turn_start(session_id, &queued).await else { return false; @@ -86,7 +130,6 @@ impl ServerRuntime { else { return false; }; - self.broadcast_updated_queue(session_id).await; let Some((turn, turn_config)) = self.prepare_queued_turn_start(session_id, &queued).await else { return false; @@ -126,24 +169,6 @@ impl ServerRuntime { true } - /// Read the current steering queue and broadcast its state to connected clients. - pub(in crate::runtime) async fn broadcast_updated_queue(&self, session_id: SessionId) { - let Some(session_handle) = self.session(session_id).await else { - return; - }; - let Some(snapshot) = session_handle.pending_queue_snapshot().await else { - return; - }; - self.broadcast_event(crate::ServerEvent::InputQueueUpdated( - devo_core::InputQueueUpdatedPayload { - session_id, - pending_count: snapshot.pending_count, - pending_texts: snapshot.pending_texts, - }, - )) - .await; - } - async fn pop_next_queued_turn_input( self: &Arc, session_id: SessionId, diff --git a/crates/server/src/runtime/turn_exec/query.rs b/crates/server/src/runtime/turn_exec/query.rs index c00448b8..87a17424 100644 --- a/crates/server/src/runtime/turn_exec/query.rs +++ b/crates/server/src/runtime/turn_exec/query.rs @@ -60,7 +60,8 @@ impl ServerRuntime { } super::super::TurnInputMode::HiddenGoalContinuation { goal } => Some(goal.clone()), }; - state.core.config.token_budget = turn_config.token_budget(); + state.core.config.token_budget = turn_config + .token_budget_for_session(state.core.config.effective_context_window_override); state.core.collaboration_mode = collaboration_mode; state.summary.collaboration_mode = collaboration_mode; if let Some(goal) = turn_goal { diff --git a/crates/server/src/runtime/turn_exec/tests.rs b/crates/server/src/runtime/turn_exec/tests.rs index 1a0f6269..c9e808a0 100644 --- a/crates/server/src/runtime/turn_exec/tests.rs +++ b/crates/server/src/runtime/turn_exec/tests.rs @@ -60,7 +60,7 @@ fn context_compaction_events_share_stable_item_lifecycle() { assert_eq!( vec![ started_event(session_id, turn_id, item_id), - completed_event(session_id, turn_id, item_id), + completed_event(session_id, turn_id, item_id, None), ], vec![ ServerEvent::ItemStarted(ItemEventPayload { @@ -496,7 +496,9 @@ async fn provider_retry_status_waits_for_channel_capacity() { fn lifecycle_and_control_query_events_are_must_deliver() { let events = [ devo_core::QueryEvent::ContextCompactionStarted, - devo_core::QueryEvent::ContextCompactionCompleted, + devo_core::QueryEvent::ContextCompactionCompleted { + compacted_items: Vec::new(), + }, devo_core::QueryEvent::ContextCompactionFailed { message: "context limit".to_string(), }, diff --git a/crates/server/src/runtime/turn_exec/trace.rs b/crates/server/src/runtime/turn_exec/trace.rs index 522f6fd7..e40fbaa9 100644 --- a/crates/server/src/runtime/turn_exec/trace.rs +++ b/crates/server/src/runtime/turn_exec/trace.rs @@ -22,7 +22,7 @@ pub(super) fn query_event_delivery_policy(event: &QueryEvent) -> QueryEventDeliv | QueryEvent::Usage { .. } => QueryEventDeliveryPolicy::BestEffort, QueryEvent::ProviderRetryStatus(_) | QueryEvent::ContextCompactionStarted - | QueryEvent::ContextCompactionCompleted + | QueryEvent::ContextCompactionCompleted { .. } | QueryEvent::ContextCompactionFailed { .. } | QueryEvent::TextDelta(_) | QueryEvent::ReasoningDelta(_) @@ -46,7 +46,7 @@ pub(super) fn query_event_trace_kind(event: &QueryEvent) -> &'static str { match event { QueryEvent::ProviderRetryStatus(_) => "provider_retry_status", QueryEvent::ContextCompactionStarted => "context_compaction_started", - QueryEvent::ContextCompactionCompleted => "context_compaction_completed", + QueryEvent::ContextCompactionCompleted { .. } => "context_compaction_completed", QueryEvent::ContextCompactionFailed { .. } => "context_compaction_failed", QueryEvent::TextDelta(_) => "text_delta", QueryEvent::ReasoningDelta(_) => "reasoning_delta", @@ -73,7 +73,7 @@ pub(super) fn query_event_trace_delta_len(event: &QueryEvent) -> usize { } => delta.len(), QueryEvent::ProviderRetryStatus(_) | QueryEvent::ContextCompactionStarted - | QueryEvent::ContextCompactionCompleted + | QueryEvent::ContextCompactionCompleted { .. } | QueryEvent::ContextCompactionFailed { .. } | QueryEvent::ReasoningCompleted | QueryEvent::ToolUseStart { .. } @@ -90,7 +90,7 @@ pub(super) fn query_event_trace_token_preview(event: &QueryEvent) -> Option assistant_token_log_preview(text), QueryEvent::ProviderRetryStatus(_) | QueryEvent::ContextCompactionStarted - | QueryEvent::ContextCompactionCompleted + | QueryEvent::ContextCompactionCompleted { .. } | QueryEvent::ContextCompactionFailed { .. } | QueryEvent::ReasoningDelta(_) | QueryEvent::ReasoningCompleted diff --git a/crates/server/src/runtime/turn_lifecycle.rs b/crates/server/src/runtime/turn_lifecycle.rs index 743aec2a..e200da5c 100644 --- a/crates/server/src/runtime/turn_lifecycle.rs +++ b/crates/server/src/runtime/turn_lifecycle.rs @@ -39,6 +39,13 @@ impl ServerRuntime { .await; } + /// Drop the abort handle so an interrupt cannot kill terminalization mid-flight. + /// + /// Cancellation via the turn token remains active; only hard abort is detached. + pub(crate) async fn detach_active_turn_abort(&self, session_id: SessionId) { + self.active_turns.remove_abort_handle(session_id).await; + } + /// Cancels and aborts the active turn for `session_id` without clearing the /// full runtime handle (used while waiting for terminal status). pub(crate) async fn signal_active_turn_interrupt(&self, session_id: SessionId) { diff --git a/crates/server/src/session.rs b/crates/server/src/session.rs index 9f0a7696..dad75590 100644 --- a/crates/server/src/session.rs +++ b/crates/server/src/session.rs @@ -1,9 +1,10 @@ pub use devo_protocol::{ - SessionCompactParams, SessionCompactResult, SessionForkParams, SessionForkResult, - SessionHistoryItem, SessionHistoryItemKind, SessionHistoryToolIo, SessionMetadata, - SessionMetadataUpdateParams, SessionMetadataUpdateResult, SessionPermissionsUpdateParams, - SessionPermissionsUpdateResult, SessionResumeParams, SessionResumeResult, SessionRollbackMode, - SessionRollbackParams, SessionRollbackResult, SessionRuntimeStatus, - SessionSandboxProfileUpdateParams, SessionSandboxProfileUpdateResult, SessionStartParams, - SessionStartResult, SessionTitleUpdateParams, SessionTitleUpdateResult, + SessionCompactParams, SessionCompactionUpdateParams, SessionCompactionUpdateResult, + SessionForkParams, SessionForkResult, SessionHistoryItem, SessionHistoryItemKind, + SessionHistoryToolIo, SessionMetadata, SessionMetadataUpdateParams, + SessionMetadataUpdateResult, SessionPermissionsUpdateParams, SessionPermissionsUpdateResult, + SessionResumeParams, SessionResumeResult, SessionRollbackMode, SessionRollbackParams, + SessionRollbackResult, SessionRuntimeStatus, SessionSandboxProfileUpdateParams, + SessionSandboxProfileUpdateResult, SessionStartParams, SessionStartResult, + SessionTitleUpdateParams, SessionTitleUpdateResult, }; diff --git a/crates/server/src/session_context.rs b/crates/server/src/session_context.rs index 64752091..d495e9c9 100644 --- a/crates/server/src/session_context.rs +++ b/crates/server/src/session_context.rs @@ -176,26 +176,50 @@ impl SessionRuntimeContext { .provider .is_operationally_equivalent_to(&inherited_config.provider) || config.provider_http != inherited_config.provider_http; - let (registry, mcp_manager) = if !has_provider_configuration - && !config.mcp.servers.iter().any(|record| record.enabled) - { - ( - Arc::clone(&inherited_context.registry), - Arc::clone(&inherited_context.mcp_manager), - ) + let workspace_cwd = workspace_root + .map(Path::to_path_buf) + .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))); + let workspace_mcp = config + .mcp + .clone() + .with_code_search_workspace_cwd(workspace_cwd.clone()); + let inherited_mcp = inherited_config + .mcp + .clone() + .with_code_search_workspace_cwd(workspace_cwd); + let mcp_runtime_equivalent = workspace_mcp.is_operationally_equivalent_to(&inherited_mcp); + let oauth_credentials_equivalent = config.mcp_oauth_credentials_store.unwrap_or_default() + == inherited_config + .mcp_oauth_credentials_store + .unwrap_or_default(); + let tool_plan = ToolPlanConfig::from_app_config(&config); + let inherited_tool_plan = ToolPlanConfig::from_app_config(&inherited_config); + // Reuse inherited MCP manager when config matches so we do not re-run + // discover_tools() (which starts lazy servers such as docker-backed MCP). + // Share the registry too when the tool plan is unchanged; otherwise rebuild + // the registry on top of the shared manager. + let (registry, mcp_manager) = if mcp_runtime_equivalent && oauth_credentials_equivalent { + if tool_plan == inherited_tool_plan { + ( + Arc::clone(&inherited_context.registry), + Arc::clone(&inherited_context.mcp_manager), + ) + } else { + let mcp_manager = Arc::clone(&inherited_context.mcp_manager); + let registry = Arc::new(StdMutex::new(Arc::new( + handlers::build_registry_from_plan_with_mcp( + &tool_plan, + Arc::clone(&mcp_manager), + ) + .await, + ))); + (registry, mcp_manager) + } } else { - let workspace_cwd = workspace_root - .map(Path::to_path_buf) - .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))); - let mcp_config = config - .mcp - .clone() - .with_code_search_workspace_cwd(workspace_cwd); let mcp_manager: Arc = Arc::new(RmcpMcpManager::new( - mcp_config, + workspace_mcp, config.mcp_oauth_credentials_store.unwrap_or_default(), )); - let tool_plan = ToolPlanConfig::from_app_config(&config); let registry = Arc::new(StdMutex::new(Arc::new( handlers::build_registry_from_plan_with_mcp(&tool_plan, Arc::clone(&mcp_manager)) .await, diff --git a/crates/server/src/turn.rs b/crates/server/src/turn.rs index f0d611af..268491f9 100644 --- a/crates/server/src/turn.rs +++ b/crates/server/src/turn.rs @@ -1,7 +1,5 @@ pub use devo_protocol::{ ActiveTurnSteeringState, CollaborationMode, InputItem, ShellCommandParams, ShellCommandResult, SteerInputRecord, TurnExecutionMode, TurnInputDisposition, TurnInterruptParams, - TurnInterruptResult, TurnKind, TurnMetadata, TurnQueueRemoveParams, TurnQueueRemoveResult, - TurnQueueSteerParams, TurnQueueSteerResult, TurnStartParams, TurnStartResult, TurnSteerParams, - TurnSteerResult, + TurnInterruptResult, TurnKind, TurnMetadata, TurnStartParams, TurnStartResult, }; diff --git a/crates/server/tests/persistence_resume.rs b/crates/server/tests/persistence_resume.rs index 91a26fc6..f21a4584 100644 --- a/crates/server/tests/persistence_resume.rs +++ b/crates/server/tests/persistence_resume.rs @@ -508,6 +508,121 @@ async fn resume_restores_plan_collaboration_mode_from_latest_turn() -> Result<() Ok(()) } +#[tokio::test] +async fn resume_restores_session_permission_preset_and_plan_mode_without_turn() -> Result<()> { + let data_root = TempDir::new()?; + let runtime = build_runtime(data_root.path())?; + let (connection_id, _notifications_rx) = initialize_connection(&runtime).await?; + + let start_response = runtime + .handle_incoming( + connection_id, + serde_json::json!({ + "id": 1, + "method": "session/start", + "params": { + "cwd": data_root.path(), + "ephemeral": false, + "title": "Session overrides", + "model": "test-model" + } + }), + ) + .await + .context("session/start response")?; + let start_result = serde_json::from_value::< + devo_server::SuccessResponse, + >(start_response)? + .result; + let session_id = start_result.session.session_id; + let started_model = start_result.session.model.clone(); + + let permissions_response = runtime + .handle_incoming( + connection_id, + serde_json::json!({ + "id": 2, + "method": "_devo/session/permissions/update", + "params": { + "session_id": session_id, + "preset": "full-access" + } + }), + ) + .await + .context("session/permissions/update response")?; + let permissions_result = serde_json::from_value::< + devo_server::SuccessResponse, + >(permissions_response)? + .result; + assert_eq!( + permissions_result.preset, + devo_protocol::PermissionPreset::FullAccess + ); + + let metadata_response = runtime + .handle_incoming( + connection_id, + serde_json::json!({ + "id": 3, + "method": "_devo/session/metadata/update", + "params": { + "session_id": session_id, + "collaboration_mode": "plan" + } + }), + ) + .await + .context("session/metadata/update response")?; + let metadata_result = serde_json::from_value::< + devo_server::SuccessResponse, + >(metadata_response)? + .result; + assert_eq!( + metadata_result.session.collaboration_mode, + devo_protocol::CollaborationMode::Plan + ); + assert_eq!( + metadata_result.session.permission_preset, + Some(devo_protocol::PermissionPreset::FullAccess) + ); + assert_eq!(metadata_result.session.model, started_model); + + drop(runtime); + let rebuilt_runtime = build_runtime(data_root.path())?; + rebuilt_runtime.load_persisted_sessions().await?; + let (connection_id, _notifications_rx) = initialize_connection(&rebuilt_runtime).await?; + + let resume_response = rebuilt_runtime + .handle_incoming( + connection_id, + serde_json::json!({ + "id": 4, + "method": "_devo/session/resume", + "params": { + "session_id": session_id + } + }), + ) + .await + .context("session/resume response")?; + let resume_result = serde_json::from_value::< + devo_server::SuccessResponse, + >(resume_response.clone()) + .with_context(|| format!("decode session/resume response: {resume_response}"))? + .result; + + assert_eq!( + resume_result.session.collaboration_mode, + devo_protocol::CollaborationMode::Plan + ); + assert_eq!( + resume_result.session.permission_preset, + Some(devo_protocol::PermissionPreset::FullAccess) + ); + Ok(()) +} + #[tokio::test] async fn runtime_generates_final_title_and_persists_explicit_rename() -> Result<()> { let data_root = TempDir::new()?; @@ -848,6 +963,7 @@ async fn resume_normalizes_historical_default_reasoning_effort() -> Result<()> { ), sandbox_policy: "workspace-write".into(), approval_mode: "on-request".into(), + effective_context_window: None, tokens_used: 0, first_user_message: None, archived_at: None, @@ -857,6 +973,8 @@ async fn resume_normalizes_historical_default_reasoning_effort() -> Result<()> { parent_session_id: None, session_context: None, latest_turn_context: None, + collaboration_mode: None, + permission_preset: None, schema_version: 2, }; let turn = TurnRecord { @@ -875,6 +993,7 @@ async fn resume_normalizes_historical_default_reasoning_effort() -> Result<()> { input_token_estimate: None, usage: None, latest_query_usage: None, + context_occupancy: None, stop_reason: None, failure_reason: None, error: None, @@ -992,6 +1111,7 @@ async fn failed_turn_resume_restores_terminal_history_without_prompt_contaminati ), sandbox_policy: "workspace-write".into(), approval_mode: "on-request".into(), + effective_context_window: None, tokens_used: 0, first_user_message: Some("failing prompt".into()), archived_at: None, @@ -1001,6 +1121,8 @@ async fn failed_turn_resume_restores_terminal_history_without_prompt_contaminati parent_session_id: None, session_context: None, latest_turn_context: None, + collaboration_mode: None, + permission_preset: None, schema_version: 2, }; let failed_running = TurnRecord { @@ -1019,6 +1141,7 @@ async fn failed_turn_resume_restores_terminal_history_without_prompt_contaminati input_token_estimate: None, usage: None, latest_query_usage: None, + context_occupancy: None, stop_reason: None, failure_reason: None, error: None, @@ -1374,11 +1497,15 @@ async fn session_compact_runs_asynchronously_and_emits_lifecycle_events() -> Res .await .context("session/compact response")?; let compact_result = serde_json::from_value::< - devo_server::SuccessResponse, + devo_server::SuccessResponse, >(compact_response)? .result; - assert_eq!(compact_result.session.session_id, session_id); + match compact_result { + devo_server::TurnStartResult::Started { .. } => {} + other => panic!("expected TurnStartResult::Started, got {other:?}"), + }; + wait_for_notification_method(&mut notifications_rx, "turn/started").await?; wait_for_notification_method(&mut notifications_rx, "session/compaction/started").await?; wait_for_notification_method(&mut notifications_rx, "session/compaction/completed").await?; Ok(()) @@ -1479,6 +1606,14 @@ async fn compacted_session_resume_keeps_full_transcript_after_restart() -> Resul "expected full transcript to survive compaction, got {:?}", resume_result.history_items ); + assert!( + resume_result.history_items.iter().any(|item| { + item.kind == SessionHistoryItemKind::ContextCompaction + && item.title == "Context compacted" + }), + "expected ContextCompaction history row after compact resume, got {:?}", + resume_result.history_items + ); assert!( resume_result .history_items @@ -1628,6 +1763,228 @@ async fn compacted_session_next_query_uses_compaction_summary_after_restart() -> Ok(()) } +/// High-usage streaming provider that also captures requests and returns a +/// compaction summary from the non-streaming completion path. +struct AutoCompactTestProvider { + requests: Mutex>, + input_tokens: usize, + output_tokens: usize, +} + +impl AutoCompactTestProvider { + fn new(input_tokens: usize, output_tokens: usize) -> Self { + Self { + requests: Mutex::new(Vec::new()), + input_tokens, + output_tokens, + } + } + + fn usage(&self) -> Usage { + Usage { + input_tokens: self.input_tokens, + output_tokens: self.output_tokens, + cache_creation_input_tokens: None, + cache_read_input_tokens: Some(self.input_tokens / 2), + reasoning_output_tokens: None, + total_tokens: Some(self.input_tokens + self.output_tokens), + } + } +} + +#[async_trait] +impl ModelProviderSDK for AutoCompactTestProvider { + async fn completion(&self, request: ModelRequest) -> Result { + self.requests.lock().expect("lock requests").push(request); + Ok(ModelResponse { + id: "compact-summary".into(), + content: vec![ResponseContent::Text( + "auto compact summary for resume".to_string(), + )], + stop_reason: Some(StopReason::EndTurn), + usage: Usage::default(), + metadata: ResponseMetadata::default(), + }) + } + + async fn completion_stream( + &self, + request: ModelRequest, + ) -> Result> + Send>>> { + self.requests.lock().expect("lock requests").push(request); + let usage = self.usage(); + Ok(Box::pin(stream::iter(vec![ + Ok(StreamEvent::UsageDelta(usage.clone())), + Ok(StreamEvent::TextDelta { + index: 0, + text: "Hello from auto compact test.".into(), + }), + Ok(StreamEvent::MessageDone { + response: ModelResponse { + id: "resp-auto-compact".into(), + content: vec![ResponseContent::Text( + "Hello from auto compact test.".into(), + )], + stop_reason: Some(StopReason::EndTurn), + usage, + metadata: ResponseMetadata::default(), + }, + }), + ]))) + } + + fn name(&self) -> &str { + "auto-compact-test-provider" + } +} + +#[tokio::test] +async fn auto_compaction_persists_snapshot_and_survives_resume() -> Result<()> { + let data_root = TempDir::new()?; + let provider = Arc::new(AutoCompactTestProvider::new( + /*input_tokens*/ 80_000, /*output_tokens*/ 1_000, + )); + let runtime = build_runtime_with_provider(data_root.path(), provider.clone())?; + let (connection_id, mut notifications_rx) = initialize_connection(&runtime).await?; + + let start_response = runtime + .handle_incoming( + connection_id, + serde_json::json!({ + "id": 201, + "method": "session/start", + "params": { + "cwd": data_root.path(), + "ephemeral": false, + "title": "Auto compact persist session", + "model": "test-model" + } + }), + ) + .await + .context("session/start response")?; + let session_id = serde_json::from_value::< + devo_server::SuccessResponse, + >(start_response)? + .result + .session + .session_id; + + let _ = runtime + .handle_incoming( + connection_id, + serde_json::json!({ + "id": 202, + "method": "session/compaction/update", + "params": { + "sessionId": session_id, + "effectiveContextWindow": 5_000 + } + }), + ) + .await + .context("session/compaction/update response")?; + + // Build enough oversized history that Auto preserve budget cannot keep it all. + for request_id in 0..3 { + let large_prompt = "x".repeat(100_000); + let _ = runtime + .handle_incoming( + connection_id, + serde_json::json!({ + "id": 210 + request_id, + "method": "_devo/turn/start", + "params": { + "session_id": session_id, + "input": [{ "type": "text", "text": large_prompt }], + "model": null, + "sandbox": null, + "approval_policy": null, + "cwd": null + } + }), + ) + .await + .context("turn/start response")?; + wait_for_turn_completed(&mut notifications_rx).await?; + } + + let capturing_provider = Arc::new(CapturingProvider::default()); + let rebuilt_runtime = + build_runtime_with_provider(data_root.path(), capturing_provider.clone())?; + rebuilt_runtime.load_persisted_sessions().await?; + let (rebuilt_connection_id, mut rebuilt_notifications_rx) = + initialize_connection(&rebuilt_runtime).await?; + let resume_response = rebuilt_runtime + .handle_incoming( + rebuilt_connection_id, + serde_json::json!({ + "id": 220, + "method": "_devo/session/resume", + "params": { + "session_id": session_id + } + }), + ) + .await + .context("session/resume after auto compact")?; + let resume_result = serde_json::from_value::< + devo_server::SuccessResponse, + >(resume_response)? + .result; + + assert!( + resume_result.history_items.iter().any(|item| { + item.kind == SessionHistoryItemKind::ContextCompaction + && item.title == "Context compacted" + }), + "expected ContextCompaction history after auto-compact resume, got {:?}", + resume_result.history_items + ); + + let _ = rebuilt_runtime + .handle_incoming( + rebuilt_connection_id, + serde_json::json!({ + "id": 221, + "method": "_devo/turn/start", + "params": { + "session_id": session_id, + "input": [{ "type": "text", "text": "continue" }], + "model": null, + "sandbox": null, + "approval_policy": null, + "cwd": null + } + }), + ) + .await + .context("post-resume turn")?; + wait_for_turn_completed(&mut rebuilt_notifications_rx).await?; + + let requests = capturing_provider.requests.lock().expect("lock requests"); + let request = requests + .last() + .context("expected captured model request after auto-compact resume")?; + assert!( + request.messages.iter().any(|message| { + message.content.iter().any(|content| match content { + devo_protocol::RequestContent::Text { text } + | devo_protocol::RequestContent::Reasoning { text } => { + text.contains("") || text.contains("auto compact summary") + } + devo_protocol::RequestContent::ProviderReasoning { .. } + | devo_protocol::RequestContent::ToolUse { .. } + | devo_protocol::RequestContent::HostedToolUse { .. } + | devo_protocol::RequestContent::ToolResult { .. } => false, + }) + }), + "expected post-resume prompt to use compacted history, got {:?}", + request.messages + ); + Ok(()) +} + #[tokio::test] async fn configured_request_model_is_used_for_turn_metadata_and_provider_request() -> Result<()> { let data_root = TempDir::new()?; @@ -2831,7 +3188,10 @@ fn sample_indexed_session( prompt_token_estimate: 0, last_query_usage: None, last_query_total_tokens: 0, + last_context_occupancy: None, status: devo_protocol::SessionRuntimeStatus::Idle, collaboration_mode: Default::default(), + effective_context_window: None, + permission_preset: None, } } diff --git a/crates/server/tests/protocol_contract.rs b/crates/server/tests/protocol_contract.rs index 12141433..2f182369 100644 --- a/crates/server/tests/protocol_contract.rs +++ b/crates/server/tests/protocol_contract.rs @@ -240,6 +240,8 @@ fn session_projection_maps_core_record() { title_state: SessionTitleState::Final(SessionTitleFinalSource::ExplicitCreate), sandbox_policy: "workspace-write".into(), approval_mode: "never".into(), + effective_context_window: None, + permission_preset: None, tokens_used: 0, first_user_message: None, archived_at: None, @@ -249,6 +251,7 @@ fn session_projection_maps_core_record() { parent_session_id: None, session_context: None, latest_turn_context: None, + collaboration_mode: None, schema_version: 2, }; @@ -276,6 +279,7 @@ fn turn_projection_preserves_turn_status_vocabulary() { input_token_estimate: None, usage: None, latest_query_usage: None, + context_occupancy: None, stop_reason: None, failure_reason: None, error: None, @@ -364,8 +368,11 @@ fn session_title_updated_event_serializes_expected_kind() { prompt_token_estimate: 0, last_query_usage: None, last_query_total_tokens: 0, + last_context_occupancy: None, status: SessionRuntimeStatus::Idle, collaboration_mode: Default::default(), + effective_context_window: None, + permission_preset: None, }, }); @@ -401,8 +408,11 @@ fn session_compaction_events_serialize_expected_kinds() { prompt_token_estimate: 0, last_query_usage: None, last_query_total_tokens: 0, + last_context_occupancy: None, status: SessionRuntimeStatus::Idle, collaboration_mode: Default::default(), + effective_context_window: None, + permission_preset: None, }; let started = ServerEvent::SessionCompactionStarted(devo_server::SessionEventPayload { diff --git a/crates/server/tests/provider_failure_reporting.rs b/crates/server/tests/provider_failure_reporting.rs index 4dfb15c5..e4b30112 100644 --- a/crates/server/tests/provider_failure_reporting.rs +++ b/crates/server/tests/provider_failure_reporting.rs @@ -163,6 +163,7 @@ async fn exhausted_provider_retries_persist_for_history_but_do_not_enter_context | ServerEvent::SessionCompactionCompleted(_) | ServerEvent::SessionCompactionFailed(_) | ServerEvent::SessionStatusChanged(_) + | ServerEvent::SessionEffectiveContextWindowUpdated(_) | ServerEvent::SessionArchived(_) | ServerEvent::SessionUnarchived(_) | ServerEvent::SessionClosed(_) @@ -173,14 +174,13 @@ async fn exhausted_provider_retries_persist_for_history_but_do_not_enter_context | ServerEvent::TurnPlanUpdated(_) | ServerEvent::TurnDiffUpdated(_) | ServerEvent::TurnUsageUpdated(_) + | ServerEvent::ContextUsageUpdated(_) | ServerEvent::ItemStarted(_) | ServerEvent::ItemCompleted(_) | ServerEvent::ItemDelta { .. } | ServerEvent::WorkspaceChangesUpdated(_) | ServerEvent::ToolCallStatusUpdated(_) | ServerEvent::RequestUserInput(_) - | ServerEvent::InputQueueUpdated(_) - | ServerEvent::SteerAccepted(_) | ServerEvent::MessageEditRecorded(_) | ServerEvent::TurnSuperseded(_) | ServerEvent::WorkspaceRestoreStarted(_) diff --git a/crates/server/tests/queue_drain_notification.rs b/crates/server/tests/queue_drain_notification.rs new file mode 100644 index 00000000..252b16ad --- /dev/null +++ b/crates/server/tests/queue_drain_notification.rs @@ -0,0 +1,530 @@ +//! Reproduction audit for the reported UX bug: a message queued while a turn +//! is running must NOT leak into the in-flight turn's model requests, and +//! once the running turn ends and the entry drains into the follow-up turn, +//! a subscribed connection must receive `queue/updated` (`drained`) carrying +//! an empty queue — otherwise clients keep rendering the stale entry. + +use std::path::Path; +use std::pin::Pin; +use std::sync::Arc; +use std::sync::Mutex; + +use anyhow::Context; +use anyhow::Result; +use async_trait::async_trait; +use devo_core::AppConfigStore; +use devo_core::ProviderVendorCatalog; +use futures::stream; +use pretty_assertions::assert_eq; +use serde_json::json; +use tempfile::TempDir; +use tokio::sync::Notify; +use tokio::sync::mpsc; +use tokio::time::Duration; +use tokio::time::timeout; + +use devo_core::BundledSkillsConfig; +use devo_core::FileSystemSkillCatalog; +use devo_core::PresetModelCatalog; +use devo_core::SkillsConfig; +use devo_core::tools::ToolCallError; +use devo_core::tools::ToolRegistry; +use devo_core::tools::ToolResult; +use devo_core::tools::ToolResultContent; +use devo_core::tools::json_schema::JsonSchema; +use devo_core::tools::registry::ToolRegistryBuilder; +use devo_core::tools::tool_handler::ToolHandler; +use devo_core::tools::tool_spec::ToolExecutionMode; +use devo_core::tools::tool_spec::ToolOutputMode; +use devo_core::tools::tool_spec::ToolSpec; +use devo_protocol::AcpNewSessionResult; +use devo_protocol::ModelRequest; +use devo_protocol::ModelResponse; +use devo_protocol::RequestContent; +use devo_protocol::ResponseContent; +use devo_protocol::ResponseMetadata; +use devo_protocol::StopReason; +use devo_protocol::StreamEvent; +use devo_protocol::Usage; +use devo_provider::ModelProviderSDK; +use devo_provider::SingleProviderRouter; +use devo_server::AcpSuccessResponse; +use devo_server::ClientTransportKind; +use devo_server::ServerRuntime; +use devo_server::ServerRuntimeDependencies; +use devo_server::SuccessResponse; + +const QUEUED_TEXT: &str = "queued follow-up message"; + +/// First stream request triggers the blocking tool; every later request ends +/// the turn with plain text. All requests are captured for content asserts. +#[derive(Default)] +struct BlockingThenDoneProvider { + stream_requests: Mutex>, +} + +#[async_trait] +impl ModelProviderSDK for BlockingThenDoneProvider { + async fn completion(&self, _request: ModelRequest) -> Result { + Ok(ModelResponse { + id: "title-1".into(), + content: vec![ResponseContent::Text("Generated title".into())], + stop_reason: Some(StopReason::EndTurn), + usage: Usage::default(), + metadata: ResponseMetadata::default(), + }) + } + + async fn completion_stream( + &self, + request: ModelRequest, + ) -> Result> + Send>>> { + let request_number = { + let mut requests = self.stream_requests.lock().expect("stream request lock"); + requests.push(request); + requests.len() + }; + let events = if request_number == 1 { + vec![ + Ok(StreamEvent::ToolCallStart { + index: 0, + id: "tool-1".into(), + name: "blocking_wait".into(), + input: json!({}), + }), + Ok(StreamEvent::ToolCallInputDelta { + index: 0, + partial_json: "{}".into(), + }), + Ok(StreamEvent::MessageDone { + response: ModelResponse { + id: "resp-1".into(), + content: vec![ResponseContent::ToolUse { + id: "tool-1".into(), + name: "blocking_wait".into(), + input: json!({}), + }], + stop_reason: Some(StopReason::ToolUse), + usage: Usage::default(), + metadata: ResponseMetadata::default(), + }, + }), + ] + } else { + vec![ + Ok(StreamEvent::TextDelta { + index: 0, + text: "Done.".into(), + }), + Ok(StreamEvent::MessageDone { + response: ModelResponse { + id: "resp-n".into(), + content: vec![ResponseContent::Text("Done.".into())], + stop_reason: Some(StopReason::EndTurn), + usage: Usage::default(), + metadata: ResponseMetadata::default(), + }, + }), + ] + }; + Ok(Box::pin(stream::iter(events))) + } + + fn name(&self) -> &str { + "blocking-then-done-provider" + } +} + +struct BlockingTool { + started: Arc, + release: Arc, +} + +#[async_trait] +impl ToolHandler for BlockingTool { + fn spec(&self) -> &ToolSpec { + Box::leak(Box::new(ToolSpec { + name: "blocking_wait".into(), + description: "Blocks until the test releases it.".into(), + input_schema: JsonSchema::object(Default::default(), None, None), + output_mode: ToolOutputMode::Text, + execution_mode: ToolExecutionMode::ReadOnly, + capability_tags: vec![], + supports_parallel: true, + preparation_feedback: devo_core::tools::ToolPreparationFeedback::None, + display_name: None, + supports_cancellation: None, + supports_streaming: None, + })) + } + + async fn handle( + &self, + _ctx: devo_core::tools::ToolContext, + _input: serde_json::Value, + _progress: Option, + ) -> std::result::Result { + self.started.notify_one(); + self.release.notified().await; + Ok(ToolResult::success( + ToolResultContent::Text("released".into()), + "released", + )) + } +} + +fn build_runtime( + data_root: &Path, + provider: Arc, + registry: Arc, +) -> Arc { + let db_path = data_root.join("test_queue_drain.db"); + let db = Arc::new(devo_server::db::Database::open(db_path).expect("open test database")); + ServerRuntime::new( + data_root.to_path_buf(), + ServerRuntimeDependencies::new( + Arc::clone(&provider), + Arc::new(SingleProviderRouter::new(provider)), + registry, + devo_server::empty_mcp_manager(), + "test-model".to_string(), + Arc::new(PresetModelCatalog::default()), + Arc::new(ProviderVendorCatalog::default()), + Box::new(FileSystemSkillCatalog::new(SkillsConfig { + enabled: false, + user_roots: Vec::new(), + workspace_roots: Vec::new(), + watch_for_changes: false, + bundled: Some(BundledSkillsConfig { enabled: false }), + include_instructions: Some(false), + config: Vec::new(), + })), + devo_core::AgentsMdConfig::default(), + db, + Arc::new(std::sync::Mutex::new( + AppConfigStore::load(data_root.to_path_buf(), None).expect("load app config store"), + )), + ), + ) +} + +async fn initialize_connection( + runtime: &Arc, +) -> Result<(u64, mpsc::Receiver)> { + let (notifications_tx, notifications_rx) = devo_server::test_outbound_channel(4096); + let connection_id = runtime + .register_connection(ClientTransportKind::Stdio, notifications_tx) + .await; + let initialize_response = runtime + .handle_incoming( + connection_id, + json!({ + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": 1, + "clientCapabilities": {}, + "clientInfo": { "name": "test", "title": "test", "version": "1.0.0" } + } + }), + ) + .await + .context("initialize response")?; + assert_eq!( + initialize_response["result"]["agentInfo"]["name"], + json!("devo-server") + ); + Ok((connection_id, notifications_rx)) +} + +fn all_user_request_texts(request: &ModelRequest) -> Vec { + request + .messages + .iter() + .filter(|message| message.role == "user") + .flat_map(|message| { + message.content.iter().filter_map(|content| match content { + RequestContent::Reasoning { text } => Some(text.clone()), + RequestContent::Text { text } => Some(text.clone()), + RequestContent::ProviderReasoning { .. } + | RequestContent::ToolUse { .. } + | RequestContent::HostedToolUse { .. } + | RequestContent::ToolResult { .. } => None, + }) + }) + .collect() +} + +fn queue_updated_change(value: &serde_json::Value) -> Option<&str> { + if value.get("method").and_then(serde_json::Value::as_str) != Some("queue/updated") { + return None; + } + value + .get("params") + .and_then(|params| params.get("change")) + .and_then(serde_json::Value::as_str) +} + +async fn recv_until( + notifications_rx: &mut mpsc::Receiver, + label: &str, + predicate: impl Fn(&serde_json::Value) -> bool, + collected: &mut Vec, +) -> Result { + loop { + match timeout(Duration::from_secs(10), notifications_rx.recv()).await { + Ok(Some(value)) => { + if predicate(&value) { + return Ok(value); + } + collected.push(value); + } + Ok(None) => anyhow::bail!("notification channel closed waiting for {label}"), + Err(_) => { + let seen: Vec = collected + .iter() + .filter_map(|value| { + value + .get("method") + .and_then(serde_json::Value::as_str) + .map(str::to_string) + }) + .collect(); + anyhow::bail!("timed out waiting for {label}; seen methods: {seen:?}"); + } + } + } +} + +fn is_turn_completed(value: &serde_json::Value) -> bool { + value.get("method").and_then(serde_json::Value::as_str) == Some("turn/completed") + || value + .get("params") + .and_then(|params| params.get("_meta").or_else(|| params.get("meta"))) + .and_then(|meta| meta.get("devo/originalMethod")) + .and_then(serde_json::Value::as_str) + == Some("turn/completed") +} + +#[tokio::test] +async fn queued_input_drains_into_followup_turn_and_broadcasts_empty_queue() -> Result<()> { + let temp_dir = TempDir::new()?; + let workspace_root = temp_dir.path().join("workspace"); + std::fs::create_dir_all(&workspace_root)?; + + let started = Arc::new(Notify::new()); + let release = Arc::new(Notify::new()); + let mut builder = ToolRegistryBuilder::new(); + builder.register_handler( + "blocking_wait", + Arc::new(BlockingTool { + started: Arc::clone(&started), + release: Arc::clone(&release), + }), + ); + builder.push_spec(ToolSpec { + name: "blocking_wait".into(), + description: "Blocks until the test releases it.".into(), + input_schema: JsonSchema::object(Default::default(), None, None), + output_mode: ToolOutputMode::Text, + execution_mode: ToolExecutionMode::ReadOnly, + capability_tags: vec![], + supports_parallel: true, + preparation_feedback: devo_core::tools::ToolPreparationFeedback::None, + display_name: None, + supports_cancellation: None, + supports_streaming: None, + }); + let provider = Arc::new(BlockingThenDoneProvider::default()); + let runtime = build_runtime(temp_dir.path(), provider.clone(), Arc::new(builder.build())); + let (connection_id, mut notifications_rx) = initialize_connection(&runtime).await?; + + let session_response = runtime + .handle_incoming( + connection_id, + json!({ + "id": 2, + "method": "session/new", + "params": { + "cwd": workspace_root, + "additionalDirectories": [], + "mcpServers": [] + } + }), + ) + .await + .context("session/new response")?; + let session_result: AcpSuccessResponse = + serde_json::from_value(session_response.clone()) + .with_context(|| format!("session/new response: {session_response}"))?; + let session_id = session_result.result.session_id; + + // Subscribe exactly like the TUI does so `event_selectors` is populated + // and `queue/updated` broadcasts target this connection. + let subscription_response = runtime + .handle_incoming( + connection_id, + json!({ + "id": 3, + "method": "subscription/create", + "params": { + "selectors": [{ "kind": "session", "sessionId": session_id.to_string() }], + "includeSnapshot": false + } + }), + ) + .await + .context("subscription/create response")?; + assert!( + subscription_response.get("error").is_none(), + "subscription/create failed: {subscription_response}" + ); + + let turn_response = runtime + .handle_incoming( + connection_id, + json!({ + "id": 4, + "method": "_devo/turn/start", + "params": { + "session_id": session_id, + "input": [{ "type": "text", "text": "Start with the tool." }], + "model": null, + "thinking": null, + "sandbox": null, + "approval_policy": null, + "cwd": null + } + }), + ) + .await + .context("turn/start response")?; + assert!( + turn_response.get("error").is_none(), + "turn/start failed: {turn_response}" + ); + timeout(Duration::from_secs(5), started.notified()) + .await + .context("timed out waiting for blocking tool to start")?; + + // Turn is running: push the message onto the queue (TUI Enter behavior). + let push_response = runtime + .handle_incoming( + connection_id, + json!({ + "id": 5, + "method": "session/queue/push", + "params": { + "sessionId": session_id, + "input": [{ "type": "text", "text": QUEUED_TEXT }], + "idempotencyKey": "queue-drain-audit" + } + }), + ) + .await + .context("session/queue/push response")?; + let push_result: SuccessResponse = + serde_json::from_value(push_response.clone()) + .with_context(|| format!("push_response: {push_response}"))?; + let devo_protocol::canonical::rpc_turn::SessionQueuePushResult::Queued { entry } = + push_result.result + else { + panic!("busy push must queue"); + }; + let queue_item_id = entry.queue_item_id.as_str().to_string(); + + // The push broadcast must reach the subscribed connection. + let mut collected = Vec::new(); + let added = recv_until( + &mut notifications_rx, + "queue/updated(added)", + |value| queue_updated_change(value) == Some("added"), + &mut collected, + ) + .await?; + assert_eq!( + added["params"]["queue"].as_array().map(Vec::len), + Some(1), + "added notification should carry the queued entry: {added}" + ); + + release.notify_one(); + + // First turn completes, the entry drains into the follow-up turn. + recv_until( + &mut notifications_rx, + "first turn/completed", + is_turn_completed, + &mut collected, + ) + .await?; + let drained = recv_until( + &mut notifications_rx, + "queue/updated(drained)", + |value| queue_updated_change(value) == Some("drained"), + &mut collected, + ) + .await?; + assert_eq!( + drained["params"]["queueItemId"].as_str(), + Some(queue_item_id.as_str()), + "drained notification should name the drained entry: {drained}" + ); + assert_eq!( + drained["params"]["queue"].as_array().map(Vec::len), + Some(0), + "drained notification must carry the empty queue, otherwise clients \ + keep rendering the stale entry: {drained}" + ); + assert!( + drained["params"]["startedTurnId"].as_str().is_some(), + "drained notification should carry the follow-up turn id: {drained}" + ); + recv_until( + &mut notifications_rx, + "second turn/completed", + is_turn_completed, + &mut collected, + ) + .await?; + + // The queue is really empty server-side. + let list_response = runtime + .handle_incoming( + connection_id, + json!({ + "id": 6, + "method": "session/queue/list", + "params": { "sessionId": session_id } + }), + ) + .await + .context("session/queue/list response")?; + assert_eq!( + list_response["result"]["entries"].as_array().map(Vec::len), + Some(0), + "queue/list should be empty after the drain: {list_response}" + ); + + // Queued input must NOT steer the in-flight turn: the first turn's + // post-tool request (request 2) never sees the queued text; only the + // drained follow-up turn (request 3) does. + let requests = provider + .stream_requests + .lock() + .expect("captured requests lock"); + assert_eq!(requests.len(), 3, "expected T1 x2 + T2 x1 model requests"); + let in_flight_texts = all_user_request_texts(&requests[1]); + assert!( + in_flight_texts + .iter() + .all(|text| !text.contains(QUEUED_TEXT)), + "queued input must not leak into the in-flight turn: {in_flight_texts:?}" + ); + let followup_texts = all_user_request_texts(&requests[2]); + assert!( + followup_texts.iter().any(|text| text.contains(QUEUED_TEXT)), + "drained input should appear in the follow-up turn: {followup_texts:?}" + ); + Ok(()) +} diff --git a/crates/server/tests/session_compaction_threshold.rs b/crates/server/tests/session_compaction_threshold.rs new file mode 100644 index 00000000..1537bf1c --- /dev/null +++ b/crates/server/tests/session_compaction_threshold.rs @@ -0,0 +1,228 @@ +//! Global compaction threshold via `session/compaction/update` and config.toml. + +use std::path::Path; +use std::pin::Pin; +use std::sync::Arc; + +use anyhow::Context; +use anyhow::Result; +use async_trait::async_trait; +use devo_core::AgentsMdConfig; +use devo_core::AppConfigStore; +use devo_core::BundledSkillsConfig; +use devo_core::FileSystemSkillCatalog; +use devo_core::PresetModelCatalog; +use devo_core::ProviderVendorCatalog; +use devo_core::SkillsConfig; +use devo_core::tools::ToolRegistry; +use devo_protocol::ModelRequest; +use devo_protocol::ModelResponse; +use devo_protocol::ResponseContent; +use devo_protocol::ResponseMetadata; +use devo_protocol::SessionId; +use devo_protocol::StopReason; +use devo_protocol::StreamEvent; +use devo_protocol::Usage; +use devo_provider::ModelProviderSDK; +use devo_provider::SingleProviderRouter; +use devo_server::ClientTransportKind; +use devo_server::ServerRuntime; +use devo_server::ServerRuntimeDependencies; +use devo_server::SuccessResponse; +use futures::Stream; +use pretty_assertions::assert_eq; +use tempfile::TempDir; + +struct NoopProvider; + +#[async_trait] +impl ModelProviderSDK for NoopProvider { + async fn completion(&self, _request: ModelRequest) -> Result { + Ok(ModelResponse { + id: "noop-response".to_string(), + content: vec![ResponseContent::Text("noop".to_string())], + stop_reason: Some(StopReason::EndTurn), + usage: Usage::default(), + metadata: ResponseMetadata::default(), + }) + } + + async fn completion_stream( + &self, + _request: ModelRequest, + ) -> Result> + Send>>> { + Ok(Box::pin(futures::stream::empty())) + } + + fn name(&self) -> &str { + "noop-compaction-threshold-provider" + } +} + +fn build_runtime(data_root: &Path) -> Result> { + let provider: Arc = Arc::new(NoopProvider); + let db = Arc::new(devo_server::db::Database::open( + data_root.join("compaction_threshold.db"), + )?); + Ok(ServerRuntime::new( + data_root.to_path_buf(), + ServerRuntimeDependencies::new( + Arc::clone(&provider), + Arc::new(SingleProviderRouter::new(provider)), + Arc::new(ToolRegistry::new()), + devo_server::empty_mcp_manager(), + "test-model".to_string(), + Arc::new(PresetModelCatalog::load()?), + Arc::new(ProviderVendorCatalog::default()), + Box::new(FileSystemSkillCatalog::new(SkillsConfig { + bundled: Some(BundledSkillsConfig { enabled: false }), + ..SkillsConfig::default() + })), + AgentsMdConfig::default(), + db, + Arc::new(std::sync::Mutex::new(AppConfigStore::load( + data_root.to_path_buf(), + /*workspace_root*/ None, + )?)), + ), + )) +} + +async fn initialize_connection(runtime: &Arc) -> Result { + let (notifications_tx, _notifications_rx) = devo_server::test_outbound_channel(128); + let connection_id = runtime + .register_connection(ClientTransportKind::Stdio, notifications_tx) + .await; + let initialize_response = runtime + .handle_incoming( + connection_id, + serde_json::json!({ + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": 1, + "clientCapabilities": {}, + "clientInfo": { + "name": "compaction-threshold-test", + "title": "Compaction Threshold Test", + "version": "1.0.0" + } + } + }), + ) + .await + .context("initialize response")?; + assert_eq!( + initialize_response["result"]["agentInfo"]["name"], + serde_json::json!("devo-server") + ); + Ok(connection_id) +} + +async fn start_session( + runtime: &Arc, + connection_id: u64, + cwd: &Path, +) -> Result { + let response = runtime + .handle_incoming( + connection_id, + serde_json::json!({ + "id": 2, + "method": "session/start", + "params": { + "cwd": cwd, + "ephemeral": true, + "title": null + } + }), + ) + .await + .context("session/start response")?; + let response: SuccessResponse = + serde_json::from_value(response)?; + Ok(response.result) +} + +async fn compaction_update( + runtime: &Arc, + connection_id: u64, + session_id: SessionId, + effective_context_window: u64, +) -> Result { + let response = runtime + .handle_incoming( + connection_id, + serde_json::json!({ + "id": 3, + "method": "session/compaction/update", + "params": { + "sessionId": session_id, + "effectiveContextWindow": effective_context_window + } + }), + ) + .await + .context("session/compaction/update response")?; + let response: SuccessResponse = + serde_json::from_value(response)?; + Ok(response.result) +} + +#[tokio::test] +async fn compaction_update_writes_global_config_and_applies_to_session() -> Result<()> { + let data_root = TempDir::new()?; + let cwd = data_root.path().join("workspace"); + std::fs::create_dir_all(&cwd)?; + let runtime = build_runtime(data_root.path())?; + let connection_id = initialize_connection(&runtime).await?; + let started = start_session(&runtime, connection_id, &cwd).await?; + assert!( + started.session.effective_context_window.is_some(), + "new session should expose an applied effective window" + ); + let model_default = started.session.effective_context_window; + + let updated = compaction_update( + &runtime, + connection_id, + started.session.session_id, + /*effective_context_window*/ 250_000, + ) + .await?; + assert_eq!(updated.effective_context_window, 250_000); + assert!(updated.context_window_tokens >= 250_000); + + let config_text = std::fs::read_to_string(data_root.path().join("config.toml"))?; + let document: toml::Value = toml::from_str(&config_text)?; + assert_eq!( + document["compaction_token_limit"].as_integer(), + Some(250_000) + ); + + let second = start_session(&runtime, connection_id, &cwd).await?; + assert_eq!( + second.session.effective_context_window, + Some(250_000), + "new sessions inherit the global compaction preference" + ); + assert_ne!(second.session.effective_context_window, model_default); + Ok(()) +} + +#[tokio::test] +async fn new_session_reads_existing_global_compaction_limit() -> Result<()> { + let data_root = TempDir::new()?; + let cwd = data_root.path().join("workspace"); + std::fs::create_dir_all(&cwd)?; + std::fs::write( + data_root.path().join("config.toml"), + "compaction_token_limit = 100000\n", + )?; + + let runtime = build_runtime(data_root.path())?; + let connection_id = initialize_connection(&runtime).await?; + let started = start_session(&runtime, connection_id, &cwd).await?; + assert_eq!(started.session.effective_context_window, Some(100_000)); + Ok(()) +} diff --git a/crates/server/tests/skills_integration.rs b/crates/server/tests/skills_integration.rs index 47f5b58f..ea829c82 100644 --- a/crates/server/tests/skills_integration.rs +++ b/crates/server/tests/skills_integration.rs @@ -1191,31 +1191,53 @@ async fn turn_steer_injects_resolved_skill_into_next_model_request() -> Result<( .await .context("timed out waiting for blocking tool to start")?; - let steer_response = runtime + // Canonical flow: push the input onto the busy session queue, then + // promote the queued entry into the running turn as a steer. + let push_response = runtime .handle_incoming( connection_id, serde_json::json!({ "id": 9, - "method": "_devo/turn/steer", + "method": "session/queue/push", "params": { - "session_id": session_id, - "expected_turn_id": start_turn_id, + "sessionId": session_id, "input": [ { "type": "text", "text": "Apply this steer now." }, - { "type": "skill", "id": "steer-rust" } - ] + { "type": "skill", "name": "steer-rust" } + ], + "idempotencyKey": "steer-skill-push" } }), ) .await - .context("turn/steer response")?; - let steer_result: SuccessResponse = + .context("session/queue/push response")?; + let push_result: SuccessResponse = + serde_json::from_value(push_response.clone()) + .with_context(|| format!("push_response: {push_response}"))?; + let devo_protocol::canonical::rpc_turn::SessionQueuePushResult::Queued { entry } = + push_result.result + else { + panic!("busy push must queue"); + }; + + let steer_response = runtime + .handle_incoming( + connection_id, + serde_json::json!({ + "id": 10, + "method": "session/queue/steer", + "params": { + "sessionId": session_id, + "queueItemId": entry.queue_item_id.as_str(), + "expectedTurnId": start_turn_id.to_string() + } + }), + ) + .await + .context("session/queue/steer response")?; + let steer_result: SuccessResponse = serde_json::from_value(steer_response)?; - assert_eq!(steer_result.result.turn_id, start_turn_id); - assert_eq!( - steer_result.result.disposition, - devo_server::TurnInputDisposition::Steered - ); + assert!(!steer_result.result.item_id.as_str().is_empty()); release.notify_one(); wait_for_turn_completed(&mut notifications_rx).await?; diff --git a/crates/server/tests/support/acp_session_setup.rs b/crates/server/tests/support/acp_session_setup.rs index 734377c7..dfcf13ff 100644 --- a/crates/server/tests/support/acp_session_setup.rs +++ b/crates/server/tests/support/acp_session_setup.rs @@ -432,6 +432,11 @@ fn sse_data(value: Value) -> String { pub(crate) async fn build_test_mcp_server_binary() -> Result { let workspace = workspace_root()?; + let binary = target_debug_binary(&workspace, "test_stdio_server"); + if binary.is_file() { + return Ok(binary); + } + let manifest = workspace.join("Cargo.toml"); let cargo_path = std::env::var_os("CARGO") .map(PathBuf::from) @@ -450,7 +455,6 @@ pub(crate) async fn build_test_mcp_server_binary() -> Result { .await .context("build MCP test stdio server")?; anyhow::ensure!(status.success(), "MCP test stdio server build failed"); - let binary = target_debug_binary(&workspace, "test_stdio_server"); anyhow::ensure!( binary.is_file(), "MCP test stdio server binary was not built at {}", diff --git a/crates/tui/AGENTS.md b/crates/tui/AGENTS.md index 99de8c24..0e0a1fa3 100644 --- a/crates/tui/AGENTS.md +++ b/crates/tui/AGENTS.md @@ -78,7 +78,7 @@ trait Renderable { All conversation transcript entries implement `HistoryCell`: -- `display_lines(width)` — lines rendered in the main chat viewport. These may be truncated or compact (e.g., `ToolResultCell` shows only 5 preview rows). +- `display_lines(width)` — lines rendered in the main chat viewport. These may be truncated or compact (e.g., `ToolResultCell` shows only 3 preview rows). - `transcript_lines(width)` — lines rendered in the Ctrl+T pager overlay. These should be complete. Override when the pager view differs from the inline view (e.g., `ExecCell` shows full command output with `$` prefix and exit status). - When adding a new cell variant, implement both methods. If they are identical, only implement `display_lines` (the default `transcript_lines` delegates to it). - Cells that animate over time (spinners, shimmer) should implement `transcript_animation_tick()` and update an internal timestamp. The transcript overlay cache key is invalidated when the active cell revision changes. diff --git a/crates/tui/README.md b/crates/tui/README.md index 5d3f294e..36c69bf5 100644 --- a/crates/tui/README.md +++ b/crates/tui/README.md @@ -355,7 +355,7 @@ The bottom pane is the user-facing input area. It contains: | Module | Purpose | |--------|---------| | `history_cell.rs` | `HistoryCell`: the display unit for transcript entries. Supports user messages, assistant messages (with reasoning), tool calls, startup headers, and animation ticks. Handles inline rendering for the main viewport and transcript rendering for the pager overlay. | -| `tool_result_cell.rs` | Compact inline display for completed tool outputs. Limits preview to 5 rows; full output available in the transcript pager. | +| `tool_result_cell.rs` | Compact inline display for completed tool outputs. Limits preview to 3 rows; full output available in the transcript pager. | ### Execution Display — `exec_cell/` diff --git a/crates/tui/src/app.rs b/crates/tui/src/app.rs index 2d6970a5..58a97274 100644 --- a/crates/tui/src/app.rs +++ b/crates/tui/src/app.rs @@ -4,6 +4,7 @@ use crate::events::SavedModelEntry; use devo_core::PermissionPreset; use devo_core::PresetModelCatalog; use devo_core::ProviderWireApi; +use devo_protocol::CollaborationMode; use devo_protocol::SessionId; /// Summary returned when the interactive TUI exits. @@ -48,6 +49,10 @@ pub struct InitialTuiSession { pub permission_preset: PermissionPreset, /// Initial sandbox profile restored from project-level config. pub sandbox_profile: Option, + /// Initial global compaction token limit from user `config.toml`, when set. + pub compaction_token_limit: Option, + /// Default collaboration mode from user `config.toml`. + pub default_collaboration_mode: CollaborationMode, /// Working directory used for the initial session. pub cwd: PathBuf, } diff --git a/crates/tui/src/app_command.rs b/crates/tui/src/app_command.rs index a44b6e65..4987bafc 100644 --- a/crates/tui/src/app_command.rs +++ b/crates/tui/src/app_command.rs @@ -11,6 +11,15 @@ use devo_protocol::TurnId; use devo_protocol::TurnStartParams; use serde::Serialize; +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Default)] +pub(crate) enum PersistScope { + /// Apply to the active session only; do not write user/project defaults. + #[default] + Session, + /// Write user/project defaults and hot-apply to the active session when one exists. + Default, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] pub(crate) enum InputHistoryDirection { Previous, @@ -70,11 +79,30 @@ pub(crate) enum AppCommand { reasoning_effort_selection: Option>, sandbox: Option>, approval_policy: Option>, + persist_scope: PersistScope, + }, + SetCollaborationMode { + collaboration_mode: CollaborationMode, + persist_scope: PersistScope, }, - SteerTurn { + /// Enqueue input on the active session while a turn is busy. + QueuePush { input: Vec, + }, + /// Promote a queued item into the active turn as a steer. + QueueSteer { + queue_item_id: String, expected_turn_id: TurnId, }, + /// Remove a queued item (e.g. before editing in the composer). + QueueRemove { + queue_item_id: String, + }, + /// Replace a queued item's content in place, preserving its position. + QueueUpdate { + queue_item_id: String, + input: Vec, + }, RunBtwQuestion { question: String, }, @@ -93,6 +121,10 @@ pub(crate) enum AppCommand { }, UpdatePermissions { preset: devo_protocol::PermissionPreset, + persist_scope: PersistScope, + }, + UpdateEffectiveContextWindow { + effective_context_window: u64, }, UpdateSandboxProfile { profile: String, @@ -106,7 +138,10 @@ pub(crate) enum AppCommand { RenameSession { title: String, }, - DeleteSession, + /// Delete a session. `None` deletes the current active session. + DeleteSession { + session_id: Option, + }, RollbackToUserTurn { user_turn_index: u32, }, @@ -168,9 +203,6 @@ pub(crate) enum AppCommandView<'a> { approval_policy: &'a Option, collaboration_mode: CollaborationMode, }, - SteerTurn { - input: &'a [InputItem], - }, RunBtwQuestion { question: &'a str, }, @@ -185,6 +217,10 @@ pub(crate) enum AppCommandView<'a> { }, UpdatePermissions { preset: devo_protocol::PermissionPreset, + persist_scope: PersistScope, + }, + UpdateEffectiveContextWindow { + effective_context_window: u64, }, UpdateSandboxProfile { profile: &'a str, @@ -304,6 +340,24 @@ impl AppCommand { reasoning_effort_selection: Option>, sandbox: Option>, approval_policy: Option>, + ) -> Self { + Self::override_turn_context_with_scope( + cwd, + model, + reasoning_effort_selection, + sandbox, + approval_policy, + PersistScope::Session, + ) + } + + pub(crate) fn override_turn_context_with_scope( + cwd: Option, + model: Option, + reasoning_effort_selection: Option>, + sandbox: Option>, + approval_policy: Option>, + persist_scope: PersistScope, ) -> Self { Self::OverrideTurnContext { cwd, @@ -311,6 +365,27 @@ impl AppCommand { reasoning_effort_selection, sandbox, approval_policy, + persist_scope, + } + } + + pub(crate) fn set_collaboration_mode( + collaboration_mode: CollaborationMode, + persist_scope: PersistScope, + ) -> Self { + Self::SetCollaborationMode { + collaboration_mode, + persist_scope, + } + } + + pub(crate) fn update_permissions( + preset: devo_protocol::PermissionPreset, + persist_scope: PersistScope, + ) -> Self { + Self::UpdatePermissions { + preset, + persist_scope, } } @@ -351,7 +426,13 @@ impl AppCommand { } pub(crate) fn delete_session() -> Self { - Self::DeleteSession + Self::DeleteSession { session_id: None } + } + + pub(crate) fn delete_session_by_id(session_id: SessionId) -> Self { + Self::DeleteSession { + session_id: Some(session_id), + } } pub(crate) fn rollback_to_user_turn(user_turn_index: u32) -> Self { @@ -376,16 +457,21 @@ impl AppCommand { Self::ClearGoal => "clear_goal", Self::UserTurn { .. } => "user_turn", Self::OverrideTurnContext { .. } => "override_turn_context", - Self::SteerTurn { .. } => "steer_turn", + Self::SetCollaborationMode { .. } => "set_collaboration_mode", + Self::QueuePush { .. } => "queue_push", + Self::QueueSteer { .. } => "queue_steer", + Self::QueueRemove { .. } => "queue_remove", + Self::QueueUpdate { .. } => "queue_update", Self::RunBtwQuestion { .. } => "run_btw_question", Self::ApprovalRespond { .. } => "approval_respond", Self::RequestUserInputRespond { .. } => "request_user_input_respond", Self::UpdatePermissions { .. } => "update_permissions", + Self::UpdateEffectiveContextWindow { .. } => "update_effective_context_window", Self::UpdateSandboxProfile { .. } => "update_sandbox_profile", Self::BrowseInputHistory { .. } => "browse_input_history", Self::SwitchSession { .. } => "switch_session", Self::RenameSession { .. } => "rename_session", - Self::DeleteSession => "delete_session", + Self::DeleteSession { .. } => "delete_session", Self::RollbackToUserTurn { .. } => "rollback_to_user_turn", Self::ForkAtUserTurn { .. } => "fork_at_user_turn", Self::ListMcpServers => "list_mcp_servers", @@ -439,6 +525,7 @@ impl AppCommand { reasoning_effort_selection, sandbox, approval_policy, + .. } => AppCommandView::OverrideTurnContext { cwd, model, @@ -446,7 +533,11 @@ impl AppCommand { sandbox, approval_policy, }, - Self::SteerTurn { input, .. } => AppCommandView::SteerTurn { input }, + Self::SetCollaborationMode { .. } => AppCommandView::ReloadUserConfig, + Self::QueuePush { .. } + | Self::QueueSteer { .. } + | Self::QueueRemove { .. } + | Self::QueueUpdate { .. } => AppCommandView::ReloadUserConfig, Self::RunBtwQuestion { question } => AppCommandView::RunBtwQuestion { question }, Self::ApprovalRespond { approval_id, @@ -466,9 +557,18 @@ impl AppCommand { request_id, response, }, - Self::UpdatePermissions { preset, .. } => { - AppCommandView::UpdatePermissions { preset: *preset } - } + Self::UpdatePermissions { + preset, + persist_scope, + } => AppCommandView::UpdatePermissions { + preset: *preset, + persist_scope: *persist_scope, + }, + Self::UpdateEffectiveContextWindow { + effective_context_window, + } => AppCommandView::UpdateEffectiveContextWindow { + effective_context_window: *effective_context_window, + }, Self::UpdateSandboxProfile { profile } => { AppCommandView::UpdateSandboxProfile { profile } } @@ -479,7 +579,7 @@ impl AppCommand { session_id: *session_id, }, Self::RenameSession { title } => AppCommandView::RenameSession { title }, - Self::DeleteSession => AppCommandView::DeleteSession, + Self::DeleteSession { .. } => AppCommandView::DeleteSession, Self::RollbackToUserTurn { user_turn_index } => AppCommandView::ThreadRollback { num_turns: *user_turn_index, }, diff --git a/crates/tui/src/app_event.rs b/crates/tui/src/app_event.rs index 5cdcdf5b..d6e187d2 100644 --- a/crates/tui/src/app_event.rs +++ b/crates/tui/src/app_event.rs @@ -10,6 +10,13 @@ use crate::app_command::AppCommand; use crate::events::PlanStep; use crate::events::TextItemKind; +/// Direction for Settings Hub value cycling (`←` / `→`). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum SettingsCycleDirection { + Next, + Previous, +} + #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct ConnectorsSnapshot { pub(crate) connectors: Vec, @@ -78,16 +85,22 @@ pub(crate) enum AppEvent { OnboardingCompleted, /// Submit the current composer text. - SubmitUserInput { text: String }, + SubmitUserInput { + text: String, + }, /// Send a command request to the host/worker adapter. Command(AppCommand), /// Open a read-only transcript overlay for a live direct child agent. - OpenSubagentOverlay { session_id: SessionId }, + OpenSubagentOverlay { + session_id: SessionId, + }, /// Inject one deterministic sub-agent monitor step for TUI debugging. - DebugSubagentStep { step: SubagentDebugStep }, + DebugSubagentStep { + step: SubagentDebugStep, + }, #[allow(dead_code)] /// Interrupt the current turn or cancel the active UI surface. @@ -107,7 +120,9 @@ pub(crate) enum AppEvent { #[allow(dead_code)] /// Execute a slash command selected or typed by the user. - RunSlashCommand { command: String }, + RunSlashCommand { + command: String, + }, #[allow(dead_code)] /// Open the model picker. @@ -115,7 +130,9 @@ pub(crate) enum AppEvent { #[allow(dead_code)] /// Apply a selected model. - ModelSelected { model: String }, + ModelSelected { + model: String, + }, #[allow(dead_code)] /// Open the reasoning-effort picker. @@ -123,9 +140,10 @@ pub(crate) enum AppEvent { #[allow(dead_code)] /// Apply a selected reasoning effort. - ReasoningEffortSelected { value: Option }, + ReasoningEffortSelected { + value: Option, + }, - #[allow(dead_code)] /// Async update of the current git branch for status-line rendering. StatusLineBranchUpdated { cwd: PathBuf, @@ -133,19 +151,28 @@ pub(crate) enum AppEvent { }, /// Request a server-backed reference-search refresh for composer popups. - ReferenceSearchRequested { query: String }, + ReferenceSearchRequested { + query: String, + }, /// Cancel the active composer reference-search session, if any. ReferenceSearchCancelled, /// Async reference-search results for a composer popup query. - ReferenceSearchResults { snapshot: ReferenceSearchSnapshot }, + ReferenceSearchResults { + snapshot: ReferenceSearchSnapshot, + }, /// Request a persistent composer-history entry by absolute log offset. - HistoryEntryRequested { log_id: u64, offset: usize }, + HistoryEntryRequested { + log_id: u64, + offset: usize, + }, /// Replace the current status message. - StatusMessageChanged { message: String }, + StatusMessageChanged { + message: String, + }, /// Insert text into the composer at the cursor (used by `/mcps` / `/skills` pickers). /// @@ -159,7 +186,9 @@ pub(crate) enum AppEvent { #[allow(dead_code)] /// Apply a user-confirmed status-line item ordering/selection. - StatusLineSetup { items: Vec }, + StatusLineSetup { + items: Vec, + }, #[allow(dead_code)] /// Dismiss the status-line setup UI without changing config. @@ -167,11 +196,15 @@ pub(crate) enum AppEvent { #[allow(dead_code)] /// Apply a user-confirmed terminal-title item ordering/selection. - TerminalTitleSetup { items: Vec }, + TerminalTitleSetup { + items: Vec, + }, #[allow(dead_code)] /// Apply a temporary terminal-title preview while the setup UI is open. - TerminalTitleSetupPreview { items: Vec }, + TerminalTitleSetupPreview { + items: Vec, + }, #[allow(dead_code)] /// Dismiss the terminal-title setup UI without changing config. @@ -182,19 +215,43 @@ pub(crate) enum AppEvent { OpenThemePicker, #[allow(dead_code)] /// Apply a selected theme. - ThemeSelected { name: String }, + ThemeSelected { + name: String, + }, /// Apply show-reasoning preference (collapsed vs full). - CollapseReasoningSelected { collapsed: bool }, + CollapseReasoningSelected { + collapsed: bool, + }, + /// Settings Hub deep-links and local actions. + SettingsOpenModel, + SettingsOpenPermissions, + SettingsOpenReasoning, + SettingsOpenCompaction, + SettingsCycleMode, + /// Cycle the UI theme from Settings Hub (`←` / `→` on the Theme row). + SettingsCycleTheme { + direction: SettingsCycleDirection, + }, + /// Coalesced transcript reload after rapid theme changes. + FlushDebouncedThemeReload { + epoch: u64, + }, /// Open the `/mcps` server list from the cached snapshot. McpOpenServerList, /// Open the `/mcps` detail panel for one server. - McpOpenServerDetail { name: String }, + McpOpenServerDetail { + name: String, + }, /// Select one MCP server from the picker (opens detail). - McpServerSelected { name: String }, + McpServerSelected { + name: String, + }, /// Open the `/skills` list from the cached snapshot. SkillOpenList, /// Select one skill from the picker (opens detail). - SkillSelected { name: String }, + SkillSelected { + name: String, + }, /// Clear the managed inline UI and re-emit committed transcript lines. /// /// Used after theme changes so already-flushed header/logo cells can be diff --git a/crates/tui/src/bottom_pane/bottom_pane_view.rs b/crates/tui/src/bottom_pane/bottom_pane_view.rs index 59958a71..e6d1b174 100644 --- a/crates/tui/src/bottom_pane/bottom_pane_view.rs +++ b/crates/tui/src/bottom_pane/bottom_pane_view.rs @@ -2,6 +2,7 @@ use crate::render::renderable::Renderable; use crossterm::event::KeyEvent; use super::CancellationEvent; +use super::ModelPickerSelection; /// Trait implemented by every view that can be shown in the bottom pane. pub(crate) trait BottomPaneView: Renderable { @@ -27,7 +28,7 @@ pub(crate) trait BottomPaneView: Renderable { None } - fn take_model_selection(&mut self) -> Option { + fn take_model_selection(&mut self) -> Option { None } @@ -35,6 +36,38 @@ pub(crate) trait BottomPaneView: Renderable { None } + /// Refresh Settings Hub rows when nested editors change current values. + fn update_settings_hub_snapshot( + &mut self, + _snapshot: super::settings_hub_view::SettingsHubSnapshot, + ) -> bool { + false + } + + /// Refresh an open `/status` panel when occupancy or session totals change. + fn update_status_panel( + &mut self, + _occupancy: Option, + _session: super::SessionTokenTotals, + ) -> bool { + false + } + + /// Propagate the active UI accent into open views (for example Settings Hub tabs). + fn set_accent_color(&mut self, _color: ratatui::style::Color) {} + + /// When true, the view replaces the composer input area instead of stacking + /// below a visible draft. + /// + /// Views that replace the composer (`true`) should still go through + /// [`super::selection_popup_common::render_menu_surface`] for consistent + /// inset padding. Stacked overlays (`false`) may also use it when they need + /// the same chrome (for example `/status`); otherwise they stay visually + /// light so the draft input remains the primary surface. + fn replaces_composer(&self) -> bool { + false + } + /// Handle Ctrl-C while this view is active. fn on_ctrl_c(&mut self) -> CancellationEvent { CancellationEvent::NotHandled diff --git a/crates/tui/src/bottom_pane/chat_composer.rs b/crates/tui/src/bottom_pane/chat_composer.rs index d5882e20..e0ed6c02 100644 --- a/crates/tui/src/bottom_pane/chat_composer.rs +++ b/crates/tui/src/bottom_pane/chat_composer.rs @@ -57,7 +57,7 @@ //! //! The bottom pane owns the active input mode and passes it into the composer for footer rendering. //! The composer renders the uppercase mode label as the first passive bottom status-line field and -//! colors the bottom input marker `┃` with the active [`InputMode`] color. +//! colors the bottom input marker `❯` with the active [`InputMode`] color. //! //! Mode-changing keys and shell-prefix parsing are handled by the bottom pane before normal text //! editing: `Shift+Tab` toggles Build <-> Plan, bare `!` enters Shell Mode with an empty draft, @@ -146,7 +146,6 @@ use ratatui::style::Style; use ratatui::style::Stylize; use ratatui::text::Line; use ratatui::text::Span; -use ratatui::widgets::Block; use ratatui::widgets::Paragraph; use ratatui::widgets::StatefulWidgetRef; use ratatui::widgets::WidgetRef; @@ -189,7 +188,7 @@ use crate::render::Insets; use crate::render::RectExt; use crate::render::renderable::Renderable; use crate::slash_command::SlashCommand; -use crate::style::user_message_style; +use crate::style::user_message_rule_line; use devo_protocol::ReferenceSearchSnapshot; use devo_protocol::user_input::TextElement; use devo_protocol::user_input::Utf8ByteSpan as ByteRange; @@ -342,6 +341,9 @@ pub(crate) struct ChatComposer { #[cfg(not(target_os = "linux"))] next_element_id: u64, context_window_used_tokens: Option, + /// Optional custom right-side context label (e.g. progress bar + token counts). + /// When set, it takes precedence over `context_window_percent` / used-token fallbacks. + context_window_label: Option, skills: Option>, plugins: Option>, connectors_snapshot: Option, @@ -466,6 +468,7 @@ impl ChatComposer { #[cfg(not(target_os = "linux"))] next_element_id: 0, context_window_used_tokens: None, + context_window_label: None, skills: None, plugins: None, connectors_snapshot: None, @@ -3202,6 +3205,14 @@ impl ChatComposer { self.context_window_used_tokens = used_tokens; } + pub(crate) fn set_context_window_label(&mut self, label: Option) -> bool { + if self.context_window_label == label { + return false; + } + self.context_window_label = label; + true + } + #[allow(dead_code)] pub(crate) fn set_esc_backtrack_hint(&mut self, show: bool) { self.esc_backtrack_hint = show; @@ -3448,14 +3459,20 @@ impl ChatComposer { show_queue_hint, ) }; - let right_line = if status_line_active { - None - } else { - Some(context_window_line( - footer_props.context_window_percent, - footer_props.context_window_used_tokens, - )) - }; + let right_line = self + .context_window_label + .as_ref() + .map(|label| Line::from(label.clone()).dim()) + .or_else(|| { + if status_line_active { + None + } else { + Some(context_window_line( + footer_props.context_window_percent, + footer_props.context_window_used_tokens, + )) + } + }); let right_width = right_line.as_ref().map(|l| l.width() as u16).unwrap_or(0); let can_show_left_and_context = can_show_left_with_context(hint_rect, left_width, right_width); @@ -3583,12 +3600,27 @@ impl ChatComposer { mask_char: Option, ) { let is_zellij = self.is_zellij; - let style = user_message_style(); - let textarea_style = style.fg(ratatui::style::Color::Reset); - Block::default().style(style).render_ref(composer_rect, buf); + let textarea_style = Style::default().fg(ratatui::style::Color::Reset); + if composer_rect.height > 0 && composer_rect.width > 0 { + let top = Rect { + x: composer_rect.x, + y: composer_rect.y, + width: composer_rect.width, + height: 1, + }; + Paragraph::new(user_message_rule_line(composer_rect.width)).render_ref(top, buf); + if composer_rect.height > 1 { + let bottom = Rect { + x: composer_rect.x, + y: composer_rect.y + composer_rect.height - 1, + width: composer_rect.width, + height: 1, + }; + Paragraph::new(user_message_rule_line(composer_rect.width)).render_ref(bottom, buf); + } + } if !remote_images_rect.is_empty() { Paragraph::new(self.remote_images_lines(remote_images_rect.width)) - .style(style) .render_ref(remote_images_rect, buf); } if is_zellij && !textarea_rect.is_empty() { @@ -3600,15 +3632,9 @@ impl ChatComposer { .map(InputMode::color) .unwrap_or(self.accent_color); let prompt = if self.input_enabled { - if is_zellij { - Span::styled("┃", style.fg(mode_color)) - } else { - Span::styled("┃", Style::default().fg(mode_color)) - } - } else if is_zellij { - Span::styled("┃", style.fg(ratatui::style::Color::DarkGray)) + Span::styled("❯", Style::default().fg(mode_color)) } else { - "┃".dark_gray() + "❯".dark_gray() }; buf.set_span( textarea_rect.x - LIVE_PREFIX_COLS, @@ -3844,7 +3870,7 @@ mod reference_popup_tests { assert_eq!( composer.handle_key_event(press(KeyCode::Enter)), - (InputResult::Command(SlashCommand::Model), true) + (InputResult::Command(SlashCommand::Skills), true) ); assert_eq!(composer.current_text(), ""); } diff --git a/crates/tui/src/bottom_pane/command_popup.rs b/crates/tui/src/bottom_pane/command_popup.rs index c50e48f5..732779e0 100644 --- a/crates/tui/src/bottom_pane/command_popup.rs +++ b/crates/tui/src/bottom_pane/command_popup.rs @@ -78,8 +78,8 @@ impl CommandPopup { if let Some(stripped) = first_line.strip_prefix('/') { // Extract the *first* token (sequence of non-whitespace - // characters) after the slash so that `/clear something` still - // shows the help for `/clear`. + // characters) after the slash so that `/status something` still + // shows the help for `/status`. let token = stripped.trim_start(); let cmd_token = token.split_whitespace().next().unwrap_or(""); @@ -131,29 +131,31 @@ impl CommandPopup { let mut prefix: Vec<(CommandItem, Option>)> = Vec::new(); let indices_for = |offset| Some((offset..offset + filter_chars).collect()); - let mut push_match = - |item: CommandItem, display: &str, name: Option<&str>, name_offset: usize| { - let display_lower = display.to_lowercase(); - let name_lower = name.map(str::to_lowercase); - let display_exact = display_lower == filter_lower; - let name_exact = name_lower.as_deref() == Some(filter_lower.as_str()); - if display_exact || name_exact { - let offset = if display_exact { 0 } else { name_offset }; - exact.push((item, indices_for(offset))); - return; - } - let display_prefix = display_lower.starts_with(&filter_lower); - let name_prefix = name_lower - .as_ref() - .is_some_and(|name| name.starts_with(&filter_lower)); - if display_prefix || name_prefix { - let offset = if display_prefix { 0 } else { name_offset }; - prefix.push((item, indices_for(offset))); - } - }; + let mut push_match = |item: CommandItem, display: &str, aliases: &[&str]| { + let display_lower = display.to_lowercase(); + let display_exact = display_lower == filter_lower; + let alias_exact = aliases + .iter() + .any(|alias| alias.eq_ignore_ascii_case(filter)); + if display_exact || alias_exact { + // Alias-only hits keep the canonical display name without a + // misleading highlight span (filter text is not a substring). + let indices = if display_exact { indices_for(0) } else { None }; + exact.push((item, indices)); + return; + } + let display_prefix = display_lower.starts_with(&filter_lower); + let alias_prefix = aliases + .iter() + .any(|alias| alias.to_lowercase().starts_with(&filter_lower)); + if display_prefix || alias_prefix { + let indices = if display_prefix { indices_for(0) } else { None }; + prefix.push((item, indices)); + } + }; for (_, cmd) in self.builtins.iter() { - push_match(CommandItem::Builtin(*cmd), cmd.command(), None, 0); + push_match(CommandItem::Builtin(*cmd), cmd.command(), cmd.aliases()); } out.extend(exact); @@ -235,6 +237,11 @@ impl WidgetRef for CommandPopup { mod tests { use super::*; use pretty_assertions::assert_eq; + use ratatui::buffer::Buffer; + use ratatui::layout::Rect; + use ratatui::widgets::WidgetRef; + + use super::super::popup_consts::MAX_POPUP_ROWS; #[test] fn filter_returns_empty_for_unknown_prefix() { @@ -310,6 +317,30 @@ mod tests { } } + #[test] + fn quit_alias_prefix_selects_exit_without_listing_quit() { + let mut popup = CommandPopup::new(CommandPopupFlags::default(), Color::Cyan); + popup.on_composer_text_change("/".to_string()); + let listed: Vec<&str> = popup + .filtered_items() + .into_iter() + .map(|item| match item { + CommandItem::Builtin(cmd) => cmd.command(), + }) + .collect(); + assert!(listed.contains(&"exit")); + assert!(!listed.contains(&"quit")); + + popup.on_composer_text_change("/qu".to_string()); + match popup.selected_item() { + Some(CommandItem::Builtin(cmd)) => { + assert_eq!(cmd.command(), "exit"); + assert_eq!(cmd.aliases(), &["quit"]); + } + other => panic!("expected exit via quit alias, got {other:?}"), + } + } + #[test] fn popup_lists_only_supported_commands() { let mut popup = CommandPopup::new(CommandPopupFlags::default(), Color::Cyan); @@ -325,7 +356,6 @@ mod tests { assert_eq!( cmds, vec![ - "theme", "model", "skills", "mcps", @@ -335,9 +365,9 @@ mod tests { "rename", "delete", "status", + "settings", "permissions", "show-reasoning", - "clear", "diff", "goal", "btw", @@ -393,4 +423,58 @@ mod tests { "expected no /debug* command in popup menu, got {cmds:?}" ); } + + fn render_popup(popup: &CommandPopup, width: u16, height: u16) -> String { + let area = Rect::new(0, 0, width, height); + let mut buf = Buffer::empty(area); + popup.render_ref(area, &mut buf); + (0..height) + .map(|row| { + (0..width) + .map(|col| buf[(col, row)].symbol().to_string()) + .collect::() + }) + .collect::>() + .join("\n") + } + + #[test] + fn full_command_list_shows_more_below_when_overflowing() { + let mut popup = CommandPopup::new(CommandPopupFlags::default(), Color::Cyan); + popup.on_composer_text_change("/".to_string()); + assert!( + popup.filtered_items().len() > MAX_POPUP_ROWS, + "expected more commands than visible popup rows" + ); + + let height = popup.calculate_required_height(80); + let rendered = render_popup(&popup, 80, height); + assert!( + rendered.contains("↓ more"), + "expected ↓ more when command list overflows:\n{rendered}" + ); + assert!( + !rendered.contains("↑ more"), + "wrap-around slash lists should not show ↑ more:\n{rendered}" + ); + } + + #[test] + fn scrolled_command_list_does_not_show_more_above() { + let mut popup = CommandPopup::new(CommandPopupFlags::default(), Color::Cyan); + popup.on_composer_text_change("/".to_string()); + let len = popup.filtered_items().len(); + assert!(len > MAX_POPUP_ROWS); + + for _ in 0..MAX_POPUP_ROWS { + popup.move_down(); + } + + let height = popup.calculate_required_height(80); + let rendered = render_popup(&popup, 80, height); + assert!( + !rendered.contains("↑ more"), + "wrap-around slash lists should not show ↑ more after scrolling:\n{rendered}" + ); + } } diff --git a/crates/tui/src/bottom_pane/compaction_threshold_view.rs b/crates/tui/src/bottom_pane/compaction_threshold_view.rs new file mode 100644 index 00000000..e8886459 --- /dev/null +++ b/crates/tui/src/bottom_pane/compaction_threshold_view.rs @@ -0,0 +1,398 @@ +//! Absolute-token compaction threshold picker for Settings › Compaction. +//! +//! Lists preset limits clamped to the model context window, annotating +//! `(recommended)` and `(current)`. Applying a row emits +//! [`AppCommand::UpdateEffectiveContextWindow`]. + +use crossterm::event::KeyCode; +use crossterm::event::KeyEvent; +use crossterm::event::KeyEventKind; +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::style::Color; +use ratatui::style::Style; +use ratatui::style::Stylize; +use ratatui::text::Line; +use ratatui::text::Span; +use ratatui::widgets::Paragraph; +use ratatui::widgets::Widget; + +use crate::app_command::AppCommand; +use crate::app_event::AppEvent; +use crate::app_event_sender::AppEventSender; +use crate::key_hint; +use crate::render::renderable::Renderable; +use crate::ui_consts::FOOTER_INDENT_COLS; + +use super::CancellationEvent; +use super::bottom_pane_view::BottomPaneView; +use super::popup_consts::MAX_POPUP_ROWS; +use super::scroll_state::ScrollState; +use super::selection_popup_common::menu_surface_padding_height; +use super::selection_popup_common::render_menu_surface; + +const LEFT_PAD: usize = FOOTER_INDENT_COLS; + +/// Snapshot used to open the compaction threshold picker. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct CompactionThresholdSnapshot { + pub(crate) model_label: String, + pub(crate) context_window_tokens: u64, + pub(crate) recommended_token_limit: u64, + pub(crate) current_token_limit: u64, +} + +/// Absolute-token list for choosing the global auto-compaction threshold. +pub(crate) struct CompactionThresholdView { + model_label: String, + context_window_tokens: u64, + recommended_token_limit: u64, + current_token_limit: u64, + options: Vec, + state: ScrollState, + complete: bool, + app_event_tx: AppEventSender, + accent_color: Color, +} + +impl CompactionThresholdView { + pub(crate) fn new( + snapshot: CompactionThresholdSnapshot, + app_event_tx: AppEventSender, + accent_color: Color, + ) -> Self { + let options = compaction_threshold_presets( + snapshot.context_window_tokens, + snapshot.recommended_token_limit, + ); + let mut state = ScrollState::new(); + let initial_idx = options + .iter() + .position(|value| *value == snapshot.current_token_limit) + .or_else(|| { + options.iter().position(|value| { + token_limits_display_equal(*value, snapshot.current_token_limit) + }) + }) + .or_else(|| { + options + .iter() + .position(|value| *value == snapshot.recommended_token_limit) + }) + .unwrap_or(0); + if options.is_empty() { + state.selected_idx = None; + } else { + state.selected_idx = Some(initial_idx.min(options.len() - 1)); + } + let visible = MAX_POPUP_ROWS.min(options.len()); + state.ensure_visible(options.len(), visible); + + Self { + model_label: snapshot.model_label, + context_window_tokens: snapshot.context_window_tokens, + recommended_token_limit: snapshot.recommended_token_limit, + current_token_limit: snapshot.current_token_limit, + options, + state, + complete: false, + app_event_tx, + accent_color, + } + } + + fn dismiss(&mut self) { + self.complete = true; + } + + fn visible_rows(&self) -> usize { + MAX_POPUP_ROWS.min(self.options.len()) + } + + fn apply_selection(&mut self) { + let Some(idx) = self.state.selected_idx else { + return; + }; + let Some(limit) = self.options.get(idx).copied() else { + return; + }; + self.app_event_tx.send(AppEvent::Command( + AppCommand::UpdateEffectiveContextWindow { + effective_context_window: limit, + }, + )); + self.dismiss(); + } + + fn render_lines(&self) -> Vec> { + let mut lines = Vec::new(); + let pad = " ".repeat(LEFT_PAD); + + lines.push(Line::from(vec![ + Span::raw(pad.clone()), + Span::styled("Settings › Compaction".to_string(), Style::default().bold()), + ])); + lines.push(Line::from("")); + lines.push(Line::from(vec![ + Span::raw(pad.clone()), + Span::styled("Model".to_string(), Style::default().dim()), + Span::raw(" ".to_string()), + Span::raw(self.model_label.clone()), + ])); + lines.push(Line::from(vec![ + Span::raw(pad.clone()), + Span::styled("Window".to_string(), Style::default().dim()), + Span::raw(" ".to_string()), + Span::raw(format_token_limit(self.context_window_tokens)), + ])); + lines.push(Line::from("")); + + if self.options.is_empty() { + lines.push(Line::from(vec![ + Span::raw(pad.clone()), + Span::styled( + "No compaction thresholds available for this model.".to_string(), + Style::default().dim(), + ), + ])); + } else { + let visible = self.visible_rows(); + let start = self.state.scroll_top; + let end = (start + visible).min(self.options.len()); + if start > 0 { + lines.push(Line::from(vec![ + Span::raw(pad.clone()), + Span::styled("↑ more".to_string(), Style::default().dim()), + ])); + } + for idx in start..end { + let value = self.options[idx]; + let focused = self.state.selected_idx == Some(idx); + lines.push(self.option_line(&pad, value, focused)); + } + if end < self.options.len() { + lines.push(Line::from(vec![ + Span::raw(pad.clone()), + Span::styled("↓ more".to_string(), Style::default().dim()), + ])); + } + } + + lines.push(Line::from("")); + lines.push(self.footer_line(&pad)); + lines + } + + fn option_line(&self, pad: &str, value: u64, focused: bool) -> Line<'static> { + let marker = if focused { "> " } else { " " }; + let mut annotations = Vec::new(); + if value == self.recommended_token_limit { + annotations.push("(recommended)"); + } + let is_current = value == self.current_token_limit + || token_limits_display_equal(value, self.current_token_limit); + if is_current { + annotations.push("(current)"); + } + let annotation = if annotations.is_empty() { + String::new() + } else { + format!(" {}", annotations.join(" ")) + }; + let style = if focused { + Style::default().fg(self.accent_color).bold() + } else { + Style::default().dim() + }; + Line::from(vec![ + Span::raw(pad.to_string()), + Span::styled( + format!("{marker}{}{annotation}", format_token_limit(value)), + style, + ), + ]) + } + + fn footer_line(&self, pad: &str) -> Line<'static> { + Line::from(vec![ + Span::raw(pad.to_string()), + key_hint::plain(KeyCode::Up).into(), + Span::raw("/".to_string()), + key_hint::plain(KeyCode::Down).into(), + Span::raw(" navigate ".to_string()), + key_hint::plain(KeyCode::Enter).into(), + Span::raw(" apply ".to_string()), + key_hint::plain(KeyCode::Esc).into(), + Span::raw(" back".to_string()), + ]) + .dim() + } +} + +impl BottomPaneView for CompactionThresholdView { + fn handle_key_event(&mut self, key_event: KeyEvent) { + if !matches!(key_event.kind, KeyEventKind::Press | KeyEventKind::Repeat) { + return; + } + let len = self.options.len(); + let visible = self.visible_rows(); + match key_event.code { + KeyCode::Esc => self.dismiss(), + KeyCode::Up | KeyCode::Char('k') => { + self.state.move_up_wrap(len); + self.state.ensure_visible(len, visible); + } + KeyCode::Down | KeyCode::Char('j') => { + self.state.move_down_wrap(len); + self.state.ensure_visible(len, visible); + } + KeyCode::Enter => self.apply_selection(), + _ => {} + } + } + + fn is_complete(&self) -> bool { + self.complete + } + + fn view_id(&self) -> Option<&'static str> { + Some("settings_compaction") + } + + fn replaces_composer(&self) -> bool { + true + } + + fn on_ctrl_c(&mut self) -> CancellationEvent { + self.dismiss(); + CancellationEvent::Handled + } + + fn prefer_esc_to_handle_key_event(&self) -> bool { + true + } +} + +impl Renderable for CompactionThresholdView { + fn render(&self, area: Rect, buf: &mut Buffer) { + if area.height == 0 || area.width == 0 { + return; + } + let content_area = render_menu_surface(area, buf); + Paragraph::new(self.render_lines()).render(content_area, buf); + } + + fn desired_height(&self, _width: u16) -> u16 { + let list_rows = self.visible_rows().saturating_add( + usize::from(self.state.scroll_top > 0) + + usize::from(self.state.scroll_top + self.visible_rows() < self.options.len()), + ); + // title + blank + model + window + blank + list + blank + footer + let content = u16::try_from(8usize.saturating_add(list_rows.max(1))).unwrap_or(u16::MAX); + menu_surface_padding_height().saturating_add(content) + } +} + +/// Builds the Settings compaction preset ladder. +/// +/// The list uses round product values only (`100K`…`1M`, plus `250K`). It is +/// intentionally **not** keyed off the raw model `context_window` — that value +/// is informational in the header, and the server clamps on apply. This avoids +/// duplicate labels such as two rows both rendering as `1M`. +pub(crate) fn compaction_threshold_presets(_context_window: u64, recommended: u64) -> Vec { + let mut values = vec![ + 100_000, 200_000, 250_000, 300_000, 400_000, 500_000, 600_000, 700_000, 800_000, 900_000, + 1_000_000, + ]; + if recommended > 0 { + values.push(recommended); + } + values.sort_unstable(); + values.dedup(); + dedupe_presets_by_display_label(values) +} + +fn dedupe_presets_by_display_label(values: Vec) -> Vec { + let mut out = Vec::with_capacity(values.len()); + for value in values { + if out + .last() + .is_some_and(|prev| token_limits_display_equal(*prev, value)) + { + // Prefer the rounder canonical step already kept in `out`. + continue; + } + out.push(value); + } + out +} + +/// Formats absolute token counts for Settings labels. +/// +/// Near-million values (for example `996147`) render as `1M` so catalog windows +/// that are slightly under a round million still read cleanly. +pub(crate) fn format_token_limit(tokens: u64) -> String { + if tokens >= 950_000 { + let millions = ((tokens as f64) / 1_000_000.0).round().max(1.0) as u64; + return format!("{millions}M"); + } + if tokens >= 1_000 && tokens.is_multiple_of(1_000) { + format!("{}K", tokens / 1_000) + } else if tokens >= 1_000 { + let whole = tokens / 1_000; + let frac = (tokens % 1_000) / 100; + if frac == 0 { + format!("{whole}K") + } else { + format!("{whole}.{frac}K") + } + } else { + tokens.to_string() + } +} + +fn token_limits_display_equal(left: u64, right: u64) -> bool { + format_token_limit(left) == format_token_limit(right) +} + +/// Product-facing recommended compaction threshold for the Settings picker. +/// +/// Fixed at `250K`; apply still clamps to the active model context window. +pub(crate) fn recommended_compaction_token_limit( + _context_window: u64, + _model_effective: u64, +) -> u64 { + 250_000 +} + +#[cfg(test)] +mod tests { + use pretty_assertions::assert_eq; + + use super::*; + + #[test] + fn presets_use_round_ladder_without_raw_window_duplicate() { + let presets = compaction_threshold_presets(1_048_576, 250_000); + assert_eq!( + presets, + vec![ + 100_000, 200_000, 250_000, 300_000, 400_000, 500_000, 600_000, 700_000, 800_000, + 900_000, 1_000_000, + ] + ); + let labels: Vec<_> = presets.iter().copied().map(format_token_limit).collect(); + assert_eq!(labels.iter().filter(|label| *label == "1M").count(), 1); + assert_eq!( + recommended_compaction_token_limit(1_048_576, 996_147), + 250_000 + ); + } + + #[test] + fn format_token_limit_uses_k_suffix() { + assert_eq!(format_token_limit(190_000), "190K"); + assert_eq!(format_token_limit(1_000_000), "1M"); + assert_eq!(format_token_limit(996_147), "1M"); + } +} diff --git a/crates/tui/src/bottom_pane/context_occupancy_view.rs b/crates/tui/src/bottom_pane/context_occupancy_view.rs new file mode 100644 index 00000000..552a6dfe --- /dev/null +++ b/crates/tui/src/bottom_pane/context_occupancy_view.rs @@ -0,0 +1,484 @@ +//! Read-only status panel opened by `/status`. +//! +//! Shows cwd / permissions, effective window usage with category shares from +//! [`ContextOccupancy`], and session-cumulative input / output / cache totals. +//! Stacks below the composer (does not replace the input) and paints the shared +//! menu-surface background. Esc (or Enter) dismisses the panel. + +use crossterm::event::KeyCode; +use crossterm::event::KeyEvent; +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::style::Style; +use ratatui::style::Stylize; +use ratatui::text::Line; +use ratatui::text::Span; +use ratatui::widgets::Paragraph; +use ratatui::widgets::Widget; + +use crate::key_hint; +use crate::render::renderable::Renderable; +use devo_protocol::canonical::item::ContextCategoryId; +use devo_protocol::canonical::item::ContextCategoryUsage; +use devo_protocol::canonical::item::ContextOccupancy; + +use super::CancellationEvent; +use super::bottom_pane_view::BottomPaneView; +use super::selection_popup_common::menu_surface_padding_height; +use super::selection_popup_common::render_menu_surface; + +const BAR_WIDTH: usize = 28; +const META_LABEL_WIDTH: usize = 12; +const TOTALS_LABEL_WIDTH: usize = 10; +const CATEGORY_COUNT: usize = 5; + +/// Session-cumulative token accounting shown beneath window occupancy. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub(crate) struct SessionTokenTotals { + pub(crate) input: usize, + pub(crate) output: usize, + pub(crate) cache_read: usize, +} + +impl SessionTokenTotals { + fn cache_hit_percent(self) -> usize { + if self.input == 0 { + 0 + } else { + (self.cache_read.saturating_mul(100) + self.input / 2) / self.input + } + } +} + +/// Snapshot of session fields shown above context occupancy. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub(crate) struct StatusPanelSnapshot { + pub(crate) cwd: String, + pub(crate) permissions_label: String, +} + +pub(crate) struct ContextOccupancyView { + occupancy: Option, + session: SessionTokenTotals, + status: StatusPanelSnapshot, + complete: bool, +} + +impl ContextOccupancyView { + pub(crate) fn new( + occupancy: Option, + session: SessionTokenTotals, + status: StatusPanelSnapshot, + ) -> Self { + Self { + occupancy, + session, + status, + complete: false, + } + } + + pub(crate) fn update_snapshot( + &mut self, + occupancy: Option, + session: SessionTokenTotals, + ) { + self.occupancy = occupancy; + self.session = session; + } + + fn dismiss(&mut self) { + self.complete = true; + } + + fn occupancy_or_zero(&self) -> ContextOccupancy { + self.occupancy.clone().unwrap_or_else(|| { + ContextOccupancy::from_category_tokens( + /*context_window_tokens*/ 0, /*base*/ 0, /*skills*/ 0, + /*tools_builtin*/ 0, /*tools_mcp*/ 0, /*conversation*/ 0, + ) + }) + } + + fn render_lines(&self, _width: u16) -> Vec> { + let mut lines = Vec::new(); + let occupancy = self.occupancy_or_zero(); + + lines.push(Line::from(Span::styled( + "Status".to_string(), + Style::default().bold(), + ))); + lines.push(Line::from("")); + lines.extend(status_meta_lines(&self.status)); + lines.push(Line::from("")); + lines.push(section_title("Context Usage")); + lines.push(Line::from("")); + lines.extend(window_summary_lines(&occupancy)); + lines.push(Line::from("")); + lines.extend(category_lines(&occupancy)); + lines.push(Line::from("")); + lines.push(section_title("Token Usage")); + lines.extend(totals_lines(self.session)); + lines.push(Line::from("")); + lines.push(self.footer_line()); + lines + } + + fn footer_line(&self) -> Line<'static> { + Line::from(vec![ + key_hint::plain(KeyCode::Esc).into(), + Span::raw(" close"), + ]) + .dim() + } + + fn content_height(&self) -> u16 { + // Status + blank + cwd + permissions + blank + Context Usage + blank + + // summary + bar + blank + categories + blank + Token Usage + input + output + + // cache + blank + footer + u16::try_from( + 1usize + .saturating_add(1) + .saturating_add(2) + .saturating_add(1) + .saturating_add(1) + .saturating_add(1) + .saturating_add(2) + .saturating_add(1) + .saturating_add(CATEGORY_COUNT) + .saturating_add(1) + .saturating_add(1) + .saturating_add(3) + .saturating_add(1) + .saturating_add(1), + ) + .unwrap_or(u16::MAX) + } +} + +fn section_title(title: &str) -> Line<'static> { + Line::from(Span::styled(title.to_string(), Style::default().bold())) +} + +fn status_meta_lines(status: &StatusPanelSnapshot) -> Vec> { + vec![ + meta_line("cwd", status.cwd.clone()), + meta_line("permissions", status.permissions_label.clone()), + ] +} + +fn meta_line(label: &str, value: String) -> Line<'static> { + Line::from(vec![ + Span::styled( + format!("{label: Vec> { + let used = occupancy.total_tokens; + let window = occupancy.context_window_tokens; + let denom = window.max(1); + let percent = ((used as f64 / denom as f64) * 100.0) + .clamp(0.0, 100.0) + .round() as u64; + vec![ + Line::from(vec![ + Span::raw(format_tokens(used)), + Span::styled(" / ", Style::default().dim()), + Span::raw(format_tokens(window)), + Span::styled(format!(" · {percent}%"), Style::default().dim()), + ]), + Line::from(Span::raw(render_bar(used, denom, BAR_WIDTH))), + ] +} + +fn category_lines(occupancy: &ContextOccupancy) -> Vec> { + let categories = display_categories(occupancy); + let label_width = categories + .iter() + .map(|category| category_label(category.id).len()) + .max() + .unwrap_or(0) + .max(12); + categories + .iter() + .map(|category| category_line(category, label_width)) + .collect() +} + +fn display_categories(occupancy: &ContextOccupancy) -> Vec { + const ORDER: [ContextCategoryId; CATEGORY_COUNT] = [ + ContextCategoryId::Base, + ContextCategoryId::Skills, + ContextCategoryId::ToolsBuiltin, + ContextCategoryId::ToolsMcp, + ContextCategoryId::Conversation, + ]; + ORDER + .into_iter() + .map(|id| { + occupancy + .categories + .iter() + .find(|category| category.id == id) + .cloned() + .unwrap_or(ContextCategoryUsage { + id, + tokens: 0, + share_bps: 0, + }) + }) + .collect() +} + +fn totals_lines(session: SessionTokenTotals) -> Vec> { + let cache_pct = session.cache_hit_percent(); + vec![ + metric_line("↑ input", format_tokens(session.input as u64)), + metric_line("↓ output", format_tokens(session.output as u64)), + Line::from(vec![ + Span::styled( + format!("{: Line<'static> { + Line::from(vec![ + Span::styled( + format!("{label: &'static str { + match id { + ContextCategoryId::Base => "base", + ContextCategoryId::Skills => "skills", + ContextCategoryId::ToolsBuiltin => "tools (builtin)", + ContextCategoryId::ToolsMcp => "tools (mcp)", + ContextCategoryId::Conversation => "conversation", + } +} + +fn category_line(category: &ContextCategoryUsage, label_width: usize) -> Line<'static> { + let label = category_label(category.id); + let share_pct = u32::from(category.share_bps) / 100; + Line::from(vec![ + Span::raw(format!("{label:6}", format_tokens(category.tokens))), + Span::styled(format!(" {share_pct:>3}%"), Style::default().dim()), + ]) +} + +fn format_tokens(value: u64) -> String { + if value >= 1_000_000 { + format!("{:.1}M", value as f64 / 1_000_000.0) + } else if value >= 1_000 { + format!("{:.1}k", value as f64 / 1_000.0) + } else { + value.to_string() + } +} + +fn render_bar(used: u64, total: u64, width: usize) -> String { + if width == 0 || total == 0 { + return String::new(); + } + let ratio = (used as f64 / total as f64).clamp(0.0, 1.0); + let filled = (ratio * width as f64).round() as usize; + let filled = filled.min(width); + let empty = width.saturating_sub(filled); + let mut bar = String::with_capacity(width.saturating_mul(3)); + for _ in 0..filled { + bar.push('▰'); + } + for _ in 0..empty { + bar.push('▱'); + } + bar +} + +impl BottomPaneView for ContextOccupancyView { + fn handle_key_event(&mut self, key_event: KeyEvent) { + match key_event.code { + KeyCode::Esc | KeyCode::Enter => self.dismiss(), + _ => {} + } + } + + fn is_complete(&self) -> bool { + self.complete + } + + fn update_status_panel( + &mut self, + occupancy: Option, + session: SessionTokenTotals, + ) -> bool { + self.update_snapshot(occupancy, session); + true + } + + fn on_ctrl_c(&mut self) -> CancellationEvent { + self.dismiss(); + CancellationEvent::Handled + } +} + +impl Renderable for ContextOccupancyView { + fn render(&self, area: Rect, buf: &mut Buffer) { + if area.height == 0 || area.width == 0 { + return; + } + let content_area = render_menu_surface(area, buf); + Paragraph::new(self.render_lines(content_area.width)).render(content_area, buf); + } + + fn desired_height(&self, _width: u16) -> u16 { + menu_surface_padding_height().saturating_add(self.content_height()) + } +} + +#[cfg(test)] +mod tests { + use pretty_assertions::assert_eq; + + use super::*; + + fn sample_status() -> StatusPanelSnapshot { + StatusPanelSnapshot { + cwd: "/tmp/project".to_string(), + permissions_label: "default".to_string(), + } + } + + #[test] + fn empty_state_renders_zeroed_layout() { + let view = ContextOccupancyView::new(None, SessionTokenTotals::default(), sample_status()); + let text = view + .render_lines(80) + .iter() + .map(ToString::to_string) + .collect::>() + .join("\n"); + assert!(text.contains("Status")); + assert!(text.contains("cwd")); + assert!(text.contains("/tmp/project")); + assert!(text.contains("permissions")); + assert!(text.contains("default")); + assert!(text.contains("Context Usage")); + assert!(text.contains("0 / 0")); + assert!(text.contains("base")); + assert!(text.contains("conversation")); + assert!(text.contains("Token Usage")); + assert!(!text.lines().any(|line| line.trim() == "Session")); + assert!(text.contains("↑ input")); + assert!(text.contains("esc close")); + } + + #[test] + fn totals_render_without_session_heading() { + let view = ContextOccupancyView::new( + None, + SessionTokenTotals { + input: 124_000, + output: 10_000, + cache_read: 82_000, + }, + sample_status(), + ); + let text = view + .render_lines(100) + .iter() + .map(ToString::to_string) + .collect::>() + .join("\n"); + assert!(text.contains("Token Usage")); + assert!(!text.lines().any(|line| line.trim() == "Session")); + assert!(text.contains("↑ input")); + assert!(text.contains("↓ output")); + assert!(text.contains("124.0k")); + assert!(text.contains("10.0k")); + assert!(text.contains("82.0k")); + assert!(text.contains("66% of input")); + assert!(text.contains("Context Usage")); + } + + #[test] + fn populated_occupancy_and_status_render_together() { + let occupancy = ContextOccupancy::from_category_tokens( + /*context_window_tokens*/ 100_000, /*base*/ 10_000, /*skills*/ 5_000, + /*tools_builtin*/ 20_000, /*tools_mcp*/ 15_000, /*conversation*/ 50_000, + ); + let view = ContextOccupancyView::new( + Some(occupancy), + SessionTokenTotals { + input: 50_000, + output: 2_000, + cache_read: 25_000, + }, + sample_status(), + ); + let text = view + .render_lines(100) + .iter() + .map(ToString::to_string) + .collect::>() + .join("\n"); + assert!(text.contains("Status")); + assert!(text.contains("Context Usage")); + assert!(text.contains("base")); + assert!(text.contains("conversation")); + assert!(text.contains("Token Usage")); + assert!(text.contains("50% of input")); + assert!(text.contains('▰')); + let base_line = text + .lines() + .find(|line| line.contains("base")) + .expect("base category"); + assert!(base_line.contains('%')); + assert!(!base_line.contains('▰')); + assert!(!base_line.contains('▱')); + } + + #[test] + fn esc_and_enter_dismiss() { + let mut view = + ContextOccupancyView::new(None, SessionTokenTotals::default(), sample_status()); + assert!(!view.is_complete()); + view.handle_key_event(KeyEvent::from(KeyCode::Enter)); + assert_eq!(view.is_complete(), true); + + let mut view = + ContextOccupancyView::new(None, SessionTokenTotals::default(), sample_status()); + view.handle_key_event(KeyEvent::from(KeyCode::Esc)); + assert_eq!(view.is_complete(), true); + } + + #[test] + fn stacks_under_composer_with_menu_surface_padding() { + let view = ContextOccupancyView::new(None, SessionTokenTotals::default(), sample_status()); + assert!(!view.replaces_composer()); + assert_eq!( + view.desired_height(/*width*/ 80), + menu_surface_padding_height().saturating_add(22) + ); + } +} diff --git a/crates/tui/src/bottom_pane/delete_session_confirm_view.rs b/crates/tui/src/bottom_pane/delete_session_confirm_view.rs new file mode 100644 index 00000000..910d0376 --- /dev/null +++ b/crates/tui/src/bottom_pane/delete_session_confirm_view.rs @@ -0,0 +1,258 @@ +//! Secondary confirmation for `/delete`: title + horizontal Cancel/Delete chips. + +use crossterm::event::KeyCode; +use crossterm::event::KeyEvent; +use crossterm::event::KeyModifiers; +use ratatui::buffer::Buffer; +use ratatui::layout::Constraint; +use ratatui::layout::Layout; +use ratatui::layout::Rect; +use ratatui::style::Color; +use ratatui::style::Stylize; +use ratatui::text::Line; +use ratatui::text::Span; +use ratatui::widgets::Paragraph; +use ratatui::widgets::Widget; + +use crate::app_event_sender::AppEventSender; +use crate::key_hint; +use crate::render::renderable::Renderable; + +use super::CancellationEvent; +use super::bottom_pane_view::BottomPaneView; +use super::horizontal_chip_strip::HorizontalChipStrip; +use super::list_selection_view::SelectionAction; +use super::selection_popup_common::menu_surface_padding_height; +use super::selection_popup_common::render_menu_surface; + +const CANCEL_LABEL: &str = "Cancel"; +const DELETE_LABEL: &str = "Delete"; + +/// Bottom-pane confirmation for deleting the current session. +pub(crate) struct DeleteSessionConfirmView { + app_event_tx: AppEventSender, + accent_color: Color, + chips: HorizontalChipStrip, + on_delete: SelectionAction, + on_cancel: SelectionAction, + complete: bool, +} + +impl DeleteSessionConfirmView { + pub(crate) fn new( + app_event_tx: AppEventSender, + accent_color: Color, + on_delete: SelectionAction, + on_cancel: SelectionAction, + ) -> Self { + Self { + app_event_tx, + accent_color, + // Default to Cancel so Enter is safe. + chips: HorizontalChipStrip::new( + vec![CANCEL_LABEL.to_string(), DELETE_LABEL.to_string()], + /*selected*/ 0, + ), + on_delete, + on_cancel, + complete: false, + } + } + + fn accept(&mut self) { + match self.chips.selected_index() { + 0 => (self.on_cancel)(&self.app_event_tx), + _ => (self.on_delete)(&self.app_event_tx), + } + self.complete = true; + } + + fn footer_hint_line() -> Line<'static> { + Line::from(vec![ + Span::raw(" ".repeat(crate::ui_consts::FOOTER_INDENT_COLS)), + key_hint::plain(KeyCode::Left).into(), + Span::raw("/"), + key_hint::plain(KeyCode::Right).into(), + Span::raw(" choose "), + key_hint::plain(KeyCode::Enter).into(), + Span::raw(" confirm "), + key_hint::plain(KeyCode::Esc).into(), + Span::raw(" cancel"), + ]) + .dim() + } +} + +impl BottomPaneView for DeleteSessionConfirmView { + fn handle_key_event(&mut self, key_event: KeyEvent) { + match key_event { + KeyEvent { + code: KeyCode::Left, + modifiers: KeyModifiers::NONE, + .. + } => self.chips.move_left(), + KeyEvent { + code: KeyCode::Right, + modifiers: KeyModifiers::NONE, + .. + } => self.chips.move_right(), + KeyEvent { + code: KeyCode::Enter, + modifiers: KeyModifiers::NONE, + .. + } => self.accept(), + KeyEvent { + code: KeyCode::Esc, .. + } => { + (self.on_cancel)(&self.app_event_tx); + self.complete = true; + } + _ => {} + } + } + + fn is_complete(&self) -> bool { + self.complete + } + + fn on_ctrl_c(&mut self) -> CancellationEvent { + (self.on_cancel)(&self.app_event_tx); + self.complete = true; + CancellationEvent::Handled + } + + fn prefer_esc_to_handle_key_event(&self) -> bool { + // Route Esc through handle_key_event so it cancels the same way as the + // Cancel chip / footer hint, rather than only via on_ctrl_c. + true + } + + fn replaces_composer(&self) -> bool { + // Match other selection-style confirmations: stack under the draft. + false + } +} + +impl Renderable for DeleteSessionConfirmView { + fn desired_height(&self, _width: u16) -> u16 { + // menu padding + title + subtitle + gap + chips + gap + footer + menu_surface_padding_height() + .saturating_add(2) // title + subtitle + .saturating_add(1) // gap + .saturating_add(1) // chips + .saturating_add(1) // gap before footer + .saturating_add(1) // footer + } + + fn render(&self, area: Rect, buf: &mut Buffer) { + if area.height == 0 || area.width == 0 { + return; + } + + let [content_area, footer_area] = + Layout::vertical([Constraint::Fill(1), Constraint::Length(1)]).areas(area); + let content_area = render_menu_surface(content_area, buf); + + let [title_area, subtitle_area, _, chips_area] = Layout::vertical([ + Constraint::Length(1), + Constraint::Length(1), + Constraint::Length(1), + Constraint::Length(1), + ]) + .areas(content_area); + + Paragraph::new(Line::from("Delete session?".bold())).render(title_area, buf); + Paragraph::new(Line::from( + "This permanently removes the current session history.".dim(), + )) + .render(subtitle_area, buf); + + let chip_line = self + .chips + .render_line(usize::from(chips_area.width), self.accent_color); + Paragraph::new(chip_line).render(chips_area, buf); + + if footer_area.height > 0 { + Paragraph::new(Self::footer_hint_line()).render(footer_area, buf); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::app_event::AppEvent; + use crate::app_event_sender::AppEventSender; + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + use pretty_assertions::assert_eq; + use tokio::sync::mpsc::unbounded_channel; + + fn key(code: KeyCode) -> KeyEvent { + KeyEvent::new(code, KeyModifiers::NONE) + } + + fn view_with_rx() -> ( + DeleteSessionConfirmView, + tokio::sync::mpsc::UnboundedReceiver, + ) { + let (tx, rx) = unbounded_channel(); + let app_event_tx = AppEventSender::new(tx); + let view = DeleteSessionConfirmView::new( + app_event_tx.clone(), + Color::Cyan, + Box::new(|tx: &AppEventSender| { + tx.send(AppEvent::StatusMessageChanged { + message: "deleted".to_string(), + }); + }), + Box::new(|tx: &AppEventSender| { + tx.send(AppEvent::StatusMessageChanged { + message: "cancelled".to_string(), + }); + }), + ); + (view, rx) + } + + #[test] + fn enter_on_cancel_does_not_delete() { + let (mut view, mut rx) = view_with_rx(); + view.handle_key_event(key(KeyCode::Enter)); + assert!(view.is_complete()); + assert_eq!( + rx.try_recv().expect("cancel status"), + AppEvent::StatusMessageChanged { + message: "cancelled".to_string(), + } + ); + } + + #[test] + fn right_then_enter_deletes() { + let (mut view, mut rx) = view_with_rx(); + view.handle_key_event(key(KeyCode::Right)); + view.handle_key_event(key(KeyCode::Enter)); + assert!(view.is_complete()); + assert_eq!( + rx.try_recv().expect("delete status"), + AppEvent::StatusMessageChanged { + message: "deleted".to_string(), + } + ); + } + + #[test] + fn esc_cancels() { + let (mut view, mut rx) = view_with_rx(); + view.handle_key_event(key(KeyCode::Esc)); + assert!(view.is_complete()); + assert_eq!( + rx.try_recv().expect("cancel status"), + AppEvent::StatusMessageChanged { + message: "cancelled".to_string(), + } + ); + } +} diff --git a/crates/tui/src/bottom_pane/footer.rs b/crates/tui/src/bottom_pane/footer.rs index 6a0beefb..ec03cbc9 100644 --- a/crates/tui/src/bottom_pane/footer.rs +++ b/crates/tui/src/bottom_pane/footer.rs @@ -50,8 +50,6 @@ use crate::ui_consts::FOOTER_INDENT_COLS; use crossterm::event::KeyCode; use ratatui::buffer::Buffer; use ratatui::layout::Rect; -use ratatui::style::Color; -use ratatui::style::Style; use ratatui::style::Stylize; use ratatui::text::Line; use ratatui::text::Span; @@ -92,8 +90,6 @@ pub(crate) struct FooterProps { } const FOOTER_CONTEXT_GAP_COLS: u16 = 1; -const MODE_SWITCH_HINT: &str = " SHIFT+TAB switch"; -const MODE_SWITCH_HINT_COLOR: Color = Color::Rgb(160, 163, 168); /// Selects which footer content is rendered. /// @@ -273,16 +269,9 @@ fn left_side_line(input_mode_indicator: Option, state: LeftSideState) fn mode_indicator_spans( input_mode_indicator: InputMode, - show_switch_hint: bool, + _show_switch_hint: bool, ) -> Vec> { - let mut spans = vec![input_mode_indicator.styled_span(/*show_cycle_hint*/ false)]; - if show_switch_hint && matches!(input_mode_indicator, InputMode::Build | InputMode::Plan) { - spans.push(Span::styled( - MODE_SWITCH_HINT, - Style::default().fg(MODE_SWITCH_HINT_COLOR), - )); - } - spans + vec![input_mode_indicator.styled_span(/*show_cycle_hint*/ false)] } pub(crate) enum SummaryLeft { @@ -600,7 +589,7 @@ pub(crate) fn status_line_with_input_mode( if let Some(input_mode_indicator) = input_mode_indicator { spans.extend(mode_indicator_spans( input_mode_indicator, - /*show_switch_hint*/ true, + /*show_switch_hint*/ false, )); } if let Some(status_line) = status_line { @@ -728,6 +717,25 @@ mod tests { .map(|span| span.content.as_ref()) .collect() } + + #[test] + fn shortcut_overlay_lists_queue_delete_hint() { + let state = ShortcutsState { + use_shift_enter_hint: false, + esc_backtrack_hint: false, + is_wsl: false, + input_modes_enabled: false, + }; + let rendered = shortcut_overlay_lines(state) + .iter() + .map(line_text) + .collect::>() + .join("\n"); + assert!( + rendered.contains("ctrl + d to delete selected queue item"), + "overlay:\n{rendered}" + ); + } } #[derive(Clone, Copy, Debug)] @@ -762,6 +770,9 @@ fn shortcut_overlay_lines(state: ShortcutsState) -> Vec> { let mut shell_commands = Line::from(""); let mut newline = Line::from(""); let mut queue_message_tab = Line::from(""); + let mut queue_steer = Line::from(""); + let mut queue_edit = Line::from(""); + let mut queue_delete = Line::from(""); let mut file_paths = Line::from(""); let mut paste_image = Line::from(""); let mut external_editor = Line::from(""); @@ -777,6 +788,9 @@ fn shortcut_overlay_lines(state: ShortcutsState) -> Vec> { ShortcutId::ShellCommands => shell_commands = text, ShortcutId::InsertNewline => newline = text, ShortcutId::QueueMessageTab => queue_message_tab = text, + ShortcutId::QueueSteer => queue_steer = text, + ShortcutId::QueueEdit => queue_edit = text, + ShortcutId::QueueDelete => queue_delete = text, ShortcutId::FilePaths => file_paths = text, ShortcutId::PasteImage => paste_image = text, ShortcutId::ExternalEditor => external_editor = text, @@ -793,6 +807,9 @@ fn shortcut_overlay_lines(state: ShortcutsState) -> Vec> { shell_commands, newline, queue_message_tab, + queue_steer, + queue_edit, + queue_delete, file_paths, paste_image, external_editor, @@ -886,6 +903,9 @@ enum ShortcutId { ShellCommands, InsertNewline, QueueMessageTab, + QueueSteer, + QueueEdit, + QueueDelete, FilePaths, PasteImage, ExternalEditor, @@ -1004,6 +1024,33 @@ const SHORTCUTS: &[ShortcutDescriptor] = &[ prefix: "", label: " to queue message", }, + ShortcutDescriptor { + id: ShortcutId::QueueSteer, + bindings: &[ShortcutBinding { + key: key_hint::ctrl(KeyCode::Char('s')), + condition: DisplayCondition::Always, + }], + prefix: "", + label: " to steer selected queue item", + }, + ShortcutDescriptor { + id: ShortcutId::QueueEdit, + bindings: &[ShortcutBinding { + key: key_hint::ctrl(KeyCode::Char('e')), + condition: DisplayCondition::Always, + }], + prefix: "", + label: " to edit selected queue item", + }, + ShortcutDescriptor { + id: ShortcutId::QueueDelete, + bindings: &[ShortcutBinding { + key: key_hint::ctrl(KeyCode::Char('d')), + condition: DisplayCondition::Always, + }], + prefix: "", + label: " to delete selected queue item", + }, ShortcutDescriptor { id: ShortcutId::FilePaths, bindings: &[ShortcutBinding { diff --git a/crates/tui/src/bottom_pane/horizontal_chip_strip.rs b/crates/tui/src/bottom_pane/horizontal_chip_strip.rs new file mode 100644 index 00000000..e44b8ea1 --- /dev/null +++ b/crates/tui/src/bottom_pane/horizontal_chip_strip.rs @@ -0,0 +1,229 @@ +//! Horizontal chip strip: left/right selection with effort-style chips. +//! +//! Selected chips render as `[label]` with accent + bold + underline; others +//! are dim. When options do not fit, a sliding window shows `‹` / `›` markers. + +use ratatui::style::Color; +use ratatui::style::Style; +use ratatui::style::Stylize; +use ratatui::text::Line; +use ratatui::text::Span; +use unicode_width::UnicodeWidthStr; + +/// Horizontal selectable chip strip (←/→), matching `/model` effort visuals. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct HorizontalChipStrip { + options: Vec, + selected: usize, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct ChipWindow { + indices: Vec, + show_left_more: bool, + show_right_more: bool, +} + +impl HorizontalChipStrip { + /// Creates a strip. Empty `options` is ignored and yields an empty strip. + pub(crate) fn new(options: Vec, selected: usize) -> Self { + let selected = if options.is_empty() { + 0 + } else { + selected.min(options.len() - 1) + }; + Self { options, selected } + } + + pub(crate) fn selected_index(&self) -> usize { + self.selected + } + + pub(crate) fn is_empty(&self) -> bool { + self.options.is_empty() + } + + /// Move selection left, wrapping at the start. + pub(crate) fn move_left(&mut self) { + self.move_by(-1); + } + + /// Move selection right, wrapping at the end. + pub(crate) fn move_right(&mut self) { + self.move_by(1); + } + + fn move_by(&mut self, delta: isize) { + if self.options.is_empty() { + return; + } + let len = self.options.len() as isize; + self.selected = (self.selected as isize + delta).rem_euclid(len) as usize; + } + + /// Render the chip line for `available_width` content columns (no left pad). + pub(crate) fn render_line(&self, available_width: usize, accent: Color) -> Line<'static> { + if self.options.is_empty() { + return Line::from(""); + } + + let window = chip_window(&self.options, self.selected, available_width); + let mut spans = Vec::new(); + if window.show_left_more { + spans.push(Span::styled("‹ ".to_string(), Style::default().dim())); + } + for (idx_in_window, option_idx) in window.indices.iter().enumerate() { + if idx_in_window > 0 { + spans.push(Span::raw(" ")); + } + let label = &self.options[*option_idx]; + let is_selected = *option_idx == self.selected; + let text = if is_selected { + format!("[{label}]") + } else { + label.clone() + }; + let style = if is_selected { + Style::default().fg(accent).bold().underlined() + } else { + Style::default().dim() + }; + spans.push(Span::styled(text, style)); + } + if window.show_right_more { + spans.push(Span::styled(" ›".to_string(), Style::default().dim())); + } + Line::from(spans) + } +} + +fn chip_width(label: &str, selected: bool) -> usize { + if selected { + UnicodeWidthStr::width(label) + 2 + } else { + UnicodeWidthStr::width(label) + } +} + +fn chip_window(options: &[String], selected_idx: usize, available_width: usize) -> ChipWindow { + if options.is_empty() { + return ChipWindow { + indices: Vec::new(), + show_left_more: false, + show_right_more: false, + }; + } + + let selected_idx = selected_idx.min(options.len() - 1); + + let all_width = options + .iter() + .enumerate() + .map(|(idx, label)| { + let chip = chip_width(label, idx == selected_idx); + if idx == 0 { chip } else { chip + 2 } + }) + .sum::(); + if all_width <= available_width { + return ChipWindow { + indices: (0..options.len()).collect(), + show_left_more: false, + show_right_more: false, + }; + } + + // Sliding window centered on the selected option. + let marker_budget = 4; // "‹ " + " ›" + let content_budget = available_width.saturating_sub(marker_budget); + let mut start = selected_idx; + let mut end = selected_idx; + let mut used = chip_width(&options[selected_idx], true); + + loop { + let can_grow_left = start > 0; + let can_grow_right = end + 1 < options.len(); + if !can_grow_left && !can_grow_right { + break; + } + + let mut grew = false; + if can_grow_left { + let next = start - 1; + let extra = chip_width(&options[next], false) + 2; + if used + extra <= content_budget { + start = next; + used += extra; + grew = true; + } + } + if can_grow_right { + let next = end + 1; + let extra = chip_width(&options[next], false) + 2; + if used + extra <= content_budget { + end = next; + used += extra; + grew = true; + } + } + if !grew { + break; + } + } + + ChipWindow { + indices: (start..=end).collect(), + show_left_more: start > 0, + show_right_more: end + 1 < options.len(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + use ratatui::style::Color; + + #[test] + fn move_left_right_wraps() { + let mut strip = HorizontalChipStrip::new( + vec!["Cancel".to_string(), "Delete".to_string()], + /*selected*/ 0, + ); + assert_eq!(strip.selected_index(), 0); + strip.move_right(); + assert_eq!(strip.selected_index(), 1); + strip.move_right(); + assert_eq!(strip.selected_index(), 0); + strip.move_left(); + assert_eq!(strip.selected_index(), 1); + } + + #[test] + fn render_selected_uses_brackets() { + let strip = HorizontalChipStrip::new( + vec!["Cancel".to_string(), "Delete".to_string()], + /*selected*/ 0, + ); + let line = strip.render_line(80, Color::Cyan); + let text: String = line.spans.iter().map(|s| s.content.as_ref()).collect(); + assert_eq!(text, "[Cancel] Delete"); + } + + #[test] + fn window_slides_when_narrow() { + let options = vec![ + "Minimal".to_string(), + "Low".to_string(), + "Medium".to_string(), + "High".to_string(), + "XHigh".to_string(), + ]; + let window = chip_window( + &options, /*selected_idx*/ 2, /*available_width*/ 20, + ); + assert!( + window.show_left_more || window.show_right_more || window.indices.len() < options.len() + ); + assert!(window.indices.contains(&2)); + } +} diff --git a/crates/tui/src/bottom_pane/mod.rs b/crates/tui/src/bottom_pane/mod.rs index 2db547de..4d0fdd11 100644 --- a/crates/tui/src/bottom_pane/mod.rs +++ b/crates/tui/src/bottom_pane/mod.rs @@ -1,5 +1,6 @@ use std::path::PathBuf; use std::time::Duration; +use std::time::Instant; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; @@ -14,24 +15,24 @@ use devo_protocol::user_input::TextElement; use ratatui::buffer::Buffer; use ratatui::layout::Rect; use ratatui::style::Color; -use ratatui::style::Style; -use ratatui::style::Stylize; use ratatui::text::Line; -use ratatui::text::Span; -use ratatui::widgets::Paragraph; -use ratatui::widgets::Widget; -use unicode_width::UnicodeWidthStr; mod approval_overlay; pub(crate) mod bottom_pane_view; mod chat_composer; mod chat_composer_history; mod command_popup; +mod compaction_threshold_view; +mod context_occupancy_view; mod custom_prompt_view; +mod delete_session_confirm_view; mod footer; +mod horizontal_chip_strip; mod input_mode; pub(crate) mod list_selection_view; +mod model_picker; mod paste_burst; +mod pending_queue; mod pending_thread_approvals; pub(crate) mod popup_consts; mod prompt_args; @@ -40,6 +41,7 @@ mod reference_popup; mod request_user_input_overlay; pub(crate) mod scroll_state; mod selection_popup_common; +mod settings_hub_view; pub(crate) mod slash_commands; pub(crate) mod textarea; mod theme_picker; @@ -50,22 +52,43 @@ pub(crate) use approval_overlay::ApprovalOverlayRequest; pub(crate) use chat_composer::ChatComposer; use chat_composer::ChatComposerConfig; use chat_composer::InputResult as ComposerInputResult; +pub(crate) use compaction_threshold_view::CompactionThresholdSnapshot; +use compaction_threshold_view::CompactionThresholdView; +pub(crate) use compaction_threshold_view::format_token_limit; +pub(crate) use compaction_threshold_view::recommended_compaction_token_limit; +use context_occupancy_view::ContextOccupancyView; +pub(crate) use context_occupancy_view::SessionTokenTotals; +pub(crate) use context_occupancy_view::StatusPanelSnapshot; pub(crate) use custom_prompt_view::CustomPromptView; +pub(crate) use delete_session_confirm_view::DeleteSessionConfirmView; +pub(crate) use horizontal_chip_strip::HorizontalChipStrip; pub(crate) use input_mode::InputMode; +pub(crate) use model_picker::ModelPickerEffortOption; +pub(crate) use model_picker::ModelPickerEntry; +pub(crate) use model_picker::ModelPickerSelection; +use model_picker::ModelPickerView; pub(crate) use proposed_plan_actions_view::ProposedPlanActionsView; +pub(crate) use settings_hub_view::SettingsHubSnapshot; +pub(crate) use settings_hub_view::SettingsHubTab; +use settings_hub_view::SettingsHubView; use crate::app_command::AppCommand; use crate::app_command::InputHistoryDirection; use crate::app_event::AppEvent; use crate::app_event_sender::AppEventSender; use crate::bottom_pane::bottom_pane_view::BottomPaneView; +pub(crate) use crate::bottom_pane::pending_queue::PendingQueueItem; +use crate::bottom_pane::pending_queue::PendingQueueList; +use crate::bottom_pane::pending_queue::PendingQueueState; +pub(crate) use crate::bottom_pane::pending_queue::QueueNavResult; use crate::bottom_pane::pending_thread_approvals::PendingThreadApprovals; use crate::bottom_pane::request_user_input_overlay::RequestUserInputOverlay; use crate::bottom_pane::unified_exec_footer::UnifiedExecFooter; -use crate::render::line_utils::prefix_lines; use crate::render::renderable::Renderable; use crate::slash_command::SlashCommand; use crate::status_indicator_widget::StatusIndicatorWidget; +use crate::status_indicator_widget::TIP_ROTATION_INTERVAL; +use crate::status_indicator_widget::composer_tip_placeholder; use crate::tui::frame_requester::FrameRequester; pub(crate) const QUIT_SHORTCUT_TIMEOUT: Duration = Duration::from_secs(2); @@ -188,22 +211,30 @@ pub(crate) enum InputResult { }, ModelSelected { model: String, + reasoning_effort: Option, }, ThemeSelected { name: String, }, + /// Steer the selected queued item into the active turn. + QueueSteer { + queue_item_id: String, + }, + /// Remove the selected queued item and load its text into the composer. + QueueEdit { + queue_item_id: String, + text: String, + }, + /// Remove the selected queued item without loading it into the composer. + QueueRemove { + queue_item_id: String, + }, + InputModeChanged { + input_mode: InputMode, + }, None, } -#[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) struct ModelPickerEntry { - pub(crate) selection_value: String, - pub(crate) display_name: String, - pub(crate) description: Option, - pub(crate) right_hint: Option, - pub(crate) is_current: bool, -} - pub(crate) struct BottomPaneParams { pub(crate) app_event_tx: AppEventSender, pub(crate) frame_requester: FrameRequester, @@ -222,10 +253,11 @@ pub(crate) struct BottomPane { frame_requester: FrameRequester, unified_exec_footer: UnifiedExecFooter, pending_thread_approvals: PendingThreadApprovals, - /// User messages queued while a turn was active, shown above the composer - /// as pending cells. Each entry is the raw text of one queued prompt. - pending_cell_texts: Vec, + /// User messages queued while a turn was active, shown below the composer. + pending_queue: PendingQueueState, placeholder_text: String, + /// Wall-clock start for rotating composer placeholder tips (`Tip: …`). + placeholder_tips_started_at: Instant, /// Status indicator shown above the composer while a task is running. status: Option, subagent_hint_visible: bool, @@ -263,15 +295,19 @@ impl BottomPane { ); composer.set_frame_requester(frame_requester.clone()); composer.set_skill_mentions(skills); - Self { + let placeholder_tips_started_at = Instant::now(); + let placeholder_text = composer_tip_placeholder(Duration::ZERO).unwrap_or(placeholder_text); + composer.set_placeholder_text(placeholder_text.clone()); + let pane = Self { composer, view_stack: Vec::new(), app_event_tx, frame_requester, unified_exec_footer: UnifiedExecFooter::new(), pending_thread_approvals: PendingThreadApprovals::new(), - pending_cell_texts: Vec::new(), + pending_queue: PendingQueueState::default(), placeholder_text, + placeholder_tips_started_at, status: None, subagent_hint_visible: false, is_task_running: false, @@ -284,12 +320,17 @@ impl BottomPane { external_history_draft: None, input_mode: InputMode::Build, accent_color: Color::Cyan, - } + }; + pane.schedule_placeholder_tip_redraw(); + pane } pub(crate) fn set_accent_color(&mut self, color: Color) { self.accent_color = color; self.composer.set_accent_color(color); + for view in &mut self.view_stack { + view.set_accent_color(color); + } self.request_redraw(); } pub(crate) fn input_mode(&self) -> InputMode { @@ -309,6 +350,14 @@ impl BottomPane { self.set_input_mode(self.input_mode.next()); } + pub(crate) fn cycle_build_plan_mode(&mut self) { + let next = match self.input_mode { + InputMode::Build => InputMode::Plan, + InputMode::Plan | InputMode::Shell => InputMode::Build, + }; + self.set_input_mode(next); + } + pub(crate) fn set_skill_mentions(&mut self, skills: Option>) { self.composer.set_skill_mentions(skills); self.request_redraw(); @@ -325,12 +374,41 @@ impl BottomPane { } if is_input_mode_cycle_key(key) && !self.composer.popup_active() { + let previous = self.input_mode; self.cycle_input_mode(); + if self.input_mode != previous { + return InputResult::InputModeChanged { + input_mode: self.input_mode, + }; + } return InputResult::None; } if is_bare_shell_mode_trigger(key) && self.composer.is_empty() { + let previous = self.input_mode; self.set_input_mode(InputMode::Shell); + if self.input_mode != previous { + return InputResult::InputModeChanged { + input_mode: self.input_mode, + }; + } + return InputResult::None; + } + + if self.pending_queue.focused() + && matches!(key.kind, KeyEventKind::Press | KeyEventKind::Repeat) + { + return self.handle_pending_queue_key(key); + } + + if key.code == KeyCode::Down + && matches!(key.kind, KeyEventKind::Press | KeyEventKind::Repeat) + && key.modifiers == KeyModifiers::NONE + && self.composer.is_empty() + && self.has_pending_cells() + && !self.composer.popup_active() + { + self.focus_pending_queue(); return InputResult::None; } @@ -385,6 +463,49 @@ impl BottomPane { self.map_composer_input_result(input_result) } + fn handle_pending_queue_key(&mut self, key: KeyEvent) -> InputResult { + match key.code { + KeyCode::Down => { + self.pending_queue_select_next(); + InputResult::None + } + KeyCode::Up => { + let _ = self.pending_queue_select_prev(); + InputResult::None + } + KeyCode::Esc => { + self.clear_pending_queue_focus(); + InputResult::None + } + KeyCode::Char('s') if key.modifiers.contains(KeyModifiers::CONTROL) => { + let Some(item) = self.selected_pending_queue_item() else { + return InputResult::None; + }; + InputResult::QueueSteer { + queue_item_id: item.queue_item_id.clone(), + } + } + KeyCode::Char('e') if key.modifiers.contains(KeyModifiers::CONTROL) => { + let Some(item) = self.selected_pending_queue_item() else { + return InputResult::None; + }; + InputResult::QueueEdit { + queue_item_id: item.queue_item_id.clone(), + text: item.text.clone(), + } + } + KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::CONTROL) => { + let Some(item) = self.selected_pending_queue_item() else { + return InputResult::None; + }; + InputResult::QueueRemove { + queue_item_id: item.queue_item_id.clone(), + } + } + _ => InputResult::None, + } + } + pub fn handle_paste(&mut self, pasted: String) { if !self.view_stack.is_empty() { let (needs_redraw, view_complete) = { @@ -422,6 +543,7 @@ impl BottomPane { } pub(crate) fn pre_draw_tick(&mut self) { + self.sync_placeholder_tip(); self.composer.sync_popups(); if self.composer.flush_paste_burst_if_due() { self.request_redraw(); @@ -437,6 +559,46 @@ impl BottomPane { self.request_redraw(); } + /// Restore the rotating `Tip: …` composer placeholder (replaces a fixed default). + pub(crate) fn set_default_placeholder(&mut self) { + self.sync_placeholder_tip(); + self.schedule_placeholder_tip_redraw(); + } + + fn sync_placeholder_tip(&mut self) { + let Some(text) = composer_tip_placeholder(self.placeholder_tips_started_at.elapsed()) + else { + return; + }; + if self.placeholder_text == text { + self.schedule_placeholder_tip_redraw(); + return; + } + self.placeholder_text = text.clone(); + self.composer.set_placeholder_text(text); + self.request_redraw(); + self.schedule_placeholder_tip_redraw(); + } + + fn schedule_placeholder_tip_redraw(&self) { + if !self.animations_enabled { + return; + } + let elapsed = self.placeholder_tips_started_at.elapsed(); + let interval_secs = TIP_ROTATION_INTERVAL.as_secs().max(1); + let into = Duration::from_secs(elapsed.as_secs() % interval_secs); + let until_next = TIP_ROTATION_INTERVAL.saturating_sub(into); + self.request_redraw_in(until_next.max(Duration::from_millis(50))); + } + + #[cfg(test)] + pub(crate) fn set_placeholder_tips_elapsed_for_test(&mut self, elapsed: Duration) { + self.placeholder_tips_started_at = Instant::now() + .checked_sub(elapsed) + .unwrap_or_else(Instant::now); + self.sync_placeholder_tip(); + } + pub(crate) fn clear_composer(&mut self) { self.composer .set_text_content(String::new(), Vec::new(), Vec::new()); @@ -496,6 +658,17 @@ impl BottomPane { self.push_view(Box::new(ModelPickerView::new(entries, self.accent_color))); } + pub(crate) fn open_status_panel( + &mut self, + occupancy: Option, + session: SessionTokenTotals, + status: StatusPanelSnapshot, + ) { + self.push_view(Box::new(ContextOccupancyView::new( + occupancy, session, status, + ))); + } + pub(crate) fn open_theme_picker( &mut self, themes: &[crate::theme::Theme], @@ -507,6 +680,55 @@ impl BottomPane { ))); } + pub(crate) fn open_settings_hub(&mut self, snapshot: SettingsHubSnapshot) { + self.push_view(Box::new(SettingsHubView::new( + snapshot, + self.app_event_tx.clone(), + self.accent_color, + ))); + } + + pub(crate) fn open_settings_hub_on_tab( + &mut self, + snapshot: SettingsHubSnapshot, + tab: settings_hub_view::SettingsHubTab, + ) { + self.push_view(Box::new( + SettingsHubView::new(snapshot, self.app_event_tx.clone(), self.accent_color) + .with_tab(tab), + )); + } + + pub(crate) fn open_compaction_threshold(&mut self, snapshot: CompactionThresholdSnapshot) { + self.push_view(Box::new(CompactionThresholdView::new( + snapshot, + self.app_event_tx.clone(), + self.accent_color, + ))); + } + + pub(crate) fn refresh_settings_hub(&mut self, snapshot: SettingsHubSnapshot) { + for view in self.view_stack.iter_mut().rev() { + if view.update_settings_hub_snapshot(snapshot.clone()) { + self.request_redraw(); + break; + } + } + } + + pub(crate) fn refresh_status_panel( + &mut self, + occupancy: Option, + session: SessionTokenTotals, + ) { + for view in self.view_stack.iter_mut().rev() { + if view.update_status_panel(occupancy.clone(), session) { + self.request_redraw(); + break; + } + } + } + pub(crate) fn open_popup_view(&mut self, view: Box) { self.push_view(view); } @@ -569,6 +791,14 @@ impl BottomPane { self.request_redraw(); } + /// Move the composer cursor to the end of its text (e.g. after loading a + /// queued message for editing, so new input appends instead of + /// prepending). + pub(crate) fn move_composer_cursor_to_end(&mut self) { + self.composer.move_cursor_to_end(); + self.request_redraw(); + } + pub(crate) fn is_normal_backtrack_mode(&self) -> bool { self.active_view().is_none() && !self.is_task_running @@ -600,6 +830,12 @@ impl BottomPane { } } + pub(crate) fn set_context_window_label(&mut self, label: Option) { + if self.composer.set_context_window_label(label) { + self.request_redraw(); + } + } + pub(crate) fn set_active_agent_label(&mut self, active_agent_label: Option) { if self.composer.set_active_agent_label(active_agent_label) { self.request_redraw(); @@ -624,9 +860,14 @@ impl BottomPane { .set_subagent_hint_visible(self.subagent_hint_visible && !status_visible); } + pub(crate) fn is_task_running(&self) -> bool { + self.is_task_running + } + pub(crate) fn set_task_running(&mut self, running: bool) { let was_running = self.is_task_running; self.is_task_running = running; + self.composer.set_task_running(running); if running { self.pending_interrupt_esc = false; self.interrupt_requested = false; @@ -682,28 +923,73 @@ impl BottomPane { } } - pub(crate) fn push_pending_cell(&mut self, text: String) { - self.pending_cell_texts.push(text); + pub(crate) fn replace_pending_queue(&mut self, items: Vec) { + self.pending_queue.replace_items(items); self.request_redraw(); } + pub(crate) fn pending_queue_items(&self) -> &[PendingQueueItem] { + self.pending_queue.items() + } + + pub(crate) fn pending_queue_focused(&self) -> bool { + self.pending_queue.focused() + } + + pub(crate) fn focus_pending_queue(&mut self) -> bool { + let focused = self.pending_queue.focus_first(); + if focused { + self.request_redraw(); + } + focused + } + + pub(crate) fn clear_pending_queue_focus(&mut self) { + if self.pending_queue.focused() { + self.pending_queue.clear_focus(); + self.request_redraw(); + } + } + + pub(crate) fn pending_queue_select_next(&mut self) -> bool { + let handled = self.pending_queue.select_next(); + if handled { + self.request_redraw(); + } + handled + } + + pub(crate) fn pending_queue_select_prev(&mut self) -> QueueNavResult { + let result = self.pending_queue.select_prev(); + if result != QueueNavResult::Ignored { + self.request_redraw(); + } + result + } + + pub(crate) fn selected_pending_queue_item(&self) -> Option<&PendingQueueItem> { + self.pending_queue.selected_item() + } + /// Pop the oldest pending cell (FIFO). Returns its text, or None if empty. pub(crate) fn pop_oldest_pending_cell(&mut self) -> Option { - if self.pending_cell_texts.is_empty() { + if self.pending_queue.is_empty() { return None; } - let result = Some(self.pending_cell_texts.remove(0)); + let mut items = self.pending_queue.items().to_vec(); + let removed = items.remove(0); + self.pending_queue.replace_items(items); self.request_redraw(); - result + Some(removed.text) } pub(crate) fn has_pending_cells(&self) -> bool { - !self.pending_cell_texts.is_empty() + !self.pending_queue.is_empty() } pub(crate) fn clear_pending_cells(&mut self) { - if !self.pending_cell_texts.is_empty() { - self.pending_cell_texts.clear(); + if !self.pending_queue.is_empty() { + self.pending_queue.clear(); self.request_redraw(); } } @@ -737,19 +1023,23 @@ impl BottomPane { self.view_stack.last().map(std::convert::AsRef::as_ref) } - /// Children for an open bottom-pane view: optional status, composer, then - /// the view below — matching `@` / `/` composer popups. + /// Children for an open bottom-pane view: optional status, optionally the + /// composer, then the view. Views that [`BottomPaneView::replaces_composer`] + /// occupy the input area instead of stacking below a draft. + /// + /// Those replacing views also paint a menu-surface background; stacked + /// views may paint one when they need panel chrome (see + /// `replaces_composer` docs). fn active_view_layout_children<'a>( &'a self, view: &'a dyn BottomPaneView, - include_composer: bool, ) -> Vec<&'a dyn Renderable> { let mut children: Vec<&dyn Renderable> = Vec::with_capacity(4); if let Some(status) = &self.status { children.push(&STATUS_SEPARATOR); children.push(status); } - if include_composer { + if !view.replaces_composer() { children.push(&self.composer); } children.push(view); @@ -791,8 +1081,11 @@ impl BottomPane { let selected_model = view.take_model_selection(); let selected_theme = view.take_theme_selection(); self.request_redraw(); - if let Some(model) = selected_model { - return InputResult::ModelSelected { model }; + if let Some(selection) = selected_model { + return InputResult::ModelSelected { + model: selection.model, + reasoning_effort: selection.reasoning_effort, + }; } if let Some(name) = selected_theme { return InputResult::ThemeSelected { name }; @@ -959,65 +1252,13 @@ impl BottomPane { } } -/// Thin renderable wrapper around a slice of pending cell texts. -/// Each cell is rendered with a `┃` prefix and a `QUEUED` badge, matching the -/// style of a normal user input cell in the history transcript. -struct PendingCellList<'a> { - texts: &'a [String], -} - -impl Renderable for PendingCellList<'_> { - fn render(&self, area: Rect, buf: &mut Buffer) { - if area.is_empty() || self.texts.is_empty() { - return; - } - let mut lines: Vec> = Vec::new(); - for text in self.texts { - lines.push(Line::from("")); - lines.push(Line::from(" QUEUED".cyan().bold())); - let wrapped = crate::wrapping::adaptive_wrap_lines( - text.lines().map(|line| Line::from(line.to_string())), - crate::wrapping::RtOptions::new(area.width as usize), - ); - if !wrapped.is_empty() { - let has_more = wrapped.len() > 3; - let truncated: Vec<_> = wrapped.into_iter().take(3).collect(); - lines.extend(prefix_lines(truncated, "┃ ".cyan(), "┃ ".cyan())); - if has_more { - lines.push(Line::from("┃ …".cyan())); // truncated_extra - } - } - } - Paragraph::new(lines).render(area, buf); - } - - fn desired_height(&self, _width: u16) -> u16 { - if self.texts.is_empty() { - return 0; - } - // Each cell: blank line + wrapped content + QUEUED badge - let content_lines: usize = self - .texts - .iter() - .map(|t| { - // blank + QUEUED badge + min(content_lines, 3) + "..." - let line_count = t.lines().count().min(3); - let truncated_extra = if t.lines().count() > 3 { 1 } else { 0 }; - // blank line + QUEUED + content + - 2 + line_count + truncated_extra - }) - .sum(); - content_lines as u16 - } -} - impl Renderable for BottomPane { fn render(&self, area: Rect, buf: &mut Buffer) { if area.is_empty() { return; } if let Some(view) = self.active_view() { - let children = self.active_view_layout_children(view, /*include_composer*/ true); + let children = self.active_view_layout_children(view); self.render_children(area, buf, &children); return; } @@ -1031,20 +1272,18 @@ impl Renderable for BottomPane { if self.status.is_none() && !self.unified_exec_footer.is_empty() { children.push(&self.unified_exec_footer); } - let pending_cells = PendingCellList { - texts: &self.pending_cell_texts, - }; - if pending_cells.desired_height(area.width) > 0 { - children.push(&pending_cells); - } children.push(&self.pending_thread_approvals); children.push(&self.composer); + let pending_queue = PendingQueueList::new(&self.pending_queue); + if pending_queue.desired_height(area.width) > 0 { + children.push(&pending_queue); + } self.render_children(area, buf, &children); } fn desired_height(&self, width: u16) -> u16 { if let Some(view) = self.active_view() { - let children = self.active_view_layout_children(view, /*include_composer*/ true); + let children = self.active_view_layout_children(view); return self.desired_children_height(width, &children); } let mut children: Vec<&dyn Renderable> = Vec::with_capacity(5); @@ -1055,21 +1294,20 @@ impl Renderable for BottomPane { if self.status.is_none() && !self.unified_exec_footer.is_empty() { children.push(&self.unified_exec_footer); } - let pending_cells = PendingCellList { - texts: &self.pending_cell_texts, - }; - if pending_cells.desired_height(width) > 0 { - children.push(&pending_cells); - } children.push(&self.pending_thread_approvals); children.push(&self.composer); + let pending_queue = PendingQueueList::new(&self.pending_queue); + if pending_queue.desired_height(width) > 0 { + children.push(&pending_queue); + } self.desired_children_height(width, &children) } fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> { if let Some(view) = self.active_view() { - // Include composer for vertical offset (view sits below it), but skip - // its caret so an unfocused draft does not steal focus from the panel. + // When the view stacks below the composer, include composer height for + // vertical offset but skip its caret so an unfocused draft does not + // steal focus. Views that replace the composer start at the input area. let mut y = area.y; if let Some(status) = &self.status { for child in [&STATUS_SEPARATOR as &dyn Renderable, status] { @@ -1077,7 +1315,9 @@ impl Renderable for BottomPane { y = y.saturating_add(height); } } - y = y.saturating_add(self.composer.desired_height(area.width)); + if !view.replaces_composer() { + y = y.saturating_add(self.composer.desired_height(area.width)); + } let view_area = Rect::new(area.x, y, area.width, area.bottom().saturating_sub(y)) .intersection(area); return view.cursor_pos(view_area); @@ -1090,146 +1330,13 @@ impl Renderable for BottomPane { if self.status.is_none() && !self.unified_exec_footer.is_empty() { children.push(&self.unified_exec_footer); } - let pending_cells = PendingCellList { - texts: &self.pending_cell_texts, - }; - if pending_cells.desired_height(area.width) > 0 { - children.push(&pending_cells); - } children.push(&self.pending_thread_approvals); children.push(&self.composer); + // Queue renders below the composer and does not own the caret. self.child_cursor_pos(area, &children) } } -struct ModelPickerView { - entries: Vec, - selection: usize, - complete: bool, - selected_model: Option, - accent_color: Color, -} - -impl ModelPickerView { - fn new(entries: Vec, accent_color: Color) -> Self { - let selection = entries - .iter() - .position(|entry| entry.is_current) - .unwrap_or(0); - Self { - entries, - selection, - complete: false, - selected_model: None, - accent_color, - } - } - - fn move_selection(&mut self, delta: isize) { - if self.entries.is_empty() { - self.selection = 0; - } else { - self.selection = - (self.selection as isize + delta).rem_euclid(self.entries.len() as isize) as usize; - } - } - - fn accept(&mut self) { - self.selected_model = self - .entries - .get(self.selection) - .map(|entry| entry.selection_value.clone()); - self.complete = true; - } - - fn render_lines(&self, width: u16) -> Vec> { - let mut lines: Vec> = Vec::new(); - for (index, entry) in self.entries.iter().enumerate() { - let is_selected = index == self.selection; - let marker = if entry.is_current { - "●" - } else if is_selected { - "›" - } else { - " " - }; - let marker_style = if entry.is_current { - Style::default().fg(self.accent_color).bold() - } else { - Style::default() - }; - let label_style = if is_selected { - Style::default().fg(self.accent_color).bold() - } else if !entry.is_current { - Style::default().dim() - } else { - Style::default() - }; - let mut title_spans = vec![ - Span::styled(marker.to_string(), marker_style), - Span::raw(" "), - Span::styled(entry.display_name.clone(), label_style), - ]; - if let Some(right_hint) = entry - .right_hint - .as_deref() - .map(str::trim) - .filter(|right_hint| !right_hint.is_empty()) - { - let title_width = Line::from(title_spans.clone()).width(); - let right_hint_width = UnicodeWidthStr::width(right_hint); - let padding = usize::from(width) - .saturating_sub(title_width + right_hint_width) - .max(2); - title_spans.push(Span::raw(" ".repeat(padding))); - title_spans.push(Span::styled(right_hint.to_string(), Style::default().dim())); - } - lines.push(Line::from(title_spans)); - if let Some(description) = entry.description.as_deref() - && !description.trim().is_empty() - { - lines.push(Line::from(format!(" {description}")).dim()); - } - } - lines - } -} - -impl BottomPaneView for ModelPickerView { - fn handle_key_event(&mut self, key_event: KeyEvent) { - match key_event.code { - KeyCode::Esc => self.complete = true, - KeyCode::Up => self.move_selection(-1), - KeyCode::Down => self.move_selection(1), - KeyCode::Enter => self.accept(), - _ => {} - } - } - - fn is_complete(&self) -> bool { - self.complete - } - - fn on_ctrl_c(&mut self) -> CancellationEvent { - self.complete = true; - CancellationEvent::Handled - } - - fn take_model_selection(&mut self) -> Option { - self.selected_model.take() - } -} - -impl Renderable for ModelPickerView { - fn render(&self, area: Rect, buf: &mut Buffer) { - Paragraph::new(self.render_lines(area.width)).render(area, buf); - } - - fn desired_height(&self, width: u16) -> u16 { - u16::try_from(self.render_lines(width).len()).unwrap_or(u16::MAX) - } -} - #[cfg(test)] mod tests { use pretty_assertions::assert_eq; @@ -1336,4 +1443,170 @@ mod tests { assert_eq!(cursor, None); } } + + #[test] + fn model_picker_replaces_composer_input_area() { + let mut pane = test_bottom_pane(); + let draft = "draft should not appear while model picker is open"; + pane.set_text_content(draft.to_string(), Vec::new(), Vec::new()); + + pane.open_model_picker(vec![ModelPickerEntry { + selection_value: "gpt".to_string(), + display_name: "GPT".to_string(), + right_hint: Some("OpenAI".to_string()), + is_current: true, + effort_options: vec![ModelPickerEffortOption { + label: "High".to_string(), + value: "high".to_string(), + }], + selected_effort: Some("high".to_string()), + }]); + + let picker_height = pane.desired_height(/*width*/ 80); + assert!(picker_height > 0); + + let rendered = render_bottom_pane(&pane, /*width*/ 80); + assert!(rendered.contains("GPT"), "model row missing:\n{rendered}"); + assert!( + !rendered.contains(draft), + "composer draft should be hidden while model picker replaces input:\n{rendered}" + ); + assert!( + rendered.contains("[High]") || rendered.contains("High"), + "effort chips missing:\n{rendered}" + ); + assert!( + !rendered.contains("Reasoning"), + "effort strip should not show Reasoning label:\n{rendered}" + ); + } + + #[test] + fn context_occupancy_stacks_below_composer_draft() { + let mut pane = test_bottom_pane(); + let draft = "keep draft while context panel is open"; + pane.set_text_content(draft.to_string(), Vec::new(), Vec::new()); + let composer_only_height = pane.desired_height(/*width*/ 80); + + pane.open_status_panel( + None, + SessionTokenTotals { + input: 1_000, + output: 100, + cache_read: 500, + }, + StatusPanelSnapshot { + cwd: "/tmp/project".to_string(), + permissions_label: "Ask for approval".to_string(), + }, + ); + + let stacked_height = pane.desired_height(/*width*/ 80); + assert!( + stacked_height > composer_only_height, + "stacked height {stacked_height} should exceed composer-only {composer_only_height}" + ); + + let rendered = render_bottom_pane(&pane, /*width*/ 80); + assert!( + rendered.contains(draft), + "composer draft should stay visible:\n{rendered}" + ); + assert!( + rendered.contains("Status"), + "status panel title missing:\n{rendered}" + ); + assert!( + rendered.contains("Context Usage"), + "context usage section missing:\n{rendered}" + ); + assert!( + rendered.contains("Token Usage"), + "token usage section missing:\n{rendered}" + ); + assert!( + !rendered.lines().any(|line| line.trim() == "Session"), + "Session heading should be gone:\n{rendered}" + ); + let draft_row = rendered + .lines() + .position(|line| line.contains(draft)) + .expect("missing composer draft"); + let panel_row = rendered + .lines() + .position(|line| line.contains("Status")) + .expect("missing status title"); + assert!( + panel_row > draft_row, + "status panel should render below composer; draft_row={draft_row} panel_row={panel_row}\n{rendered}" + ); + } + + #[test] + fn pending_queue_stacks_below_composer_and_supports_edit_key() { + let mut pane = test_bottom_pane(); + let draft = "composer draft"; + pane.set_text_content(draft.to_string(), Vec::new(), Vec::new()); + pane.replace_pending_queue(vec![PendingQueueItem { + queue_item_id: "q1".into(), + text: "queued\nmulti".into(), + }]); + let rendered = render_bottom_pane(&pane, /*width*/ 80); + let draft_row = rendered + .lines() + .position(|line| line.contains(draft)) + .expect("missing composer draft"); + let queue_row = rendered + .lines() + .position(|line| line.contains("1 ›") && line.contains("queued multi")) + .expect("missing queue row"); + assert!( + queue_row > draft_row, + "queue should render below composer; draft_row={draft_row} queue_row={queue_row}\n{rendered}" + ); + + pane.set_text_content(String::new(), Vec::new(), Vec::new()); + assert_eq!( + pane.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)), + InputResult::None + ); + assert!(pane.pending_queue_focused()); + assert_eq!( + pane.handle_key_event(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::CONTROL)), + InputResult::QueueSteer { + queue_item_id: "q1".into(), + } + ); + assert_eq!( + pane.handle_key_event(KeyEvent::new(KeyCode::Char('e'), KeyModifiers::CONTROL)), + InputResult::QueueEdit { + queue_item_id: "q1".into(), + text: "queued\nmulti".into(), + } + ); + assert_eq!( + pane.handle_key_event(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL)), + InputResult::QueueRemove { + queue_item_id: "q1".into(), + } + ); + } + + #[test] + fn composer_placeholder_rotates_working_tips() { + use crate::status_indicator_widget::WORKING_TIPS; + + let mut pane = test_bottom_pane(); + assert_eq!(pane.placeholder_text(), format!("Tip: {}", WORKING_TIPS[0])); + + pane.set_placeholder_tips_elapsed_for_test(Duration::from_secs(6)); + assert_eq!(pane.placeholder_text(), format!("Tip: {}", WORKING_TIPS[1])); + + pane.set_default_placeholder(); + assert_eq!( + pane.placeholder_text(), + format!("Tip: {}", WORKING_TIPS[1]), + "default placeholder should keep the current rotating tip" + ); + } } diff --git a/crates/tui/src/bottom_pane/model_picker.rs b/crates/tui/src/bottom_pane/model_picker.rs new file mode 100644 index 00000000..fb80de69 --- /dev/null +++ b/crates/tui/src/bottom_pane/model_picker.rs @@ -0,0 +1,1033 @@ +//! Combined model + reasoning-effort picker for `/model`. +//! +//! Shows a vertically scrollable model list with a horizontal reasoning-effort +//! strip below it. Up/Down (and j/k) move the model selection; Left/Right cycle +//! effort for the focused model; Enter applies both in one step. +//! +//! Replaces the composer input area, so it paints the shared menu-surface +//! background — see [`BottomPaneView::replaces_composer`]. + +use crossterm::event::KeyCode; +use crossterm::event::KeyEvent; +use crossterm::event::KeyModifiers; +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::style::Color; +use ratatui::style::Style; +use ratatui::style::Stylize; +use ratatui::text::Line; +use ratatui::text::Span; +use ratatui::widgets::Paragraph; +use ratatui::widgets::Widget; +use unicode_width::UnicodeWidthStr; + +use crate::key_hint; +use crate::line_truncation::truncate_line_with_ellipsis_if_overflow; +use crate::render::renderable::Renderable; + +use super::CancellationEvent; +use super::bottom_pane_view::BottomPaneView; +use super::popup_consts::MAX_POPUP_ROWS; +use super::scroll_state::ScrollState; +use super::selection_popup_common::menu_surface_padding_height; +use super::selection_popup_common::render_menu_surface; + +/// Marker column + following space before the model name. +const MARKER_COLS: usize = 2; + +/// One reasoning-effort chip shown in the horizontal strip. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct ModelPickerEffortOption { + pub(crate) label: String, + pub(crate) value: String, +} + +/// One row in the `/model` picker. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct ModelPickerEntry { + pub(crate) selection_value: String, + pub(crate) display_name: String, + pub(crate) right_hint: Option, + pub(crate) is_current: bool, + pub(crate) effort_options: Vec, + /// Initial effort for this model when the picker opened (session-resolved). + pub(crate) selected_effort: Option, +} + +/// Result returned when the user confirms a model picker selection. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct ModelPickerSelection { + pub(crate) model: String, + pub(crate) reasoning_effort: Option, +} + +pub(crate) struct ModelPickerView { + entries: Vec, + state: ScrollState, + /// Currently selected effort value for the focused model. + effort_selection: Option, + complete: bool, + selected: Option, + accent_color: Color, +} + +impl ModelPickerView { + pub(crate) fn new(entries: Vec, accent_color: Color) -> Self { + let mut state = ScrollState::new(); + let initial_idx = entries + .iter() + .position(|entry| entry.is_current) + .unwrap_or(0); + if entries.is_empty() { + state.selected_idx = None; + } else { + state.selected_idx = Some(initial_idx.min(entries.len() - 1)); + } + let visible = MAX_POPUP_ROWS.min(entries.len()); + state.ensure_visible(entries.len(), visible); + + let mut view = Self { + entries, + state, + effort_selection: None, + complete: false, + selected: None, + accent_color, + }; + view.sync_effort_for_current_model(); + view + } + + fn visible_rows(&self) -> usize { + MAX_POPUP_ROWS.min(self.entries.len()) + } + + fn has_more_above(&self) -> bool { + self.state.scroll_top > 0 + } + + fn has_more_below(&self) -> bool { + let len = self.entries.len(); + let visible = self.visible_rows(); + visible > 0 && self.state.scroll_top + visible < len + } + + fn has_effort_row(&self) -> bool { + self.current_entry() + .is_some_and(|entry| !entry.effort_options.is_empty()) + } + + /// Height of the picker without building styled lines (avoids duplicate work + /// across `desired_height` and `render` in the same frame). + fn computed_height(&self) -> u16 { + let mut height = self.visible_rows(); + if self.has_more_above() { + height = height.saturating_add(1); + } + if self.has_more_below() { + height = height.saturating_add(1); + } + if self.has_effort_row() { + // blank separator + effort chips + height = height.saturating_add(2); + } + // blank separator + footer + height = height.saturating_add(2); + u16::try_from(height).unwrap_or(u16::MAX) + } + + fn current_entry(&self) -> Option<&ModelPickerEntry> { + self.state + .selected_idx + .and_then(|idx| self.entries.get(idx)) + } + + fn sync_effort_for_current_model(&mut self) { + let Some(entry) = self.current_entry() else { + self.effort_selection = None; + return; + }; + if entry.effort_options.is_empty() { + self.effort_selection = None; + return; + } + if let Some(current) = self.effort_selection.as_deref() + && entry + .effort_options + .iter() + .any(|option| option.value == current) + { + return; + } + self.effort_selection = entry.selected_effort.clone().or_else(|| { + entry + .effort_options + .first() + .map(|option| option.value.clone()) + }); + } + + fn move_model_selection(&mut self, delta: isize) { + let len = self.entries.len(); + if delta < 0 { + self.state.move_up_wrap(len); + } else if delta > 0 { + self.state.move_down_wrap(len); + } + self.state.ensure_visible(len, self.visible_rows()); + self.sync_effort_for_current_model(); + } + + fn move_effort(&mut self, delta: isize) { + let Some(entry) = self.current_entry() else { + return; + }; + let options = &entry.effort_options; + if options.is_empty() { + return; + } + let current_idx = self + .effort_selection + .as_ref() + .and_then(|value| options.iter().position(|option| &option.value == value)) + .unwrap_or(0); + let new_idx = (current_idx as isize + delta).rem_euclid(options.len() as isize) as usize; + self.effort_selection = Some(options[new_idx].value.clone()); + } + + fn accept(&mut self) { + let Some(entry) = self.current_entry() else { + self.complete = true; + return; + }; + let reasoning_effort = if entry.effort_options.is_empty() { + None + } else { + self.effort_selection.clone() + }; + self.selected = Some(ModelPickerSelection { + model: entry.selection_value.clone(), + reasoning_effort, + }); + self.complete = true; + } + + /// Width of the name cell (display name + optional ` ‹`), shared so provider + /// hints start in one column like a two-column table. + fn name_column_width(&self, total_width: u16) -> usize { + let prefix_width = MARKER_COLS; // marker + space + let gap_width = 2; + // Reserve a modest amount for the provider column when present. + let has_any_provider = self.entries.iter().any(|entry| { + entry + .right_hint + .as_deref() + .map(str::trim) + .is_some_and(|hint| !hint.is_empty()) + }); + let provider_reserve = if has_any_provider { + self.entries + .iter() + .filter_map(|entry| { + entry + .right_hint + .as_deref() + .map(str::trim) + .filter(|hint| !hint.is_empty()) + .map(UnicodeWidthStr::width) + }) + .max() + .unwrap_or(0) + .clamp(8, 16) + } else { + 0 + }; + let available = usize::from(total_width) + .saturating_sub(prefix_width) + .saturating_sub(if has_any_provider { + gap_width + provider_reserve + } else { + 0 + }); + let natural = self + .entries + .iter() + .map(|entry| { + let mut width = UnicodeWidthStr::width(entry.display_name.as_str()); + if entry.is_current { + width = width.saturating_add(2); // " ‹" + } + width + }) + .max() + .unwrap_or(0); + natural.min(available).max(1) + } + + fn render_model_row( + &self, + index: usize, + entry: &ModelPickerEntry, + width: u16, + ) -> Line<'static> { + let is_selected = self.state.selected_idx == Some(index); + let marker = if is_selected { "›" } else { " " }; + let marker_style = if is_selected { + Style::default().fg(self.accent_color).bold() + } else { + Style::default() + }; + // Focused rows use underline in addition to accent+bold so the selection + // stays readable on themes where the accent is close to the default fg. + let label_style = if is_selected { + Style::default().fg(self.accent_color).bold().underlined() + } else if !entry.is_current { + Style::default().dim() + } else { + Style::default() + }; + let name_col = self.name_column_width(width); + let mut name_width = UnicodeWidthStr::width(entry.display_name.as_str()); + let mut title_spans = vec![ + Span::styled(marker.to_string(), marker_style), + Span::raw(" "), + Span::styled(entry.display_name.clone(), label_style), + ]; + // Current-session mark sits immediately after the model name. + if entry.is_current { + title_spans.push(Span::raw(" ")); + title_spans.push(Span::styled( + "‹".to_string(), + Style::default().fg(self.accent_color).bold(), + )); + name_width = name_width.saturating_add(2); + } + if name_width < name_col { + title_spans.push(Span::raw(" ".repeat(name_col - name_width))); + } + if let Some(right_hint) = entry + .right_hint + .as_deref() + .map(str::trim) + .filter(|right_hint| !right_hint.is_empty()) + { + title_spans.push(Span::raw(" ")); + title_spans.push(Span::styled(right_hint.to_string(), Style::default().dim())); + } + truncate_line_with_ellipsis_if_overflow(Line::from(title_spans), usize::from(width)) + } + + fn scroll_overflow_line(&self, more_above: bool) -> Line<'static> { + // Align with model names: marker column + following space. + let label = if more_above { "↑ more" } else { "↓ more" }; + Line::from(vec![ + Span::raw(" ".repeat(MARKER_COLS)), + Span::styled(label.to_string(), Style::default().dim()), + ]) + } + + fn render_effort_line(&self, width: u16) -> Option> { + let entry = self.current_entry()?; + if entry.effort_options.is_empty() { + return None; + } + + let available = usize::from(width); + let window = effort_window( + &entry.effort_options, + self.effort_selection.as_deref(), + available, + ); + + let mut spans = Vec::new(); + if window.show_left_more { + spans.push(Span::styled("‹ ".to_string(), Style::default().dim())); + } + for (idx_in_window, option_idx) in window.indices.iter().enumerate() { + if idx_in_window > 0 { + spans.push(Span::raw(" ")); + } + let option = &entry.effort_options[*option_idx]; + let is_selected = self.effort_selection.as_deref() == Some(option.value.as_str()); + let label = if is_selected { + format!("[{}]", option.label) + } else { + option.label.clone() + }; + let style = if is_selected { + Style::default().fg(self.accent_color).bold().underlined() + } else { + Style::default().dim() + }; + spans.push(Span::styled(label, style)); + } + if window.show_right_more { + spans.push(Span::styled(" ›".to_string(), Style::default().dim())); + } + Some(Line::from(spans)) + } + + fn footer_hint_line(&self) -> Line<'static> { + let has_effort = self + .current_entry() + .is_some_and(|entry| !entry.effort_options.is_empty()); + let mut spans = vec![ + key_hint::plain(KeyCode::Up).into(), + Span::raw("/"), + key_hint::plain(KeyCode::Down).into(), + Span::raw(" model"), + ]; + if has_effort { + spans.extend([ + Span::raw(" "), + key_hint::plain(KeyCode::Left).into(), + Span::raw("/"), + key_hint::plain(KeyCode::Right).into(), + Span::raw(" effort"), + ]); + } + spans.extend([ + Span::raw(" "), + key_hint::plain(KeyCode::Enter).into(), + Span::raw(" confirm "), + key_hint::plain(KeyCode::Esc).into(), + Span::raw(" cancel"), + ]); + Line::from(spans).dim() + } + + fn render_lines(&self, width: u16) -> Vec> { + let mut lines: Vec> = Vec::new(); + let len = self.entries.len(); + let visible = self.visible_rows(); + + if self.has_more_above() { + lines.push(self.scroll_overflow_line(/*more_above*/ true)); + } + if visible > 0 { + let start = self.state.scroll_top; + let end = (start + visible).min(len); + for index in start..end { + if let Some(entry) = self.entries.get(index) { + lines.push(self.render_model_row(index, entry, width)); + } + } + } + if self.has_more_below() { + lines.push(self.scroll_overflow_line(/*more_above*/ false)); + } + + if let Some(effort_line) = self.render_effort_line(width) { + lines.push(Line::from("")); + lines.push(effort_line); + } + + lines.push(Line::from("")); + lines.push(self.footer_hint_line()); + debug_assert_eq!( + lines.len(), + usize::from(self.computed_height()), + "render_lines length must match computed_height" + ); + lines + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct EffortWindow { + indices: Vec, + show_left_more: bool, + show_right_more: bool, +} + +fn option_chip_width(option: &ModelPickerEffortOption, selected: bool) -> usize { + if selected { + UnicodeWidthStr::width(option.label.as_str()) + 2 + } else { + UnicodeWidthStr::width(option.label.as_str()) + } +} + +fn effort_window( + options: &[ModelPickerEffortOption], + selected_value: Option<&str>, + available_width: usize, +) -> EffortWindow { + if options.is_empty() { + return EffortWindow { + indices: Vec::new(), + show_left_more: false, + show_right_more: false, + }; + } + + let selected_idx = selected_value + .and_then(|value| options.iter().position(|option| option.value == value)) + .unwrap_or(0); + + // Try to fit all options first (with gaps and optional edge markers budget). + let all_width = options + .iter() + .enumerate() + .map(|(idx, option)| { + let chip = option_chip_width(option, idx == selected_idx); + if idx == 0 { chip } else { chip + 2 } + }) + .sum::(); + if all_width <= available_width { + return EffortWindow { + indices: (0..options.len()).collect(), + show_left_more: false, + show_right_more: false, + }; + } + + // Sliding window centered on the selected option. + let marker_budget = 4; // "‹ " + " ›" + let content_budget = available_width.saturating_sub(marker_budget); + let mut start = selected_idx; + let mut end = selected_idx; + let mut used = option_chip_width(&options[selected_idx], true); + + loop { + let can_grow_left = start > 0; + let can_grow_right = end + 1 < options.len(); + if !can_grow_left && !can_grow_right { + break; + } + + let mut grew = false; + if can_grow_left { + let next = start - 1; + let extra = option_chip_width(&options[next], false) + 2; + if used + extra <= content_budget { + start = next; + used += extra; + grew = true; + } + } + if can_grow_right { + let next = end + 1; + let extra = option_chip_width(&options[next], false) + 2; + if used + extra <= content_budget { + end = next; + used += extra; + grew = true; + } + } + if !grew { + // Prefer expanding toward the side that still has room if one side + // failed only because the other already took the budget — stop. + break; + } + } + + EffortWindow { + indices: (start..=end).collect(), + show_left_more: start > 0, + show_right_more: end + 1 < options.len(), + } +} + +impl BottomPaneView for ModelPickerView { + fn handle_key_event(&mut self, key_event: KeyEvent) { + match key_event { + KeyEvent { + code: KeyCode::Esc, .. + } => self.complete = true, + KeyEvent { + code: KeyCode::Up, .. + } + | KeyEvent { + code: KeyCode::Char('p'), + modifiers: KeyModifiers::CONTROL, + .. + } + | KeyEvent { + code: KeyCode::Char('k'), + modifiers: KeyModifiers::NONE, + .. + } => self.move_model_selection(-1), + KeyEvent { + code: KeyCode::Down, + .. + } + | KeyEvent { + code: KeyCode::Char('n'), + modifiers: KeyModifiers::CONTROL, + .. + } + | KeyEvent { + code: KeyCode::Char('j'), + modifiers: KeyModifiers::NONE, + .. + } => self.move_model_selection(1), + KeyEvent { + code: KeyCode::Left, + .. + } => self.move_effort(-1), + KeyEvent { + code: KeyCode::Right, + .. + } => self.move_effort(1), + KeyEvent { + code: KeyCode::Enter, + .. + } => self.accept(), + _ => {} + } + } + + fn is_complete(&self) -> bool { + self.complete + } + + fn replaces_composer(&self) -> bool { + // Replaces the input → paint menu-surface background in `render`. + true + } + + fn on_ctrl_c(&mut self) -> CancellationEvent { + self.complete = true; + CancellationEvent::Handled + } + + fn take_model_selection(&mut self) -> Option { + self.selected.take() + } +} + +impl Renderable for ModelPickerView { + fn render(&self, area: Rect, buf: &mut Buffer) { + if area.height == 0 || area.width == 0 { + return; + } + let content_area = render_menu_surface(area, buf); + Paragraph::new(self.render_lines(content_area.width)).render(content_area, buf); + } + + fn desired_height(&self, _width: u16) -> u16 { + menu_surface_padding_height().saturating_add(self.computed_height()) + } +} + +#[cfg(test)] +mod tests { + use pretty_assertions::assert_eq; + use ratatui::style::Color; + + use super::*; + + fn entry( + value: &str, + name: &str, + is_current: bool, + efforts: &[(&str, &str)], + selected_effort: Option<&str>, + ) -> ModelPickerEntry { + ModelPickerEntry { + selection_value: value.to_string(), + display_name: name.to_string(), + right_hint: None, + is_current, + effort_options: efforts + .iter() + .map(|(label, value)| ModelPickerEffortOption { + label: (*label).to_string(), + value: (*value).to_string(), + }) + .collect(), + selected_effort: selected_effort.map(str::to_string), + } + } + + fn key(code: KeyCode) -> KeyEvent { + KeyEvent::from(code) + } + + #[test] + fn caps_visible_rows_and_scrolls_with_selection() { + let entries: Vec<_> = (0..12) + .map(|i| entry(&format!("m{i}"), &format!("Model {i}"), i == 0, &[], None)) + .collect(); + let mut view = ModelPickerView::new(entries, Color::Cyan); + + assert_eq!(view.visible_rows(), MAX_POPUP_ROWS); + // 8 models + more-below + blank + footer, plus menu-surface padding + assert_eq!( + view.desired_height(80), + menu_surface_padding_height() + MAX_POPUP_ROWS as u16 + 3 + ); + assert_eq!( + view.render_lines(80).len(), + usize::from(view.computed_height()) + ); + + for _ in 0..10 { + view.handle_key_event(key(KeyCode::Down)); + } + assert_eq!(view.state.selected_idx, Some(10)); + assert!(view.state.scroll_top + view.visible_rows() > 10); + assert!(view.state.selected_idx.unwrap() >= view.state.scroll_top); + assert!(view.state.selected_idx.unwrap() < view.state.scroll_top + view.visible_rows()); + assert!(view.has_more_above()); + // Near the end: may still have more below depending on scroll window. + let lines = view.render_lines(80); + assert!( + lines + .iter() + .any(|line| line.to_string().contains("↓ more") + || line.to_string().contains("↑ more")), + "expected scroll overflow marker:\n{}", + lines + .iter() + .map(ToString::to_string) + .collect::>() + .join("\n") + ); + assert_eq!(lines.len(), usize::from(view.computed_height())); + } + + #[test] + fn focused_row_uses_chevron_and_current_uses_right_mark() { + let view = ModelPickerView::new( + vec![ + ModelPickerEntry { + selection_value: "a".to_string(), + display_name: "Current".to_string(), + right_hint: Some("OpenAI".to_string()), + is_current: true, + effort_options: Vec::new(), + selected_effort: None, + }, + entry("b", "Other", false, &[], None), + ], + Color::Cyan, + ); + let focused_current = view.render_model_row(0, &view.entries[0], 80).to_string(); + assert!( + focused_current.contains('›'), + "expected › on focused row: {focused_current:?}" + ); + assert!( + focused_current.contains("Current ‹"), + "‹ should sit immediately after the model name: {focused_current:?}" + ); + assert!( + focused_current.contains("OpenAI"), + "provider hint should remain: {focused_current:?}" + ); + + let mut view = view; + view.handle_key_event(key(KeyCode::Down)); + let current_unfocused = view.render_model_row(0, &view.entries[0], 80).to_string(); + let focused_other = view.render_model_row(1, &view.entries[1], 80).to_string(); + assert!( + !current_unfocused.contains('›') && current_unfocused.contains("Current ‹"), + "unfocused current keeps ‹ after name: {current_unfocused:?}" + ); + assert!( + focused_other.contains('›') && !focused_other.contains('‹'), + "focused non-current: {focused_other:?}" + ); + } + + #[test] + fn provider_column_aligns_across_rows() { + let view = ModelPickerView::new( + vec![ + ModelPickerEntry { + selection_value: "a".to_string(), + display_name: "Short".to_string(), + right_hint: Some("OpenAI".to_string()), + is_current: false, + effort_options: Vec::new(), + selected_effort: None, + }, + ModelPickerEntry { + selection_value: "b".to_string(), + display_name: "A Longer Model Name".to_string(), + right_hint: Some("Anthropic".to_string()), + is_current: true, + effort_options: Vec::new(), + selected_effort: None, + }, + ], + Color::Cyan, + ); + let short = view.render_model_row(0, &view.entries[0], 80).to_string(); + let longer = view.render_model_row(1, &view.entries[1], 80).to_string(); + let provider_display_col = |line: &str, provider: &str| { + let byte_idx = line.find(provider).expect("provider present"); + UnicodeWidthStr::width(&line[..byte_idx]) + }; + assert_eq!( + provider_display_col(&short, "OpenAI"), + provider_display_col(&longer, "Anthropic"), + "providers should share a column\nshort={short:?}\nlonger={longer:?}" + ); + } + + #[test] + fn scroll_overflow_markers_appear_when_list_is_long() { + let entries: Vec<_> = (0..12) + .map(|i| entry(&format!("m{i}"), &format!("Model {i}"), i == 0, &[], None)) + .collect(); + let mut view = ModelPickerView::new(entries, Color::Cyan); + + let at_top = view.render_lines(80); + assert!( + !view.has_more_above() && view.has_more_below(), + "start at top of long list" + ); + assert!( + at_top.iter().any(|line| { + let text = line.to_string(); + text.contains("↓ more") && text.starts_with(" ↓ more") + }), + "overflow marker should align under model names:\n{}", + at_top + .iter() + .map(ToString::to_string) + .collect::>() + .join("\n") + ); + + for _ in 0..11 { + view.handle_key_event(key(KeyCode::Down)); + } + assert!(view.has_more_above()); + assert!(!view.has_more_below() || view.state.scroll_top + view.visible_rows() < 12); + let at_bottomish = view.render_lines(80); + assert!( + at_bottomish + .iter() + .any(|line| line.to_string().contains("↑ more")), + "expected upper overflow marker near bottom" + ); + } + + #[test] + fn left_right_cycle_effort_for_focused_model() { + let mut view = ModelPickerView::new( + vec![entry( + "gpt", + "GPT", + true, + &[("Low", "low"), ("Medium", "medium"), ("High", "high")], + Some("medium"), + )], + Color::Cyan, + ); + assert_eq!(view.effort_selection.as_deref(), Some("medium")); + + view.handle_key_event(key(KeyCode::Right)); + assert_eq!(view.effort_selection.as_deref(), Some("high")); + view.handle_key_event(key(KeyCode::Right)); + assert_eq!(view.effort_selection.as_deref(), Some("low")); + view.handle_key_event(key(KeyCode::Left)); + assert_eq!(view.effort_selection.as_deref(), Some("high")); + } + + #[test] + fn switching_models_preserves_effort_when_supported() { + let mut view = ModelPickerView::new( + vec![ + entry( + "a", + "A", + true, + &[("Low", "low"), ("High", "high")], + Some("low"), + ), + entry( + "b", + "B", + false, + &[("High", "high"), ("Max", "max")], + Some("max"), + ), + ], + Color::Cyan, + ); + view.handle_key_event(key(KeyCode::Right)); + assert_eq!(view.effort_selection.as_deref(), Some("high")); + + view.handle_key_event(key(KeyCode::Down)); + assert_eq!(view.state.selected_idx, Some(1)); + assert_eq!(view.effort_selection.as_deref(), Some("high")); + } + + #[test] + fn switching_models_falls_back_when_effort_unsupported() { + let mut view = ModelPickerView::new( + vec![ + entry( + "a", + "A", + true, + &[("Low", "low"), ("High", "high")], + Some("low"), + ), + entry("b", "B", false, &[("Max", "max")], Some("max")), + ], + Color::Cyan, + ); + assert_eq!(view.effort_selection.as_deref(), Some("low")); + view.handle_key_event(key(KeyCode::Down)); + assert_eq!(view.effort_selection.as_deref(), Some("max")); + } + + #[test] + fn hides_effort_row_when_unsupported() { + let view = + ModelPickerView::new(vec![entry("plain", "Plain", true, &[], None)], Color::Cyan); + let lines = view.render_lines(80); + assert!( + lines + .iter() + .all(|line| !line.to_string().contains("Reasoning")) + ); + // model row + blank + footer + assert_eq!(lines.len(), 3); + } + + #[test] + fn effort_line_omits_reasoning_label() { + let view = ModelPickerView::new( + vec![entry( + "gpt", + "GPT", + true, + &[("Low", "low"), ("High", "high")], + Some("low"), + )], + Color::Cyan, + ); + let effort = view + .render_effort_line(80) + .expect("effort line") + .to_string(); + assert!(!effort.contains("Reasoning")); + assert!(effort.contains("[Low]") || effort.contains("Low")); + assert!( + effort.starts_with('[') || effort.starts_with('L') || effort.starts_with('‹'), + "effort line should start at content edge (menu surface supplies inset): {effort:?}" + ); + } + + #[test] + fn content_lines_use_menu_surface_inset_without_extra_left_pad() { + let view = ModelPickerView::new( + vec![entry( + "gpt", + "GPT", + true, + &[("Off", "disabled"), ("High", "high")], + Some("disabled"), + )], + Color::Cyan, + ); + let lines = view.render_lines(80); + let model = lines[0].to_string(); + let effort = lines + .iter() + .find(|line| line.to_string().contains("[Off]")) + .expect("effort line") + .to_string(); + let footer = lines.last().expect("footer").to_string(); + // Marker (›/ /·) then name — no extra FOOTER_INDENT on top of menu surface. + assert!( + model.starts_with('›') || model.starts_with(' ') || model.starts_with('·'), + "model row: {model:?}" + ); + assert!( + !model.starts_with(" ›") && !model.starts_with(" "), + "model row should not double-pad: {model:?}" + ); + assert!( + effort.starts_with('[') || effort.starts_with('O'), + "effort row: {effort:?}" + ); + assert!( + !footer.starts_with(" "), + "footer should not double-pad: {footer:?}" + ); + } + + #[test] + fn left_right_noop_without_effort_options() { + let mut view = + ModelPickerView::new(vec![entry("plain", "Plain", true, &[], None)], Color::Cyan); + view.handle_key_event(key(KeyCode::Left)); + view.handle_key_event(key(KeyCode::Right)); + assert_eq!(view.effort_selection, None); + assert!(!view.is_complete()); + } + + #[test] + fn enter_returns_model_and_effort() { + let mut view = ModelPickerView::new( + vec![entry( + "gpt", + "GPT", + true, + &[("Low", "low"), ("High", "high")], + Some("low"), + )], + Color::Cyan, + ); + view.handle_key_event(key(KeyCode::Right)); + view.handle_key_event(key(KeyCode::Enter)); + assert!(view.is_complete()); + assert_eq!( + view.take_model_selection(), + Some(ModelPickerSelection { + model: "gpt".to_string(), + reasoning_effort: Some("high".to_string()), + }) + ); + } + + #[test] + fn esc_completes_without_selection() { + let mut view = ModelPickerView::new( + vec![entry("gpt", "GPT", true, &[("High", "high")], Some("high"))], + Color::Cyan, + ); + view.handle_key_event(key(KeyCode::Esc)); + assert!(view.is_complete()); + assert_eq!(view.take_model_selection(), None); + } + + #[test] + fn effort_window_slides_when_narrow() { + let options = vec![ + ModelPickerEffortOption { + label: "Minimal".to_string(), + value: "minimal".to_string(), + }, + ModelPickerEffortOption { + label: "Low".to_string(), + value: "low".to_string(), + }, + ModelPickerEffortOption { + label: "Medium".to_string(), + value: "medium".to_string(), + }, + ModelPickerEffortOption { + label: "High".to_string(), + value: "high".to_string(), + }, + ModelPickerEffortOption { + label: "XHigh".to_string(), + value: "xhigh".to_string(), + }, + ]; + let window = effort_window(&options, Some("medium"), 20); + assert!( + window.show_left_more || window.show_right_more || window.indices.len() < options.len() + ); + assert!(window.indices.contains(&2)); + } +} diff --git a/crates/tui/src/bottom_pane/pending_queue.rs b/crates/tui/src/bottom_pane/pending_queue.rs new file mode 100644 index 00000000..fef68aa7 --- /dev/null +++ b/crates/tui/src/bottom_pane/pending_queue.rs @@ -0,0 +1,327 @@ +//! Composer-adjacent pending input queue (canonical `session/queue/*`). + +use crossterm::event::KeyCode; +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::style::Modifier; +use ratatui::style::Style; +use ratatui::style::Stylize; +use ratatui::text::Line; +use ratatui::text::Span; +use ratatui::widgets::Paragraph; +use ratatui::widgets::Widget; + +use crate::key_hint; +use crate::line_truncation::truncate_line_with_ellipsis_if_overflow; +use crate::queue_ops::queue_render_preview; +use crate::render::renderable::Renderable; + +/// One queued prompt shown under the composer. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct PendingQueueItem { + pub queue_item_id: String, + pub text: String, +} + +#[derive(Debug, Default)] +pub(crate) struct PendingQueueState { + items: Vec, + /// Selected index when queue focus is active. + selected: Option, + focused: bool, +} + +impl PendingQueueState { + pub(crate) fn items(&self) -> &[PendingQueueItem] { + &self.items + } + + pub(crate) fn is_empty(&self) -> bool { + self.items.is_empty() + } + + pub(crate) fn len(&self) -> usize { + self.items.len() + } + + pub(crate) fn focused(&self) -> bool { + self.focused + } + + pub(crate) fn selected_index(&self) -> Option { + self.selected + } + + pub(crate) fn selected_item(&self) -> Option<&PendingQueueItem> { + self.selected.and_then(|idx| self.items.get(idx)) + } + + pub(crate) fn replace_items(&mut self, items: Vec) { + let prev_selected_id = self + .selected + .and_then(|idx| self.items.get(idx)) + .map(|item| item.queue_item_id.clone()); + self.items = items; + if self.items.is_empty() { + self.selected = None; + self.focused = false; + return; + } + if let Some(id) = prev_selected_id + && let Some(idx) = self.items.iter().position(|item| item.queue_item_id == id) + { + self.selected = Some(idx); + } else if self.focused { + self.selected = Some(self.selected.unwrap_or(0).min(self.items.len() - 1)); + } else { + self.selected = None; + } + } + + pub(crate) fn clear(&mut self) { + self.items.clear(); + self.selected = None; + self.focused = false; + } + + pub(crate) fn focus_first(&mut self) -> bool { + if self.items.is_empty() { + return false; + } + self.focused = true; + self.selected = Some(0); + true + } + + pub(crate) fn clear_focus(&mut self) { + self.focused = false; + self.selected = None; + } + + /// Move selection down. Returns true if handled. + pub(crate) fn select_next(&mut self) -> bool { + if !self.focused || self.items.is_empty() { + return false; + } + let idx = self.selected.unwrap_or(0); + if idx + 1 < self.items.len() { + self.selected = Some(idx + 1); + } + true + } + + /// Move selection up. Returns `LeftQueue` when leaving to the composer. + pub(crate) fn select_prev(&mut self) -> QueueNavResult { + if !self.focused || self.items.is_empty() { + return QueueNavResult::Ignored; + } + match self.selected { + Some(0) | None => { + self.clear_focus(); + QueueNavResult::ReturnToInput + } + Some(idx) => { + self.selected = Some(idx - 1); + QueueNavResult::Handled + } + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum QueueNavResult { + Ignored, + Handled, + ReturnToInput, +} + +pub(crate) struct PendingQueueList<'a> { + state: &'a PendingQueueState, +} + +impl<'a> PendingQueueList<'a> { + pub(crate) fn new(state: &'a PendingQueueState) -> Self { + Self { state } + } +} + +impl Renderable for PendingQueueList<'_> { + fn render(&self, area: Rect, buf: &mut Buffer) { + if area.is_empty() || self.state.items.is_empty() { + return; + } + let mut lines: Vec> = Vec::new(); + lines.push(Line::from("")); + if !self.state.focused { + lines.push(Line::from(vec![ + " ".into(), + key_hint::plain(KeyCode::Down).into(), + " to select".dim(), + ])); + } + for (idx, item) in self.state.items.iter().enumerate() { + let selected = self.state.focused && self.state.selected == Some(idx); + let preview = queue_render_preview(&item.text); + let label = if preview.is_empty() { + "(empty)".to_string() + } else { + preview + }; + let mut row = Line::from(vec![ + Span::styled(format!(" {} › ", idx + 1), Style::default().cyan()), + Span::raw(label), + ]); + if selected { + row = row.style( + Style::default() + .fg(ratatui::style::Color::Black) + .bg(ratatui::style::Color::Cyan) + .add_modifier(Modifier::BOLD), + ); + } + lines.push(truncate_line_with_ellipsis_if_overflow( + row, + area.width as usize, + )); + } + if self.state.focused { + lines.push(Line::from( + " ↑/↓ navigate · ctrl+s steer · ctrl+e edit · ctrl+d delete · esc back".dim(), + )); + } + Paragraph::new(lines).render(area, buf); + } + + fn desired_height(&self, width: u16) -> u16 { + if self.state.items.is_empty() { + return 0; + } + let _ = width; + // blank + optional select hint + one row per item + optional focus hint + let select_hint = if self.state.focused { 0 } else { 1 }; + let focus_hint = if self.state.focused { 1 } else { 0 }; + (1 + select_hint + self.state.items.len() + focus_hint) as u16 + } +} + +#[cfg(test)] +mod tests { + use pretty_assertions::assert_eq; + use ratatui::buffer::Buffer; + use ratatui::layout::Rect; + + use super::*; + use crate::queue_ops::queue_render_preview; + use crate::render::renderable::Renderable; + + #[test] + fn render_preview_collapses_newlines() { + assert_eq!(queue_render_preview("one\ntwo\n\nthree"), "one two three"); + } + + #[test] + fn up_on_first_returns_to_input() { + let mut state = PendingQueueState::default(); + state.replace_items(vec![ + PendingQueueItem { + queue_item_id: "a".into(), + text: "a".into(), + }, + PendingQueueItem { + queue_item_id: "b".into(), + text: "b".into(), + }, + ]); + assert!(state.focus_first()); + assert_eq!(state.select_prev(), QueueNavResult::ReturnToInput); + assert!(!state.focused()); + } + + #[test] + fn pending_queue_renders_one_visual_row_per_item() { + let mut state = PendingQueueState::default(); + state.replace_items(vec![PendingQueueItem { + queue_item_id: "q1".into(), + text: "hello\nworld\nand more".into(), + }]); + let list = PendingQueueList::new(&state); + assert_eq!(list.desired_height(/*width*/ 40), 3); + + let area = Rect::new(0, 0, 40, 3); + let mut buf = Buffer::empty(area); + list.render(area, &mut buf); + let header: String = (0..40) + .map(|x| buf[(x, 1)].symbol().to_string()) + .collect::(); + assert!( + header.contains("↓") && header.contains("to select"), + "header={header}" + ); + let row: String = (0..40) + .map(|x| buf[(x, 2)].symbol().to_string()) + .collect::(); + assert!(row.contains("1 ›"), "row={row}"); + assert!(row.contains("hello world and more"), "row={row}"); + } + + #[test] + fn pending_queue_header_shows_down_select_hint_until_focused() { + let mut state = PendingQueueState::default(); + state.replace_items(vec![ + PendingQueueItem { + queue_item_id: "q1".into(), + text: "first".into(), + }, + PendingQueueItem { + queue_item_id: "q2".into(), + text: "second".into(), + }, + ]); + let list = PendingQueueList::new(&state); + assert_eq!(list.desired_height(/*width*/ 60), 4); + + let area = Rect::new(0, 0, 60, 4); + let mut buf = Buffer::empty(area); + list.render(area, &mut buf); + let rendered: Vec = (0..4) + .map(|y| { + (0..60) + .map(|x| buf[(x, y)].symbol().to_string()) + .collect::() + }) + .collect(); + assert!( + rendered[1].contains("↓") && rendered[1].contains("to select"), + "header={}", + rendered[1] + ); + assert!( + !rendered[1].contains("queued"), + "header should not show count: {}", + rendered[1] + ); + assert!(rendered[2].contains("1 › first"), "row={}", rendered[2]); + assert!(rendered[3].contains("2 › second"), "row={}", rendered[3]); + + assert!(state.focus_first()); + let list = PendingQueueList::new(&state); + assert_eq!(list.desired_height(/*width*/ 60), 4); + + let area = Rect::new(0, 0, 60, 4); + let mut buf = Buffer::empty(area); + list.render(area, &mut buf); + let focused: Vec = (0..4) + .map(|y| { + (0..60) + .map(|x| buf[(x, y)].symbol().to_string()) + .collect::() + }) + .collect(); + assert!( + focused[1].contains("1 › first"), + "focused skips select hint: {}", + focused[1] + ); + assert!(focused[3].contains("ctrl+d delete"), "hint={}", focused[3]); + } +} diff --git a/crates/tui/src/bottom_pane/selection_popup_common.rs b/crates/tui/src/bottom_pane/selection_popup_common.rs index 91a4f2be..faa7a64e 100644 --- a/crates/tui/src/bottom_pane/selection_popup_common.rs +++ b/crates/tui/src/bottom_pane/selection_popup_common.rs @@ -7,7 +7,6 @@ use ratatui::style::Style; use ratatui::style::Stylize; use ratatui::text::Line; use ratatui::text::Span; -use ratatui::widgets::Block; use ratatui::widgets::Widget; use std::borrow::Cow; use unicode_width::UnicodeWidthChar; @@ -17,7 +16,6 @@ use crate::key_hint::KeyBinding; use crate::line_truncation::truncate_line_with_ellipsis_if_overflow; use crate::render::Insets; use crate::render::RectExt as _; -use crate::style::user_message_style; use super::scroll_state::ScrollState; @@ -76,18 +74,18 @@ pub(crate) const fn menu_surface_padding_height() -> u16 { MENU_SURFACE_INSET_V * 2 } -/// Paint the shared menu background and return the inset content area. +/// Paint the shared menu chrome and return the inset content area. /// -/// This keeps the surface treatment consistent across selection-style overlays +/// This keeps inset spacing consistent across selection-style overlays /// (for example `/model`, approvals, and request-user-input). Callers should /// render all inner content in the returned rect, not the original area. -pub(crate) fn render_menu_surface(area: Rect, buf: &mut Buffer) -> Rect { +/// +/// The surface no longer paints a tinted background; panels sit on the terminal +/// background with inset padding only. +pub(crate) fn render_menu_surface(area: Rect, _buf: &mut Buffer) -> Rect { if area.is_empty() { return area; } - Block::default() - .style(user_message_style()) - .render(area, buf); menu_surface_inset(area) } @@ -338,6 +336,14 @@ fn compute_item_window_start( start_idx } +fn has_more_below(rows_len: usize, start_idx: usize, max_items: usize) -> bool { + max_items > 0 && start_idx.saturating_add(max_items) < rows_len +} + +fn scroll_overflow_line() -> Line<'static> { + Line::from(Span::styled("↓ more".to_string(), Style::default().dim())) +} + fn is_selected_visible_in_wrapped_viewport( rows_all: &[GenericDisplayRow], start_idx: usize, @@ -527,7 +533,15 @@ fn render_rows_inner( if max_items == 0 { return 0; } - let desc_measure_items = max_items.min(area.height.max(1) as usize); + + // First pass: item window + overflow hint so we can reserve the ↓ more row + // before measuring wrapped selection visibility against the remaining height. + // Wrap-around lists only show ↓ more (not ↑ more). + let provisional_start = compute_item_window_start(rows_all, state, max_items); + let more_below = has_more_below(rows_all.len(), provisional_start, max_items); + let indicator_rows = u16::from(more_below); + let content_height = area.height.saturating_sub(indicator_rows); + let desc_measure_items = max_items.min(content_height.max(1) as usize); // Keep item-window semantics, then correct for wrapped row heights so the // selected row remains visible in a line-based viewport. @@ -537,9 +551,10 @@ fn render_rows_inner( max_items, desc_measure_items, area.width, - area.height, + content_height, col_width_mode, ); + let more_below = has_more_below(rows_all.len(), start_idx, max_items); let desc_col = compute_desc_col( rows_all, @@ -549,12 +564,20 @@ fn render_rows_inner( col_width_mode, ); - // Render items, wrapping descriptions and aligning wrapped lines under the - // shared description column. Stop when we run out of vertical space. let mut cur_y = area.y; let mut rendered_lines: u16 = 0; + let bottom_y = area.y.saturating_add(area.height); + + let item_bottom_y = if more_below { + bottom_y.saturating_sub(1) + } else { + bottom_y + }; + + // Render items, wrapping descriptions and aligning wrapped lines under the + // shared description column. Stop when we run out of vertical space. for (i, row) in rows_all.iter().enumerate().skip(start_idx).take(max_items) { - if cur_y >= area.y + area.height { + if cur_y >= item_bottom_y { break; } @@ -568,7 +591,7 @@ fn render_rows_inner( // Render the wrapped lines. for line in wrapped { - if cur_y >= area.y + area.height { + if cur_y >= item_bottom_y { break; } line.render( @@ -585,6 +608,19 @@ fn render_rows_inner( } } + if more_below && cur_y < bottom_y { + scroll_overflow_line().render( + Rect { + x: area.x, + y: cur_y.min(item_bottom_y), + width: area.width, + height: 1, + }, + buf, + ); + rendered_lines = rendered_lines.saturating_add(1); + } + rendered_lines } @@ -861,6 +897,9 @@ fn measure_rows_height_inner( let wrapped_lines = wrap_row_lines(row, desc_col, content_width).len(); total = total.saturating_add(wrapped_lines as u16); } + if has_more_below(rows_all.len(), start_idx, visible_items) { + total = total.saturating_add(1); + } total.max(1) } diff --git a/crates/tui/src/bottom_pane/settings_hub_view.rs b/crates/tui/src/bottom_pane/settings_hub_view.rs new file mode 100644 index 00000000..ecc21784 --- /dev/null +++ b/crates/tui/src/bottom_pane/settings_hub_view.rs @@ -0,0 +1,517 @@ +//! Settings Hub bottom-pane view for `/settings`. +//! +//! Tabbed overview (Session / Appearance / Agent) that deep-links into existing +//! pickers and cycles Theme with ←/→ via [`AppEvent`] actions. + +use crossterm::event::KeyCode; +use crossterm::event::KeyEvent; +use crossterm::event::KeyEventKind; +use crossterm::event::KeyModifiers; +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::style::Color; +use ratatui::style::Style; +use ratatui::style::Stylize; +use ratatui::text::Line; +use ratatui::text::Span; +use ratatui::widgets::Paragraph; +use ratatui::widgets::Widget; + +use crate::app_event::AppEvent; +use crate::app_event::SettingsCycleDirection; +use crate::app_event_sender::AppEventSender; +use crate::bottom_pane::InputMode; +use crate::key_hint; +use crate::render::renderable::Renderable; +use crate::ui_consts::FOOTER_INDENT_COLS; + +use super::CancellationEvent; +use super::bottom_pane_view::BottomPaneView; +use super::selection_popup_common::menu_surface_padding_height; +use super::selection_popup_common::render_menu_surface; + +const LEFT_PAD: usize = FOOTER_INDENT_COLS; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum SettingsHubTab { + Session, + Appearance, + Agent, +} + +impl SettingsHubTab { + fn label(self) -> &'static str { + match self { + Self::Session => "Session", + Self::Appearance => "Appearance", + Self::Agent => "Agent", + } + } + + fn next(self) -> Self { + match self { + Self::Session => Self::Appearance, + Self::Appearance => Self::Agent, + Self::Agent => Self::Session, + } + } + + fn previous(self) -> Self { + match self { + Self::Session => Self::Agent, + Self::Appearance => Self::Session, + Self::Agent => Self::Appearance, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct SettingsHubSnapshot { + pub(crate) model_label: String, + pub(crate) permissions_label: String, + pub(crate) mode: InputMode, + pub(crate) compaction_threshold_label: String, + pub(crate) theme_label: String, + pub(crate) reasoning_view_label: String, +} + +/// Interactive Settings Hub panel. +pub(crate) struct SettingsHubView { + snapshot: SettingsHubSnapshot, + tab: SettingsHubTab, + selected_row: usize, + complete: bool, + app_event_tx: AppEventSender, + accent_color: Color, +} + +impl SettingsHubView { + pub(crate) fn new( + snapshot: SettingsHubSnapshot, + app_event_tx: AppEventSender, + accent_color: Color, + ) -> Self { + Self { + snapshot, + tab: SettingsHubTab::Session, + selected_row: 0, + complete: false, + app_event_tx, + accent_color, + } + } + + pub(crate) fn with_tab(mut self, tab: SettingsHubTab) -> Self { + self.tab = tab; + self.selected_row = 0; + self + } + + pub(crate) fn update_snapshot(&mut self, snapshot: SettingsHubSnapshot) { + self.snapshot = snapshot; + self.clamp_selection(); + } + + fn clamp_selection(&mut self) { + let len = self.row_count().max(1); + if self.selected_row >= len { + self.selected_row = len - 1; + } + } + + fn row_count(&self) -> usize { + match self.tab { + SettingsHubTab::Session => 4, + SettingsHubTab::Appearance => 2, + SettingsHubTab::Agent => 0, + } + } + + fn theme_row_focused(&self) -> bool { + self.tab == SettingsHubTab::Appearance && self.selected_row == 0 + } + + fn dismiss(&mut self) { + self.complete = true; + } + + fn cycle_theme(&mut self, direction: SettingsCycleDirection) { + self.app_event_tx + .send(AppEvent::SettingsCycleTheme { direction }); + } + + fn activate(&mut self) { + match self.tab { + SettingsHubTab::Session => match self.selected_row { + 0 => self.app_event_tx.send(AppEvent::SettingsOpenModel), + 1 => self.app_event_tx.send(AppEvent::SettingsOpenPermissions), + 2 => self.app_event_tx.send(AppEvent::SettingsCycleMode), + 3 => self.app_event_tx.send(AppEvent::SettingsOpenCompaction), + _ => {} + }, + SettingsHubTab::Appearance => match self.selected_row { + 0 => self.cycle_theme(SettingsCycleDirection::Next), + 1 => self.app_event_tx.send(AppEvent::SettingsOpenReasoning), + _ => {} + }, + SettingsHubTab::Agent => {} + } + } + + fn render_lines(&self) -> Vec> { + let mut lines = Vec::new(); + let pad = " ".repeat(LEFT_PAD); + + lines.push(Line::from(vec![ + Span::raw(pad.clone()), + Span::styled("Settings".to_string(), Style::default().bold()), + ])); + lines.push(Line::from("")); + lines.push(self.tab_line(&pad)); + lines.push(Line::from(vec![ + Span::raw(pad.clone()), + Span::styled("─".repeat(40), Style::default().dim()), + ])); + lines.push(Line::from("")); + + match self.tab { + SettingsHubTab::Session => { + lines.push(self.setting_row( + &pad, + "Default Model", + &self.snapshot.model_label, + self.selected_row == 0, + /*cycleable*/ false, + )); + lines.push(self.setting_row( + &pad, + "Default Permissions", + &self.snapshot.permissions_label, + self.selected_row == 1, + /*cycleable*/ false, + )); + lines.push(self.setting_row( + &pad, + "Default Mode", + self.snapshot.mode.label(), + self.selected_row == 2, + /*cycleable*/ false, + )); + lines.push(Line::from("")); + lines.push(self.setting_row( + &pad, + "Default Compaction Limit", + &self.snapshot.compaction_threshold_label, + self.selected_row == 3, + /*cycleable*/ false, + )); + } + SettingsHubTab::Appearance => { + lines.push(self.setting_row( + &pad, + "Theme", + &self.snapshot.theme_label, + self.selected_row == 0, + /*cycleable*/ true, + )); + lines.push(self.setting_row( + &pad, + "Show reasoning", + &self.snapshot.reasoning_view_label, + self.selected_row == 1, + /*cycleable*/ false, + )); + } + SettingsHubTab::Agent => { + lines.push(Line::from(vec![ + Span::raw(pad.clone()), + Span::styled("Coming soon".to_string(), Style::default().dim()), + ])); + lines.push(Line::from(vec![ + Span::raw(pad.clone()), + Span::styled( + "Personality and agent style settings will live here.".to_string(), + Style::default().dim(), + ), + ])); + } + } + + lines.push(Line::from("")); + lines.push(self.footer_line(&pad)); + lines + } + + fn tab_line(&self, pad: &str) -> Line<'static> { + let tabs = [ + SettingsHubTab::Session, + SettingsHubTab::Appearance, + SettingsHubTab::Agent, + ]; + let mut spans = vec![Span::raw(pad.to_string())]; + for (idx, tab) in tabs.iter().enumerate() { + if idx > 0 { + spans.push(Span::raw(" ".to_string())); + } + let active = *tab == self.tab; + let marker = if active { "● " } else { "○ " }; + let style = if active { + Style::default().fg(self.accent_color).bold() + } else { + Style::default().dim() + }; + spans.push(Span::styled(format!("{marker}{}", tab.label()), style)); + } + Line::from(spans) + } + + fn setting_row( + &self, + pad: &str, + label: &str, + value: &str, + focused: bool, + cycleable: bool, + ) -> Line<'static> { + let label_width = 26usize; + let label_text = format!("{label: Line<'static> { + let mut spans = vec![ + Span::raw(pad.to_string()), + key_hint::plain(KeyCode::Up).into(), + Span::raw("/".to_string()), + key_hint::plain(KeyCode::Down).into(), + Span::raw(" navigate ".to_string()), + ]; + if self.theme_row_focused() { + spans.push(key_hint::plain(KeyCode::Left).into()); + spans.push(Span::raw("/".to_string())); + spans.push(key_hint::plain(KeyCode::Right).into()); + spans.push(Span::raw(" theme ".to_string())); + } else if self.tab != SettingsHubTab::Agent { + spans.push(key_hint::plain(KeyCode::Enter).into()); + spans.push(Span::raw(" open ".to_string())); + } + spans.push(key_hint::plain(KeyCode::Tab).into()); + spans.push(Span::raw(" switch tab ".to_string())); + spans.push(key_hint::plain(KeyCode::Esc).into()); + spans.push(Span::raw(" close".to_string())); + Line::from(spans).dim() + } +} + +impl BottomPaneView for SettingsHubView { + fn handle_key_event(&mut self, key_event: KeyEvent) { + if !matches!(key_event.kind, KeyEventKind::Press | KeyEventKind::Repeat) { + return; + } + match key_event.code { + KeyCode::Esc => self.dismiss(), + KeyCode::Tab if key_event.modifiers.contains(KeyModifiers::SHIFT) => { + self.tab = self.tab.previous(); + self.selected_row = 0; + } + KeyCode::BackTab => { + self.tab = self.tab.previous(); + self.selected_row = 0; + } + KeyCode::Tab => { + self.tab = self.tab.next(); + self.selected_row = 0; + } + KeyCode::Up | KeyCode::Char('k') => { + if self.row_count() == 0 { + return; + } + if self.selected_row == 0 { + self.selected_row = self.row_count() - 1; + } else { + self.selected_row -= 1; + } + } + KeyCode::Down | KeyCode::Char('j') => { + if self.row_count() == 0 { + return; + } + self.selected_row = (self.selected_row + 1) % self.row_count(); + } + KeyCode::Left | KeyCode::Char('h') if self.theme_row_focused() => { + self.cycle_theme(SettingsCycleDirection::Previous); + } + KeyCode::Right | KeyCode::Char('l') if self.theme_row_focused() => { + self.cycle_theme(SettingsCycleDirection::Next); + } + KeyCode::Enter => self.activate(), + _ => {} + } + } + + fn is_complete(&self) -> bool { + self.complete + } + + fn view_id(&self) -> Option<&'static str> { + Some("settings_hub") + } + + fn update_settings_hub_snapshot(&mut self, snapshot: SettingsHubSnapshot) -> bool { + self.update_snapshot(snapshot); + true + } + + fn set_accent_color(&mut self, color: Color) { + self.accent_color = color; + } + + fn replaces_composer(&self) -> bool { + true + } + + fn on_ctrl_c(&mut self) -> CancellationEvent { + self.dismiss(); + CancellationEvent::Handled + } + + fn prefer_esc_to_handle_key_event(&self) -> bool { + true + } +} + +impl Renderable for SettingsHubView { + fn render(&self, area: Rect, buf: &mut Buffer) { + if area.height == 0 || area.width == 0 { + return; + } + let content_area = render_menu_surface(area, buf); + Paragraph::new(self.render_lines()).render(content_area, buf); + } + + fn desired_height(&self, _width: u16) -> u16 { + // title + blank + tabs + separator + blank + content + blank + footer + let content = match self.tab { + SettingsHubTab::Session => 13, + SettingsHubTab::Appearance => 10, + SettingsHubTab::Agent => 10, + }; + menu_surface_padding_height().saturating_add(content) + } +} + +#[cfg(test)] +mod tests { + use pretty_assertions::assert_eq; + use tokio::sync::mpsc::unbounded_channel; + + use super::*; + use crate::app_event_sender::AppEventSender; + + fn view() -> ( + SettingsHubView, + tokio::sync::mpsc::UnboundedReceiver, + ) { + let (tx, rx) = unbounded_channel(); + let view = SettingsHubView::new( + SettingsHubSnapshot { + model_label: "deepseek-v4-flash".into(), + permissions_label: "default".into(), + mode: InputMode::Build, + compaction_threshold_label: "250K".into(), + theme_label: "devo (default)".into(), + reasoning_view_label: "Collapsed".into(), + }, + AppEventSender::new(tx), + Color::Cyan, + ); + (view, rx) + } + + #[test] + fn session_rows_keep_space_between_label_and_value() { + let (view, _rx) = view(); + let text = view + .render_lines() + .iter() + .map(ToString::to_string) + .collect::>() + .join("\n"); + assert!( + text.contains("Default Compaction Limit 250K") + || text.contains("Default Compaction Limit 250K"), + "label must not run into value: {text}" + ); + assert!( + !text.contains("Default Compaction Limit250K"), + "missing gap between compaction label and value: {text}" + ); + } + + #[test] + fn tab_cycles_with_tab_key() { + let (mut view, _rx) = view(); + assert_eq!(view.tab, SettingsHubTab::Session); + view.handle_key_event(KeyEvent::from(KeyCode::Tab)); + assert_eq!(view.tab, SettingsHubTab::Appearance); + view.handle_key_event(KeyEvent::from(KeyCode::Tab)); + assert_eq!(view.tab, SettingsHubTab::Agent); + view.handle_key_event(KeyEvent::from(KeyCode::Tab)); + assert_eq!(view.tab, SettingsHubTab::Session); + } + + #[test] + fn enter_on_model_emits_open_model_event() { + let (mut view, mut rx) = view(); + view.handle_key_event(KeyEvent::from(KeyCode::Enter)); + assert_eq!(rx.try_recv().ok(), Some(AppEvent::SettingsOpenModel)); + } + + #[test] + fn left_right_on_theme_emits_cycle_events() { + let (mut view, mut rx) = view(); + view.handle_key_event(KeyEvent::from(KeyCode::Tab)); + assert_eq!(view.tab, SettingsHubTab::Appearance); + view.handle_key_event(KeyEvent::from(KeyCode::Right)); + assert_eq!( + rx.try_recv().ok(), + Some(AppEvent::SettingsCycleTheme { + direction: SettingsCycleDirection::Next + }) + ); + view.handle_key_event(KeyEvent::from(KeyCode::Left)); + assert_eq!( + rx.try_recv().ok(), + Some(AppEvent::SettingsCycleTheme { + direction: SettingsCycleDirection::Previous + }) + ); + } + + #[test] + fn theme_row_renders_cycle_markers_when_focused() { + let (mut view, _rx) = view(); + view.handle_key_event(KeyEvent::from(KeyCode::Tab)); + let text = view + .render_lines() + .iter() + .map(ToString::to_string) + .collect::>() + .join("\n"); + assert!(text.contains("◀ devo (default) ▶")); + } +} diff --git a/crates/tui/src/bottom_pane/slash_commands.rs b/crates/tui/src/bottom_pane/slash_commands.rs index 32488215..50d1f3e1 100644 --- a/crates/tui/src/bottom_pane/slash_commands.rs +++ b/crates/tui/src/bottom_pane/slash_commands.rs @@ -27,7 +27,13 @@ pub(crate) fn find_builtin_command( } pub(crate) fn has_builtin_prefix(name: &str, _flags: BuiltinCommandFlags) -> bool { + if SlashCommand::from_str(name).is_ok() { + return true; + } built_in_slash_commands() .into_iter() - .any(|(command_name, _)| command_name.starts_with(name)) + .any(|(command_name, cmd)| { + command_name.starts_with(name) + || cmd.aliases().iter().any(|alias| alias.starts_with(name)) + }) } diff --git a/crates/tui/src/chatwidget.rs b/crates/tui/src/chatwidget.rs index e6289ca6..a56e2c8f 100644 --- a/crates/tui/src/chatwidget.rs +++ b/crates/tui/src/chatwidget.rs @@ -10,6 +10,7 @@ use std::collections::HashMap; use std::collections::HashSet; use std::collections::VecDeque; use std::path::PathBuf; +use std::time::Duration; use std::time::Instant; use devo_core::ItemId; @@ -88,7 +89,6 @@ mod worker_events; use self::permission_presets::permission_preset_items; use self::permission_presets::permission_preset_label; use self::resume_browser::ResumeBrowserState; -use self::sandbox_profiles::sandbox_profile_label; use self::session_header::SessionHeaderParams; use self::subagent_monitor::SubagentMonitorState; @@ -109,6 +109,8 @@ pub(crate) struct ChatWidgetInit { pub(crate) initial_reasoning_effort_selection: Option, pub(crate) initial_permission_preset: devo_protocol::PermissionPreset, pub(crate) initial_sandbox_profile: Option, + pub(crate) initial_compaction_token_limit: Option, + pub(crate) initial_default_collaboration_mode: devo_protocol::CollaborationMode, pub(crate) initial_user_message: Option, pub(crate) enhanced_keys_supported: bool, pub(crate) is_first_run: bool, @@ -221,19 +223,6 @@ enum DotStatus { Failed, } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum PickerMode { - Model, - ReasoningEffort, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -struct PendingModelSelection { - selection: String, - display_name: String, - reasoning_effort_selection: Option, -} - #[derive(Debug, Clone, PartialEq, Eq)] struct PendingApprovalRequest { session_id: devo_protocol::SessionId, @@ -284,10 +273,10 @@ pub(crate) struct ChatWidget { resume_browser_loading: bool, resuming_session: bool, subagent_monitor: SubagentMonitorState, - picker_mode: Option, - pending_model_selection: Option, theme_set: ThemeSet, active_theme_name: String, + /// Monotonic epoch used to coalesce rapid theme-driven transcript reloads. + theme_reload_epoch: u64, collapse_reasoning: bool, resume_browser_last_height: Cell, turn_count: usize, @@ -297,10 +286,14 @@ pub(crate) struct ChatWidget { prompt_token_estimate: usize, last_query_input_tokens: usize, last_query_total_tokens: usize, + last_context_occupancy: Option, last_plan_progress: Option<(usize, usize)>, queued_count: usize, queued_input_modes: VecDeque, promoted_input_modes: VecDeque, + /// Queue item currently loaded into the composer for editing (ctrl+e); + /// resubmitting while busy updates it in place instead of pushing a new entry. + editing_queue_item_id: Option, active_turn_id: Option, failed_turn_visually_finalized: bool, current_turn_mode: InputMode, @@ -312,6 +305,14 @@ pub(crate) struct ChatWidget { pending_proposed_plan_actions: bool, permission_preset: devo_protocol::PermissionPreset, sandbox_profile: Option, + /// Applied auto-compaction threshold for the active session (clamped to model). + effective_context_window: Option, + /// Global default compaction limit from settings/`config.toml` (survives `/new`). + default_compaction_token_limit: Option, + /// Global default collaboration mode from settings/`config.toml`. + default_collaboration_mode: devo_protocol::CollaborationMode, + /// Persist scope for the next model/permissions picker selection. + settings_picker_persist_scope: crate::app_command::PersistScope, busy: bool, selection_mode: bool, selected_user_cell_index: Option, @@ -327,6 +328,14 @@ pub(crate) struct ChatWidget { skills_snapshot: Option>, /// After enable/disable, reopen this skill's detail once list refreshes. skills_reopen_detail: Option, + /// Cached git branch for the footer status line (`None` when unavailable). + status_line_branch: Option, + /// Cwd used for the last/pending git-branch lookup. + status_line_branch_cwd: Option, + /// Whether an async git-branch lookup is currently in flight. + status_line_branch_pending: bool, + /// Earliest time the next light git-branch refresh may run. + status_line_branch_next_refresh_at: Instant, } impl ChatWidget { @@ -370,6 +379,10 @@ impl ChatWidget { true } + pub(crate) fn is_task_running(&self) -> bool { + self.bottom_pane.is_task_running() + } + #[cfg(test)] pub(crate) fn is_resuming_session_for_test(&self) -> bool { self.resuming_session @@ -391,6 +404,16 @@ impl ChatWidget { pub(crate) fn has_bottom_pane_view_for_test(&self) -> bool { self.bottom_pane.has_view_for_test() } + + #[cfg(test)] + pub(crate) fn bottom_pane_has_pending_for_test(&self) -> bool { + self.bottom_pane.has_pending_cells() + } + + #[cfg(test)] + pub(crate) fn bottom_pane_mut_for_test(&mut self) -> &mut crate::bottom_pane::BottomPane { + &mut self.bottom_pane + } } impl ChatWidget { @@ -418,6 +441,8 @@ impl ChatWidget { initial_reasoning_effort_selection, initial_permission_preset, initial_sandbox_profile, + initial_compaction_token_limit, + initial_default_collaboration_mode, initial_user_message, enhanced_keys_supported, is_first_run, @@ -523,10 +548,9 @@ impl ChatWidget { resume_browser_loading: false, resuming_session: false, subagent_monitor: SubagentMonitorState::default(), - picker_mode: None, - pending_model_selection: None, theme_set, active_theme_name, + theme_reload_epoch: 0, collapse_reasoning: initial_collapse_reasoning, resume_browser_last_height: Cell::new(0), turn_count: 0, @@ -536,13 +560,17 @@ impl ChatWidget { prompt_token_estimate: 0, last_query_input_tokens: 0, last_query_total_tokens: 0, + last_context_occupancy: None, last_plan_progress: None, queued_count: 0, queued_input_modes: VecDeque::new(), promoted_input_modes: VecDeque::new(), + editing_queue_item_id: None, active_turn_id: None, failed_turn_visually_finalized: false, - current_turn_mode: InputMode::Build, + current_turn_mode: InputMode::from_collaboration_mode( + initial_default_collaboration_mode, + ), committed_server_assistant_in_turn: false, boundary_committed_assistant_items: HashSet::new(), current_turn_has_user_shell_command: false, @@ -551,6 +579,10 @@ impl ChatWidget { pending_proposed_plan_actions: false, permission_preset: initial_permission_preset, sandbox_profile: initial_sandbox_profile, + effective_context_window: initial_compaction_token_limit, + default_compaction_token_limit: initial_compaction_token_limit, + default_collaboration_mode: initial_default_collaboration_mode, + settings_picker_persist_scope: crate::app_command::PersistScope::Session, busy: false, selection_mode: false, selected_user_cell_index: None, @@ -562,6 +594,10 @@ impl ChatWidget { mcp_reopen_detail: None, skills_snapshot: None, skills_reopen_detail: None, + status_line_branch: None, + status_line_branch_cwd: None, + status_line_branch_pending: false, + status_line_branch_next_refresh_at: Instant::now(), }; // Model onboarding can inject additional startup UI before the first frame is drawn. @@ -580,8 +616,17 @@ impl ChatWidget { } // Keep the bottom pane summary in sync with the assembled widget state. + widget + .bottom_pane + .set_input_mode(InputMode::from_collaboration_mode( + initial_default_collaboration_mode, + )); + widget.request_status_line_branch_refresh(); widget.sync_bottom_pane_summary(); widget.maybe_start_subagent_debug_scenario(); widget } } + +/// How often the footer re-checks the current git branch while the TUI is open. +pub(super) const STATUS_LINE_BRANCH_REFRESH_INTERVAL: Duration = Duration::from_secs(3); diff --git a/crates/tui/src/chatwidget/configuration.rs b/crates/tui/src/chatwidget/configuration.rs index 4835ff20..f034a159 100644 --- a/crates/tui/src/chatwidget/configuration.rs +++ b/crates/tui/src/chatwidget/configuration.rs @@ -12,6 +12,7 @@ use ratatui::text::Line; use crate::app_command::AppCommand; use crate::app_event::AppEvent; +use crate::bottom_pane::ModelPickerEffortOption; use crate::bottom_pane::ModelPickerEntry; use crate::bottom_pane::list_selection_view::ListSelectionView; use crate::bottom_pane::list_selection_view::SelectionViewParams; @@ -19,13 +20,10 @@ use crate::events::SavedModelEntry; use crate::history_cell; use super::ChatWidget; -use super::PendingModelSelection; -use super::PickerMode; use super::permission_preset_items; use super::permission_preset_label; use super::reasoning_effort; use super::reasoning_effort::ReasoningEffortListEntry; -use super::sandbox_profile_label; impl ChatWidget { pub(crate) fn set_model(&mut self, model: Model) { @@ -323,49 +321,59 @@ impl ChatWidget { } pub(super) fn open_model_picker(&mut self) { - self.picker_mode = Some(PickerMode::Model); - self.pending_model_selection = None; + self.open_model_picker_with_scope(crate::app_command::PersistScope::Session); + } + + pub(super) fn open_model_picker_for_defaults(&mut self) { + self.open_model_picker_with_scope(crate::app_command::PersistScope::Default); + } + + fn open_model_picker_with_scope(&mut self, persist_scope: crate::app_command::PersistScope) { + self.settings_picker_persist_scope = persist_scope; + let session_effort = self.reasoning_effort_selection.clone(); let entries = self .saved_models .iter() - .map(|entry| ModelPickerEntry { - selection_value: Self::saved_model_selection_value(entry).to_string(), - display_name: self.saved_model_display_label(entry), - description: None, - right_hint: Self::saved_model_provider_name(entry), - is_current: self.saved_model_entry_is_current(entry), + .map(|entry| { + let model = self.model_for_saved_entry(entry); + let effort_entries = reasoning_effort::reasoning_effort_entries_for_model( + &model, + session_effort.as_deref(), + ); + let selected_effort = effort_entries + .iter() + .find(|option| option.is_current) + .map(|option| option.value.clone()); + ModelPickerEntry { + selection_value: Self::saved_model_selection_value(entry).to_string(), + display_name: self.saved_model_display_label(entry), + right_hint: Self::saved_model_provider_name(entry), + is_current: self.saved_model_entry_is_current(entry), + effort_options: effort_entries + .into_iter() + .map(|option| ModelPickerEffortOption { + label: option.label, + value: option.value, + }) + .collect(), + selected_effort, + } }) .collect(); self.bottom_pane.open_model_picker(entries); self.set_status_message("Select a model"); } - pub(super) fn handle_model_picker_selection(&mut self, slug: String) { + pub(super) fn handle_model_picker_selection( + &mut self, + slug: String, + reasoning_effort: Option, + ) { if let Some(entry) = self.saved_model_entry_for_selection(&slug).cloned() { let selected_model = self.apply_saved_model_entry_to_session(&entry); - let reasoning_effort_selection = - reasoning_effort::current_reasoning_effort_selection_for_model( - &selected_model, - self.reasoning_effort_selection.as_deref(), - ); - self.pending_model_selection = Some(PendingModelSelection { - selection: Self::saved_model_selection_value(&entry).to_string(), - display_name: self.saved_model_display_label(&entry), - reasoning_effort_selection: reasoning_effort_selection.clone(), - }); - self.reasoning_effort_selection = reasoning_effort_selection; - self.refresh_header_box(); - - if selected_model - .effective_reasoning_capability() - .options() - .is_empty() - { - self.finalize_pending_model_selection(); - return; - } - - self.open_reasoning_effort_picker(); + let display_name = self.saved_model_display_label(&entry); + let selection = Self::saved_model_selection_value(&entry).to_string(); + self.apply_model_and_effort(selection, display_name, &selected_model, reasoning_effort); return; } @@ -379,34 +387,50 @@ impl ChatWidget { return; }; - let reasoning_effort_selection = - reasoning_effort::current_reasoning_effort_selection_for_model( - &selected_model, - self.reasoning_effort_selection.as_deref(), - ); - self.pending_model_selection = Some(PendingModelSelection { - selection: selected_model.slug.clone(), - display_name: selected_model.display_name.clone(), - reasoning_effort_selection: reasoning_effort_selection.clone(), - }); self.current_model_binding_id = None; self.session.model_binding_id = None; self.session.provider = Some(selected_model.provider); self.session.model = Some(selected_model.clone()); self.session.request_model = None; - self.reasoning_effort_selection = reasoning_effort_selection; - self.refresh_header_box(); - - if selected_model - .effective_reasoning_capability() - .options() - .is_empty() - { - self.finalize_pending_model_selection(); - return; - } + let display_name = selected_model.display_name.clone(); + let selection = selected_model.slug.clone(); + self.apply_model_and_effort(selection, display_name, &selected_model, reasoning_effort); + } - self.open_reasoning_effort_picker(); + fn apply_model_and_effort( + &mut self, + selection: String, + display_name: String, + model: &Model, + reasoning_effort: Option, + ) { + self.reasoning_effort_selection = + if model.effective_reasoning_capability().options().is_empty() { + None + } else { + reasoning_effort::current_reasoning_effort_selection_for_model( + model, + reasoning_effort + .as_deref() + .or(self.reasoning_effort_selection.as_deref()), + ) + }; + self.session.reasoning_effort = model + .resolve_reasoning_effort_selection(self.reasoning_effort_selection.as_deref()) + .effective_reasoning_effort; + self.refresh_header_box(); + self.app_event_tx.send(AppEvent::Command( + AppCommand::override_turn_context_with_scope( + /*cwd*/ None, + Some(selection), + Some(self.reasoning_effort_selection.clone()), + /*sandbox*/ None, + /*approval_policy*/ None, + self.settings_picker_persist_scope, + ), + )); + self.settings_picker_persist_scope = crate::app_command::PersistScope::Session; + self.set_status_message(format!("Model set to {display_name}")); } pub(super) fn open_theme_picker(&mut self) { @@ -416,13 +440,25 @@ impl ChatWidget { } pub(super) fn open_permissions_picker(&mut self) { + self.open_permissions_picker_with_scope(crate::app_command::PersistScope::Session); + } + + pub(super) fn open_permissions_picker_for_defaults(&mut self) { + self.open_permissions_picker_with_scope(crate::app_command::PersistScope::Default); + } + + fn open_permissions_picker_with_scope( + &mut self, + persist_scope: crate::app_command::PersistScope, + ) { + self.settings_picker_persist_scope = persist_scope; let current = self.permission_preset; self.bottom_pane .open_popup_view(Box::new(ListSelectionView::new( SelectionViewParams { title: Some("Update Permissions".to_string()), footer_hint: Some(Line::from("Press enter to confirm or esc to go back")), - items: permission_preset_items(current), + items: permission_preset_items(current, persist_scope), ..SelectionViewParams::default() }, self.app_event_tx.clone(), @@ -467,6 +503,20 @@ impl ChatWidget { self.frame_requester.schedule_frame(); } + pub(crate) fn apply_collaboration_mode( + &mut self, + collaboration_mode: devo_protocol::CollaborationMode, + persist_scope: crate::app_command::PersistScope, + ) { + let input_mode = crate::bottom_pane::InputMode::from_collaboration_mode(collaboration_mode); + if persist_scope == crate::app_command::PersistScope::Default { + self.default_collaboration_mode = collaboration_mode; + } + self.current_turn_mode = input_mode; + self.bottom_pane.set_input_mode(input_mode); + self.refresh_settings_hub_if_open(); + } + pub(crate) fn note_permissions_updated(&mut self, preset: devo_protocol::PermissionPreset) { self.permission_preset = preset; self.sandbox_profile = Some( @@ -483,20 +533,51 @@ impl ChatWidget { None, )); self.set_status_message(format!("Permissions updated to {label}")); + self.refresh_settings_hub_if_open(); } - pub(crate) fn note_sandbox_profile_updated(&mut self, profile: String) { - let label = sandbox_profile_label(&profile).to_string(); - self.sandbox_profile = Some(profile); + pub(crate) fn note_effective_context_window_updated(&mut self, effective_context_window: u64) { + self.effective_context_window = Some(effective_context_window); + self.default_compaction_token_limit = Some(effective_context_window); + if let Some(occupancy) = self.last_context_occupancy.as_mut() { + occupancy.context_window_tokens = effective_context_window; + } + let label = crate::bottom_pane::format_token_limit(effective_context_window); self.add_to_history(history_cell::new_info_event( - format!("Sandbox profile updated to {label}"), + format!("Compaction threshold updated to {label}"), None, )); - self.set_status_message(format!("Sandbox profile updated to {label}")); + self.set_status_message(format!("Compaction threshold updated to {label}")); + self.sync_bottom_pane_summary(); + self.refresh_status_panel_if_open(); + self.refresh_settings_hub_if_open(); + } + + fn refresh_status_panel_if_open(&mut self) { + self.bottom_pane.refresh_status_panel( + self.last_context_occupancy.clone(), + crate::bottom_pane::SessionTokenTotals { + input: self.total_input_tokens, + output: self.total_output_tokens, + cache_read: self.total_cache_read_tokens, + }, + ); + } + + pub(crate) fn note_sandbox_profile_updated(&mut self, profile: String) { + // Sandbox follows the permission preset (or an explicit /sandbox pick). Keep local + // state in sync for settings/status, but do not emit a transcript or status line — + // "Sandbox profile updated to …" is noise next to "Permissions updated to …". + self.sandbox_profile = Some(profile); + self.refresh_settings_hub_if_open(); } pub(super) fn apply_theme_selection(&mut self, name: String) { if let Some(theme) = self.theme_set.find(&name).cloned() { + if self.active_theme_name == name { + self.refresh_settings_hub_if_open(); + return; + } self.active_theme_name = name.clone(); self.bottom_pane.set_accent_color(theme.accent_color); let _ = crate::onboarding::save_theme_selection(&name); @@ -510,18 +591,169 @@ impl ChatWidget { )); } self.set_status_message(format!("Theme set to {name}")); + self.refresh_settings_hub_if_open(); // Header/logo are flushed into terminal scrollback. Reset the flush // cursor and ask the host to clear the managed inline area so the // next draw re-emits transcript lines with the new accent. if self.next_history_flush_index > 0 { self.next_history_flush_index = 0; - self.app_event_tx.send(AppEvent::ReloadInlineTranscript); + self.schedule_debounced_inline_transcript_reload(); } else { self.frame_requester.schedule_frame(); } } } + pub(super) fn cycle_theme(&mut self, direction: crate::app_event::SettingsCycleDirection) { + let themes = &self.theme_set.themes; + if themes.is_empty() { + return; + } + let current = themes + .iter() + .position(|theme| theme.name == self.active_theme_name) + .unwrap_or(0); + let next = match direction { + crate::app_event::SettingsCycleDirection::Next => (current + 1) % themes.len(), + crate::app_event::SettingsCycleDirection::Previous => { + if current == 0 { + themes.len() - 1 + } else { + current - 1 + } + } + }; + let name = themes[next].name.clone(); + self.apply_theme_selection(name); + } + + fn schedule_debounced_inline_transcript_reload(&mut self) { + self.theme_reload_epoch = self.theme_reload_epoch.wrapping_add(1); + let epoch = self.theme_reload_epoch; + let tx = self.app_event_tx.clone(); + match tokio::runtime::Handle::try_current() { + Ok(handle) => { + handle.spawn(async move { + tokio::time::sleep(std::time::Duration::from_millis(120)).await; + tx.send(crate::app_event::AppEvent::FlushDebouncedThemeReload { epoch }); + }); + } + Err(_) => { + // Unit tests may lack a Tokio runtime; reload immediately. + tx.send(crate::app_event::AppEvent::ReloadInlineTranscript); + } + } + } + + pub(super) fn flush_debounced_theme_reload(&mut self, epoch: u64) { + if epoch != self.theme_reload_epoch { + return; + } + self.app_event_tx + .send(crate::app_event::AppEvent::ReloadInlineTranscript); + } + + pub(super) fn open_settings_hub(&mut self) { + let snapshot = self.settings_hub_snapshot(); + self.bottom_pane.open_settings_hub(snapshot); + self.set_status_message("Settings"); + } + + pub(super) fn open_settings_hub_appearance(&mut self) { + let snapshot = self.settings_hub_snapshot(); + self.bottom_pane + .open_settings_hub_on_tab(snapshot, crate::bottom_pane::SettingsHubTab::Appearance); + self.set_status_message("Settings"); + } + + pub(super) fn open_compaction_threshold_picker(&mut self) { + let snapshot = self.compaction_threshold_snapshot(); + self.bottom_pane.open_compaction_threshold(snapshot); + self.set_status_message("Select compaction threshold"); + } + + pub(super) fn refresh_settings_hub_if_open(&mut self) { + let snapshot = self.settings_hub_snapshot(); + self.bottom_pane.refresh_settings_hub(snapshot); + } + + fn settings_hub_snapshot(&self) -> crate::bottom_pane::SettingsHubSnapshot { + crate::bottom_pane::SettingsHubSnapshot { + model_label: self + .session + .model + .as_ref() + .map(|model| model.slug.clone()) + .unwrap_or_else(|| "unknown".to_string()), + permissions_label: permission_preset_label(self.permission_preset).to_string(), + mode: crate::bottom_pane::InputMode::from_collaboration_mode( + self.default_collaboration_mode, + ), + compaction_threshold_label: crate::bottom_pane::format_token_limit( + self.default_compaction_token_limit + .map(|limit| { + let model_window = self + .session + .model + .as_ref() + .map(|model| u64::from(model.context_window.max(1))) + .unwrap_or(u64::MAX); + limit.min(model_window).max(1) + }) + .unwrap_or_else(|| self.effective_compaction_threshold_tokens()), + ), + theme_label: self.active_theme_name.clone(), + reasoning_view_label: super::reasoning_view::reasoning_view_label( + self.collapse_reasoning, + ) + .to_string(), + } + } + + fn compaction_threshold_snapshot(&self) -> crate::bottom_pane::CompactionThresholdSnapshot { + let model = self.session.model.as_ref(); + let context_window_tokens = model + .map(|model| u64::from(model.context_window.max(1))) + .unwrap_or(1); + let model_effective = model + .map(|model| u64::from(model.effective_context_window())) + .unwrap_or(context_window_tokens); + let recommended_token_limit = crate::bottom_pane::recommended_compaction_token_limit( + context_window_tokens, + model_effective, + ); + // Current / hub label follow the applied (model-clamped) window so they + // stay consistent with the status bar. The stored global preference is + // kept separately for picker memory when this client initiated an update. + let current_token_limit = self + .effective_context_window + .unwrap_or(model_effective) + .min(context_window_tokens) + .max(1); + crate::bottom_pane::CompactionThresholdSnapshot { + model_label: model + .map(|model| model.slug.clone()) + .unwrap_or_else(|| "unknown".to_string()), + context_window_tokens, + recommended_token_limit, + current_token_limit, + } + } + + fn effective_compaction_threshold_tokens(&self) -> u64 { + let model = self.session.model.as_ref(); + let model_window = model + .map(|model| u64::from(model.context_window.max(1))) + .unwrap_or(u64::MAX); + let model_effective = model + .map(|model| u64::from(model.effective_context_window())) + .unwrap_or(1); + self.effective_context_window + .unwrap_or(model_effective) + .min(model_window) + .max(1) + } + pub(super) fn active_accent_color(&self) -> Color { self.theme_set .find(&self.active_theme_name) @@ -605,35 +837,13 @@ impl ChatWidget { self.set_status_message(format!("Model set to {slug}")); } - pub(super) fn open_reasoning_effort_picker(&mut self) { - self.picker_mode = Some(PickerMode::ReasoningEffort); - let entries = self.reasoning_effort_entries(); - if entries.is_empty() { - self.set_status_message("Reasoning effort unsupported"); - return; - } - let model_entries = entries - .into_iter() - .map(|entry| ModelPickerEntry { - selection_value: entry.value, - display_name: entry.label, - description: Some(entry.description), - right_hint: None, - is_current: entry.is_current, - }) - .collect(); - self.bottom_pane.open_model_picker(model_entries); - self.set_status_message("Select reasoning effort"); - } - pub(super) fn apply_reasoning_effort_selection(&mut self, value: String) { self.reasoning_effort_selection = Some(value.clone()); - if let Some(pending) = self.pending_model_selection.as_mut() { - pending.reasoning_effort_selection = Some(value); - self.finalize_pending_model_selection(); - return; + if let Some(model) = self.session.model.as_ref() { + self.session.reasoning_effort = model + .resolve_reasoning_effort_selection(Some(value.as_str())) + .effective_reasoning_effort; } - self.refresh_header_box(); self.app_event_tx .send(AppEvent::Command(AppCommand::override_turn_context( @@ -645,23 +855,4 @@ impl ChatWidget { ))); self.set_status_message(format!("Reasoning effort set to {value}")); } - - pub(super) fn finalize_pending_model_selection(&mut self) { - let Some(pending) = self.pending_model_selection.take() else { - return; - }; - - self.picker_mode = None; - self.reasoning_effort_selection = pending.reasoning_effort_selection.clone(); - self.refresh_header_box(); - self.app_event_tx - .send(AppEvent::Command(AppCommand::override_turn_context( - /*cwd*/ None, - Some(pending.selection.clone()), - Some(self.reasoning_effort_selection.clone()), - /*sandbox*/ None, - /*approval_policy*/ None, - ))); - self.set_status_message(format!("Model set to {}", pending.display_name)); - } } diff --git a/crates/tui/src/chatwidget/input.rs b/crates/tui/src/chatwidget/input.rs index 46f01d24..9703994a 100644 --- a/crates/tui/src/chatwidget/input.rs +++ b/crates/tui/src/chatwidget/input.rs @@ -31,7 +31,6 @@ use devo_protocol::CollaborationMode; use super::ChatWidget; use super::ExternalEditorState; -use super::PickerMode; use super::UserMessage; impl ChatWidget { @@ -111,24 +110,25 @@ impl ChatWidget { mention_bindings, }; if self.busy && !user_message.text.trim().is_empty() { - // Turn is active — show in bottom pane as pending cell. - self.bottom_pane - .push_pending_cell(user_message.text.clone()); - self.queued_input_modes.push_back(input_mode); - self.queued_count += 1; - self.app_event_tx.send(AppEvent::Command( - AppCommand::user_turn_with_collaboration_mode( - input_items_for_user_message(&user_message), - Some(self.session.cwd.clone()), - self.user_turn_model(), - self.user_turn_model_binding_id(), - self.reasoning_effort_selection.clone(), - /*sandbox*/ None, - Some("on-request".to_string()), - collaboration_mode, - ), - )); - self.set_status_message("Message queued"); + if let Some(queue_item_id) = self.editing_queue_item_id.take() { + // Resubmit of a queue edit — replace the entry in place + // so it keeps its position and existing mode slot. + self.app_event_tx + .send(AppEvent::Command(AppCommand::QueueUpdate { + queue_item_id, + input: input_items_for_user_message(&user_message), + })); + self.set_status_message("Queued message updated"); + } else { + // Turn is active — enqueue via canonical session/queue/push. + // Mode is applied when QueueUpdated assigns a queue_item_id. + self.queued_input_modes.push_back(input_mode); + self.app_event_tx + .send(AppEvent::Command(AppCommand::QueuePush { + input: input_items_for_user_message(&user_message), + })); + self.set_status_message("Message queued"); + } } else { self.submit_user_message_with_modes( user_message, @@ -171,14 +171,60 @@ impl ChatWidget { } self.handle_slash_command(command, argument); } - InputResult::ModelSelected { model } => match self.picker_mode.take() { - Some(PickerMode::ReasoningEffort) => self.apply_reasoning_effort_selection(model), - _ => self.handle_model_picker_selection(model), - }, + InputResult::ModelSelected { + model, + reasoning_effort, + } => { + self.handle_model_picker_selection(model, reasoning_effort); + } InputResult::ThemeSelected { name } => { self.apply_theme_selection(name); } + InputResult::QueueSteer { queue_item_id } => { + let Some(turn_id) = self.active_turn_id else { + self.set_status_message("No active turn to steer"); + return; + }; + self.app_event_tx + .send(AppEvent::Command(AppCommand::QueueSteer { + queue_item_id, + expected_turn_id: turn_id, + })); + self.bottom_pane.clear_pending_queue_focus(); + self.set_status_message("Steering queued message…"); + } + InputResult::QueueEdit { + queue_item_id, + text, + } => { + // Load the queued text into the composer and remember the item; + // resubmitting while busy updates it in place (position kept). + self.editing_queue_item_id = Some(queue_item_id); + self.bottom_pane.clear_pending_queue_focus(); + self.bottom_pane + .set_text_content(text, Vec::new(), Vec::new()); + self.bottom_pane.move_composer_cursor_to_end(); + self.set_status_message("Editing queued message"); + } + InputResult::QueueRemove { queue_item_id } => { + self.app_event_tx + .send(AppEvent::Command(AppCommand::QueueRemove { queue_item_id })); + self.set_status_message("Removing queued message"); + } InputResult::None => {} + InputResult::InputModeChanged { input_mode } => { + self.current_turn_mode = input_mode; + if matches!( + input_mode, + crate::bottom_pane::InputMode::Build | crate::bottom_pane::InputMode::Plan + ) { + self.app_event_tx + .send(AppEvent::Command(AppCommand::set_collaboration_mode( + input_mode.collaboration_mode(), + crate::app_command::PersistScope::Session, + ))); + } + } } } @@ -228,6 +274,7 @@ impl ChatWidget { self.advance_startup_header_animation(); self.run_stream_commit_tick(); self.tick_subagent_monitor(Instant::now()); + self.maybe_refresh_status_line_branch(); self.bottom_pane.pre_draw_tick(); } @@ -236,13 +283,42 @@ impl ChatWidget { AppEvent::Redraw => self.frame_requester.schedule_frame(), AppEvent::SubmitUserInput { text } => self.submit_text(text), AppEvent::ModelSelected { model } => { - self.handle_model_picker_selection(model); + self.handle_model_picker_selection(model, None); } AppEvent::ThemeSelected { name } => { self.apply_theme_selection(name); } AppEvent::CollapseReasoningSelected { collapsed } => { self.apply_collapse_reasoning(collapsed); + self.refresh_settings_hub_if_open(); + } + AppEvent::SettingsOpenModel => { + self.open_model_picker_for_defaults(); + } + AppEvent::SettingsOpenPermissions => { + self.open_permissions_picker_for_defaults(); + } + AppEvent::SettingsOpenReasoning => { + self.open_reasoning_view_picker(); + } + AppEvent::SettingsOpenCompaction => { + self.open_compaction_threshold_picker(); + } + AppEvent::SettingsCycleMode => { + self.bottom_pane.cycle_build_plan_mode(); + self.current_turn_mode = self.bottom_pane.input_mode(); + self.app_event_tx + .send(AppEvent::Command(AppCommand::set_collaboration_mode( + self.current_turn_mode.collaboration_mode(), + crate::app_command::PersistScope::Default, + ))); + self.refresh_settings_hub_if_open(); + } + AppEvent::SettingsCycleTheme { direction } => { + self.cycle_theme(direction); + } + AppEvent::FlushDebouncedThemeReload { epoch } => { + self.flush_debounced_theme_reload(epoch); } AppEvent::McpOpenServerList => { self.open_mcp_server_list(); @@ -313,6 +389,10 @@ impl ChatWidget { } self.frame_requester.schedule_frame(); } + AppEvent::StatusLineBranchUpdated { cwd, branch } => { + self.apply_status_line_branch_update(cwd, branch); + self.frame_requester.schedule_frame(); + } AppEvent::Exit(_) | AppEvent::OnboardingCompleted | AppEvent::OpenSlashCommandPopup @@ -321,7 +401,6 @@ impl ChatWidget { | AppEvent::OpenReasoningEffortPicker | AppEvent::OpenThemePicker | AppEvent::OpenSubagentOverlay { .. } - | AppEvent::StatusLineBranchUpdated { .. } | AppEvent::ReferenceSearchRequested { .. } | AppEvent::ReferenceSearchCancelled | AppEvent::StatusLineSetup { .. } @@ -389,6 +468,11 @@ impl ChatWidget { return; } + // Remember the submitted mode for TurnStarted/TurnFinished even if the + // composer mode changes while the turn is still starting. + if !self.busy { + self.promoted_input_modes.push_back(input_mode); + } self.current_turn_mode = input_mode; let local_image_paths = user_message .local_images @@ -413,7 +497,7 @@ impl ChatWidget { self.user_turn_model_binding_id(), self.reasoning_effort_selection.clone(), /*sandbox*/ None, - Some("on-request".to_string()), + /*approval_policy*/ None, collaboration_mode, ), )); @@ -607,6 +691,11 @@ impl ChatWidget { self.bottom_pane.input_mode() } + #[cfg(test)] + pub(crate) fn permission_preset_for_test(&self) -> devo_protocol::PermissionPreset { + self.permission_preset + } + pub(crate) fn composer_is_empty(&self) -> bool { self.bottom_pane.current_text().trim().is_empty() } diff --git a/crates/tui/src/chatwidget/permission_presets.rs b/crates/tui/src/chatwidget/permission_presets.rs index c0f9f5c5..b027f516 100644 --- a/crates/tui/src/chatwidget/permission_presets.rs +++ b/crates/tui/src/chatwidget/permission_presets.rs @@ -6,24 +6,28 @@ use devo_protocol::PermissionPreset; use crate::app_command::AppCommand; +use crate::app_command::PersistScope; use crate::app_event::AppEvent; use crate::bottom_pane::list_selection_view::SelectionItem; -pub(super) fn permission_preset_items(current: PermissionPreset) -> Vec { +pub(super) fn permission_preset_items( + current: PermissionPreset, + persist_scope: PersistScope, +) -> Vec { [ ( PermissionPreset::Default, - "Default", - "Workspace sandbox; read, write, and run commands in workspace; network blocked.", + "Ask for approval", + "Workspace sandbox; read, write, and run commands in workspace; network blocked. You approve sensitive tools.", ), ( PermissionPreset::AutoReview, - "Auto-review", - "Workspace sandbox; same as Default, but auto-reviewer handles approvals first.", + "Approve for me", + "Same sandbox as Ask for approval. An AI reviewer may approve low-risk tools; uncertain ones still ask you.", ), ( PermissionPreset::FullAccess, - "Full Access", + "Full-Access", "No OS sandbox and no approval prompts; use with caution.", ), ] @@ -34,7 +38,10 @@ pub(super) fn permission_preset_items(current: PermissionPreset) -> Vec Vec &'static str { match preset { - PermissionPreset::Default => "Default", - PermissionPreset::AutoReview => "Auto-review", - PermissionPreset::FullAccess => "Full Access", + PermissionPreset::Default => "Ask for approval", + PermissionPreset::AutoReview => "Approve for me", + PermissionPreset::FullAccess => "Full-Access", } } @@ -63,12 +70,15 @@ mod tests { permission_preset_label(PermissionPreset::FullAccess), ]; - assert_eq!(actual, ["Default", "Auto-review", "Full Access"]); + assert_eq!( + actual, + ["Ask for approval", "Approve for me", "Full-Access"] + ); } #[test] fn permission_preset_items_mark_current_selection() { - let items = permission_preset_items(PermissionPreset::AutoReview); + let items = permission_preset_items(PermissionPreset::AutoReview, PersistScope::Session); let actual: Vec<_> = items .iter() .map(|item| { @@ -85,9 +95,9 @@ mod tests { assert_eq!( actual, vec![ - ("Default", true, false, true, 1), - ("Auto-review", true, true, true, 1), - ("Full Access", true, false, true, 1), + ("Ask for approval", true, false, true, 1), + ("Approve for me", true, true, true, 1), + ("Full-Access", true, false, true, 1), ] ); } diff --git a/crates/tui/src/chatwidget/reasoning_view.rs b/crates/tui/src/chatwidget/reasoning_view.rs index e6500289..0883a98b 100644 --- a/crates/tui/src/chatwidget/reasoning_view.rs +++ b/crates/tui/src/chatwidget/reasoning_view.rs @@ -4,6 +4,7 @@ //! main transcript viewport. use std::path::Path; +use std::path::PathBuf; use ratatui::style::Style; use ratatui::text::Line; @@ -14,11 +15,155 @@ use crate::bottom_pane::list_selection_view::SelectionItem; use crate::history_cell; use crate::history_cell::HistoryCell; use crate::history_cell::ReasoningViewportMode; +use crate::history_cell::collapse_consecutive_blank_lines; use crate::markdown::append_markdown; +use crate::wrapping::RtOptions; +use crate::wrapping::adaptive_wrap_lines; -/// Live streaming window when reasoning view is collapsed. +/// Maximum *visual* terminal rows for collapsed reasoning **body** content. +/// +/// Counted after wrap, not by markdown/logical newlines — a single long +/// paragraph that wraps to many rows still counts toward this budget. +/// Live streaming keeps a sticky `▌ Thinking:` heading above these body rows. pub(super) const COLLAPSED_REASONING_LIVE_LINES: usize = 3; +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum CollapsedReasoningMode { + /// Streaming: sticky Thinking heading + latest wrapped body rows. + Live, + /// Committed: short bodies stay full; longer bodies compact to Thought. + Completed, +} + +/// Width-aware collapsed reasoning cell. +/// +/// Truncates by wrapped visual height so one long logical line cannot expand +/// past [`COLLAPSED_REASONING_LIVE_LINES`] rows in the main viewport. +#[derive(Debug)] +struct CollapsedReasoningCell { + content: String, + cwd: PathBuf, + heading: String, + heading_style: Style, + text_style: Style, + initial_prefix: Line<'static>, + subsequent_prefix: Line<'static>, + mode: CollapsedReasoningMode, +} + +impl CollapsedReasoningCell { + fn body_lines(&self) -> Vec> { + let mut body_lines = Vec::new(); + append_markdown( + &self.content, + /*width*/ None, + Some(self.cwd.as_path()), + &mut body_lines, + ); + for line in &mut body_lines { + line.spans = line + .spans + .iter() + .cloned() + .map(|span| span.patch_style(self.text_style)) + .collect(); + } + body_lines + } + + /// Sticky live header: `▌ Thinking:` — never scrolls away with the body tail. + fn sticky_heading_line(&self) -> Line<'static> { + let mut spans = self.initial_prefix.spans.clone(); + spans.push(Span::styled(self.heading.clone(), self.heading_style)); + Line { + style: self.initial_prefix.style, + alignment: self.initial_prefix.alignment, + spans, + } + } + + /// Body-only wrap for live streaming (heading is rendered separately). + fn wrap_body_only(&self, width: u16) -> Vec> { + let body_lines = self.body_lines(); + collapse_consecutive_blank_lines(adaptive_wrap_lines( + &body_lines, + RtOptions::new(width as usize) + .initial_indent(self.subsequent_prefix.clone()) + .subsequent_indent(self.subsequent_prefix.clone()), + )) + } + + fn wrap_body_with_heading(&self, width: u16) -> Vec> { + let mut body_lines = self.body_lines(); + if let Some(first_line) = body_lines.first_mut() { + first_line + .spans + .insert(0, Span::styled(self.heading.clone(), self.heading_style)); + } + collapse_consecutive_blank_lines(adaptive_wrap_lines( + &body_lines, + RtOptions::new(width as usize) + .initial_indent(self.initial_prefix.clone()) + .subsequent_indent(self.subsequent_prefix.clone()), + )) + } + + fn take_last_visual_rows(mut lines: Vec>, max_rows: usize) -> Vec> { + if lines.len() > max_rows { + lines = lines.split_off(lines.len() - max_rows); + } + lines + } + + fn compact_lines(&self, width: u16) -> Vec> { + history_cell::ReasoningSummaryCell::new( + String::new(), + self.content.clone(), + &self.cwd, + ReasoningViewportMode::Compact, + ) + .display_lines(width) + } + + fn live_display_lines(&self, width: u16) -> Vec> { + let mut lines = vec![self.sticky_heading_line()]; + lines.extend(Self::take_last_visual_rows( + self.wrap_body_only(width), + COLLAPSED_REASONING_LIVE_LINES, + )); + lines.push(history_cell::reasoning_transcript_hint_line()); + lines + } +} + +impl HistoryCell for CollapsedReasoningCell { + fn display_lines(&self, width: u16) -> Vec> { + match self.mode { + CollapsedReasoningMode::Live => self.live_display_lines(width), + CollapsedReasoningMode::Completed => { + let wrapped = self.wrap_body_with_heading(width); + if wrapped.len() <= COLLAPSED_REASONING_LIVE_LINES { + let mut lines = wrapped; + lines.push(history_cell::reasoning_transcript_hint_line()); + lines + } else { + self.compact_lines(width) + } + } + } + } + + fn transcript_lines(&self, width: u16) -> Vec> { + history_cell::ReasoningSummaryCell::new( + String::new(), + self.content.clone(), + &self.cwd, + ReasoningViewportMode::Full, + ) + .transcript_lines(width) + } +} + pub(super) fn reasoning_view_items(collapse_reasoning: bool) -> Vec { [(true, "Collapsed"), (false, "Full")] .into_iter() @@ -43,10 +188,32 @@ pub(super) fn reasoning_view_label(collapse_reasoning: bool) -> &'static str { } } +/// Live streaming cell for collapsed reasoning (latest visual rows only). +pub(super) fn collapsed_reasoning_live_cell( + content: String, + cwd: &Path, + status_heading: &str, + status_heading_style: Style, + reasoning_text_style: Style, + dot_prefix: Line<'static>, +) -> Box { + Box::new(CollapsedReasoningCell { + content, + cwd: cwd.to_path_buf(), + heading: status_heading.to_string(), + heading_style: status_heading_style, + text_style: reasoning_text_style, + initial_prefix: dot_prefix, + subsequent_prefix: " ".into(), + mode: CollapsedReasoningMode::Live, + }) +} + /// Build the committed reasoning cell for collapsed mode. /// -/// Short reasoning stays fully visible. Longer reasoning becomes a one-line -/// Thought summary in the main viewport, with the full body kept for Ctrl+T. +/// Short reasoning (≤ [`COLLAPSED_REASONING_LIVE_LINES`] *visual* rows after +/// wrap) stays fully visible. Longer reasoning becomes a one-line Thought +/// summary in the main viewport, with the full body kept for Ctrl+T. pub(super) fn collapsed_reasoning_history_cell( content: String, cwd: &Path, @@ -55,34 +222,14 @@ pub(super) fn collapsed_reasoning_history_cell( reasoning_text_style: Style, dot_prefix: Line<'static>, ) -> Box { - let mut body_lines = Vec::new(); - append_markdown(&content, /*width*/ None, Some(cwd), &mut body_lines); - for line in &mut body_lines { - line.spans = line - .spans - .iter() - .cloned() - .map(|span| span.patch_style(reasoning_text_style)) - .collect(); - } - - if body_lines.len() <= COLLAPSED_REASONING_LIVE_LINES { - if let Some(first_line) = body_lines.first_mut() { - first_line.spans.insert( - 0, - Span::styled(status_heading.to_string(), status_heading_style), - ); - } - body_lines.push(history_cell::reasoning_transcript_hint_line()); - return Box::new(history_cell::AgentMessageCell::new_ai_response_with_prefix( - body_lines, dot_prefix, " ", false, - )); - } - - Box::new(history_cell::ReasoningSummaryCell::new( - String::new(), + Box::new(CollapsedReasoningCell { content, - cwd, - ReasoningViewportMode::Compact, - )) + cwd: cwd.to_path_buf(), + heading: status_heading.to_string(), + heading_style: status_heading_style, + text_style: reasoning_text_style, + initial_prefix: dot_prefix, + subsequent_prefix: " ".into(), + mode: CollapsedReasoningMode::Completed, + }) } diff --git a/crates/tui/src/chatwidget/restored_session.rs b/crates/tui/src/chatwidget/restored_session.rs index 82b7531c..09bd923d 100644 --- a/crates/tui/src/chatwidget/restored_session.rs +++ b/crates/tui/src/chatwidget/restored_session.rs @@ -285,6 +285,16 @@ impl ChatWidget { }; self.add_history_entry_without_redraw(Box::new(summary)); } + devo_protocol::SessionHistoryItemKind::ContextCompaction => { + let title = if item.title.is_empty() { + "Context compacted".to_string() + } else { + item.title.clone() + }; + self.add_history_entry_without_redraw(Box::new( + history_cell::new_live_aligned_info_event(title, None), + )); + } } } @@ -293,9 +303,6 @@ impl ChatWidget { } pub(super) fn add_restored_user_prompt(&mut self, body: String) { - self.add_history_entry_without_redraw(Box::new(history_cell::PlainHistoryCell::new(vec![ - Line::from(""), - ]))); self.add_history_entry_without_redraw(Box::new(history_cell::new_user_prompt( body, Vec::new(), @@ -304,9 +311,6 @@ impl ChatWidget { self.active_accent_color(), InputMode::Build, ))); - self.add_history_entry_without_redraw(Box::new(history_cell::PlainHistoryCell::new(vec![ - Line::from(""), - ]))); } fn add_restored_file_change_item( @@ -482,14 +486,25 @@ impl ChatWidget { if item.kind != devo_protocol::SessionHistoryItemKind::ToolResult { return None; } + if let Some(SessionHistoryMetadata::Edited { changes }) = &item.metadata { + return (!changes.is_empty()).then(|| changes.clone()); + } let lower_title = item.title.to_ascii_lowercase(); + let body_for_parse = item + .tool_io + .as_ref() + .and_then(|tool_io| tool_io.output.as_ref()) + .map(ToString::to_string) + .filter(|text| text.contains("\"files\"") || text.contains("\"diff\"")) + .unwrap_or_else(|| item.body.clone()); if !lower_title.contains("apply_patch") && !lower_title.contains("write") - && !item.body.contains("\"files\"") + && !lower_title.contains("edit") + && !body_for_parse.contains("\"files\"") { return None; } - let value: serde_json::Value = serde_json::from_str(&item.body).ok()?; + let value: serde_json::Value = serde_json::from_str(&body_for_parse).ok()?; let files = value.get("files")?.as_array()?; let diff = value .get("diff") @@ -524,7 +539,12 @@ impl ChatWidget { .unwrap_or_else(|| "\n".repeat(deletions as usize)), }, "update" | "move" => devo_protocol::protocol::FileChange::Update { - unified_diff: diff.clone(), + unified_diff: file + .get("diff") + .or_else(|| file.get("patch")) + .and_then(serde_json::Value::as_str) + .map(ToOwned::to_owned) + .unwrap_or_else(|| diff.clone()), old_text: file .get("oldContent") .or_else(|| file.get("preContent")) diff --git a/crates/tui/src/chatwidget/resume_browser.rs b/crates/tui/src/chatwidget/resume_browser.rs index 3771fd74..ab600588 100644 --- a/crates/tui/src/chatwidget/resume_browser.rs +++ b/crates/tui/src/chatwidget/resume_browser.rs @@ -8,6 +8,7 @@ use crossterm::event::KeyEvent; use crossterm::event::KeyEventKind; use ratatui::buffer::Buffer; use ratatui::layout::Rect; +use ratatui::style::Color; use ratatui::style::Style; use ratatui::style::Stylize; use ratatui::text::Line; @@ -20,15 +21,26 @@ use ratatui::widgets::Wrap; use crate::app_command::AppCommand; use crate::app_event::AppEvent; +use crate::bottom_pane::HorizontalChipStrip; use crate::events::SessionListEntry; +use crate::key_hint; +use crate::ui_consts::FOOTER_INDENT_COLS; +use devo_core::SessionId; use super::ChatWidget; +const DELETE_CONFIRM_CANCEL: usize = 0; +const DELETE_CONFIRM_DELETE: usize = 1; + #[derive(Debug, Clone)] pub(super) struct ResumeBrowserState { pub(super) sessions: Vec, pub(super) selection: usize, pub(super) scroll_offset: usize, + /// When set, the footer shows Cancel/Delete chips for this session. + pub(super) pending_delete_session_id: Option, + /// Chip selection for the pending delete confirm (default Cancel). + pub(super) pending_delete_chips: HorizontalChipStrip, } impl ChatWidget { @@ -42,10 +54,19 @@ impl ChatWidget { sessions, selection, scroll_offset: 0, + pending_delete_session_id: None, + pending_delete_chips: Self::new_delete_confirm_chips(), }); self.set_status_message("Resume session"); } + fn new_delete_confirm_chips() -> HorizontalChipStrip { + HorizontalChipStrip::new( + vec!["Cancel".to_string(), "Delete".to_string()], + /*selected*/ DELETE_CONFIRM_CANCEL, + ) + } + pub(super) fn handle_resume_browser_key_event(&mut self, key: KeyEvent) { if !matches!(key.kind, KeyEventKind::Press) { return; @@ -53,11 +74,70 @@ impl ChatWidget { let Some(browser) = self.resume_browser.as_mut() else { return; }; + let confirming_delete = browser.pending_delete_session_id.is_some(); let page_step = Self::resume_browser_visible_capacity( self.resume_browser_last_height.get(), !browser.sessions.is_empty(), + confirming_delete, ) .max(1); + + if confirming_delete { + match key.code { + KeyCode::Esc => { + browser.pending_delete_session_id = None; + browser.pending_delete_chips = Self::new_delete_confirm_chips(); + self.set_status_message("Resume session"); + self.frame_requester.schedule_frame(); + } + KeyCode::Char('q') => { + self.resume_browser = None; + self.resume_browser_loading = false; + self.set_status_message("Ready"); + self.frame_requester.schedule_frame(); + } + KeyCode::Left => { + browser.pending_delete_chips.move_left(); + self.frame_requester.schedule_frame(); + } + KeyCode::Right => { + browser.pending_delete_chips.move_right(); + self.frame_requester.schedule_frame(); + } + KeyCode::Enter => { + let selected = browser.pending_delete_chips.selected_index(); + if selected != DELETE_CONFIRM_DELETE { + browser.pending_delete_session_id = None; + browser.pending_delete_chips = Self::new_delete_confirm_chips(); + self.set_status_message("Resume session"); + self.frame_requester.schedule_frame(); + return; + } + let Some(session_id) = browser.pending_delete_session_id.take() else { + return; + }; + browser.pending_delete_chips = Self::new_delete_confirm_chips(); + let deleting_active = browser + .sessions + .iter() + .find(|session| session.session_id == session_id) + .is_some_and(|session| session.is_active); + if deleting_active { + self.resume_browser = None; + self.clear_for_session_switch(); + } + self.app_event_tx + .send(AppEvent::Command(AppCommand::delete_session_by_id( + session_id, + ))); + self.set_status_message("Deleting session"); + self.frame_requester.schedule_frame(); + } + _ => {} + } + return; + } + match key.code { KeyCode::Esc | KeyCode::Char('q') => { self.resume_browser = None; @@ -118,6 +198,14 @@ impl ChatWidget { self.ensure_resume_selection_visible(u16::MAX); self.frame_requester.schedule_frame(); } + KeyCode::Char('d') | KeyCode::Delete => { + if let Some(selected) = browser.sessions.get(browser.selection) { + browser.pending_delete_session_id = Some(selected.session_id); + browser.pending_delete_chips = Self::new_delete_confirm_chips(); + self.set_status_message("Confirm session delete"); + self.frame_requester.schedule_frame(); + } + } KeyCode::Enter => { if let Some(selected) = browser.sessions.get(browser.selection) { let session_id = selected.session_id; @@ -135,7 +223,6 @@ impl ChatWidget { | KeyCode::F(_) | KeyCode::Tab | KeyCode::BackTab - | KeyCode::Delete | KeyCode::Insert | KeyCode::Null | KeyCode::CapsLock @@ -154,16 +241,55 @@ impl ChatWidget { self.resume_browser_loading || self.resume_browser.is_some() } + /// Drop a deleted session from an open resume browser, if present. + pub(super) fn remove_session_from_resume_browser(&mut self, session_id: &str) { + let Some(browser) = self.resume_browser.as_mut() else { + return; + }; + let before = browser.sessions.len(); + browser + .sessions + .retain(|session| session.session_id.to_string() != session_id); + if browser.sessions.len() == before { + return; + } + browser.pending_delete_session_id = None; + browser.pending_delete_chips = Self::new_delete_confirm_chips(); + if browser.sessions.is_empty() { + browser.selection = 0; + browser.scroll_offset = 0; + } else { + browser.selection = browser + .selection + .min(browser.sessions.len().saturating_sub(1)); + self.ensure_resume_selection_visible(u16::MAX); + } + self.frame_requester.schedule_frame(); + } + fn resume_browser_entry_height() -> usize { 1 } - fn resume_browser_chrome_height(has_sessions: bool) -> usize { - if has_sessions { 7 } else { 6 } + fn resume_browser_chrome_height(has_sessions: bool, confirming_delete: bool) -> usize { + let base: usize = if has_sessions { 7 } else { 6 }; + // Pending delete adds a chip row between the prompt and the hint. + if confirming_delete { + base.saturating_add(1) + } else { + base + } } - fn resume_browser_visible_capacity(area_height: u16, has_sessions: bool) -> usize { - area_height.saturating_sub(Self::resume_browser_chrome_height(has_sessions) as u16) as usize + fn resume_browser_visible_capacity( + area_height: u16, + has_sessions: bool, + confirming_delete: bool, + ) -> usize { + area_height + .saturating_sub( + Self::resume_browser_chrome_height(has_sessions, confirming_delete) as u16, + ) as usize } fn resume_browser_window( @@ -171,11 +297,13 @@ impl ChatWidget { selection: usize, requested_offset: usize, area_height: u16, + confirming_delete: bool, ) -> (usize, usize, bool, bool) { if sessions_len == 0 { return (0, 0, false, false); } - let list_window = Self::resume_browser_visible_capacity(area_height, true); + let list_window = + Self::resume_browser_visible_capacity(area_height, true, confirming_delete); if list_window == 0 { return (selection.min(sessions_len.saturating_sub(1)), 0, true, true); } @@ -209,14 +337,49 @@ impl ChatWidget { } } - fn resume_browser_footer_lines(has_sessions: bool) -> Vec> { + fn resume_browser_delete_footer_hint() -> Line<'static> { + Line::from(vec![ + key_hint::plain(KeyCode::Left).into(), + Span::raw("/"), + key_hint::plain(KeyCode::Right).into(), + Span::raw(" choose "), + key_hint::plain(KeyCode::Enter).into(), + Span::raw(" confirm "), + key_hint::plain(KeyCode::Esc).into(), + Span::raw(" cancel"), + ]) + .dim() + } + + fn pad_resume_line(line: Line<'static>) -> Line<'static> { + let mut spans = vec![Span::raw(" ".repeat(FOOTER_INDENT_COLS))]; + spans.extend(line.spans); + Line::from(spans) + } + + fn resume_browser_footer_lines( + has_sessions: bool, + pending_delete: Option<(&str, &HorizontalChipStrip)>, + accent: Color, + chip_width: usize, + ) -> Vec> { + if let Some((title, chips)) = pending_delete { + let available = chip_width.saturating_sub(FOOTER_INDENT_COLS); + return vec![ + Self::pad_resume_line(Line::from(format!("Delete \"{title}\"?").bold())), + Self::pad_resume_line(chips.render_line(available, accent)), + Self::pad_resume_line(Self::resume_browser_delete_footer_hint()), + ]; + } if has_sessions { vec![ - Line::from("↑/↓ select pgup/pgdn page home/end jump".dim()), - Line::from("enter resume q back".dim()), + Self::pad_resume_line(Line::from( + "↑/↓ select pgup/pgdn page home/end jump".dim(), + )), + Self::pad_resume_line(Line::from("enter resume d delete q back".dim())), ] } else { - vec![Line::from("q back".dim())] + vec![Self::pad_resume_line(Line::from("q back".dim()))] } } @@ -225,13 +388,14 @@ impl ChatWidget { sessions_len: usize, rendered_start: usize, area_height: u16, + confirming_delete: bool, ) -> String { if sessions_len == 0 { return " 0 / 0 · 100% ".to_string(); } let position = selection.saturating_add(1); let total = sessions_len; - let capacity = Self::resume_browser_visible_capacity(area_height, true); + let capacity = Self::resume_browser_visible_capacity(area_height, true, confirming_delete); let max_scroll = sessions_len.saturating_sub(capacity.max(1)); let percent = if max_scroll == 0 { 100 @@ -250,41 +414,39 @@ impl ChatWidget { browser.scroll_offset = 0; return; } - - let selection = browser + browser.selection = browser .selection .min(browser.sessions.len().saturating_sub(1)); - browser.selection = selection; - let capacity = Self::resume_browser_visible_capacity(area_height, true); + let confirming_delete = browser.pending_delete_session_id.is_some(); + let capacity = Self::resume_browser_visible_capacity(area_height, true, confirming_delete); if capacity == 0 { - browser.scroll_offset = selection; + browser.scroll_offset = browser.selection; return; } - - if selection < browser.scroll_offset { - browser.scroll_offset = selection; + let selection = browser.selection; + let mut offset = browser.scroll_offset; + if selection < offset { + offset = selection; } else { let selection_bottom = selection + Self::resume_browser_entry_height(); - let viewport_bottom = browser.scroll_offset + capacity; - if selection_bottom > viewport_bottom { - browser.scroll_offset = selection_bottom.saturating_sub(capacity); + let visible_end = offset + capacity; + if selection_bottom > visible_end { + offset = selection_bottom.saturating_sub(capacity); } } - - let max_offset = browser.sessions.len().saturating_sub(capacity); - browser.scroll_offset = browser.scroll_offset.min(max_offset); + let max_offset = browser.sessions.len().saturating_sub(capacity.max(1)); + browser.scroll_offset = offset.min(max_offset); } pub(super) fn render_resume_browser_if_open(&self, area: Rect, buf: &mut Buffer) -> bool { if self.resume_browser_loading { let lines = vec![ - Line::from("Resume Session".bold()), - Line::from("Loading saved sessions...".dim()), + Self::pad_resume_line(Line::from("Resume Session".bold())), + Self::pad_resume_line(Line::from("Loading saved sessions...".dim())), Line::from(""), - Line::from("Please wait.".dim()), + Self::pad_resume_line(Line::from("Please wait.".dim())), ]; Paragraph::new(Text::from(lines)) - .block(Block::default().title("Devo Sessions")) .wrap(Wrap { trim: false }) .render(area, buf); return true; @@ -296,11 +458,13 @@ impl ChatWidget { self.resume_browser_last_height.set(area.height); Block::default().style(Style::default()).render(area, buf); + let confirming_delete = browser.pending_delete_session_id.is_some(); let (scroll_offset, end, has_above, has_below) = Self::resume_browser_window( browser.sessions.len(), browser.selection, browser.scroll_offset, area.height, + confirming_delete, ); let title_width = browser .sessions @@ -314,36 +478,50 @@ impl ChatWidget { browser.sessions.len(), scroll_offset, area.height, + confirming_delete, ); - let mut lines = vec![Line::from(vec![ + let pending_delete = browser.pending_delete_session_id.and_then(|session_id| { + browser + .sessions + .iter() + .find(|session| session.session_id == session_id) + .map(|session| (session.title.as_str(), &browser.pending_delete_chips)) + }); + let mut lines = vec![Self::pad_resume_line(Line::from(vec![ Span::styled("Resume Session", Style::default().bold()), Span::raw(" "), Span::styled(progress, Style::default().dim()), - ])]; + ]))]; if browser.sessions.is_empty() { - lines.push(Line::from("No saved sessions found.".dim())); + lines.push(Self::pad_resume_line(Line::from( + "No saved sessions found.".dim(), + ))); } else { - lines.push( + // Indent column headers under the title text (after marker + space). + let col_pad = " ".repeat(2); + lines.push(Self::pad_resume_line( Line::from(format!( - " {:title_width$} {:<36} {}", + "{col_pad}{:title_width$} {:<36} {}", "Title", "Session ID", "Updated", title_width = title_width )) .dim(), - ); - lines.push( + )); + lines.push(Self::pad_resume_line( Line::from(format!( - " {} {} {}", + "{col_pad}{} {} {}", "-".repeat(title_width), "-".repeat(36), "-".repeat(23) )) .dim(), - ); + )); if has_above { - lines.push(Line::from(" ↑ more").dim()); + lines.push(Self::pad_resume_line(Line::from( + format!("{col_pad}↑ more").dim(), + ))); } for (index, session) in browser .sessions @@ -368,23 +546,27 @@ impl ChatWidget { "{marker} {} {:<16} {}", display_title, session.session_id, session.updated_at ); - lines.push(if is_selected { + lines.push(Self::pad_resume_line(if is_selected { Line::from(line).bold() } else if session.is_active { Line::from(line).style(Style::default().fg(self.active_accent_color())) } else { Line::from(line) - }); + })); } if has_below { - lines.push(Line::from(" ↓ more").dim()); + lines.push(Self::pad_resume_line(Line::from( + format!("{col_pad}↓ more").dim(), + ))); } } lines.extend(Self::resume_browser_footer_lines( !browser.sessions.is_empty(), + pending_delete, + self.active_accent_color(), + usize::from(area.width.saturating_sub(2)), )); Paragraph::new(Text::from(lines)) - .block(Block::default().title("Devo Sessions")) .wrap(Wrap { trim: false }) .render(area, buf); true @@ -408,4 +590,11 @@ impl ChatWidget { pub(crate) fn open_resume_browser_for_test(&mut self, sessions: Vec) { self.open_resume_browser(sessions); } + + #[cfg(test)] + pub(crate) fn resume_browser_pending_delete_for_test(&self) -> Option { + self.resume_browser + .as_ref() + .and_then(|browser| browser.pending_delete_session_id) + } } diff --git a/crates/tui/src/chatwidget/selection.rs b/crates/tui/src/chatwidget/selection.rs index db27b85f..ea3066c2 100644 --- a/crates/tui/src/chatwidget/selection.rs +++ b/crates/tui/src/chatwidget/selection.rs @@ -81,9 +81,7 @@ impl ChatWidget { self.selection_mode = false; self.selected_user_cell_index = None; self.sync_selected_user_cell_highlight(); - self.bottom_pane - .set_status_line(Some(Line::from(self.session_summary_text()).dim())); - self.bottom_pane.set_status_line_enabled(true); + self.sync_bottom_pane_summary(); self.frame_requester.schedule_frame(); } diff --git a/crates/tui/src/chatwidget/session_header.rs b/crates/tui/src/chatwidget/session_header.rs index 9551594b..ce9ad1ba 100644 --- a/crates/tui/src/chatwidget/session_header.rs +++ b/crates/tui/src/chatwidget/session_header.rs @@ -5,16 +5,19 @@ use std::fmt::Write as _; use std::path::Path; +use std::path::PathBuf; use std::time::Instant; use devo_protocol::Model; use devo_protocol::ProviderWireApi; +use devo_util_git::current_branch_name; use ratatui::style::Color; use ratatui::style::Style; use ratatui::style::Stylize; use ratatui::text::Line; use ratatui::text::Span; +use crate::app_event::AppEvent; use crate::history_cell; use crate::history_cell::HistoryCell; use crate::startup_header::STARTUP_HEADER_ANIMATION_INTERVAL; @@ -23,6 +26,7 @@ use crate::ui_consts::REASONING_ACCENT_COLOR; use super::ChatWidget; use super::DotStatus; +use super::STATUS_LINE_BRANCH_REFRESH_INTERVAL; /// Blue used for the pending-state dot prefix. pub(super) const PENDING_DOT_COLOR: Color = Color::Rgb(110, 200, 255); @@ -240,22 +244,41 @@ impl ChatWidget { /// Context length for the status bar. /// - /// Uses the latest completed query's display total (`input_tokens + - /// output_tokens`, or provider `total_tokens` when available). This is - /// **not** the session cumulative `total_input_tokens`. + /// `used` follows the latest query display total from `TurnUsageUpdated` so + /// the bar moves mid-turn. The denominator prefers the live session + /// effective-context override (Settings › Compaction threshold), then the + /// occupancy snapshot window, then the model effective window. Occupancy + /// `total_tokens` is only a fallback when no last-query usage has arrived + /// yet (for example a hydrate that set occupancy alone). pub(super) fn context_usage(&self) -> Option<(usize, usize, usize)> { - let model = self.session.model.as_ref()?; - let total = model.context_window as usize; - let used = self.last_query_total_tokens; - let capped_used = used.min(total); - let percent = if total == 0 { - 0 + let total = if let Some(limit) = self.effective_context_window { + limit as usize + } else if let Some(occupancy) = self.last_context_occupancy.as_ref() { + occupancy.context_window_tokens as usize + } else { + self.session.model.as_ref()?.effective_context_window() as usize + }; + let used = if self.last_query_total_tokens > 0 { + self.last_query_total_tokens + } else if let Some(occupancy) = self.last_context_occupancy.as_ref() { + occupancy.total_tokens as usize } else { - capped_used.saturating_mul(100) / total + 0 }; + let percent = Self::usage_percent(used, total); Some((used, total, percent)) } + fn usage_percent(used: usize, total: usize) -> usize { + if total == 0 { + 0 + } else { + ((used as f64 / total as f64) * 100.0) + .clamp(0.0, 100.0) + .round() as usize + } + } + pub(super) fn format_compact_token_count(value: usize) -> String { let mut rendered = String::new(); Self::push_compact_token_count(&mut rendered, value); @@ -267,7 +290,7 @@ impl ChatWidget { write!(rendered, "{:.1}M", value as f64 / 1_000_000.0) .expect("writing to String should not fail"); } else if value >= 1_000 { - write!(rendered, "{:.0}k", value as f64 / 1_000.0) + write!(rendered, "{:.1}k", value as f64 / 1_000.0) .expect("writing to String should not fail"); } else { write!(rendered, "{value}").expect("writing to String should not fail"); @@ -297,49 +320,37 @@ impl ChatWidget { write!(rendered, " {pct}%").expect("writing to String should not fail"); } - pub(super) fn percent_of(numerator: usize, denominator: usize) -> usize { - if denominator == 0 { - 0 - } else { - (numerator.saturating_mul(100) + denominator / 2) / denominator - } - } - pub(super) fn session_summary_text(&self) -> String { self.session_summary_text_with_context(/*include_context*/ true) } fn session_summary_text_with_context(&self, include_context: bool) -> String { let model = self.model_display_name(); - let reasoning_effort_selection = self - .display_reasoning_effort_selection() - .unwrap_or_else(|| "default".to_string()); - let cached_input_percent = - Self::percent_of(self.total_cache_read_tokens, self.total_input_tokens); - - let mut summary = - String::with_capacity(model.len() + reasoning_effort_selection.len() + 96); + let mut summary = String::with_capacity(model.len().saturating_add(64)); summary.push_str(model); - summary.push(' '); - summary.push_str(&reasoning_effort_selection); - summary.push_str(" ↑"); - Self::push_compact_token_count(&mut summary, self.total_input_tokens); - summary.push_str(" (cached "); - Self::push_compact_token_count(&mut summary, self.total_cache_read_tokens); - write!(summary, " {cached_input_percent}%) ↓").expect("writing to String should not fail"); - Self::push_compact_token_count(&mut summary, self.total_output_tokens); - - if include_context && let Some((used, total, _percent)) = self.context_usage() { + if let Some(branch) = self.status_line_branch.as_deref() { + summary.push_str(" · "); + summary.push_str(branch); + } + + if include_context && let Some(context) = self.session_context_summary_text() { summary.push_str(" "); - Self::push_progress_bar(&mut summary, used, total, 10); - summary.push(' '); - Self::push_compact_token_count(&mut summary, used); - summary.push('/'); - Self::push_compact_token_count(&mut summary, total); + summary.push_str(&context); } summary } + fn session_context_summary_text(&self) -> Option { + let (used, total, _percent) = self.context_usage()?; + let mut context = String::with_capacity(48); + Self::push_progress_bar(&mut context, used, total, 10); + context.push(' '); + Self::push_compact_token_count(&mut context, used); + context.push('/'); + Self::push_compact_token_count(&mut context, total); + Some(context) + } + pub(super) fn model_display_name(&self) -> &str { self.session .request_model @@ -354,11 +365,80 @@ impl ChatWidget { } pub(super) fn sync_bottom_pane_summary(&mut self) { - let summary = - self.session_summary_text_with_context(self.session.active_agent_label.is_none()); + // Left: mode · model · branch. Right: context meter (right-aligned). + let summary = self.session_summary_text_with_context(/*include_context*/ false); self.bottom_pane .set_status_line(Some(Line::from(summary).dim())); self.bottom_pane.set_status_line_enabled(true); + let context_label = if self.session.active_agent_label.is_none() { + self.session_context_summary_text() + } else { + None + }; + self.bottom_pane.set_context_window_label(context_label); + } + + fn status_line_cwd(&self) -> &Path { + self.session.cwd.as_path() + } + + /// Forces a git-branch refresh for the footer status line. + pub(super) fn request_status_line_branch_refresh(&mut self) { + let cwd = self.status_line_cwd().to_path_buf(); + if self + .status_line_branch_cwd + .as_ref() + .is_none_or(|path| path != &cwd) + { + self.status_line_branch_cwd = Some(cwd.clone()); + self.status_line_branch = None; + self.status_line_branch_pending = false; + } + self.request_status_line_branch(cwd); + } + + /// Light periodic refresh so external `git checkout` stays visible. + pub(super) fn maybe_refresh_status_line_branch(&mut self) { + let now = Instant::now(); + if now < self.status_line_branch_next_refresh_at { + return; + } + self.status_line_branch_next_refresh_at = now + STATUS_LINE_BRANCH_REFRESH_INTERVAL; + self.request_status_line_branch_refresh(); + } + + fn request_status_line_branch(&mut self, cwd: PathBuf) { + if self.status_line_branch_pending { + return; + } + let Ok(handle) = tokio::runtime::Handle::try_current() else { + // Unit tests construct widgets without a Tokio runtime. + return; + }; + self.status_line_branch_pending = true; + let tx = self.app_event_tx.clone(); + handle.spawn(async move { + let branch = current_branch_name(&cwd).await; + tx.send(AppEvent::StatusLineBranchUpdated { cwd, branch }); + }); + } + + pub(super) fn apply_status_line_branch_update(&mut self, cwd: PathBuf, branch: Option) { + if self + .status_line_branch_cwd + .as_ref() + .is_some_and(|path| path != &cwd) + { + // Stale completion after a cwd change. + return; + } + self.status_line_branch_pending = false; + self.status_line_branch_cwd = Some(cwd); + if self.status_line_branch == branch { + return; + } + self.status_line_branch = branch; + self.sync_bottom_pane_summary(); } pub(super) fn push_session_header( @@ -517,7 +597,8 @@ impl ChatWidget { } /// Completed reasoning uses a muted style so finished Thought cells read as - /// settled history, while the live "Thinking" cell keeps the accent color. + /// settled history, while the live "Thinking" heading keeps the accent color + /// and the live body stays dim. pub(super) fn reasoning_completed_heading_style() -> Style { Style::default().dim().italic() } @@ -543,14 +624,15 @@ impl ChatWidget { #[cfg(test)] mod tests { + use std::hint::black_box; use std::path::PathBuf; use std::time::Instant; use devo_protocol::PermissionPreset; use devo_protocol::ReasoningCapability; use devo_protocol::ReasoningEffort; + use devo_protocol::canonical::item::ContextOccupancy; use pretty_assertions::assert_eq; - use std::hint::black_box; use tokio::sync::mpsc; use crate::app_event_sender::AppEventSender; @@ -576,6 +658,8 @@ mod tests { initial_reasoning_effort_selection: None, initial_permission_preset: PermissionPreset::Default, initial_sandbox_profile: Some("workspace".to_string()), + initial_compaction_token_limit: None, + initial_default_collaboration_mode: devo_protocol::CollaborationMode::Build, initial_user_message: None, enhanced_keys_supported: true, is_first_run: false, @@ -615,6 +699,8 @@ mod tests { initial_reasoning_effort_selection: None, initial_permission_preset: PermissionPreset::Default, initial_sandbox_profile: Some("workspace".to_string()), + initial_compaction_token_limit: None, + initial_default_collaboration_mode: devo_protocol::CollaborationMode::Build, initial_user_message: None, enhanced_keys_supported: true, is_first_run: false, @@ -629,8 +715,9 @@ mod tests { let summary = widget.status_summary_text(); + assert_eq!(summary.contains("high"), false); assert_eq!(summary.contains("default"), false); - assert_eq!(summary.starts_with("deepseek-v4-flash high"), true); + assert_eq!(summary.starts_with("deepseek-v4-flash"), true); } #[test] @@ -639,7 +726,22 @@ mod tests { assert_eq!( widget.status_summary_text(), - "Test Model default ↑124k (cached 82k 66%) ↓10k ▰▰▰▰▰▰▰▰▱▱ 79% 157k/200k" + "Test Model ▰▰▰▰▰▰▰▰▱▱ 83% 157.0k/190.0k" + ); + } + + #[test] + fn session_summary_text_includes_git_branch_before_context() { + let mut widget = widget_for_summary_bench(); + widget.status_line_branch = Some("feat/status-bar".to_string()); + + assert_eq!( + widget.status_summary_text(), + "Test Model · feat/status-bar ▰▰▰▰▰▰▰▰▱▱ 83% 157.0k/190.0k" + ); + assert_eq!( + widget.session_summary_text_with_context(/*include_context*/ false), + "Test Model · feat/status-bar" ); } @@ -649,7 +751,88 @@ mod tests { widget.last_query_input_tokens = 7; widget.last_query_total_tokens = 9; - assert_eq!(widget.context_usage(), Some((9, 200_000, 0))); + assert_eq!(widget.context_usage(), Some((9, 190_000, 0))); + } + + #[test] + fn context_usage_uses_last_query_with_occupancy_window() { + let mut widget = widget_for_summary_bench(); + widget.last_query_total_tokens = 99_000; + widget.last_context_occupancy = Some(ContextOccupancy::from_category_tokens( + /*context_window_tokens*/ 996_147, /*base*/ 10_000, /*skills*/ 0, + /*tools_builtin*/ 0, /*tools_mcp*/ 0, /*conversation*/ 6_700, + )); + + // Mid-turn TurnUsageUpdated advances last_query while occupancy lags + // until turn finalize; the status bar must follow last_query. + assert_eq!(widget.context_usage(), Some((99_000, 996_147, 10))); + assert_eq!( + widget.status_summary_text(), + "Test Model ▰▱▱▱▱▱▱▱▱▱ 10% 99.0k/996.1k" + ); + } + + #[test] + fn context_usage_prefers_session_effective_window_over_occupancy() { + let mut widget = widget_for_summary_bench(); + widget.last_query_total_tokens = 50_000; + widget.last_context_occupancy = Some(ContextOccupancy::from_category_tokens( + /*context_window_tokens*/ 996_147, /*base*/ 10_000, /*skills*/ 0, + /*tools_builtin*/ 0, /*tools_mcp*/ 0, /*conversation*/ 40_000, + )); + widget.effective_context_window = Some(250_000); + + assert_eq!(widget.context_usage(), Some((50_000, 250_000, 20))); + assert!(widget.status_summary_text().contains("50.0k/250.0k")); + } + + #[test] + fn effective_context_window_update_rewrites_occupancy_and_status_bar() { + let mut widget = widget_for_summary_bench(); + widget.last_query_total_tokens = 50_000; + widget.last_context_occupancy = Some(ContextOccupancy::from_category_tokens( + /*context_window_tokens*/ 996_147, /*base*/ 10_000, /*skills*/ 0, + /*tools_builtin*/ 0, /*tools_mcp*/ 0, /*conversation*/ 40_000, + )); + + assert_eq!(widget.context_usage(), Some((50_000, 996_147, 5))); + + widget.note_effective_context_window_updated(250_000); + + assert_eq!(widget.effective_context_window, Some(250_000)); + assert_eq!( + widget + .last_context_occupancy + .as_ref() + .map(|o| o.context_window_tokens), + Some(250_000) + ); + assert_eq!(widget.context_usage(), Some((50_000, 250_000, 20))); + assert!(widget.status_summary_text().contains("50.0k/250.0k")); + } + + #[test] + fn usage_updated_moves_context_bar_even_when_occupancy_is_stale() { + let mut widget = widget_for_summary_bench(); + widget.last_query_total_tokens = 16_700; + widget.last_context_occupancy = Some(ContextOccupancy::from_category_tokens( + /*context_window_tokens*/ 190_000, /*base*/ 10_000, /*skills*/ 0, + /*tools_builtin*/ 0, /*tools_mcp*/ 0, /*conversation*/ 6_700, + )); + assert_eq!(widget.context_usage(), Some((16_700, 190_000, 9))); + + widget.handle_worker_event(crate::events::WorkerEvent::UsageUpdated { + total_input_tokens: 550, + total_output_tokens: 110, + total_tokens: 660, + total_cache_read_tokens: 60, + last_query_total_tokens: 48_000, + last_query_input_tokens: 40_000, + }); + + assert_eq!(widget.last_query_total_tokens, 48_000); + assert_eq!(widget.context_usage(), Some((48_000, 190_000, 25))); + assert!(widget.status_summary_text().contains("48.0k/190.0k")); } #[test] @@ -673,7 +856,7 @@ mod tests { assert_eq!(widget.last_query_total_tokens, 48); assert_eq!(widget.last_query_input_tokens, 35); assert_eq!(widget.prompt_token_estimate, 35); - assert_eq!(widget.context_usage(), Some((48, 200_000, 0))); + assert_eq!(widget.context_usage(), Some((48, 190_000, 0))); } #[test] diff --git a/crates/tui/src/chatwidget/session_history.rs b/crates/tui/src/chatwidget/session_history.rs index 7ff7ab76..f075afe5 100644 --- a/crates/tui/src/chatwidget/session_history.rs +++ b/crates/tui/src/chatwidget/session_history.rs @@ -70,8 +70,7 @@ impl ChatWidget { } pub(super) fn set_default_placeholder(&mut self) { - self.bottom_pane - .set_placeholder_text("Ask Devo".to_string()); + self.bottom_pane.set_default_placeholder(); } pub(super) fn on_plan_updated(&mut self, explanation: Option, steps: Vec) { @@ -190,7 +189,7 @@ impl ChatWidget { implement_model_binding_id.clone(), implement_reasoning.clone(), /*sandbox*/ None, - Some("on-request".to_string()), + /*approval_policy*/ None, CollaborationMode::Build, ), )); @@ -204,7 +203,7 @@ impl ChatWidget { revise_model_binding_id.clone(), revise_reasoning.clone(), /*sandbox*/ None, - Some("on-request".to_string()), + /*approval_policy*/ None, CollaborationMode::Plan, ), )); diff --git a/crates/tui/src/chatwidget/slash_commands.rs b/crates/tui/src/chatwidget/slash_commands.rs index 9b6bb492..570d867c 100644 --- a/crates/tui/src/chatwidget/slash_commands.rs +++ b/crates/tui/src/chatwidget/slash_commands.rs @@ -3,21 +3,24 @@ //! The bottom pane parses slash commands, and this module translates them into //! chat-widget state changes or app commands sent back to the host loop. -use ratatui::style::Stylize; use ratatui::text::Line; use crate::app_command::AppCommand; use crate::app_command::GoalObjectiveMode; use crate::app_event::AppEvent; +use crate::app_event_sender::AppEventSender; +use crate::bottom_pane::DeleteSessionConfirmView; +use crate::bottom_pane::SessionTokenTotals; +use crate::bottom_pane::StatusPanelSnapshot; use crate::get_git_diff::get_git_diff; use crate::history_cell; use crate::history_cell::PlainHistoryCell; use crate::slash_command::SlashCommand; use devo_protocol::MAX_THREAD_GOAL_OBJECTIVE_CHARS; -use devo_protocol::PermissionPreset; use devo_protocol::ThreadGoalStatus; use super::ChatWidget; +use super::permission_preset_label; impl ChatWidget { pub(super) fn can_change_configuration(&self) -> bool { @@ -33,13 +36,13 @@ impl ChatWidget { SlashCommand::Delete => "session", SlashCommand::Resume => "session", SlashCommand::Permissions => "permissions", + SlashCommand::Settings => "settings", SlashCommand::Diff => "diff", SlashCommand::Mcp | SlashCommand::Skills | SlashCommand::Goal | SlashCommand::Exit | SlashCommand::Status - | SlashCommand::Clear | SlashCommand::ShowReasoning | SlashCommand::Rename | SlashCommand::Btw => { @@ -64,56 +67,24 @@ impl ChatWidget { self.app_event_tx .send(AppEvent::Exit(crate::app_event::ExitMode::ShutdownFirst)); } - SlashCommand::Clear => { - self.clear_transcript_view(); - self.set_status_message("Transcript cleared"); - } SlashCommand::Status => { - let model = self - .session - .model - .as_ref() - .map(|m| m.slug.as_str()) - .unwrap_or("unknown"); - let reasoning_effort_selection = self - .reasoning_effort_selection - .as_deref() - .unwrap_or("default"); - let cwd = self.session.cwd.display().to_string(); - let turns = self.turn_count; - let tokens_in = Self::format_token_count(self.total_input_tokens); - let tokens_out = Self::format_token_count(self.total_output_tokens); - let lines = history_cell::with_border(vec![ - Line::from("Session Status".bold()), - Line::from(""), - Line::from(format!(" model: {model}")), - Line::from(format!( - " reasoning_effort_selection: {reasoning_effort_selection}" - )), - Line::from(format!(" cwd: {cwd}")), - Line::from(format!( - " permissions: {}", - match self.permission_preset { - PermissionPreset::Default => "default", - PermissionPreset::AutoReview => "auto-review", - PermissionPreset::FullAccess => "full-access", - } - )), - Line::from(format!( - " sandbox: {}", - self.sandbox_profile.as_deref().unwrap_or("workspace") - )), - Line::from(format!( - " reasoning: {}", - super::reasoning_view::reasoning_view_label(self.collapse_reasoning) - )), - Line::from(format!(" turns: {turns}")), - Line::from(format!( - " tokens: \u{2191}{tokens_in} \u{2193}{tokens_out}", - )), - ]); - self.add_to_history(PlainHistoryCell::new(lines)); - self.set_status_message("Session status shown"); + self.bottom_pane.open_status_panel( + self.last_context_occupancy.clone(), + SessionTokenTotals { + input: self.total_input_tokens, + output: self.total_output_tokens, + cache_read: self.total_cache_read_tokens, + }, + StatusPanelSnapshot { + cwd: self.session.cwd.display().to_string(), + permissions_label: permission_preset_label(self.permission_preset) + .to_string(), + }, + ); + self.set_status_message("Status shown"); + } + SlashCommand::Settings => { + self.open_settings_hub(); } SlashCommand::Permissions => { self.open_permissions_picker(); @@ -122,7 +93,7 @@ impl ChatWidget { self.open_reasoning_view_picker(); } SlashCommand::Theme => { - self.open_theme_picker(); + self.open_settings_hub_appearance(); } SlashCommand::Model => { if argument.is_empty() { @@ -171,9 +142,7 @@ impl ChatWidget { self.set_status_message("Renaming session"); } SlashCommand::Delete => { - self.app_event_tx - .send(AppEvent::Command(AppCommand::delete_session())); - self.set_status_message("Deleting session"); + self.show_delete_session_confirmation(); } SlashCommand::Resume => { self.resume_browser = None; @@ -286,4 +255,21 @@ impl ChatWidget { ))); self.set_status_message("Setting goal"); } + + pub(super) fn show_delete_session_confirmation(&mut self) { + self.bottom_pane + .open_popup_view(Box::new(DeleteSessionConfirmView::new( + self.app_event_tx.clone(), + self.active_accent_color(), + Box::new(|tx: &AppEventSender| { + tx.send(AppEvent::Command(AppCommand::delete_session())); + }), + Box::new(|tx: &AppEventSender| { + tx.send(AppEvent::StatusMessageChanged { + message: "Session unchanged".to_string(), + }); + }), + ))); + self.set_status_message("Confirm session delete"); + } } diff --git a/crates/tui/src/chatwidget/text_stream.rs b/crates/tui/src/chatwidget/text_stream.rs index ab67fbb4..f0bd7740 100644 --- a/crates/tui/src/chatwidget/text_stream.rs +++ b/crates/tui/src/chatwidget/text_stream.rs @@ -540,6 +540,19 @@ impl ChatWidget { return None; } + if self.collapse_reasoning { + // Width-aware live window: cap by wrapped visual rows, not logical + // newlines, so one long paragraph cannot blow past the budget. + return Some(super::reasoning_view::collapsed_reasoning_live_cell( + item.raw_text.clone(), + &self.session.cwd, + "Thinking: ", + Self::reasoning_heading_style(), + Self::reasoning_text_style(), + Self::reasoning_dot_prefix(item.status), + )); + } + let mut body_lines = Vec::new(); append_markdown( &item.raw_text, @@ -547,23 +560,13 @@ impl ChatWidget { Some(&self.session.cwd), &mut body_lines, ); - // Keep live reasoning content undimmed so the in-flight cell stays - // colorful; completed Thought cells apply the muted style instead. - if self.collapse_reasoning - && body_lines.len() > super::reasoning_view::COLLAPSED_REASONING_LIVE_LINES - { - let start = body_lines.len() - super::reasoning_view::COLLAPSED_REASONING_LIVE_LINES; - body_lines = body_lines.split_off(start); - } + Self::patch_lines_style(&mut body_lines, Self::reasoning_text_style()); if let Some(first_line) = body_lines.first_mut() { first_line.spans.insert( 0, Span::styled("Thinking: ", Self::reasoning_heading_style()), ); } - if self.collapse_reasoning { - body_lines.push(history_cell::reasoning_transcript_hint_line()); - } Some(Box::new( history_cell::AgentMessageCell::new_ai_response_with_prefix( body_lines, diff --git a/crates/tui/src/chatwidget/transcript_view.rs b/crates/tui/src/chatwidget/transcript_view.rs index 684e6f15..431e67b8 100644 --- a/crates/tui/src/chatwidget/transcript_view.rs +++ b/crates/tui/src/chatwidget/transcript_view.rs @@ -10,6 +10,7 @@ use crate::events::TextItemKind; use crate::history_cell; use crate::history_cell::HistoryCell; use crate::history_cell::ScrollbackLine; +use crate::render::line_utils::is_horizontal_rule_line; use crate::tool_io_cell::ToolIoCell; use crate::tool_io_cell::ToolIoCellOptions; @@ -385,8 +386,12 @@ impl ChatWidget { } let should_insert_separator = !target.is_empty() - && target.last().is_some_and(|line| !Self::is_blank_line(line)) - && next.first().is_some_and(|line| !Self::is_blank_line(line)); + && target + .last() + .is_some_and(|line| !Self::is_blank_line(line) && !is_horizontal_rule_line(line)) + && next + .first() + .is_some_and(|line| !Self::is_blank_line(line) && !is_horizontal_rule_line(line)); if should_insert_separator { target.push(Line::from("")); } @@ -410,19 +415,25 @@ impl ChatWidget { let should_insert_separator = index > 0 && !cell_lines.is_empty() && !lines.is_empty() - && lines - .last() - .is_some_and(|line: &ScrollbackLine| !Self::is_blank_line(&line.line)) - && cell_lines - .first() - .is_some_and(|line| !Self::is_blank_line(line)); + && lines.last().is_some_and(|line: &ScrollbackLine| { + !Self::is_blank_line(&line.line) && !is_horizontal_rule_line(&line.line) + }) + && cell_lines.first().is_some_and(|line| { + !Self::is_blank_line(line) && !is_horizontal_rule_line(line) + }); if should_insert_separator { lines.push(ScrollbackLine::new(Line::from(""))); } lines.extend(cell_lines.into_iter().map(ScrollbackLine::new)); } self.next_history_flush_index = self.history.len(); - if !lines.is_empty() { + // Rule-lined cells (e.g. user prompts) already provide visual separation; avoid an + // extra blank row after them so adjacent content sits flush against the rule. + if !lines.is_empty() + && lines + .last() + .is_some_and(|line| !is_horizontal_rule_line(&line.line)) + { lines.push(ScrollbackLine::new(Line::from(""))); } lines diff --git a/crates/tui/src/chatwidget/worker_events.rs b/crates/tui/src/chatwidget/worker_events.rs index c966bde7..66aedac1 100644 --- a/crates/tui/src/chatwidget/worker_events.rs +++ b/crates/tui/src/chatwidget/worker_events.rs @@ -918,6 +918,13 @@ impl ChatWidget { self.last_query_total_tokens = last_query_total_tokens; self.last_query_input_tokens = last_query_input_tokens; self.prompt_token_estimate = last_query_input_tokens; + self.sync_bottom_pane_summary(); + self.frame_requester.schedule_frame(); + } + WorkerEvent::ContextUsageUpdated { occupancy } => { + self.last_query_total_tokens = occupancy.total_tokens as usize; + self.last_context_occupancy = Some(occupancy); + self.sync_bottom_pane_summary(); self.frame_requester.schedule_frame(); } WorkerEvent::TurnFinished { @@ -1257,6 +1264,8 @@ impl ChatWidget { model_binding_id, reasoning_effort_selection, reasoning_effort, + permission_preset, + collaboration_mode, active_agent_label, last_query_total_tokens: _, last_query_input_tokens: _, @@ -1278,9 +1287,15 @@ impl ChatWidget { self.pending_tool_calls.clear(); self.active_text_items.clear(); self.committed_server_assistant_in_turn = false; - self.current_turn_mode = InputMode::Build; + let restored_mode = InputMode::from_collaboration_mode(collaboration_mode); + self.current_turn_mode = restored_mode; + self.bottom_pane.set_input_mode(restored_mode); + self.permission_preset = permission_preset; + self.queued_count = 0; self.queued_input_modes.clear(); self.promoted_input_modes.clear(); + self.editing_queue_item_id = None; + self.bottom_pane.clear_pending_cells(); self.stream_chunking_policy.reset(); self.busy = false; self.turn_count = 0; @@ -1290,6 +1305,8 @@ impl ChatWidget { self.last_query_total_tokens = 0; self.last_query_input_tokens = 0; self.prompt_token_estimate = 0; + self.last_context_occupancy = None; + self.effective_context_window = self.default_compaction_token_limit; if should_append_header { self.push_session_header(/*is_first_run*/ false, None); } else { @@ -1316,8 +1333,10 @@ impl ChatWidget { history_items, rich_history_items, loaded_item_count, - pending_texts, + pending_texts: _, collaboration_mode, + permission_preset, + effective_context_window, } => { self.resume_browser_loading = false; self.finish_session_resume(); @@ -1339,8 +1358,12 @@ impl ChatWidget { let restored_mode = InputMode::from_collaboration_mode(collaboration_mode); self.current_turn_mode = restored_mode; self.bottom_pane.set_input_mode(restored_mode); + if let Some(preset) = permission_preset { + self.permission_preset = preset; + } self.queued_input_modes.clear(); self.promoted_input_modes.clear(); + self.editing_queue_item_id = None; self.stream_chunking_policy.reset(); self.total_input_tokens = total_input_tokens; self.total_output_tokens = total_output_tokens; @@ -1348,6 +1371,8 @@ impl ChatWidget { self.last_query_total_tokens = last_query_total_tokens; self.last_query_input_tokens = last_query_input_tokens; self.prompt_token_estimate = prompt_token_estimate; + self.last_context_occupancy = None; + self.effective_context_window = effective_context_window; if !self.rebuild_restored_session_history_from_rich_items( &rich_history_items, loaded_item_count, @@ -1361,14 +1386,10 @@ impl ChatWidget { title.as_deref(), ); } - // Restore pending queue state from the resumed session - self.queued_count = pending_texts.len(); + // Queue entries arrive via QueueUpdated after subscription/create. + self.queued_count = 0; self.queued_input_modes.clear(); self.bottom_pane.clear_pending_cells(); - for text in &pending_texts { - self.bottom_pane.push_pending_cell(text.clone()); - self.queued_input_modes.push_back(InputMode::Build); - } self.busy = false; if collaboration_mode == CollaborationMode::Plan && history_awaits_proposed_plan_decision(&rich_history_items) @@ -1425,12 +1446,18 @@ impl ChatWidget { self.set_status_message("Session renamed"); } WorkerEvent::SessionDeleted { session_id } => { + self.remove_session_from_resume_browser(&session_id); self.add_to_history(history_cell::new_info_event( format!("deleted session {session_id}"), None, )); self.set_status_message("Session deleted"); } + WorkerEvent::EffectiveContextWindowUpdated { + effective_context_window, + } => { + self.note_effective_context_window_updated(effective_context_window); + } WorkerEvent::SessionCompactionStarted => { if self.status_message != "Session compaction in progress" { self.add_to_history(history_cell::new_live_aligned_info_event( @@ -1454,6 +1481,7 @@ impl ChatWidget { prompt_token_estimate, } => { self.busy = false; + self.active_turn_id = None; self.bottom_pane.set_task_running(false); self.total_input_tokens = total_input_tokens; self.total_output_tokens = total_output_tokens; @@ -1469,6 +1497,16 @@ impl ChatWidget { self.set_status_message("Session compacted"); } WorkerEvent::ContextCompactionCompleted { title: _ } => { + // Mid-turn auto-compaction only emits item lifecycle events (not + // session/compaction/completed). Clear the compacting indicator here. + if self.active_turn_id.is_some() { + if let Some(status) = self.bottom_pane.status_widget_mut() { + status.update_header("Working".to_string()); + } + } else { + self.busy = false; + self.bottom_pane.set_task_running(false); + } if self.status_message != "Session compacted" && self.status_message != "Context compacted" { @@ -1481,6 +1519,7 @@ impl ChatWidget { } WorkerEvent::SessionCompactionFailed { message } => { self.busy = false; + self.active_turn_id = None; self.bottom_pane.set_task_running(false); if self.status_message != "Session compaction failed" { self.add_to_history(history_cell::new_live_aligned_error_event_with_hint( @@ -1499,27 +1538,10 @@ impl ChatWidget { WorkerEvent::InputHistoryLoaded { direction: _, text } => { self.bottom_pane.restore_input_from_history(text); } - WorkerEvent::InputQueueUpdated { - pending_count, - pending_texts, + WorkerEvent::QueueUpdated { + change, entries, .. } => { - if self.queued_count > pending_count { - self.commit_active_streams(DotStatus::Completed); - } - // If the queue shrunk, unqueue the oldest queued cells. - while self.queued_count > pending_count { - self.unqueue_oldest_pending(); - } - // If the queue grew outside the local submit path, add the new - // pending cells from the server snapshot using Build mode as the - // only safe fallback because queue updates do not carry mode. - if self.queued_count < pending_count { - for text in pending_texts.iter().skip(self.queued_count) { - self.bottom_pane.push_pending_cell(text.clone()); - self.queued_input_modes.push_back(InputMode::Build); - } - self.queued_count = pending_count; - } + self.apply_canonical_queue_snapshot(change, entries); self.frame_requester.schedule_frame(); } WorkerEvent::SteerAccepted { .. } => { @@ -1527,6 +1549,91 @@ impl ChatWidget { } } } + + fn apply_canonical_queue_snapshot( + &mut self, + change: devo_protocol::canonical::queue::QueueChange, + entries: Vec, + ) { + use crate::bottom_pane::PendingQueueItem; + use crate::queue_ops::queue_entry_text; + use std::collections::HashMap; + + let old_items = self.bottom_pane.pending_queue_items().to_vec(); + let new_items: Vec = entries + .iter() + .map(|entry| PendingQueueItem { + queue_item_id: entry.queue_item_id.to_string(), + text: queue_entry_text(entry), + }) + .collect(); + let new_ids: std::collections::HashSet<&str> = new_items + .iter() + .map(|item| item.queue_item_id.as_str()) + .collect(); + + // The item being edited was deleted or drained meanwhile; fall back to + // pushing a fresh entry on the next busy submit. + if self + .editing_queue_item_id + .as_deref() + .is_some_and(|id| !new_ids.contains(id)) + { + self.editing_queue_item_id = None; + } + + let mut mode_by_id: HashMap = HashMap::new(); + for (item, mode) in old_items.iter().zip(self.queued_input_modes.iter()) { + mode_by_id.insert(item.queue_item_id.clone(), *mode); + } + // Modes queued ahead of known ids (busy push before snapshot returned). + let mut pending_new_modes: std::collections::VecDeque = self + .queued_input_modes + .iter() + .skip(old_items.len()) + .copied() + .collect(); + + for old in &old_items { + if new_ids.contains(old.queue_item_id.as_str()) { + continue; + } + let mode = mode_by_id + .remove(&old.queue_item_id) + .unwrap_or(InputMode::Build); + match change { + devo_protocol::canonical::queue::QueueChange::Drained => { + if self.queued_count > new_items.len() { + self.commit_active_streams(DotStatus::Completed); + } + self.promoted_input_modes.push_back(mode); + self.add_to_history(history_cell::new_user_prompt( + old.text.clone(), + Vec::new(), + Vec::new(), + Vec::new(), + self.active_accent_color(), + mode, + )); + } + devo_protocol::canonical::queue::QueueChange::Removed + | devo_protocol::canonical::queue::QueueChange::Promoted => {} + _ => {} + } + } + + let mut next_modes = std::collections::VecDeque::new(); + for item in &new_items { + let mode = mode_by_id + .remove(&item.queue_item_id) + .or_else(|| pending_new_modes.pop_front()) + .unwrap_or(InputMode::Build); + next_modes.push_back(mode); + } + self.queued_input_modes = next_modes; + self.bottom_pane.replace_pending_queue(new_items); + self.queued_count = self.bottom_pane.pending_queue_items().len(); + } } fn history_awaits_proposed_plan_decision(items: &[SessionHistoryItem]) -> bool { diff --git a/crates/tui/src/chatwidget_tail_follow_tests.rs b/crates/tui/src/chatwidget_tail_follow_tests.rs index 1346b78d..26bea43e 100644 --- a/crates/tui/src/chatwidget_tail_follow_tests.rs +++ b/crates/tui/src/chatwidget_tail_follow_tests.rs @@ -24,6 +24,8 @@ fn widget_with_model(model: Model, cwd: PathBuf) -> ChatWidget { initial_reasoning_effort_selection: None, initial_permission_preset: devo_protocol::PermissionPreset::Default, initial_sandbox_profile: Some("workspace".to_string()), + initial_compaction_token_limit: None, + initial_default_collaboration_mode: devo_protocol::CollaborationMode::Build, initial_user_message: None, enhanced_keys_supported: true, is_first_run: false, @@ -121,3 +123,59 @@ fn overflowing_live_assistant_viewport_follows_latest_tail() { "expected rendered viewport to show latest assistant token line:\n{rows}" ); } + +#[test] +fn working_keeps_content_sized_height_and_grows_with_stream() { + let cwd = std::env::current_dir().expect("current directory is available"); + let model = Model { + slug: "test-model".to_string(), + display_name: "Test Model".to_string(), + ..Model::default() + }; + let mut widget = widget_with_model(model, cwd); + let idle_height = widget.desired_height(80); + assert!(idle_height < u16::MAX); + + widget.handle_worker_event(WorkerEvent::TurnStarted { + model: "test-model".to_string(), + model_binding_id: None, + reasoning_effort_selection: None, + reasoning_effort: None, + turn_id: Default::default(), + }); + assert!(widget.is_task_running()); + assert!(widget.desired_height(80) < u16::MAX); + assert!(widget.desired_height(80) >= idle_height); + + let assistant_id = ItemId::new(); + widget.handle_worker_event(WorkerEvent::TextItemStarted { + item_id: assistant_id, + kind: TextItemKind::Assistant, + }); + let height_before_stream = widget.desired_height(80); + for index in 0..8 { + widget.handle_worker_event(WorkerEvent::TextItemDelta { + item_id: assistant_id, + kind: TextItemKind::Assistant, + delta: format!("pin-line-{index}\n"), + }); + widget.pre_draw_tick(); + drain_assistant_stream(&mut widget); + } + assert!(widget.desired_height(80) > height_before_stream); + assert!(widget.desired_height(80) < u16::MAX); + + widget.handle_worker_event(WorkerEvent::TurnFinished { + stop_reason: "stop".to_string(), + turn_count: 1, + total_input_tokens: 0, + total_output_tokens: 0, + total_tokens: 0, + total_cache_read_tokens: 0, + last_query_total_tokens: 0, + last_query_input_tokens: 0, + prompt_token_estimate: 0, + }); + assert!(!widget.is_task_running()); + assert!(widget.desired_height(80) < u16::MAX); +} diff --git a/crates/tui/src/chatwidget_tests.rs b/crates/tui/src/chatwidget_tests.rs index eda1db33..71917136 100644 --- a/crates/tui/src/chatwidget_tests.rs +++ b/crates/tui/src/chatwidget_tests.rs @@ -31,6 +31,7 @@ use crate::app_command::AppCommand; use crate::app_event::AppEvent; use crate::app_event::ExitMode; use crate::app_event_sender::AppEventSender; +use crate::bottom_pane::InputMode; use crate::chatwidget::ChatWidget; use crate::chatwidget::ChatWidgetInit; use crate::chatwidget::ReasoningEffortListEntry; @@ -65,6 +66,8 @@ fn widget_with_model_and_reasoning_effort( initial_reasoning_effort_selection, initial_permission_preset: devo_protocol::PermissionPreset::Default, initial_sandbox_profile: Some("workspace".to_string()), + initial_compaction_token_limit: None, + initial_default_collaboration_mode: devo_protocol::CollaborationMode::Build, initial_user_message: None, enhanced_keys_supported: true, is_first_run: false, @@ -105,6 +108,8 @@ fn onboarding_widget_with_model( initial_reasoning_effort_selection: None, initial_permission_preset: devo_protocol::PermissionPreset::Default, initial_sandbox_profile: Some("workspace".to_string()), + initial_compaction_token_limit: None, + initial_default_collaboration_mode: devo_protocol::CollaborationMode::Build, initial_user_message: None, enhanced_keys_supported: true, is_first_run: false, @@ -141,6 +146,8 @@ fn onboarding_widget_with_available_model_and_exit_after_onboarding( initial_reasoning_effort_selection: None, initial_permission_preset: devo_protocol::PermissionPreset::Default, initial_sandbox_profile: Some("workspace".to_string()), + initial_compaction_token_limit: None, + initial_default_collaboration_mode: devo_protocol::CollaborationMode::Build, initial_user_message: None, enhanced_keys_supported: true, is_first_run: false, @@ -275,7 +282,7 @@ fn user_prompt_multiline_has_single_marker_and_aligned_continuation_rows() { assert_eq!( user_lines, - [" ", "▌ line one", " line two", " line three"] + [&"─".repeat(80), "❯ line one", " line two", " line three"] ); } @@ -716,6 +723,8 @@ fn session_switched_clears_resume_blocking_state() { loaded_item_count: 0, pending_texts: Vec::new(), collaboration_mode: CollaborationMode::Build, + permission_preset: None, + effective_context_window: None, }); assert!(!widget.is_resuming_session_for_test()); @@ -1156,7 +1165,7 @@ fn approval_request_bottom_pane_menu_denies_with_n_shortcut() { } #[test] -fn submitted_prompt_requests_on_request_approval_policy() { +fn submitted_prompt_omits_approval_policy() { let model = Model { slug: "test-model".to_string(), display_name: "Test Model".to_string(), @@ -1173,7 +1182,8 @@ fn submitted_prompt_requests_on_request_approval_policy() { else { panic!("expected user turn command"); }; - assert_eq!(approval_policy, Some("on-request".to_string())); + // None keeps the server's session permission mode (do not force Interactive). + assert_eq!(approval_policy, None); } /// Trace: L2-DES-TUI-003 @@ -1189,6 +1199,11 @@ fn shift_tab_plan_submission_marks_user_turn_plan_mode() { let (mut widget, mut app_event_rx) = widget_with_model(model, cwd); widget.handle_key_event(KeyEvent::new(KeyCode::BackTab, KeyModifiers::SHIFT)); + // Mode change persists collaboration mode before the user turn is submitted. + assert!(matches!( + app_event_rx.try_recv().expect("collaboration mode event"), + AppEvent::Command(AppCommand::SetCollaborationMode { .. }) + )); paste_and_submit(&mut widget, "plan this"); let AppEvent::Command(AppCommand::UserTurn { @@ -1215,13 +1230,13 @@ fn composer_marker_color(widget: &ChatWidget) -> Color { let row_text = (0..area.width) .map(|col| buf[(col, row)].symbol()) .collect::(); - if !row_text.contains("Ask Devo") { + if !row_text.contains("Tip:") { continue; } for col in 0..area.width { let cell = &buf[(col, row)]; - if cell.symbol() == "┃" { + if cell.symbol() == "❯" { return cell.fg; } } @@ -1244,7 +1259,7 @@ fn scrollback_marker_color_for_text(widget: &mut ChatWidget, needle: &str) -> Co } for span in &line.line.spans { - if span.content.contains('▌') + if span.content.contains('❯') && let Some(color) = span.style.fg { return color; @@ -1264,9 +1279,9 @@ fn paste_and_submit(widget: &mut ChatWidget, text: &str) { } /// Trace: L2-DES-TUI-003 -/// Verifies: Mode labels and switch hints render as the first bottom status-line field. +/// Verifies: Mode labels render as the first bottom status-line field. #[test] -fn mode_label_and_switch_hint_render_at_left_of_status_line() { +fn mode_label_renders_at_left_of_status_line() { let model = Model { slug: "test-model".to_string(), display_name: "Test Model".to_string(), @@ -1276,25 +1291,21 @@ fn mode_label_and_switch_hint_render_at_left_of_status_line() { let rows = rendered_rows(&widget, 100, 12); let build_row = status_row_starting_with(&rows, "BUILD"); + assert!(build_row.trim_start().starts_with("BUILD ·")); assert!( - build_row - .trim_start() - .starts_with("BUILD SHIFT+TAB switch ·") + !build_row.contains("SHIFT+TAB switch"), + "mode switch hint should not appear in the status line:\n{build_row}" ); widget.handle_key_event(KeyEvent::new(KeyCode::BackTab, KeyModifiers::SHIFT)); let rows = rendered_rows(&widget, 100, 12); let plan_row = status_row_starting_with(&rows, "PLAN"); - assert!(plan_row.trim_start().starts_with("PLAN SHIFT+TAB switch ·")); + assert!(plan_row.trim_start().starts_with("PLAN ·")); widget.handle_key_event(KeyEvent::new(KeyCode::BackTab, KeyModifiers::SHIFT)); let rows = rendered_rows(&widget, 100, 12); let build_row = status_row_starting_with(&rows, "BUILD"); - assert!( - build_row - .trim_start() - .starts_with("BUILD SHIFT+TAB switch ·") - ); + assert!(build_row.trim_start().starts_with("BUILD ·")); assert!( rows.iter() .all(|row| !row.trim_start().starts_with("SHELL")), @@ -1308,6 +1319,46 @@ fn mode_label_and_switch_hint_render_at_left_of_status_line() { assert!(shell_row.trim_start().starts_with("SHELL ·")); } +#[test] +fn status_line_shows_branch_left_and_context_right() { + let model = Model { + slug: "test-model".to_string(), + display_name: "Test Model".to_string(), + ..Model::default() + }; + let (mut widget, _app_event_rx) = widget_with_model(model, PathBuf::from(".")); + widget.handle_app_event(AppEvent::StatusLineBranchUpdated { + cwd: PathBuf::from("."), + branch: Some("feat/status-bar".to_string()), + }); + + let rows = rendered_rows(&widget, 100, 12); + let status_row = status_row_starting_with(&rows, "BUILD"); + let trimmed = status_row.trim_end(); + assert!( + trimmed.contains("BUILD · Test Model · feat/status-bar"), + "expected mode/model/branch on the left:\n{trimmed}" + ); + assert!( + !trimmed.contains("SHIFT+TAB switch"), + "mode switch hint should be absent:\n{trimmed}" + ); + // Context meter is right-aligned; look for the token fraction suffix. + assert!( + trimmed.contains('/') && (trimmed.contains('▰') || trimmed.contains('▱')), + "expected right-aligned context meter on the status row:\n{trimmed}" + ); + let branch_idx = trimmed + .find("feat/status-bar") + .expect("branch should be present"); + let context_idx = trimmed.find('▰').or_else(|| trimmed.find('▱')); + let context_idx = context_idx.expect("context meter should be present"); + assert!( + branch_idx < context_idx, + "context should render to the right of the branch:\n{trimmed}" + ); +} + /// Trace: L2-DES-TUI-003 /// Verifies: Composer prompt marker color follows the active input mode. #[test] @@ -1422,9 +1473,26 @@ fn queued_prompt_keeps_submitted_mode_when_promoted_to_history() { widget.handle_key_event(KeyEvent::new(KeyCode::BackTab, KeyModifiers::SHIFT)); paste_and_submit(&mut widget, "queued plan"); - widget.handle_worker_event(crate::events::WorkerEvent::InputQueueUpdated { - pending_count: 0, - pending_texts: Vec::new(), + let queue_item_id = devo_protocol::canonical::ids::QueueItemId::from_string("qit_plan".into()); + widget.handle_worker_event(crate::events::WorkerEvent::QueueUpdated { + change: devo_protocol::canonical::queue::QueueChange::Added, + queue_item_id: queue_item_id.clone(), + started_turn_id: None, + entries: vec![devo_protocol::canonical::queue::QueueEntry { + queue_item_id: queue_item_id.clone(), + position: 1, + input: vec![devo_protocol::canonical::item::UserInput::Text { + text: "queued plan".to_string(), + }], + preview: "queued plan".to_string(), + enqueued_at: chrono::Utc::now(), + }], + }); + widget.handle_worker_event(crate::events::WorkerEvent::QueueUpdated { + change: devo_protocol::canonical::queue::QueueChange::Drained, + queue_item_id, + started_turn_id: Some(TurnId::new()), + entries: Vec::new(), }); assert_eq!( @@ -1490,10 +1558,36 @@ fn queued_prompt_promotes_after_active_assistant_stream() { }); paste_and_submit(&mut widget, "queued prompt"); - widget.handle_worker_event(crate::events::WorkerEvent::InputQueueUpdated { - pending_count: 0, - pending_texts: Vec::new(), + let queue_item_id = + devo_protocol::canonical::ids::QueueItemId::from_string("qit_prompt".into()); + widget.handle_worker_event(crate::events::WorkerEvent::QueueUpdated { + change: devo_protocol::canonical::queue::QueueChange::Added, + queue_item_id: queue_item_id.clone(), + started_turn_id: None, + entries: vec![devo_protocol::canonical::queue::QueueEntry { + queue_item_id: queue_item_id.clone(), + position: 1, + input: vec![devo_protocol::canonical::item::UserInput::Text { + text: "queued prompt".to_string(), + }], + preview: "queued prompt".to_string(), + enqueued_at: chrono::Utc::now(), + }], + }); + assert!( + widget.bottom_pane_has_pending_for_test(), + "queued entry should appear in the pending queue UI" + ); + widget.handle_worker_event(crate::events::WorkerEvent::QueueUpdated { + change: devo_protocol::canonical::queue::QueueChange::Drained, + queue_item_id, + started_turn_id: Some(TurnId::new()), + entries: Vec::new(), }); + assert!( + !widget.bottom_pane_has_pending_for_test(), + "drained entry should leave the pending queue UI" + ); widget.handle_worker_event(crate::events::WorkerEvent::TextItemCompleted { item_id, kind: TextItemKind::Assistant, @@ -1501,6 +1595,11 @@ fn queued_prompt_promotes_after_active_assistant_stream() { }); let history = scrollback_plain_lines(&widget.drain_scrollback_lines(100)); + assert!( + history.iter().any(|line| line.contains("queued prompt")), + "queued prompt should be promoted:\n{}", + history.join("\n") + ); let assistant_indexes = history .iter() .enumerate() @@ -1523,6 +1622,151 @@ fn queued_prompt_promotes_after_active_assistant_stream() { ); } +fn drain_commands(rx: &mut mpsc::UnboundedReceiver) -> Vec { + let mut commands = Vec::new(); + while let Ok(event) = rx.try_recv() { + if let AppEvent::Command(command) = event { + commands.push(command); + } + } + commands +} + +fn test_queue_entry(id: &str, text: &str) -> devo_protocol::canonical::queue::QueueEntry { + devo_protocol::canonical::queue::QueueEntry { + queue_item_id: devo_protocol::canonical::ids::QueueItemId::from_string(id.to_string()), + position: 1, + input: vec![devo_protocol::canonical::item::UserInput::Text { + text: text.to_string(), + }], + preview: text.to_string(), + enqueued_at: chrono::Utc::now(), + } +} + +fn start_busy_turn(widget: &mut ChatWidget) { + widget.handle_worker_event(crate::events::WorkerEvent::TurnStarted { + model: "test-model".to_string(), + model_binding_id: None, + reasoning_effort_selection: None, + reasoning_effort: None, + turn_id: TurnId::new(), + }); +} + +fn push_queue_snapshot( + widget: &mut ChatWidget, + change: devo_protocol::canonical::queue::QueueChange, + queue_item_id: &str, + entries: Vec, +) { + widget.handle_worker_event(crate::events::WorkerEvent::QueueUpdated { + change, + queue_item_id: devo_protocol::canonical::ids::QueueItemId::from_string( + queue_item_id.to_string(), + ), + started_turn_id: None, + entries, + }); +} + +#[test] +fn queue_edit_resubmit_updates_item_in_place() { + let model = Model { + slug: "test-model".to_string(), + display_name: "Test Model".to_string(), + ..Model::default() + }; + let (mut widget, mut app_event_rx) = widget_with_model(model, PathBuf::from(".")); + start_busy_turn(&mut widget); + push_queue_snapshot( + &mut widget, + devo_protocol::canonical::queue::QueueChange::Added, + "qit_edit", + vec![test_queue_entry("qit_edit", "original text")], + ); + drain_commands(&mut app_event_rx); + + widget.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); + widget.handle_key_event(KeyEvent::new(KeyCode::Char('e'), KeyModifiers::CONTROL)); + assert_eq!( + drain_commands(&mut app_event_rx), + Vec::new(), + "queue edit must not remove the item" + ); + + widget.handle_key_event(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE)); + // Flush the composer's held first char (paste-burst flicker suppression) + // before submitting, same idiom as paste_and_submit. + std::thread::sleep(crate::bottom_pane::ChatComposer::recommended_paste_flush_delay()); + widget.pre_draw_tick(); + widget.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert_eq!( + drain_commands(&mut app_event_rx), + vec![AppCommand::QueueUpdate { + queue_item_id: "qit_edit".to_string(), + input: vec![devo_protocol::InputItem::Text { + text: "original textx".to_string(), + }], + }], + "busy resubmit after edit should update the queued item in place" + ); + + // The edit flag is consumed: a subsequent busy submit pushes a new entry. + widget.handle_key_event(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE)); + std::thread::sleep(crate::bottom_pane::ChatComposer::recommended_paste_flush_delay()); + widget.pre_draw_tick(); + widget.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert_eq!( + drain_commands(&mut app_event_rx), + vec![AppCommand::QueuePush { + input: vec![devo_protocol::InputItem::Text { + text: "y".to_string(), + }], + }], + "submit after the edit was applied should push a fresh queue entry" + ); +} + +#[test] +fn queue_edit_falls_back_to_push_when_item_vanishes() { + let model = Model { + slug: "test-model".to_string(), + display_name: "Test Model".to_string(), + ..Model::default() + }; + let (mut widget, mut app_event_rx) = widget_with_model(model, PathBuf::from(".")); + start_busy_turn(&mut widget); + push_queue_snapshot( + &mut widget, + devo_protocol::canonical::queue::QueueChange::Added, + "qit_edit", + vec![test_queue_entry("qit_edit", "original text")], + ); + widget.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); + widget.handle_key_event(KeyEvent::new(KeyCode::Char('e'), KeyModifiers::CONTROL)); + drain_commands(&mut app_event_rx); + + // The item is removed (or drained) while its text sits in the composer. + push_queue_snapshot( + &mut widget, + devo_protocol::canonical::queue::QueueChange::Removed, + "qit_edit", + Vec::new(), + ); + + widget.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert_eq!( + drain_commands(&mut app_event_rx), + vec![AppCommand::QueuePush { + input: vec![devo_protocol::InputItem::Text { + text: "original text".to_string(), + }], + }], + "submit should fall back to a queue push once the edited item is gone" + ); +} + /// Trace: L2-DES-TUI-003 /// Verifies: Bare bang enters Shell mode and submits composer text as a shell command. #[test] @@ -1664,9 +1908,9 @@ fn permissions_command_opens_bottom_pane_picker_and_updates_default() { let rendered = rendered_rows(&widget, 100, 18).join("\n"); assert!(rendered.contains("Update Permissions")); - assert!(rendered.contains("● 1. Default")); - assert!(rendered.contains("Auto-review")); - assert!(rendered.contains("Full Access")); + assert!(rendered.contains("● 1. Ask for approval")); + assert!(rendered.contains("Approve for me")); + assert!(rendered.contains("Full-Access")); widget.handle_key_event(KeyEvent::new(KeyCode::Char('1'), KeyModifiers::NONE)); @@ -1675,6 +1919,7 @@ fn permissions_command_opens_bottom_pane_picker_and_updates_default() { event, AppEvent::Command(AppCommand::UpdatePermissions { preset: devo_protocol::PermissionPreset::Default, + persist_scope: crate::app_command::PersistScope::Session, }) ); } @@ -1731,6 +1976,8 @@ fn permissions_command_marks_initial_project_preset_current() { initial_reasoning_effort_selection: None, initial_permission_preset: PermissionPreset::FullAccess, initial_sandbox_profile: Some("workspace".to_string()), + initial_compaction_token_limit: None, + initial_default_collaboration_mode: devo_protocol::CollaborationMode::Build, initial_user_message: None, enhanced_keys_supported: true, is_first_run: false, @@ -1748,7 +1995,7 @@ fn permissions_command_marks_initial_project_preset_current() { }); let rendered = rendered_rows(&widget, 100, 18).join("\n"); - assert!(rendered.contains("● 3. Full Access")); + assert!(rendered.contains("● 3. Full-Access")); } #[test] @@ -1846,55 +2093,20 @@ fn trailing_space_exit_slash_command_exits() { } #[test] -fn typed_clear_slash_command_clears_history_and_active_streams() { +fn trailing_space_quit_slash_command_exits() { let model = Model { slug: "test-model".to_string(), display_name: "Test Model".to_string(), ..Model::default() }; - let (mut widget, _app_event_rx) = widget_with_model(model, PathBuf::from(".")); - widget.handle_app_event(AppEvent::ClearTranscript); - - paste_and_submit(&mut widget, "old prompt"); - widget.handle_worker_event(crate::events::WorkerEvent::TurnStarted { - model: "test-model".to_string(), - - model_binding_id: None, - reasoning_effort_selection: None, - reasoning_effort: None, - turn_id: TurnId::new(), - }); - widget.handle_worker_event(crate::events::WorkerEvent::TextDelta( - "active stream body".to_string(), - )); - let before_scrollback = scrollback_plain_lines(&widget.drain_scrollback_lines(100)).join( - " -", - ); - assert!(before_scrollback.contains("old prompt")); - let before = rendered_rows(&widget, 100, 16).join( - " -", - ); - assert!(before.contains("active stream body")); + let (mut widget, mut app_event_rx) = widget_with_model(model, PathBuf::from(".")); - paste_and_submit(&mut widget, "/clear"); + widget.handle_paste("/quit ".to_string()); + widget.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - let after_scrollback = scrollback_plain_lines(&widget.drain_scrollback_lines(100)).join( - " -", - ); - let after = rendered_rows(&widget, 100, 16).join( - " -", - ); - assert!( - !after_scrollback.contains("old prompt") && !after.contains("active stream body"), - "typed /clear should remove visible history and active streams: -scrollback: -{after_scrollback} -rendered: -{after}" + assert_eq!( + app_event_rx.try_recv().ok(), + Some(AppEvent::Exit(crate::app_event::ExitMode::ShutdownFirst)) ); } @@ -2058,7 +2270,42 @@ fn rename_slash_command_without_title_shows_usage() { } #[test] -fn delete_slash_command_emits_delete_session() { +fn delete_slash_command_requires_confirmation_before_emitting() { + let model = Model { + slug: "test-model".to_string(), + display_name: "Test Model".to_string(), + ..Model::default() + }; + let (mut widget, mut app_event_rx) = widget_with_model(model, PathBuf::from(".")); + + widget.handle_paste("/delete".to_string()); + widget.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + assert!( + app_event_rx.try_recv().is_err(), + "delete should wait for confirmation" + ); + let rows = rendered_rows(&widget, 80, 16).join("\n"); + assert!( + rows.contains("Delete session?"), + "expected delete confirmation:\n{rows}" + ); + assert!( + rows.contains("[Cancel]") && rows.contains("Delete"), + "expected horizontal Cancel/Delete chips:\n{rows}" + ); + + // Select Delete with ←/→ then confirm. + widget.handle_key_event(KeyEvent::new(KeyCode::Right, KeyModifiers::NONE)); + widget.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert_eq!( + app_event_rx.try_recv().expect("confirmed delete command"), + AppEvent::Command(AppCommand::DeleteSession { session_id: None }) + ); +} + +#[test] +fn delete_slash_command_esc_cancels_without_deleting() { let model = Model { slug: "test-model".to_string(), display_name: "Test Model".to_string(), @@ -2068,11 +2315,111 @@ fn delete_slash_command_emits_delete_session() { widget.handle_paste("/delete".to_string()); widget.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert!( + rendered_rows(&widget, 80, 16) + .join("\n") + .contains("Delete session?"), + "expected delete confirmation open" + ); + + // Esc must dismiss even if Delete chip is selected. + widget.handle_key_event(KeyEvent::new(KeyCode::Right, KeyModifiers::NONE)); + widget.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + + let rows = rendered_rows(&widget, 80, 16).join("\n"); + assert!( + !rows.contains("Delete session?"), + "esc should dismiss delete confirmation:\n{rows}" + ); + let mut saw_delete = false; + while let Ok(event) = app_event_rx.try_recv() { + if matches!(event, AppEvent::Command(AppCommand::DeleteSession { .. })) { + saw_delete = true; + } + } + assert!(!saw_delete, "esc must not emit delete"); +} + +#[test] +fn resume_browser_delete_requires_confirmation() { + let model = Model { + slug: "test-model".to_string(), + display_name: "Test Model".to_string(), + ..Model::default() + }; + let (mut widget, mut app_event_rx) = widget_with_model(model, PathBuf::from(".")); + let target = SessionId::new(); + let other = SessionId::new(); + widget.open_resume_browser_for_test(vec![ + crate::events::SessionListEntry { + session_id: target, + title: "Keep me".to_string(), + updated_at: "2026-05-18 10:00".to_string(), + is_active: false, + }, + crate::events::SessionListEntry { + session_id: other, + title: "Other".to_string(), + updated_at: "2026-05-18 11:00".to_string(), + is_active: true, + }, + ]); + // Active session is selected by default; move to the non-active row. + widget.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)); + assert_eq!(widget.resume_browser_selection_for_test(), Some(0)); + + widget.handle_key_event(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE)); + assert_eq!( + widget.resume_browser_pending_delete_for_test(), + Some(target) + ); + assert!(app_event_rx.try_recv().is_err()); + + let rows = rendered_rows(&widget, 100, 16).join("\n"); + assert!( + rows.contains("Delete \"Keep me\"?"), + "expected pending delete footer:\n{rows}" + ); + assert!( + rows.contains("[Cancel]") && rows.contains("Delete"), + "expected horizontal Cancel/Delete chips:\n{rows}" + ); + assert!( + rows.lines() + .any(|line| line.contains("[Cancel]") && line.starts_with(" [")), + "expected left-padded chip row:\n{rows}" + ); + assert!( + rows.lines() + .any(|line| line.contains("Resume Session") && line.starts_with(" Resume")), + "expected left-padded Resume Session title:\n{rows}" + ); + assert!( + !rows.contains("Devo Sessions"), + "block title Devo Sessions should be removed:\n{rows}" + ); + assert!(rows.contains("choose"), "expected choose hint:\n{rows}"); + + widget.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + assert_eq!(widget.resume_browser_pending_delete_for_test(), None); + assert!(widget.is_resume_browser_open()); + + widget.handle_key_event(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE)); + // Default Cancel: Enter dismisses confirm without deleting. + widget.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert_eq!(widget.resume_browser_pending_delete_for_test(), None); + assert!(app_event_rx.try_recv().is_err()); + widget.handle_key_event(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE)); + widget.handle_key_event(KeyEvent::new(KeyCode::Right, KeyModifiers::NONE)); + widget.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); assert_eq!( - app_event_rx.try_recv().expect("delete command event"), - AppEvent::Command(AppCommand::DeleteSession) + app_event_rx.try_recv().expect("delete command"), + AppEvent::Command(AppCommand::DeleteSession { + session_id: Some(target) + }) ); + assert!(widget.is_resume_browser_open()); } #[test] @@ -2374,7 +2721,7 @@ fn submit_text_emits_user_turn_with_model_and_reasoning_effort() { model_binding_id: None, reasoning_effort_selection: Some("disabled".to_string()), sandbox: None, - approval_policy: Some("on-request".to_string()), + approval_policy: None, collaboration_mode: devo_protocol::CollaborationMode::Build, }) ); @@ -2410,7 +2757,7 @@ fn typed_character_submits_after_paste_burst_flush() { model_binding_id: None, reasoning_effort_selection: None, sandbox: None, - approval_policy: Some("on-request".to_string()), + approval_policy: None, collaboration_mode: devo_protocol::CollaborationMode::Build, }) ); @@ -2521,7 +2868,7 @@ fn key_release_does_not_duplicate_text_input() { model_binding_id: None, reasoning_effort_selection: None, sandbox: None, - approval_policy: Some("on-request".to_string()), + approval_policy: None, collaboration_mode: devo_protocol::CollaborationMode::Build, }) ); @@ -2712,6 +3059,8 @@ fn session_switch_restores_plan_mode_and_proposed_plan_actions() { loaded_item_count: 1, pending_texts: Vec::new(), collaboration_mode: CollaborationMode::Plan, + permission_preset: None, + effective_context_window: None, }); assert_eq!( @@ -2780,6 +3129,8 @@ fn session_switch_restores_plan_turn_summary_label() { loaded_item_count: 2, pending_texts: Vec::new(), collaboration_mode: CollaborationMode::Plan, + permission_preset: None, + effective_context_window: None, }); assert_eq!( @@ -2798,7 +3149,7 @@ fn session_switch_restores_plan_turn_summary_label() { } #[test] -fn session_switch_after_implement_stays_in_build_without_plan_actions() { +fn session_switch_restores_context_compaction_info_row() { let cwd = std::env::current_dir().expect("current directory is available"); let model = Model { slug: "test-model".to_string(), @@ -2808,9 +3159,9 @@ fn session_switch_after_implement_stays_in_build_without_plan_actions() { let (mut widget, _app_event_rx) = widget_with_model(model, cwd.clone()); widget.handle_worker_event(crate::events::WorkerEvent::SessionSwitched { - session_id: "session-build".to_string(), + session_id: "session-compaction-row".to_string(), cwd, - title: Some("Build session".to_string()), + title: Some("Compacted session".to_string()), model: Some("test-model".to_string()), model_binding_id: None, reasoning_effort_selection: None, @@ -2820,56 +3171,128 @@ fn session_switch_after_implement_stays_in_build_without_plan_actions() { total_output_tokens: 0, total_tokens: 0, total_cache_read_tokens: 0, - last_query_total_tokens: 0, - last_query_input_tokens: 0, - prompt_token_estimate: 0, + last_query_total_tokens: 50_000, + last_query_input_tokens: 45_000, + prompt_token_estimate: 50_000, history_items: Vec::new(), rich_history_items: vec![ devo_protocol::SessionHistoryItem { tool_call_id: None, - kind: devo_protocol::SessionHistoryItemKind::Assistant, + kind: devo_protocol::SessionHistoryItemKind::User, title: String::new(), - body: "## Approach\n\n1. Inspect\n2. Patch\n".to_string(), + body: "before compact".to_string(), tool_io: None, - metadata: Some(devo_protocol::SessionHistoryMetadata::ProposedPlan), + metadata: None, duration_ms: None, }, devo_protocol::SessionHistoryItem { tool_call_id: None, - kind: devo_protocol::SessionHistoryItemKind::User, + kind: devo_protocol::SessionHistoryItemKind::ContextCompaction, + title: "Context compacted".to_string(), + body: String::new(), + tool_io: None, + metadata: None, + duration_ms: None, + }, + devo_protocol::SessionHistoryItem { + tool_call_id: None, + kind: devo_protocol::SessionHistoryItemKind::Assistant, title: String::new(), - body: "Implement Plan".to_string(), + body: "after compact".to_string(), tool_io: None, metadata: None, duration_ms: None, }, ], - loaded_item_count: 2, + loaded_item_count: 3, pending_texts: Vec::new(), collaboration_mode: CollaborationMode::Build, + permission_preset: None, + effective_context_window: None, }); - assert_eq!( - widget.input_mode_for_test(), - crate::bottom_pane::InputMode::Build + let rendered = scrollback_plain_lines(&widget.drain_scrollback_lines(100)).join("\n"); + assert!( + rendered.contains("Context compacted"), + "expected restored Context compacted row:\n{rendered}" ); - assert!(!widget.has_bottom_pane_view_for_test()); - assert_eq!(widget.status_message_for_test(), "Session switched"); } #[test] -fn proposed_plan_implement_action_sends_build_turn() { +fn session_switch_after_implement_stays_in_build_without_plan_actions() { let cwd = std::env::current_dir().expect("current directory is available"); let model = Model { slug: "test-model".to_string(), display_name: "Test Model".to_string(), ..Model::default() }; - let (mut widget, mut app_event_rx) = widget_with_model(model, cwd.clone()); - let plan_id = ItemId::new(); + let (mut widget, _app_event_rx) = widget_with_model(model, cwd.clone()); - widget - .handle_worker_event(crate::events::WorkerEvent::ProposedPlanStarted { item_id: plan_id }); + widget.handle_worker_event(crate::events::WorkerEvent::SessionSwitched { + session_id: "session-build".to_string(), + cwd, + title: Some("Build session".to_string()), + model: Some("test-model".to_string()), + model_binding_id: None, + reasoning_effort_selection: None, + reasoning_effort: None, + active_agent_label: None, + total_input_tokens: 0, + total_output_tokens: 0, + total_tokens: 0, + total_cache_read_tokens: 0, + last_query_total_tokens: 0, + last_query_input_tokens: 0, + prompt_token_estimate: 0, + history_items: Vec::new(), + rich_history_items: vec![ + devo_protocol::SessionHistoryItem { + tool_call_id: None, + kind: devo_protocol::SessionHistoryItemKind::Assistant, + title: String::new(), + body: "## Approach\n\n1. Inspect\n2. Patch\n".to_string(), + tool_io: None, + metadata: Some(devo_protocol::SessionHistoryMetadata::ProposedPlan), + duration_ms: None, + }, + devo_protocol::SessionHistoryItem { + tool_call_id: None, + kind: devo_protocol::SessionHistoryItemKind::User, + title: String::new(), + body: "Implement Plan".to_string(), + tool_io: None, + metadata: None, + duration_ms: None, + }, + ], + loaded_item_count: 2, + pending_texts: Vec::new(), + collaboration_mode: CollaborationMode::Build, + permission_preset: None, + effective_context_window: None, + }); + + assert_eq!( + widget.input_mode_for_test(), + crate::bottom_pane::InputMode::Build + ); + assert!(!widget.has_bottom_pane_view_for_test()); + assert_eq!(widget.status_message_for_test(), "Session switched"); +} + +#[test] +fn proposed_plan_implement_action_sends_build_turn() { + let cwd = std::env::current_dir().expect("current directory is available"); + let model = Model { + slug: "test-model".to_string(), + display_name: "Test Model".to_string(), + ..Model::default() + }; + let (mut widget, mut app_event_rx) = widget_with_model(model, cwd.clone()); + let plan_id = ItemId::new(); + + widget + .handle_worker_event(crate::events::WorkerEvent::ProposedPlanStarted { item_id: plan_id }); widget.handle_worker_event(crate::events::WorkerEvent::ProposedPlanCompleted { item_id: plan_id, final_text: "## Summary\n\nBuild the feature.".to_string(), @@ -3002,6 +3425,8 @@ fn session_switch_restores_plan_metadata_into_progress() { loaded_item_count: 1, pending_texts: vec![], collaboration_mode: CollaborationMode::Build, + permission_preset: None, + effective_context_window: None, }); assert_eq!(widget.last_plan_progress_for_test(), Some((1, 2))); @@ -3053,6 +3478,8 @@ fn session_switch_restores_explored_metadata_into_history() { loaded_item_count: 1, pending_texts: vec![], collaboration_mode: CollaborationMode::Build, + permission_preset: None, + effective_context_window: None, }); let blob = scrollback_plain_lines(&widget.drain_scrollback_lines(80)).join("\n"); @@ -3117,6 +3544,8 @@ fn session_switch_restores_edited_metadata_into_history() { loaded_item_count: 1, pending_texts: vec![], collaboration_mode: CollaborationMode::Build, + permission_preset: None, + effective_context_window: None, }); let blob = scrollback_plain_lines(&widget.drain_scrollback_lines(80)).join("\n"); @@ -3189,6 +3618,8 @@ fn session_switch_merges_consecutive_explored_items() { loaded_item_count: 2, pending_texts: vec![], collaboration_mode: CollaborationMode::Build, + permission_preset: None, + effective_context_window: None, }); let blob = scrollback_plain_lines(&widget.drain_scrollback_lines(100)).join("\n"); @@ -3247,6 +3678,8 @@ fn session_switch_restores_error_via_tool_result_cell_style() { loaded_item_count: 1, pending_texts: vec![], collaboration_mode: CollaborationMode::Build, + permission_preset: None, + effective_context_window: None, }); let blob = scrollback_plain_lines(&widget.drain_scrollback_lines(80)).join("\n"); @@ -3329,6 +3762,8 @@ fn rich_session_restore_orders_terminal_error_before_single_failed_footer() { loaded_item_count: 4, pending_texts: vec![], collaboration_mode: CollaborationMode::Build, + permission_preset: None, + effective_context_window: None, }); let history = scrollback_plain_lines(&widget.drain_scrollback_lines(100)).join("\n"); @@ -3407,6 +3842,8 @@ fn live_and_resume_error_share_same_rendering_chain() { loaded_item_count: 1, pending_texts: vec![], collaboration_mode: CollaborationMode::Build, + permission_preset: None, + effective_context_window: None, }); let resume_blob = scrollback_plain_lines(&resume_widget.drain_scrollback_lines(80)) .into_iter() @@ -3511,10 +3948,9 @@ fn onboarding_validation_succeeded_waits_for_provider_upsert() { ); assert_eq!(app_event_rx.try_recv().is_err(), true); assert_eq!(widget.is_onboarding_active(), false); - assert_eq!(widget.placeholder_text(), "Ask Devo"); assert_eq!( - widget.current_model().map(|model| model.slug.as_str()), - Some("deepseek-v4-flash") + widget.placeholder_text(), + format!("Tip: {}", crate::status_indicator_widget::WORKING_TIPS[0]) ); assert_eq!( widget.status_summary_text().contains("DeepSeek-V4-Flash"), @@ -4080,6 +4516,8 @@ fn session_switch_restores_header_and_spacing_before_user_input() { loaded_item_count: 2, pending_texts: vec![], collaboration_mode: CollaborationMode::Build, + permission_preset: None, + effective_context_window: None, }); let committed_lines = widget.drain_scrollback_lines(80); @@ -4105,12 +4543,11 @@ fn session_switch_restores_header_and_spacing_before_user_input() { assert!(committed_text.contains("world")); assert!(!committed_text.contains("session 1 lingering line")); assert!( - committed_rows - .windows(5) - .any(|window| window[0].contains("▌ hello") - && window[1].trim().is_empty() - && window[2].trim().is_empty() - && window[3].contains("world")), + committed_rows.windows(3).any(|window| { + window[0].contains("❯ hello") + && window[1].chars().all(|ch| ch == '─') + && window[2].contains("world") + }), "expected restored spaced user prompt before assistant response: {committed_lines:?}" ); } @@ -4170,12 +4607,14 @@ fn restored_user_spacing_matches_live_turn_batch_spacing() { loaded_item_count: 2, pending_texts: vec![], collaboration_mode: CollaborationMode::Build, + permission_preset: None, + effective_context_window: None, }); let restored_rows = scrollback_plain_lines(&restored_widget.drain_scrollback_lines(80)); let live_user = live_rows .iter() - .position(|row| row.contains("▌ hello")) + .position(|row| row.contains("❯ hello")) .expect("live user row"); let live_assistant = live_rows .iter() @@ -4183,7 +4622,7 @@ fn restored_user_spacing_matches_live_turn_batch_spacing() { .expect("live assistant row"); let restored_user = restored_rows .iter() - .position(|row| row.contains("▌ hello")) + .position(|row| row.contains("❯ hello")) .expect("restored user row"); let restored_assistant = restored_rows .iter() @@ -4244,16 +4683,17 @@ fn rich_session_switch_restores_user_spacing_before_assistant_response() { loaded_item_count: 2, pending_texts: vec![], collaboration_mode: CollaborationMode::Build, + permission_preset: None, + effective_context_window: None, }); let committed_rows = scrollback_plain_lines(&widget.drain_scrollback_lines(80)); assert!( - committed_rows - .windows(5) - .any(|window| window[0].contains("▌ hello") - && window[1].trim().is_empty() - && window[2].trim().is_empty() - && window[3].contains("world")), + committed_rows.windows(3).any(|window| { + window[0].contains("❯ hello") + && window[1].chars().all(|ch| ch == '─') + && window[2].contains("world") + }), "expected restored rich user prompt to keep live spacing before assistant response: {committed_rows:?}" ); } @@ -5346,19 +5786,19 @@ fn paired_failed_turn_events_finalize_ui_once_in_order() { ); let authoritative_summary = widget.status_summary_text(); assert!( - authoritative_summary.contains("↑20"), - "summary should use TurnFinished input total: {authoritative_summary}" + !authoritative_summary.contains("↑"), + "status summary should omit session input totals: {authoritative_summary}" ); assert!( - authoritative_summary.contains("cached 2 10%"), - "summary should use TurnFinished cache total: {authoritative_summary}" + !authoritative_summary.contains("cached"), + "status summary should omit cache totals: {authoritative_summary}" ); assert!( - authoritative_summary.contains("↓4"), - "summary should use TurnFinished output total: {authoritative_summary}" + !authoritative_summary.contains("↓"), + "status summary should omit session output totals: {authoritative_summary}" ); assert!( - authoritative_summary.contains("14/200k"), + authoritative_summary.contains("14/190.0k"), "summary should use TurnFinished latest-query total: {authoritative_summary}" ); let history = scrollback_plain_lines(&widget.drain_scrollback_lines(100)).join("\n"); @@ -5665,6 +6105,8 @@ fn restored_reasoning_text_is_visible_in_transcript() { loaded_item_count: 1, pending_texts: vec![], collaboration_mode: CollaborationMode::Build, + permission_preset: None, + effective_context_window: None, }); let scrollback = widget.drain_scrollback_lines(80); @@ -6093,6 +6535,8 @@ fn slash_model_opens_model_picker_instead_of_printing_current_model() { initial_reasoning_effort_selection: None, initial_permission_preset: devo_protocol::PermissionPreset::Default, initial_sandbox_profile: Some("workspace".to_string()), + initial_compaction_token_limit: None, + initial_default_collaboration_mode: devo_protocol::CollaborationMode::Build, initial_user_message: None, enhanced_keys_supported: true, is_first_run: false, @@ -6112,7 +6556,10 @@ fn slash_model_opens_model_picker_instead_of_printing_current_model() { command: "model".to_string(), }); - assert_eq!(widget.placeholder_text(), "Ask Devo"); + assert_eq!( + widget.placeholder_text(), + format!("Tip: {}", crate::status_indicator_widget::WORKING_TIPS[0]) + ); assert_eq!( widget.current_model().map(|m| m.slug.as_str()), Some("test-model") @@ -6157,6 +6604,8 @@ fn session_switch_updates_session_identity_projection() { loaded_item_count: 0, pending_texts: vec![], collaboration_mode: CollaborationMode::Build, + permission_preset: None, + effective_context_window: None, }); assert_eq!(widget.current_cwd(), resumed_cwd.as_path()); @@ -6201,13 +6650,15 @@ fn status_summary_uses_last_turn_total_when_idle_and_live_estimate_while_busy() loaded_item_count: 0, pending_texts: vec![], collaboration_mode: CollaborationMode::Build, + permission_preset: None, + effective_context_window: None, }); let idle_summary = widget.status_summary_text(); - assert!(idle_summary.contains("↑12")); - assert!(idle_summary.contains("cached 4 33%")); - assert!(idle_summary.contains("↓18")); - assert!(idle_summary.contains("42/200k")); + assert!(!idle_summary.contains("↑")); + assert!(!idle_summary.contains("cached")); + assert!(!idle_summary.contains("↓")); + assert!(idle_summary.contains("42/190.0k")); widget.handle_worker_event(crate::events::WorkerEvent::TurnStarted { model: "test-model".to_string(), @@ -6227,9 +6678,9 @@ fn status_summary_uses_last_turn_total_when_idle_and_live_estimate_while_busy() }); let busy_summary = widget.status_summary_text(); - assert!(busy_summary.contains("↑7")); - assert!(busy_summary.contains("cached 6 86%")); - assert!(busy_summary.contains("9/200k")); + assert!(!busy_summary.contains("↑")); + assert!(!busy_summary.contains("cached")); + assert!(busy_summary.contains("9/190.0k")); widget.handle_worker_event(crate::events::WorkerEvent::TurnFinished { stop_reason: "stop".to_string(), @@ -6244,9 +6695,9 @@ fn status_summary_uses_last_turn_total_when_idle_and_live_estimate_while_busy() }); let finished_summary = widget.status_summary_text(); - assert!(finished_summary.contains("↑19")); - assert!(finished_summary.contains("cached 6 32%")); - assert!(finished_summary.contains("9/200k")); + assert!(!finished_summary.contains("↑")); + assert!(!finished_summary.contains("cached")); + assert!(finished_summary.contains("9/190.0k")); } #[test] @@ -6280,6 +6731,8 @@ fn session_compacted_updates_context_bar_to_compacted_prompt_estimate() { loaded_item_count: 0, pending_texts: vec![], collaboration_mode: CollaborationMode::Build, + permission_preset: None, + effective_context_window: None, }); widget.handle_worker_event(crate::events::WorkerEvent::SessionCompacted { @@ -6292,9 +6745,9 @@ fn session_compacted_updates_context_bar_to_compacted_prompt_estimate() { }); let summary = widget.status_summary_text(); - assert!(summary.contains("↑10k")); - assert!(summary.contains("1k/200k")); - assert!(!summary.contains("9k/200k")); + assert!(!summary.contains("↑")); + assert!(summary.contains("1.2k/190.0k")); + assert!(!summary.contains("9.0k/190.0k")); } #[test] @@ -6329,12 +6782,14 @@ fn usage_updated_keeps_context_bar_on_last_query_not_cumulative_totals() { loaded_item_count: 0, pending_texts: vec![], collaboration_mode: CollaborationMode::Build, + permission_preset: None, + effective_context_window: None, }); let idle_summary = widget.status_summary_text(); - assert!(idle_summary.contains("↑500")); - assert!(idle_summary.contains("42/200k")); - assert!(!idle_summary.contains("500/200k")); + assert!(!idle_summary.contains("↑")); + assert!(idle_summary.contains("42/190.0k")); + assert!(!idle_summary.contains("500/190.0k")); widget.handle_worker_event(crate::events::WorkerEvent::UsageUpdated { total_input_tokens: 550, @@ -6346,9 +6801,9 @@ fn usage_updated_keeps_context_bar_on_last_query_not_cumulative_totals() { }); let busy_summary = widget.status_summary_text(); - assert!(busy_summary.contains("↑550")); - assert!(busy_summary.contains("48/200k")); - assert!(!busy_summary.contains("550/200k")); + assert!(!busy_summary.contains("↑")); + assert!(busy_summary.contains("48/190.0k")); + assert!(!busy_summary.contains("550/190.0k")); } #[test] @@ -6642,6 +7097,42 @@ fn interrupt_request_switches_working_status_to_stopping_immediately() { assert!(history.contains("interrupted"), "history:\n{history}"); } +/// Trace: L2-DES-AGENT-002 +/// Verifies: Esc interrupt is accepted while a compaction turn is active. +#[test] +fn interrupt_during_session_compaction_is_accepted() { + let model = Model { + slug: "test-model".to_string(), + display_name: "Test Model".to_string(), + ..Model::default() + }; + let (mut widget, mut app_event_rx) = widget_with_model(model, PathBuf::from(".")); + let turn_id = TurnId::new(); + + widget.handle_worker_event(crate::events::WorkerEvent::TurnStarted { + model: "test-model".to_string(), + model_binding_id: None, + reasoning_effort_selection: None, + reasoning_effort: None, + turn_id, + }); + widget.handle_worker_event(crate::events::WorkerEvent::SessionCompactionStarted); + widget.handle_key_event(press_key(KeyCode::Esc)); + assert!(app_event_rx.try_recv().is_err()); + widget.handle_key_event(press_key(KeyCode::Esc)); + assert_eq!(app_event_rx.try_recv(), Ok(AppEvent::Interrupt)); + assert!(widget.request_interrupt()); + let rows = rendered_rows(&widget, 120, 20).join("\n"); + assert!(rows.contains("Stopping…"), "rows:\n{rows}"); + + widget.handle_worker_event(crate::events::WorkerEvent::SessionCompactionFailed { + message: "compaction canceled".to_string(), + }); + let rows = rendered_rows(&widget, 120, 20).join("\n"); + assert!(!rows.contains("Stopping…"), "rows:\n{rows}"); + assert!(!rows.contains("Compacting session"), "rows:\n{rows}"); +} + #[test] fn interrupt_failure_restores_working_status() { let model = Model { @@ -6685,7 +7176,7 @@ fn session_compaction_live_rows_use_live_prefix_cols() { assert!( started_history .iter() - .any(|line| line.starts_with(&format!("{live_prefix}Compaction started"))), + .any(|line| line.starts_with("▌ Compaction started")), "context compaction start should be visible in history:\n{}", started_history.join("\n") ); @@ -6711,7 +7202,7 @@ fn session_compaction_live_rows_use_live_prefix_cols() { assert!( history .iter() - .any(|line| { line.starts_with(&format!("{live_prefix}Context compacted")) }), + .any(|line| line.starts_with("▌ Context compacted")), "compaction completion history should align with live prefix:\n{}", history.join("\n") ); @@ -6741,6 +7232,51 @@ fn session_compaction_live_rows_use_live_prefix_cols() { ); } +#[test] +fn context_compaction_completed_clears_compacting_status_indicator() { + let model = Model { + slug: "test-model".to_string(), + display_name: "Test Model".to_string(), + ..Model::default() + }; + let (mut widget, _app_event_rx) = widget_with_model(model, PathBuf::from(".")); + + // Mid-turn auto-compaction: start → item completed (no SessionCompacted). + widget.handle_worker_event(crate::events::WorkerEvent::TurnStarted { + model: "test-model".to_string(), + model_binding_id: None, + reasoning_effort_selection: None, + reasoning_effort: None, + turn_id: TurnId::new(), + }); + widget.handle_worker_event(crate::events::WorkerEvent::SessionCompactionStarted); + let rows = rendered_rows(&widget, 120, 20).join("\n"); + assert!(rows.contains("Compacting session"), "rows:\n{rows}"); + + widget.handle_worker_event(crate::events::WorkerEvent::ContextCompactionCompleted { + title: "Context compacted".to_string(), + }); + let rows = rendered_rows(&widget, 120, 20).join("\n"); + assert!(!rows.contains("Compacting session"), "rows:\n{rows}"); + assert!(rows.contains("Working"), "rows:\n{rows}"); + + // Standalone compaction (no active turn): item completed should hide indicator. + let (mut widget, _app_event_rx) = widget_with_model( + Model { + slug: "test-model".to_string(), + display_name: "Test Model".to_string(), + ..Model::default() + }, + PathBuf::from("."), + ); + widget.handle_worker_event(crate::events::WorkerEvent::SessionCompactionStarted); + widget.handle_worker_event(crate::events::WorkerEvent::ContextCompactionCompleted { + title: "Context compacted".to_string(), + }); + let rows = rendered_rows(&widget, 120, 20).join("\n"); + assert!(!rows.contains("Compacting session"), "rows:\n{rows}"); +} + #[test] fn context_compaction_item_lifecycle_emits_worker_events() { let session_id = SessionId::new(); @@ -7438,6 +7974,8 @@ fn session_switch_sets_active_agent_footer_label() { loaded_item_count: 0, pending_texts: vec![], collaboration_mode: CollaborationMode::Build, + permission_preset: None, + effective_context_window: None, }); let rows = rendered_rows(&widget, 160, 16); @@ -7481,6 +8019,8 @@ fn new_session_prepared_appends_header_after_existing_history_and_resets_status( loaded_item_count: 0, pending_texts: vec![], collaboration_mode: CollaborationMode::Build, + permission_preset: None, + effective_context_window: None, }); widget.add_to_history(crate::history_cell::new_info_event( "old session line".to_string(), @@ -7498,6 +8038,8 @@ fn new_session_prepared_appends_header_after_existing_history_and_resets_status( last_query_total_tokens: 25, last_query_input_tokens: 20, total_cache_read_tokens: 12, + permission_preset: devo_protocol::PermissionPreset::Default, + collaboration_mode: devo_protocol::CollaborationMode::Build, }); assert_eq!(widget.current_cwd(), initial_cwd.as_path()); @@ -7507,10 +8049,10 @@ fn new_session_prepared_appends_header_after_existing_history_and_resets_status( ); let summary = widget.status_summary_text(); - assert!(summary.contains("↑0")); - assert!(summary.contains("cached 0 0%")); - assert!(summary.contains("↓0")); - assert!(summary.contains("0/200k")); + assert!(!summary.contains("↑")); + assert!(!summary.contains("cached")); + assert!(!summary.contains("↓")); + assert!(summary.contains("0/190.0k")); let transcript_lines = scrollback_plain_lines( &widget @@ -7528,6 +8070,215 @@ fn new_session_prepared_appends_header_after_existing_history_and_resets_status( assert!(header_index > old_line_index); } +#[test] +fn new_session_prepared_clears_pending_queue() { + let cwd = std::env::current_dir().expect("current directory is available"); + let model = Model { + slug: "test-model".to_string(), + display_name: "Test Model".to_string(), + ..Model::default() + }; + let (mut widget, _app_event_rx) = widget_with_model(model, cwd.clone()); + + let queue_item_id = devo_protocol::canonical::ids::QueueItemId::from_string("qit_stale".into()); + widget.handle_worker_event(crate::events::WorkerEvent::QueueUpdated { + change: devo_protocol::canonical::queue::QueueChange::Added, + queue_item_id: queue_item_id.clone(), + started_turn_id: None, + entries: vec![devo_protocol::canonical::queue::QueueEntry { + queue_item_id, + position: 1, + input: vec![devo_protocol::canonical::item::UserInput::Text { + text: "stale queued".to_string(), + }], + preview: "stale queued".to_string(), + enqueued_at: chrono::Utc::now(), + }], + }); + assert!(widget.bottom_pane_has_pending_for_test()); + + widget.handle_worker_event(crate::events::WorkerEvent::NewSessionPrepared { + cwd, + model: "new-session-model".to_string(), + model_binding_id: None, + reasoning_effort_selection: None, + reasoning_effort: None, + active_agent_label: None, + last_query_total_tokens: 0, + last_query_input_tokens: 0, + total_cache_read_tokens: 0, + permission_preset: devo_protocol::PermissionPreset::Default, + collaboration_mode: devo_protocol::CollaborationMode::Build, + }); + + assert!( + !widget.bottom_pane_has_pending_for_test(), + "new session should clear the previous session queue UI" + ); +} + +#[test] +fn new_session_prepared_restores_default_compaction_limit() { + let cwd = std::env::current_dir().expect("current directory is available"); + let model = Model { + slug: "test-model".to_string(), + display_name: "Test Model".to_string(), + context_window: 200_000, + effective_context_window_percent: Some(95), + ..Model::default() + }; + let (app_event_tx, _app_event_rx) = mpsc::unbounded_channel(); + let mut widget = ChatWidget::new_with_app_event(ChatWidgetInit { + frame_requester: FrameRequester::test_dummy(), + app_event_tx: AppEventSender::new(app_event_tx), + initial_session: TuiSessionState::new(cwd.clone(), Some(model)), + initial_reasoning_effort_selection: None, + initial_permission_preset: devo_protocol::PermissionPreset::Default, + initial_sandbox_profile: Some("workspace".to_string()), + initial_compaction_token_limit: Some(100_000), + initial_default_collaboration_mode: devo_protocol::CollaborationMode::Build, + initial_user_message: None, + enhanced_keys_supported: true, + is_first_run: false, + available_models: Vec::new(), + saved_models: Vec::new(), + show_model_onboarding: false, + exit_after_onboarding: false, + startup_tooltip_override: None, + initial_theme_name: None, + initial_collapse_reasoning: false, + }); + + widget.handle_worker_event(crate::events::WorkerEvent::SessionSwitched { + session_id: "session-1".to_string(), + cwd: cwd.clone(), + title: Some("Resumed".to_string()), + model: Some("test-model".to_string()), + model_binding_id: None, + reasoning_effort_selection: None, + reasoning_effort: None, + active_agent_label: None, + total_input_tokens: 0, + total_output_tokens: 0, + total_tokens: 0, + total_cache_read_tokens: 0, + last_query_total_tokens: 0, + last_query_input_tokens: 0, + prompt_token_estimate: 0, + history_items: Vec::new(), + rich_history_items: Vec::new(), + loaded_item_count: 0, + pending_texts: Vec::new(), + collaboration_mode: CollaborationMode::Build, + permission_preset: None, + effective_context_window: Some(50_000), + }); + assert!( + widget.status_summary_text().contains("50.0k"), + "session override should use 50K compaction threshold: {}", + widget.status_summary_text() + ); + + widget.handle_worker_event(crate::events::WorkerEvent::NewSessionPrepared { + cwd, + model: "test-model".to_string(), + model_binding_id: None, + reasoning_effort_selection: None, + reasoning_effort: None, + permission_preset: devo_protocol::PermissionPreset::Default, + collaboration_mode: devo_protocol::CollaborationMode::Build, + active_agent_label: None, + last_query_total_tokens: 0, + last_query_input_tokens: 0, + total_cache_read_tokens: 0, + }); + assert!( + widget.status_summary_text().contains("100.0k"), + "new session should restore the default 100K compaction threshold: {}", + widget.status_summary_text() + ); +} + +#[test] +fn new_session_prepared_restores_default_permissions_and_mode() { + let cwd = std::env::current_dir().expect("current directory is available"); + let model = Model { + slug: "test-model".to_string(), + display_name: "Test Model".to_string(), + ..Model::default() + }; + let (app_event_tx, _app_event_rx) = mpsc::unbounded_channel(); + let mut widget = ChatWidget::new_with_app_event(ChatWidgetInit { + frame_requester: FrameRequester::test_dummy(), + app_event_tx: AppEventSender::new(app_event_tx), + initial_session: TuiSessionState::new(cwd.clone(), Some(model)), + initial_reasoning_effort_selection: None, + initial_permission_preset: PermissionPreset::Default, + initial_sandbox_profile: Some("workspace".to_string()), + initial_compaction_token_limit: None, + initial_default_collaboration_mode: CollaborationMode::Plan, + initial_user_message: None, + enhanced_keys_supported: true, + is_first_run: false, + available_models: Vec::new(), + saved_models: Vec::new(), + show_model_onboarding: false, + exit_after_onboarding: false, + startup_tooltip_override: None, + initial_theme_name: None, + initial_collapse_reasoning: false, + }); + + widget.handle_worker_event(crate::events::WorkerEvent::SessionSwitched { + session_id: "session-1".to_string(), + cwd: cwd.clone(), + title: Some("Resumed".to_string()), + model: Some("test-model".to_string()), + model_binding_id: None, + reasoning_effort_selection: None, + reasoning_effort: None, + active_agent_label: None, + total_input_tokens: 0, + total_output_tokens: 0, + total_tokens: 0, + total_cache_read_tokens: 0, + last_query_total_tokens: 0, + last_query_input_tokens: 0, + prompt_token_estimate: 0, + history_items: Vec::new(), + rich_history_items: Vec::new(), + loaded_item_count: 0, + pending_texts: Vec::new(), + collaboration_mode: CollaborationMode::Build, + permission_preset: Some(PermissionPreset::FullAccess), + effective_context_window: None, + }); + assert_eq!(widget.input_mode_for_test(), InputMode::Build); + assert_eq!( + widget.permission_preset_for_test(), + PermissionPreset::FullAccess + ); + + widget.handle_worker_event(crate::events::WorkerEvent::NewSessionPrepared { + cwd, + model: "test-model".to_string(), + model_binding_id: None, + reasoning_effort_selection: None, + reasoning_effort: None, + permission_preset: PermissionPreset::Default, + collaboration_mode: CollaborationMode::Plan, + active_agent_label: None, + last_query_total_tokens: 0, + last_query_input_tokens: 0, + total_cache_read_tokens: 0, + }); + assert_eq!(widget.input_mode_for_test(), InputMode::Plan); + assert_eq!( + widget.permission_preset_for_test(), + PermissionPreset::Default + ); +} + #[test] fn new_session_prepared_does_not_duplicate_startup_header_without_history() { let cwd = std::env::current_dir().expect("current directory is available"); @@ -7549,11 +8300,13 @@ fn new_session_prepared_does_not_duplicate_startup_header_without_history() { last_query_total_tokens: 10, last_query_input_tokens: 10, total_cache_read_tokens: 4, + permission_preset: devo_protocol::PermissionPreset::Default, + collaboration_mode: devo_protocol::CollaborationMode::Build, }); let rows = rendered_rows(&widget, 80, 16); assert_eq!(rows.iter().filter(|row| row.contains("Devo")).count(), 1); - assert!(widget.status_summary_text().contains("cached 0 0%")); + assert!(!widget.status_summary_text().contains("cached")); } #[test] @@ -7582,6 +8335,8 @@ fn model_selection_updates_session_projection_and_emits_context_override() { initial_reasoning_effort_selection: None, initial_permission_preset: devo_protocol::PermissionPreset::Default, initial_sandbox_profile: Some("workspace".to_string()), + initial_compaction_token_limit: None, + initial_default_collaboration_mode: devo_protocol::CollaborationMode::Build, initial_user_message: None, enhanced_keys_supported: true, is_first_run: false, @@ -7600,7 +8355,6 @@ fn model_selection_updates_session_projection_and_emits_context_override() { widget.handle_app_event(AppEvent::ModelSelected { model: "second-model".to_string(), }); - widget.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); widget.submit_text("hello".to_string()); assert_eq!(widget.current_model(), Some(&alt_model)); @@ -7614,6 +8368,7 @@ fn model_selection_updates_session_projection_and_emits_context_override() { reasoning_effort_selection: Some(Some("high".to_string())), sandbox: None, approval_policy: None, + persist_scope: crate::app_command::PersistScope::Session, }) ); assert_eq!( @@ -7628,14 +8383,17 @@ fn model_selection_updates_session_projection_and_emits_context_override() { model_binding_id: None, reasoning_effort_selection: Some("high".to_string()), sandbox: None, - approval_policy: Some("on-request".to_string()), + approval_policy: None, collaboration_mode: devo_protocol::CollaborationMode::Build, }) ); } #[test] -fn model_selection_with_reasoning_effort_support_waits_for_second_step() { +/// Trace: L2-DES-TUI-CMD-002 +/// Verifies: selecting a reasoning-capable model via AppEvent applies model and +/// default effort in one step (no second picker). +fn model_selection_with_reasoning_effort_support_applies_default_immediately() { let cwd = std::env::current_dir().expect("current directory is available"); let model = Model { slug: "test-model".to_string(), @@ -7660,6 +8418,8 @@ fn model_selection_with_reasoning_effort_support_waits_for_second_step() { initial_reasoning_effort_selection: None, initial_permission_preset: devo_protocol::PermissionPreset::Default, initial_sandbox_profile: Some("workspace".to_string()), + initial_compaction_token_limit: None, + initial_default_collaboration_mode: devo_protocol::CollaborationMode::Build, initial_user_message: None, enhanced_keys_supported: true, is_first_run: false, @@ -7677,10 +8437,6 @@ fn model_selection_with_reasoning_effort_support_waits_for_second_step() { }); assert_eq!(widget.current_model(), Some(&alt_model)); - assert!(app_event_rx.try_recv().is_err()); - - widget.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - assert_eq!( app_event_rx .try_recv() @@ -7691,6 +8447,7 @@ fn model_selection_with_reasoning_effort_support_waits_for_second_step() { reasoning_effort_selection: Some(Some("high".to_string())), sandbox: None, approval_policy: None, + persist_scope: crate::app_command::PersistScope::Session, }) ); } @@ -7717,6 +8474,8 @@ fn model_selection_without_reasoning_effort_support_finishes_immediately() { initial_reasoning_effort_selection: None, initial_permission_preset: devo_protocol::PermissionPreset::Default, initial_sandbox_profile: Some("workspace".to_string()), + initial_compaction_token_limit: None, + initial_default_collaboration_mode: devo_protocol::CollaborationMode::Build, initial_user_message: None, enhanced_keys_supported: true, is_first_run: false, @@ -7744,6 +8503,7 @@ fn model_selection_without_reasoning_effort_support_finishes_immediately() { reasoning_effort_selection: Some(None), sandbox: None, approval_policy: None, + persist_scope: crate::app_command::PersistScope::Session, }) ); } @@ -7976,6 +8736,8 @@ fn collapsed_reasoning_live_view_keeps_only_latest_lines() { initial_reasoning_effort_selection: None, initial_permission_preset: PermissionPreset::Default, initial_sandbox_profile: Some("workspace".to_string()), + initial_compaction_token_limit: None, + initial_default_collaboration_mode: devo_protocol::CollaborationMode::Build, initial_user_message: None, enhanced_keys_supported: true, is_first_run: false, @@ -8018,12 +8780,102 @@ fn collapsed_reasoning_live_view_keeps_only_latest_lines() { !live.contains("line one"), "collapsed live view should drop older reasoning lines:\n{live}" ); + assert!( + live.contains("Thinking:"), + "collapsed live view should keep sticky Thinking heading while body tails:\n{live}" + ); assert!( live.contains("ctrl + t to view transcript"), "collapsed live reasoning should hint Ctrl+T:\n{live}" ); } +#[test] +fn collapsed_reasoning_live_view_caps_wrapped_visual_rows() { + let model = Model { + slug: "test-model".to_string(), + display_name: "Test Model".to_string(), + ..Model::default() + }; + let (app_event_tx, _app_event_rx) = mpsc::unbounded_channel(); + let mut widget = ChatWidget::new_with_app_event(ChatWidgetInit { + frame_requester: FrameRequester::test_dummy(), + app_event_tx: AppEventSender::new(app_event_tx), + initial_session: TuiSessionState::new(PathBuf::from("."), Some(model)), + initial_reasoning_effort_selection: None, + initial_permission_preset: PermissionPreset::Default, + initial_sandbox_profile: Some("workspace".to_string()), + initial_compaction_token_limit: None, + initial_default_collaboration_mode: devo_protocol::CollaborationMode::Build, + initial_user_message: None, + enhanced_keys_supported: true, + is_first_run: false, + available_models: Vec::new(), + saved_models: Vec::new(), + show_model_onboarding: false, + exit_after_onboarding: false, + startup_tooltip_override: None, + initial_theme_name: None, + initial_collapse_reasoning: true, + }); + + widget.handle_worker_event(crate::events::WorkerEvent::TurnStarted { + model: "test-model".to_string(), + model_binding_id: None, + reasoning_effort_selection: None, + reasoning_effort: None, + turn_id: Default::default(), + }); + // One logical line that wraps to many visual rows at a narrow width. + let long_line = "word ".repeat(80); + widget.handle_worker_event(crate::events::WorkerEvent::ReasoningDelta(long_line)); + + let width = 40u16; + let live_lines = widget.active_viewport_lines_for_test(width); + let content_rows: Vec = live_lines + .iter() + .map(|line| { + line.spans + .iter() + .map(|span| span.content.as_ref()) + .collect::() + }) + .filter(|text| !text.contains("ctrl + t to view transcript")) + .collect(); + let body_rows = content_rows + .iter() + .filter(|text| !text.contains("Thinking:")) + .count(); + assert!( + content_rows.iter().any(|text| text.contains("Thinking:")), + "collapsed live view should keep sticky Thinking heading:\n{}", + content_rows.join("\n") + ); + assert!( + body_rows <= 3, + "collapsed live view should cap wrapped body rows to 3, got {body_rows}:\n{}", + content_rows.join("\n") + ); + let live = live_lines + .iter() + .map(|line| { + line.spans + .iter() + .map(|span| span.content.as_ref()) + .collect::() + }) + .collect::>() + .join("\n"); + assert!( + live.contains("Thinking:"), + "sticky Thinking heading must remain while body wraps:\n{live}" + ); + assert!( + live.contains("ctrl + t to view transcript"), + "collapsed live reasoning should still hint Ctrl+T:\n{live}" + ); +} + #[test] fn collapsed_short_reasoning_stays_full_after_completion() { let model = Model { @@ -8039,6 +8891,8 @@ fn collapsed_short_reasoning_stays_full_after_completion() { initial_reasoning_effort_selection: None, initial_permission_preset: PermissionPreset::Default, initial_sandbox_profile: Some("workspace".to_string()), + initial_compaction_token_limit: None, + initial_default_collaboration_mode: devo_protocol::CollaborationMode::Build, initial_user_message: None, enhanced_keys_supported: true, is_first_run: false, @@ -8076,6 +8930,68 @@ fn collapsed_short_reasoning_stays_full_after_completion() { ); } +#[test] +fn collapsed_wrapping_reasoning_compacts_after_completion() { + let model = Model { + slug: "test-model".to_string(), + display_name: "Test Model".to_string(), + ..Model::default() + }; + let (app_event_tx, _app_event_rx) = mpsc::unbounded_channel(); + let mut widget = ChatWidget::new_with_app_event(ChatWidgetInit { + frame_requester: FrameRequester::test_dummy(), + app_event_tx: AppEventSender::new(app_event_tx), + initial_session: TuiSessionState::new(PathBuf::from("."), Some(model)), + initial_reasoning_effort_selection: None, + initial_permission_preset: PermissionPreset::Default, + initial_sandbox_profile: Some("workspace".to_string()), + initial_compaction_token_limit: None, + initial_default_collaboration_mode: devo_protocol::CollaborationMode::Build, + initial_user_message: None, + enhanced_keys_supported: true, + is_first_run: false, + available_models: Vec::new(), + saved_models: Vec::new(), + show_model_onboarding: false, + exit_after_onboarding: false, + startup_tooltip_override: None, + initial_theme_name: None, + initial_collapse_reasoning: true, + }); + + widget.handle_worker_event(crate::events::WorkerEvent::TurnStarted { + model: "test-model".to_string(), + model_binding_id: None, + reasoning_effort_selection: None, + reasoning_effort: None, + turn_id: Default::default(), + }); + // Single logical paragraph that wraps past the collapsed visual budget. + let long_thought = format!("alpha {}", "word ".repeat(80).trim()); + widget.handle_worker_event(crate::events::WorkerEvent::ReasoningDelta( + long_thought.clone(), + )); + widget.handle_worker_event(crate::events::WorkerEvent::ReasoningCompleted( + long_thought.clone(), + )); + + let width = 40u16; + let scrollback = scrollback_plain_lines(&widget.drain_scrollback_lines(width)).join("\n"); + assert!( + scrollback.contains("Thought ·"), + "wrapping collapsed reasoning should compact to Thought summary:\n{scrollback}" + ); + assert!( + scrollback.contains('…') || scrollback.contains("ctrl + t to view transcript"), + "wrapping collapsed reasoning should truncate or hint transcript:\n{scrollback}" + ); + // Full body must not appear as multi-row Thought: content. + assert!( + !scrollback.contains("Thought:"), + "wrapping collapsed reasoning should not keep the full Thought: body:\n{scrollback}" + ); +} + #[test] fn collapsed_long_reasoning_compacts_to_one_line_after_completion() { let model = Model { @@ -8091,6 +9007,8 @@ fn collapsed_long_reasoning_compacts_to_one_line_after_completion() { initial_reasoning_effort_selection: None, initial_permission_preset: PermissionPreset::Default, initial_sandbox_profile: Some("workspace".to_string()), + initial_compaction_token_limit: None, + initial_default_collaboration_mode: devo_protocol::CollaborationMode::Build, initial_user_message: None, enhanced_keys_supported: true, is_first_run: false, @@ -8199,12 +9117,8 @@ fn transcript_overlay_lines_include_full_completed_tool_output() { "inline output should include the transcript hint when truncated: {inline}" ); assert!( - inline.contains("line 7") && inline.contains("line 8"), - "inline output should include the tail of the preview: {inline}" - ); - assert!( - !inline.contains("line 3") && !inline.contains("line 6"), - "inline output should stay compact: {inline}" + !inline.contains("line 3") && !inline.contains("line 7") && !inline.contains("line 8"), + "inline output should keep only the head plus fold hint: {inline}" ); assert!( transcript.contains("line 5") && transcript.contains("line 8"), @@ -8567,6 +9481,8 @@ fn restored_session_transcript_overlay_preserves_paired_tool_io() { loaded_item_count: 2, pending_texts: vec![], collaboration_mode: CollaborationMode::Build, + permission_preset: None, + effective_context_window: None, }); let transcript = line_texts(widget.transcript_overlay_lines(100)).join("\n"); @@ -8634,6 +9550,8 @@ fn legacy_restored_session_without_tool_io_keeps_existing_tool_result_rendering( loaded_item_count: 2, pending_texts: vec![], collaboration_mode: CollaborationMode::Build, + permission_preset: None, + effective_context_window: None, }); let transcript = line_texts(widget.transcript_overlay_lines(100)).join("\n"); @@ -9901,6 +10819,8 @@ fn session_switch_without_rich_edited_metadata_degrades_to_tool_result_path() { loaded_item_count: 1, pending_texts: vec![], collaboration_mode: CollaborationMode::Build, + permission_preset: None, + effective_context_window: None, }); let blob = scrollback_plain_lines(&widget.drain_scrollback_lines(80)).join("\n"); @@ -9957,6 +10877,8 @@ fn session_switch_restores_added_file_content_in_edited_block() { loaded_item_count: 1, pending_texts: Vec::new(), collaboration_mode: CollaborationMode::Build, + permission_preset: None, + effective_context_window: None, }); let blob = scrollback_plain_lines(&widget.drain_scrollback_lines(100)).join("\n"); @@ -10013,6 +10935,8 @@ fn session_switch_without_rich_edited_metadata_still_restores_edited_block() { loaded_item_count: 1, pending_texts: vec![], collaboration_mode: CollaborationMode::Build, + permission_preset: None, + effective_context_window: None, }); let blob = scrollback_plain_lines(&widget.drain_scrollback_lines(80)).join("\n"); diff --git a/crates/tui/src/events.rs b/crates/tui/src/events.rs index f783a5be..6410998c 100644 --- a/crates/tui/src/events.rs +++ b/crates/tui/src/events.rs @@ -20,6 +20,7 @@ use devo_protocol::RequestUserInputQuestion; use devo_protocol::SessionHistoryItem; use devo_protocol::SessionRuntimeStatus; use devo_protocol::ThreadGoal; +use devo_protocol::canonical::item::ContextOccupancy; use devo_protocol::parse_command::ParsedCommand; use devo_protocol::protocol::ExecCommandSource; use devo_protocol::protocol::FileChange; @@ -176,10 +177,12 @@ pub(crate) enum WorkerEvent { }, /// The active session identifier is now known. SessionActivated { session_id: SessionId }, - /// Input queue state updated by the server. - InputQueueUpdated { - pending_count: usize, - pending_texts: Vec, + /// Canonical session queue snapshot (`queue/updated` / list / push). + QueueUpdated { + change: devo_protocol::canonical::queue::QueueChange, + queue_item_id: devo_protocol::canonical::ids::QueueItemId, + started_turn_id: Option, + entries: Vec, }, /// A steer (/btw) was accepted by the server. SteerAccepted { turn_id: TurnId }, @@ -376,6 +379,11 @@ pub(crate) enum WorkerEvent { /// Estimated prompt tokens for the just-completed request. prompt_token_estimate: usize, }, + /// Live context-window occupancy breakdown for the active session. + ContextUsageUpdated { + /// Category occupancy anchored to the latest query display total. + occupancy: ContextOccupancy, + }, /// The interrupt request could not be delivered or accepted. InterruptFailed { /// Human-readable failure reason to restore into the working status. @@ -556,6 +564,8 @@ pub(crate) enum WorkerEvent { reasoning_effort_selection: Option, /// Effective reasoning effort currently configured for the next session. reasoning_effort: Option, + permission_preset: devo_protocol::PermissionPreset, + collaboration_mode: CollaborationMode, /// Contextual footer label for the active child agent, when viewing one. active_agent_label: Option, /// Latest completed query display total for the fresh session. @@ -607,6 +617,10 @@ pub(crate) enum WorkerEvent { pending_texts: Vec, /// Collaboration mode restored from the resumed session metadata. collaboration_mode: CollaborationMode, + /// Permission preset restored from the resumed session metadata. + permission_preset: Option, + /// Session auto-compaction token limit override, when one is set. + effective_context_window: Option, }, /// The current session title changed. SessionRenamed { @@ -620,6 +634,11 @@ pub(crate) enum WorkerEvent { /// The deleted session identifier. session_id: String, }, + /// Server confirmed a compaction-threshold hot update. + EffectiveContextWindowUpdated { + /// Absolute token threshold applied for the active session (model-clamped). + effective_context_window: u64, + }, /// The active session or its context-compaction transcript item started compaction. SessionCompactionStarted, /// The active session completed a proactive compaction request. diff --git a/crates/tui/src/exec_cell/render.rs b/crates/tui/src/exec_cell/render.rs index 1d0ea641..1d4b39b5 100644 --- a/crates/tui/src/exec_cell/render.rs +++ b/crates/tui/src/exec_cell/render.rs @@ -172,33 +172,14 @@ pub(crate) fn output_lines( out.push(line); } - let show_ellipsis = total > 2 * line_limit; + let show_ellipsis = total > line_limit; let omitted = if show_ellipsis { - Some(total - 2 * line_limit) + Some(total - line_limit) } else { None }; if show_ellipsis { - let omitted = total - 2 * line_limit; - out.push(ExecCell::output_ellipsis_line(omitted)); - } - - let tail_start = if show_ellipsis { - total - line_limit - } else { - head_end - }; - for raw in lines[tail_start..].iter() { - let mut line = ansi_escape_line(raw); - if include_prefix { - line.spans.insert(0, " ".into()); - } - if dim { - line.spans.iter_mut().for_each(|span| { - span.style = span.style.add_modifier(Modifier::DIM); - }); - } - out.push(line); + out.push(ExecCell::output_ellipsis_line(total - line_limit)); } OutputLines { @@ -644,8 +625,7 @@ impl ExecCell { } /// Truncates a list of lines to fit within `max_rows` viewport rows, - /// keeping a head portion and a tail portion with an ellipsis line - /// in between. + /// keeping a head portion followed by an ellipsis line. /// /// `max_rows` is measured in viewport rows (the actual space a line /// occupies after `Paragraph::wrap`), not logical lines. Each line's @@ -707,14 +687,12 @@ impl ExecCell { } let available_rows = max_rows - ellipsis_rows; - let head_budget = available_rows / 2; - let tail_budget = available_rows - head_budget; let mut head_lines: Vec> = Vec::new(); let mut head_rows = 0usize; let mut head_end = 0usize; while head_end < lines.len() { let line_row_count = line_rows[head_end]; - if head_rows + line_row_count > head_budget { + if head_rows + line_row_count > available_rows { break; } head_rows += line_row_count; @@ -722,33 +700,17 @@ impl ExecCell { head_end += 1; } - let mut tail_lines_reversed: Vec> = Vec::new(); - let mut tail_rows = 0usize; - let mut tail_start = lines.len(); - while tail_start > head_end { - let idx = tail_start - 1; - let line_row_count = line_rows[idx]; - if tail_rows + line_row_count > tail_budget { - break; - } - tail_rows += line_row_count; - tail_lines_reversed.push(lines[idx].clone()); - tail_start -= 1; - } - let mut out = head_lines; let base = omitted_hint.unwrap_or(0); let additional = lines .len() - .saturating_sub(out.len() + tail_lines_reversed.len()) + .saturating_sub(out.len()) .saturating_sub(usize::from(omitted_hint.is_some())); out.push(Self::output_ellipsis_line_with_prefix( base + additional, ellipsis_prefix.as_ref(), )); - out.extend(tail_lines_reversed.into_iter().rev()); - out } @@ -826,7 +788,7 @@ impl ExecDisplayLayout { const EXEC_DISPLAY_LAYOUT: ExecDisplayLayout = ExecDisplayLayout::new( PrefixedBlock::new(" │ ", " │ "), PrefixedBlock::new(" └ ", " "), - /*output_max_lines*/ 5, + /*output_max_lines*/ 3, ); #[cfg(test)] diff --git a/crates/tui/src/history_cell.rs b/crates/tui/src/history_cell.rs index 5fd6ab88..bfa281b6 100644 --- a/crates/tui/src/history_cell.rs +++ b/crates/tui/src/history_cell.rs @@ -27,9 +27,10 @@ use crate::render::renderable::Renderable; use crate::slash_command::SlashCommand; use crate::startup_header::StartupHeaderData; use crate::startup_header::build_startup_header; -use crate::style::user_message_style; +use crate::style::user_message_rule_line; use crate::text_formatting::truncate_text; use crate::theme::ThemeSet; +use crate::ui_consts::ALERT_COLOR; use crate::ui_consts::LIVE_PREFIX_COLS; use crate::version::CLI_VERSION; use crate::wrapping::RtOptions; @@ -338,14 +339,13 @@ impl HistoryCell for UserHistoryCell { let accent = self.accent_color; let mode_color = self.input_mode.color(); let style = if self.selected { - user_message_style().fg(mode_color) + Style::default().fg(mode_color) } else { - user_message_style() + Style::default() }; let element_style = style.fg(accent); let slash_command_style = style.fg(accent); let prefix_style = Style::default().fg(mode_color); - let blank_prefixed_line = || Line::from(Span::styled(" ", style)).style(style); let wrapped_message = if self.message.is_empty() && self.text_elements.is_empty() { None @@ -371,15 +371,15 @@ impl HistoryCell for UserHistoryCell { (!wrapped.is_empty()).then_some(wrapped) }; - let mut lines = vec![blank_prefixed_line()]; + let mut lines = vec![user_message_rule_line(width)]; if let Some(wrapped_message) = wrapped_message { lines.extend(prefix_lines( wrapped_message, - Span::styled("▌ ", prefix_style), + Span::styled("❯ ", prefix_style), Span::styled(" ", style), )); } - lines.push(blank_prefixed_line()); + lines.push(user_message_rule_line(width)); lines } } @@ -1433,7 +1433,7 @@ pub(crate) fn new_live_aligned_info_event( message: String, hint: Option, ) -> PlainHistoryCell { - let mut line = vec![" ".repeat(LIVE_PREFIX_COLS as usize).into(), message.into()]; + let mut line = vec!["▌ ".dim(), message.into()]; if let Some(hint) = hint { line.push(" ".into()); line.push(hint.dark_gray()); @@ -1447,31 +1447,26 @@ pub(crate) fn new_error_event(message: String) -> PlainHistoryCell { } pub(crate) fn new_error_event_with_hint(message: String, hint: Option) -> PlainHistoryCell { - // Use a hair space (U+200A) to create a subtle, near-invisible separation - // before the text. VS16 is intentionally omitted to keep spacing tighter - // in terminals like Ghostty. - let mut lines: Vec> = vec![vec![format!("■ {message}").red()].into()]; - if let Some(hint) = hint { - lines.push(vec![" ".into(), hint.dark_gray()].into()); - } - PlainHistoryCell { lines } + new_live_aligned_error_event_with_hint(message, hint) } pub(crate) fn new_live_aligned_error_event_with_hint( message: String, hint: Option, ) -> PlainHistoryCell { + // Use a hair space (U+200A) to create a subtle, near-invisible separation + // before the text. VS16 is intentionally omitted to keep spacing tighter + // in terminals like Ghostty. let prefix = " ".repeat(LIVE_PREFIX_COLS as usize); - let mut lines: Vec> = - vec![vec![prefix.into(), format!("■ {message}").red()].into()]; + let mut lines: Vec> = vec![ + vec![ + prefix.clone().into(), + format!("■ {message}").fg(ALERT_COLOR), + ] + .into(), + ]; if let Some(hint) = hint { - lines.push( - vec![ - " ".repeat(LIVE_PREFIX_COLS as usize).into(), - hint.dark_gray(), - ] - .into(), - ); + lines.push(vec![prefix.into(), hint.dark_gray()].into()); } PlainHistoryCell { lines } } @@ -1855,12 +1850,59 @@ mod tests { assert_eq!(format_duration_hms(3_723), "1h2m3s"); } + #[test] + fn user_prompt_uses_full_width_dim_rule_lines() { + use ratatui::style::Modifier; + + let cell = new_user_prompt( + "hello".to_string(), + Vec::new(), + Vec::new(), + Vec::new(), + Color::Yellow, + InputMode::Build, + ); + let width = 12u16; + let lines = cell.display_lines(width); + assert_eq!(lines.len(), 3, "expected rule + body + rule: {lines:?}"); + + let expected_rule = "─".repeat(width as usize); + let first = lines[0] + .spans + .iter() + .map(|span| span.content.as_ref()) + .collect::(); + let last = lines[lines.len() - 1] + .spans + .iter() + .map(|span| span.content.as_ref()) + .collect::(); + assert_eq!(first, expected_rule); + assert_eq!(last, expected_rule); + + for (index, line) in [0usize, lines.len() - 1] + .into_iter() + .map(|i| (i, &lines[i])) + { + assert_eq!( + line.style.bg, None, + "rule line {index} should have no background" + ); + assert!( + line.spans + .iter() + .any(|span| span.style.add_modifier.contains(Modifier::DIM)), + "rule line {index} should be dim: {line:?}" + ); + } + } + #[test] fn user_prompt_highlights_leading_slash_command_without_text_element() { assert_eq!( content_spans_for_message("/btw check this"), vec![ - ("▌ ".to_string(), Some(Color::Cyan)), + ("❯ ".to_string(), Some(Color::Cyan)), ("/btw".to_string(), Some(Color::Yellow)), (" check this".to_string(), None), ] @@ -1872,7 +1914,7 @@ mod tests { assert_eq!( content_spans_for_message("/unknown check this"), vec![ - ("▌ ".to_string(), Some(Color::Cyan)), + ("❯ ".to_string(), Some(Color::Cyan)), ("/unknown check this".to_string(), None), ] ); @@ -1915,4 +1957,35 @@ mod tests { ); assert_eq!(rendered[1].trim(), REASONING_TRANSCRIPT_HINT); } + + #[test] + fn error_event_uses_orange_and_left_padding() { + use crate::ui_consts::ALERT_COLOR; + use crate::ui_consts::LIVE_PREFIX_COLS; + + use super::new_error_event_with_hint; + + let cell = new_error_event_with_hint( + "no active session exists yet; send a prompt or switch to a saved session first" + .to_string(), + /*hint*/ None, + ); + let line = cell.display_lines(120).into_iter().next().expect("line"); + let text: String = line + .spans + .iter() + .map(|span| span.content.as_ref()) + .collect(); + assert!( + text.starts_with(&" ".repeat(LIVE_PREFIX_COLS as usize)), + "expected left padding: {text:?}" + ); + assert!(text.contains("■ no active session exists yet")); + assert!( + line.spans + .iter() + .any(|span| { span.content.contains('■') && span.style.fg == Some(ALERT_COLOR) }), + "expected orange alert color: {line:?}" + ); + } } diff --git a/crates/tui/src/inline_onboarding_tests.rs b/crates/tui/src/inline_onboarding_tests.rs index 14522479..15d62106 100644 --- a/crates/tui/src/inline_onboarding_tests.rs +++ b/crates/tui/src/inline_onboarding_tests.rs @@ -43,6 +43,8 @@ fn onboarding_widget_with_models( initial_reasoning_effort_selection: None, initial_permission_preset: devo_protocol::PermissionPreset::Default, initial_sandbox_profile: Some("workspace".to_string()), + initial_compaction_token_limit: None, + initial_default_collaboration_mode: devo_protocol::CollaborationMode::Build, initial_user_message: None, enhanced_keys_supported: true, is_first_run: false, @@ -149,16 +151,36 @@ fn model_selection_footer_stays_visible_in_short_viewport() { widget.handle_key_event(press_key(KeyCode::Down)); } - let rows = rendered_rows(&widget, 80, 8).join("\n"); + let height = widget.desired_height(80); + let rows = rendered_rows(&widget, 80, height).join("\n"); assert!( rows.contains("model-10"), "expected selected model in:\n{rows}" ); + assert!( + rows.contains("↓ more") || rows.contains("↑ more"), + "expected scroll overflow marker in:\n{rows}" + ); + assert!( + !rows.contains("Model 10 Display Name"), + "display name subtitle should not render:\n{rows}" + ); assert!( rows.contains("Enter select · Esc cancel"), "expected fixed onboarding footer in:\n{rows}" ); assert!(!rows.contains("Complete onboarding to start chatting")); + + // Short viewports still keep selection + footer visible (overflow markers may clip). + let short = rendered_rows(&widget, 80, 8).join("\n"); + assert!( + short.contains("model-10"), + "expected selected model in short viewport:\n{short}" + ); + assert!( + short.contains("Enter select · Esc cancel"), + "expected fixed onboarding footer in short viewport:\n{short}" + ); } #[test] @@ -197,7 +219,10 @@ fn onboarding_completion_appends_header_after_success_record() { }); assert_eq!(widget.is_onboarding_active(), false); - assert_eq!(widget.placeholder_text(), "Ask Devo"); + assert_eq!( + widget.placeholder_text(), + format!("Tip: {}", crate::status_indicator_widget::WORKING_TIPS[0]) + ); assert_eq!( widget.current_model().map(|model| model.slug.as_str()), Some("deepseek-v4-flash") diff --git a/crates/tui/src/insert_history.rs b/crates/tui/src/insert_history.rs index 33c24922..61060dd7 100644 --- a/crates/tui/src/insert_history.rs +++ b/crates/tui/src/insert_history.rs @@ -443,7 +443,7 @@ mod tests { let lines = vec![ ScrollbackLine::new(Line::from(" ")), - ScrollbackLine::new(Line::from("▌ hello")), + ScrollbackLine::new(Line::from("❯ hello")), ScrollbackLine::new(Line::from(" ")), ]; insert_history_lines(&mut term, lines).expect("insert history"); @@ -451,7 +451,7 @@ mod tests { let rows: Vec = term.backend().vt100().screen().rows(0, width).collect(); let user_row = rows .iter() - .position(|row| row.contains("▌ hello")) + .position(|row| row.contains("❯ hello")) .unwrap_or_else(|| panic!("expected user row, rows: {rows:?}")); assert!( diff --git a/crates/tui/src/interactive.rs b/crates/tui/src/interactive.rs index 4db98ec1..3af3597d 100644 --- a/crates/tui/src/interactive.rs +++ b/crates/tui/src/interactive.rs @@ -22,6 +22,7 @@ use crate::app::AppExit; use crate::app::InitialTuiSession; use crate::app::InteractiveTuiConfig; use crate::app_command::AppCommand; +use crate::app_command::PersistScope; use crate::app_event::AppEvent; use crate::app_event_sender::AppEventSender; use crate::chatwidget::ChatWidget; @@ -34,6 +35,7 @@ use crate::host_overlay::OverlayState; use crate::onboarding::OnboardingModelBinding; use crate::onboarding::onboarding_provider_model_binding; use crate::onboarding::onboarding_provider_vendor; +use crate::onboarding::save_default_collaboration_mode; use crate::onboarding::save_last_used_model; use crate::onboarding::save_project_permission_preset; use crate::onboarding::save_project_sandbox_profile; @@ -244,6 +246,7 @@ pub async fn run_interactive_tui(config: InteractiveTuiConfig) -> Result Result {} + | WorkerEvent::BtwFailed { .. } + | WorkerEvent::EffectiveContextWindowUpdated { .. } => {} } if matches!(&worker_event, WorkerEvent::SessionsListed { .. }) { loop_state.resume_browser_pending = false; @@ -1038,11 +1045,23 @@ fn handle_app_command( AppCommand::SubmitShellInput { command } => { worker.submit_shell_input(command.clone())?; } - AppCommand::SteerTurn { - input, + AppCommand::QueuePush { input } => { + worker.queue_push(input.clone())?; + } + AppCommand::QueueSteer { + queue_item_id, expected_turn_id, } => { - worker.submit_steer(input.clone(), *expected_turn_id)?; + worker.queue_steer(queue_item_id.clone(), *expected_turn_id)?; + } + AppCommand::QueueRemove { queue_item_id } => { + worker.queue_remove(queue_item_id.clone())?; + } + AppCommand::QueueUpdate { + queue_item_id, + input, + } => { + worker.queue_update(queue_item_id.clone(), input.clone())?; } AppCommand::RunBtwQuestion { question } => { worker.run_btw_question(question.clone())?; @@ -1075,41 +1094,72 @@ fn handle_app_command( response.clone(), )?; } - AppCommand::UpdatePermissions { preset } => { - worker.update_permissions(*preset)?; - save_project_permission_preset(context.project_config_key, *preset)?; + AppCommand::UpdatePermissions { + preset, + persist_scope, + } => { + worker.update_permissions(*preset, *persist_scope)?; let implied_sandbox = match preset { PermissionPreset::FullAccess => "off", PermissionPreset::Default | PermissionPreset::AutoReview => "workspace", }; - save_project_sandbox_profile(context.project_config_key, implied_sandbox)?; worker.update_sandbox_profile(implied_sandbox.to_string())?; - chat_widget.note_permissions_updated(*preset); + // Sync implied sandbox locally without a user-visible notice (permissions + // message below is enough). chat_widget.note_sandbox_profile_updated(implied_sandbox.to_string()); + if *persist_scope == PersistScope::Default { + save_project_permission_preset(context.project_config_key, *preset)?; + save_project_sandbox_profile(context.project_config_key, implied_sandbox)?; + } + chat_widget.note_permissions_updated(*preset); + } + AppCommand::UpdateEffectiveContextWindow { + effective_context_window, + } => { + crate::onboarding::save_compaction_token_limit(*effective_context_window)?; + chat_widget.note_effective_context_window_updated(*effective_context_window); + worker.update_effective_context_window(*effective_context_window)?; } AppCommand::UpdateSandboxProfile { profile } => { worker.update_sandbox_profile(profile.clone())?; save_project_sandbox_profile(context.project_config_key, profile)?; + // No transcript notice: sandbox is an implementation detail of permissions / + // explicit picks; settings hub reflects the change silently. chat_widget.note_sandbox_profile_updated(profile.clone()); } AppCommand::OverrideTurnContext { model, reasoning_effort_selection, + persist_scope, .. } => { if let Some(model) = model { - worker.set_model(model.clone())?; - let provider = context - .model_catalog - .get(model) - .map(Model::provider_wire_api) - .unwrap_or(context.default_provider); - save_last_used_model(/*wire_api*/ None, provider, model)?; + worker.set_model_selection_with_scope(model.clone(), None, *persist_scope)?; + if *persist_scope == PersistScope::Default { + let provider = context + .model_catalog + .get(model) + .map(Model::provider_wire_api) + .unwrap_or(context.default_provider); + save_last_used_model(/*wire_api*/ None, provider, model)?; + } } if let Some(reasoning_effort_selection) = reasoning_effort_selection { worker.set_reasoning_effort(reasoning_effort_selection.clone())?; - save_reasoning_effort_selection(reasoning_effort_selection.as_deref())?; + if *persist_scope == PersistScope::Default { + save_reasoning_effort_selection(reasoning_effort_selection.as_deref())?; + } + } + } + AppCommand::SetCollaborationMode { + collaboration_mode, + persist_scope, + } => { + if *persist_scope == PersistScope::Default { + save_default_collaboration_mode(*collaboration_mode)?; } + worker.set_collaboration_mode(*collaboration_mode, *persist_scope)?; + chat_widget.apply_collaboration_mode(*collaboration_mode, *persist_scope); } AppCommand::RunUserShellCommand { command } => { if command == "session list" { @@ -1220,8 +1270,8 @@ fn handle_app_command( AppCommand::RenameSession { title } => { worker.rename_session(title.clone())?; } - AppCommand::DeleteSession => { - worker.delete_session()?; + AppCommand::DeleteSession { session_id } => { + worker.delete_session(*session_id)?; } AppCommand::ShowGoal => { worker.show_goal()?; diff --git a/crates/tui/src/lib.rs b/crates/tui/src/lib.rs index 811d9271..09b5d4df 100644 --- a/crates/tui/src/lib.rs +++ b/crates/tui/src/lib.rs @@ -49,6 +49,7 @@ mod onboarding_widget; #[cfg(test)] mod onboarding_widget_tests; mod pager_overlay; +mod queue_ops; mod read_display; mod render; mod shell_output; diff --git a/crates/tui/src/mcp_command_tests.rs b/crates/tui/src/mcp_command_tests.rs index fa625591..53568800 100644 --- a/crates/tui/src/mcp_command_tests.rs +++ b/crates/tui/src/mcp_command_tests.rs @@ -23,6 +23,8 @@ fn widget_with_model(model: Model) -> (ChatWidget, mpsc::UnboundedReceiver) -> Result Ok(()) } +pub(crate) fn save_compaction_token_limit(limit: u64) -> Result<()> { + let config_home = find_devo_home().context("could not determine user config path")?; + let mut store = devo_core::AppConfigStore::load(config_home, /*workspace_root*/ None) + .map_err(|error| anyhow::anyhow!(error))?; + store + .set_compaction_token_limit(limit) + .context("failed to persist compaction_token_limit")?; + Ok(()) +} + +pub(crate) fn save_default_collaboration_mode( + mode: devo_protocol::CollaborationMode, +) -> Result<()> { + let config_home = find_devo_home().context("could not determine user config path")?; + let mut store = devo_core::AppConfigStore::load(config_home, /*workspace_root*/ None) + .map_err(|error| anyhow::anyhow!(error))?; + store + .set_default_collaboration_mode(mode) + .context("failed to persist default_collaboration_mode")?; + Ok(()) +} + pub(crate) fn save_theme_selection(name: &str) -> Result<()> { let path = find_devo_home() .context("could not determine user config path")? diff --git a/crates/tui/src/onboarding_widget.rs b/crates/tui/src/onboarding_widget.rs index f1a7c44a..682f58cd 100644 --- a/crates/tui/src/onboarding_widget.rs +++ b/crates/tui/src/onboarding_widget.rs @@ -45,8 +45,11 @@ use crate::onboarding_viewport::render_lines_with_anchor; use crate::onboarding_viewport::render_lines_with_fixed_footer; use crate::render::renderable::Renderable; use crate::tui::frame_requester::FrameRequester; +use crate::ui_consts::FOOTER_INDENT_COLS; const SPINNER_INTERVAL: std::time::Duration = std::time::Duration::from_millis(80); +/// Left inset for onboarding list rows, matching `/model` / composer gutters. +const LIST_LEFT_PAD: usize = FOOTER_INDENT_COLS; const VALIDATION_FAILED_ACTIONS: [&str; 4] = [ "Add model anyway", "Retry with current settings", @@ -1599,13 +1602,14 @@ impl OnboardingWidget { fn render_footer(lines: &mut Vec>, primary: &str, secondary: &str) { lines.push(Line::from("")); if secondary.is_empty() { - lines.push(Line::from(vec![Span::styled( - primary.to_string(), - Style::default().dim(), - )])); + lines.push(Line::from(vec![ + Span::raw(" ".repeat(LIST_LEFT_PAD)), + Span::styled(primary.to_string(), Style::default().dim()), + ])); return; } lines.push(Line::from(vec![ + Span::raw(" ".repeat(LIST_LEFT_PAD)), Span::styled(primary.to_string(), Style::default().dim()), Span::styled(" · ", Style::default().dim()), Span::styled(secondary.to_string(), Style::default().dim()), @@ -1618,19 +1622,20 @@ impl OnboardingWidget { description: Option, is_selected: bool, ) { - let marker = if is_selected { ">" } else { " " }; + let marker = if is_selected { "›" } else { " " }; let marker_style = if is_selected { Style::default().cyan().bold() } else { Style::default().dim() }; let label_style = if is_selected { - Style::default().bold() + Style::default().cyan().bold().underlined() } else { Style::default() }; lines.push(Line::from(vec![ + Span::raw(" ".repeat(LIST_LEFT_PAD)), Span::styled(marker.to_string(), marker_style), Span::raw(" "), Span::styled(label, label_style), @@ -1638,12 +1643,21 @@ impl OnboardingWidget { if let Some(description) = description { lines.push(Line::from(vec![ - Span::styled(" ", Style::default().dim()), + Span::raw(" ".repeat(LIST_LEFT_PAD + 2)), Span::styled(description, Style::default().dim()), ])); } } + fn scroll_overflow_line(more_above: bool) -> Line<'static> { + // Align with option labels: LIST_LEFT_PAD + marker + following space. + let label = if more_above { "↑ more" } else { "↓ more" }; + Line::from(vec![ + Span::raw(" ".repeat(LIST_LEFT_PAD + 2)), + Span::styled(label.to_string(), Style::default().dim()), + ]) + } + fn render_inline_setup_header(lines: &mut Vec>, model: &str) { lines.push(Line::from(vec![Span::styled( "Configure provider binding", @@ -1884,14 +1898,14 @@ impl OnboardingWidget { Style::default().dim() }; let label_style = if is_selected { - Style::default().bold() + Style::default().cyan().bold().underlined() } else { Style::default() }; lines.push(Line::from(vec![ Span::styled("| ", Style::default().cyan().bold()), - Span::styled(if is_selected { ">" } else { " " }, marker_style), + Span::styled(if is_selected { "›" } else { " " }, marker_style), Span::raw(" "), Span::styled(label.to_string(), label_style), ])); @@ -2085,8 +2099,13 @@ impl OnboardingWidget { let scroll_offset = state .scroll_top .min(filtered_indices.len().saturating_sub(max_visible)); + let has_more_above = scroll_offset > 0; + let has_more_below = scroll_offset + max_visible < filtered_indices.len(); let mut anchor = None; + if has_more_above { + lines.push(Self::scroll_overflow_line(/*more_above*/ true)); + } for (vis_idx, &actual_idx) in filtered_indices .iter() .enumerate() @@ -2096,12 +2115,13 @@ impl OnboardingWidget { if let Some(item) = items.get(actual_idx) { let is_selected = state.selected_idx == Some(vis_idx); let start = lines.len(); - let description = if item.display_name == item.slug { - None - } else { - Some(item.display_name.clone()) - }; - Self::render_option_row(&mut lines, item.slug.clone(), description, is_selected); + // Slug-only rows: display_name is used for filtering, not shown. + Self::render_option_row( + &mut lines, + item.slug.clone(), + /*description*/ None, + is_selected, + ); if is_selected { anchor = Some(ViewportAnchor { start, @@ -2110,6 +2130,9 @@ impl OnboardingWidget { } } } + if has_more_below { + lines.push(Self::scroll_overflow_line(/*more_above*/ false)); + } let mut footer_lines = Vec::new(); Self::render_footer(&mut footer_lines, "Enter select", "Esc cancel"); @@ -2135,7 +2158,7 @@ impl OnboardingWidget { let byte_pos = Self::byte_index_for_char(input, cursor_pos); lines.push(Line::from(vec![ - Span::styled("> ", Style::default().cyan()), + Span::styled("› ", Style::default().cyan()), Span::styled( format!("{}▌{}", &input[..byte_pos], &input[byte_pos..]), Style::default(), @@ -2442,7 +2465,6 @@ impl Renderable for OnboardingWidget { fn desired_height(&self, _width: u16) -> u16 { match &self.state { OnboardingState::ModelSelection { - items, state, filtered_indices, .. @@ -2451,15 +2473,12 @@ impl Renderable for OnboardingWidget { let scroll_offset = state .scroll_top .min(filtered_indices.len().saturating_sub(max_visible)); - let option_rows = filtered_indices - .iter() - .skip(scroll_offset) - .take(max_visible) - .filter_map(|idx| items.get(*idx)) - .map(|item| if item.display_name == item.slug { 1 } else { 2 }) - .sum::() - .max(1); - option_rows + 9 + let has_more_above = scroll_offset > 0; + let has_more_below = scroll_offset + max_visible < filtered_indices.len(); + let option_rows = u16::try_from(max_visible).unwrap_or(u16::MAX).max(1); + let overflow_rows = u16::from(has_more_above) + u16::from(has_more_below); + // title + hint + blank + filter + blank + options + overflow + footer spacing + option_rows + overflow_rows + 9 } OnboardingState::CustomModelSlug { .. } => 8, OnboardingState::ProviderSelection { items, .. } => items.len() as u16 * 2 + 6, diff --git a/crates/tui/src/onboarding_widget_tests.rs b/crates/tui/src/onboarding_widget_tests.rs index c86e0993..f7ee26e6 100644 --- a/crates/tui/src/onboarding_widget_tests.rs +++ b/crates/tui/src/onboarding_widget_tests.rs @@ -280,7 +280,7 @@ fn onboarding_validation_failure_defaults_to_add_model_anyway() { let (widget, _app_event_rx) = failed_validation_widget(); let view = rendered_rows(&widget, 160, 40).join("\n"); - assert_eq!(view.contains("> Add model anyway"), true); + assert_eq!(view.contains("› Add model anyway"), true); assert_eq!(view.contains(" Retry with current settings"), true); } @@ -538,14 +538,14 @@ fn onboarding_invocation_and_reasoning_popups_render_inline_and_use_model_preset invocation_view.contains("Invocation Method: OpenAI Chat Completions"), true ); - assert_eq!(invocation_view.contains("> OpenAI Chat Completions"), true); + assert_eq!(invocation_view.contains("› OpenAI Chat Completions"), true); widget.handle_key_event(press(KeyCode::Enter)); let reasoning_view = rendered_rows(&widget, 160, 60).join("\n"); assert_eq!(reasoning_view.contains("Reason Effort: High"), true); assert_eq!(reasoning_view.contains(" Off"), true); - assert_eq!(reasoning_view.contains("> High"), true); + assert_eq!(reasoning_view.contains("› High"), true); assert_eq!(reasoning_view.contains(" Max"), true); assert_eq!(reasoning_view.contains("Medium"), false); assert_eq!(reasoning_view.contains("XHigh"), false); @@ -594,7 +594,7 @@ fn onboarding_toggle_model_reasoning_popup_shows_off_and_on() { let reasoning_view = rendered_rows(&widget, 160, 60).join("\n"); assert_eq!(reasoning_view.contains("Reason Effort: On"), true); assert_eq!(reasoning_view.contains(" Off"), true); - assert_eq!(reasoning_view.contains("> On"), true); + assert_eq!(reasoning_view.contains("› On"), true); assert_eq!(reasoning_view.contains("Medium"), false); widget.handle_key_event(press(KeyCode::Enter)); @@ -626,7 +626,7 @@ fn onboarding_invocation_popup_keeps_active_section_visible_when_short() { "expected invocation hint in short viewport:\n{invocation_view}" ); assert!( - invocation_view.contains("> OpenAI Chat Completions"), + invocation_view.contains("› OpenAI Chat Completions"), "expected selected invocation option in short viewport:\n{invocation_view}" ); } @@ -646,7 +646,7 @@ fn onboarding_reasoning_popup_keeps_active_section_visible_when_short_and_narrow "expected wrapped reasoning hint in short viewport:\n{reasoning_view}" ); assert!( - reasoning_view.contains("> High"), + reasoning_view.contains("› High"), "expected selected reasoning effort in short viewport:\n{reasoning_view}" ); } diff --git a/crates/tui/src/queue_ops.rs b/crates/tui/src/queue_ops.rs new file mode 100644 index 00000000..ec0f334b --- /dev/null +++ b/crates/tui/src/queue_ops.rs @@ -0,0 +1,42 @@ +//! Helpers for canonical session queue entries in the TUI worker/UI. + +use devo_protocol::InputItem; +use devo_protocol::canonical::item::UserInput; +use devo_protocol::canonical::queue::QueueEntry; + +/// Map legacy [`InputItem`] values into canonical [`UserInput`] for queue RPCs. +pub(crate) fn user_input_from_input_items(items: &[InputItem]) -> Vec { + items + .iter() + .map(|item| match item { + InputItem::Text { text } => UserInput::Text { text: text.clone() }, + InputItem::Skill { name, .. } => UserInput::Skill { name: name.clone() }, + InputItem::LocalImage { path } => UserInput::LocalImage { + path: path.clone(), + detail: None, + }, + InputItem::Mention { path, .. } => UserInput::Mention { uri: path.clone() }, + }) + .collect() +} + +/// Extract full editable text from a queue entry (display-only join of text parts). +pub(crate) fn queue_entry_text(entry: &QueueEntry) -> String { + let texts: Vec<&str> = entry + .input + .iter() + .filter_map(|part| match part { + UserInput::Text { text } => Some(text.as_str()), + _ => None, + }) + .collect(); + if !texts.is_empty() { + return texts.join("\n"); + } + entry.preview.clone() +} + +/// Collapse newlines to spaces for a single-line queue preview (render only). +pub(crate) fn queue_render_preview(text: &str) -> String { + text.split_whitespace().collect::>().join(" ") +} diff --git a/crates/tui/src/render/line_utils.rs b/crates/tui/src/render/line_utils.rs index ce606fbf..5760fbd3 100644 --- a/crates/tui/src/render/line_utils.rs +++ b/crates/tui/src/render/line_utils.rs @@ -36,6 +36,20 @@ pub fn is_blank_line_spaces_only(line: &Line<'_>) -> bool { .all(|s| s.content.is_empty() || s.content.bytes().all(|byte| byte == b' ')) } +/// True when the line is a non-empty horizontal rule made only of `─` characters. +pub fn is_horizontal_rule_line(line: &Line<'_>) -> bool { + let mut saw_rule = false; + for span in &line.spans { + for ch in span.content.chars() { + if ch != '─' { + return false; + } + saw_rule = true; + } + } + saw_rule +} + /// Prefix each line with `initial_prefix` for the first line and /// `subsequent_prefix` for following lines. Returns a new Vec of owned lines. pub fn prefix_lines( @@ -100,6 +114,15 @@ mod tests { assert_eq!(detected, vec![true, true, true]); } + #[test] + fn horizontal_rule_line_detection() { + assert!(is_horizontal_rule_line(&Line::from("─".repeat(8).dim()))); + assert!(is_horizontal_rule_line(&Line::from("────"))); + assert!(!is_horizontal_rule_line(&Line::from(""))); + assert!(!is_horizontal_rule_line(&Line::from("── x ──"))); + assert!(!is_horizontal_rule_line(&Line::from(" "))); + } + #[test] fn blank_line_detection_rejects_tabs_newlines_and_text() { let lines = [ diff --git a/crates/tui/src/status_indicator_widget.rs b/crates/tui/src/status_indicator_widget.rs index cc3a795f..f108be3f 100644 --- a/crates/tui/src/status_indicator_widget.rs +++ b/crates/tui/src/status_indicator_widget.rs @@ -34,21 +34,41 @@ use crate::wrapping::word_wrap_lines; pub(crate) const STATUS_DETAILS_DEFAULT_MAX_LINES: usize = 3; const DETAILS_PREFIX: &str = " └ "; const STATUS_ANIMATION_INTERVAL: Duration = Duration::from_millis(80); -const TIP_ROTATION_INTERVAL: Duration = Duration::from_secs(6); -const WORKING_TIPS: &[&str] = &[ +pub(crate) const TIP_ROTATION_INTERVAL: Duration = Duration::from_secs(6); +pub(crate) const WORKING_TIPS: &[&str] = &[ "You can type your next message while Devo is working; it will be queued.", "Press ESC twice to stop the current turn.", "Use /model to switch models for the next turn.", "Use /compact when a long session starts losing focus.", "Enter '@' to mention file paths when you want Devo to edit specific files.", + "@: mention files | ! to run a shell command", "Queue follow-up instructions while Devo is working; they will run next.", "Use /resume to continue a previous session.", "Use /new to start fresh when the current session has too much context.", "Keep prompts narrow when you want a small, low-risk code change.", "Press SHIFT+TAB to switch modes.", "Enter '!' to enter SHELL mode; press SHIFT+TAB to switch modes.", + "Press Ctrl+S to add guidance without waiting for the turn to finish.", + "You can also send messages while Devo is working; they enter the wait queue.", + "Use /status to check current token and context usage.", + "Use /settings to change the compact limit, theme, and more.", ]; +/// Tip text for `elapsed` wall time, rotating every [`TIP_ROTATION_INTERVAL`]. +pub(crate) fn rotating_working_tip(elapsed: Duration) -> Option<&'static str> { + if WORKING_TIPS.is_empty() { + return None; + } + let interval_secs = TIP_ROTATION_INTERVAL.as_secs().max(1); + let index = (elapsed.as_secs() / interval_secs) as usize % WORKING_TIPS.len(); + Some(WORKING_TIPS[index]) +} + +/// Composer placeholder string for the tip at `elapsed` (`Tip: …`). +pub(crate) fn composer_tip_placeholder(elapsed: Duration) -> Option { + rotating_working_tip(elapsed).map(|tip| format!("Tip: {tip}")) +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum StatusDetailsCapitalization { CapitalizeFirst, @@ -252,13 +272,10 @@ impl StatusIndicatorWidget { } fn working_tip_at(&self, elapsed: Duration) -> Option<&'static str> { - if !self.show_working_tip || WORKING_TIPS.is_empty() { + if !self.show_working_tip { return None; } - - let interval_secs = TIP_ROTATION_INTERVAL.as_secs().max(1); - let index = (elapsed.as_secs() / interval_secs) as usize % WORKING_TIPS.len(); - Some(WORKING_TIPS[index]) + rotating_working_tip(elapsed) } fn working_tip_line(&self, width: u16, elapsed: Duration) -> Option> { @@ -434,6 +451,18 @@ mod tests { ); } + #[test] + fn composer_tip_placeholder_reuses_working_tips() { + assert_eq!( + composer_tip_placeholder(Duration::ZERO), + Some(format!("Tip: {}", WORKING_TIPS[0])) + ); + assert_eq!( + composer_tip_placeholder(Duration::from_secs(6)), + Some(format!("Tip: {}", WORKING_TIPS[1])) + ); + } + #[test] #[ignore] fn bench_status_indicator_render_without_details() { diff --git a/crates/tui/src/style.rs b/crates/tui/src/style.rs index 26a71bd8..f9a17f8f 100644 --- a/crates/tui/src/style.rs +++ b/crates/tui/src/style.rs @@ -4,6 +4,8 @@ use crate::terminal_palette::best_color; use crate::terminal_palette::default_bg; use ratatui::style::Color; use ratatui::style::Style; +use ratatui::style::Stylize; +use ratatui::text::Line; pub fn user_message_style() -> Style { user_message_style_for(default_bg()) @@ -17,6 +19,11 @@ pub fn user_message_style_for(terminal_bg: Option<(u8, u8, u8)>) -> Style { } } +/// Full-width dim `─` rule used above/below user history cells and the chat composer. +pub(crate) fn user_message_rule_line(width: u16) -> Line<'static> { + Line::from_iter(["─".repeat(width as usize).dim()]) +} + #[allow(clippy::disallowed_methods)] pub fn user_message_bg(terminal_bg: (u8, u8, u8)) -> Color { let (top, alpha) = if is_light(terminal_bg) { diff --git a/crates/tui/src/theme.rs b/crates/tui/src/theme.rs index b9e7fe96..552b6c1b 100644 --- a/crates/tui/src/theme.rs +++ b/crates/tui/src/theme.rs @@ -76,7 +76,7 @@ impl ThemeSet { "Use Esc to interrupt an active task.", "Press Up/Down in the composer to browse input history.", "Use /model to switch between configured models.", - "Use /theme to change the UI color scheme.", + "Use /settings to change the UI color scheme.", "Use /status to see session configuration and token usage.", "Use /compact to reclaim context window space.", "Use /resume to pick up a previous chat.", diff --git a/crates/tui/src/tool_rendering_e2e_tests.rs b/crates/tui/src/tool_rendering_e2e_tests.rs index a7a9a6d0..7a2f49b8 100644 --- a/crates/tui/src/tool_rendering_e2e_tests.rs +++ b/crates/tui/src/tool_rendering_e2e_tests.rs @@ -24,6 +24,8 @@ fn widget_with_model( initial_reasoning_effort_selection: None, initial_permission_preset: devo_protocol::PermissionPreset::Default, initial_sandbox_profile: Some("workspace".to_string()), + initial_compaction_token_limit: None, + initial_default_collaboration_mode: devo_protocol::CollaborationMode::Build, initial_user_message: None, enhanced_keys_supported: true, is_first_run: false, diff --git a/crates/tui/src/tool_result_cell.rs b/crates/tui/src/tool_result_cell.rs index b63e1146..5b34d8d7 100644 --- a/crates/tui/src/tool_result_cell.rs +++ b/crates/tui/src/tool_result_cell.rs @@ -11,8 +11,8 @@ use crate::exec_cell::truncated_tool_output_preview; use crate::history_cell::AgentMessageCell; use crate::history_cell::HistoryCell; -const INLINE_OUTPUT_PREVIEW_ROWS: usize = 5; -const INLINE_OUTPUT_PREVIEW_LINE_LIMIT: usize = 5; +const INLINE_OUTPUT_PREVIEW_ROWS: usize = 3; +const INLINE_OUTPUT_PREVIEW_LINE_LIMIT: usize = 2; #[derive(Debug)] pub(crate) struct ToolResultCell { @@ -165,10 +165,9 @@ mod tests { assert!(inline.contains("line 1")); assert!(inline.contains("line 2")); assert!(inline.contains("ctrl + t to view transcript")); - assert!(inline.contains("line 7")); - assert!(inline.contains("line 8")); assert!(!inline.contains("line 3")); - assert!(!inline.contains("line 6")); + assert!(!inline.contains("line 7")); + assert!(!inline.contains("line 8")); assert!(transcript.contains("line 5")); assert!(transcript.contains("line 8")); } diff --git a/crates/tui/src/tui.rs b/crates/tui/src/tui.rs index 33e7a074..f18c9013 100644 --- a/crates/tui/src/tui.rs +++ b/crates/tui/src/tui.rs @@ -71,6 +71,7 @@ use ratatui::backend::CrosstermBackend; use ratatui::crossterm::execute; use ratatui::crossterm::terminal::disable_raw_mode; use ratatui::crossterm::terminal::enable_raw_mode; +use ratatui::layout::Rect; use ratatui::layout::Size; use tokio::sync::broadcast; use tokio_stream::Stream; @@ -595,16 +596,12 @@ impl Tui { is_zellij: bool, ) -> Result { let size = terminal.size()?; + let (area, scroll_by) = next_inline_viewport_area(terminal.viewport_area, size, height); let mut needs_full_repaint = false; - let mut area = terminal.viewport_area; - area.height = height.min(size.height); - area.width = size.width; - if area.bottom() > size.height { - let scroll_by = area.bottom() - size.height; + if scroll_by > 0 { Self::append_expanded_viewport(terminal, size, scroll_by, is_zellij)?; needs_full_repaint = true; - area.y = size.height - area.height; } if area != terminal.viewport_area { apply_inline_viewport_area_change(terminal, area)?; @@ -741,6 +738,27 @@ impl Tui { } } +/// Compute the next inline viewport rect for a target `height`. +/// +/// Returns `(area, scroll_by)`. When `scroll_by > 0`, the caller must append that +/// many rows before applying `area` so growth remains append-only. +/// +/// Shrinks keep `area.y` unchanged. Pinning the bottom on shrink would move `y` +/// downward and clear the vacated rows (see `apply_inline_viewport_area_change`), +/// which accumulates blank gaps after tall bottom-pane views such as `/model`. +fn next_inline_viewport_area(previous: Rect, size: Size, height: u16) -> (Rect, u16) { + let mut area = previous; + area.height = height.min(size.height); + area.width = size.width; + if area.bottom() > size.height { + let scroll_by = area.bottom() - size.height; + area.y = size.height - area.height; + (area, scroll_by) + } else { + (area, 0) + } +} + #[cfg(test)] mod tests { use std::io::Write as _; @@ -748,11 +766,13 @@ mod tests { use crossterm::event::KeyboardEnhancementFlags; use pretty_assertions::assert_eq; use ratatui::layout::Rect; + use ratatui::layout::Size; use ratatui::text::Line; use super::Tui; use super::apply_inline_viewport_area_change; use super::keyboard_enhancement_flags; + use super::next_inline_viewport_area; use crate::custom_terminal::Terminal as CustomTerminal; use crate::history_cell::ScrollbackLine; use crate::insert_history::insert_history_lines; @@ -767,6 +787,35 @@ mod tests { assert!(flags.contains(KeyboardEnhancementFlags::REPORT_ALTERNATE_KEYS)); } + #[test] + fn next_inline_viewport_area_expands_with_scroll_when_overflowing() { + let previous = Rect::new(0, 20, 80, 10); + let size = Size::new(80, 40); + let (area, scroll_by) = next_inline_viewport_area(previous, size, 39); + assert_eq!(scroll_by, 19); + assert_eq!(area, Rect::new(0, 1, 80, 39)); + } + + #[test] + fn next_inline_viewport_area_shrink_keeps_top_to_avoid_blank_gaps() { + // Bottom-aligned full-height viewport shrinking (e.g. closing /model) must + // keep y stable. Moving y down would clear the vacated rows into blank gaps. + let previous = Rect::new(0, 1, 80, 39); + let size = Size::new(80, 40); + let (area, scroll_by) = next_inline_viewport_area(previous, size, 5); + assert_eq!(scroll_by, 0); + assert_eq!(area, Rect::new(0, 1, 80, 5)); + } + + #[test] + fn next_inline_viewport_area_preserves_mid_screen_growth_without_overflow() { + let previous = Rect::new(0, 10, 80, 5); + let size = Size::new(80, 40); + let (area, scroll_by) = next_inline_viewport_area(previous, size, 8); + assert_eq!(scroll_by, 0); + assert_eq!(area, Rect::new(0, 10, 80, 8)); + } + #[test] fn apply_inline_viewport_area_change_clears_from_new_viewport_when_old_area_is_empty() { let width: u16 = 24; diff --git a/crates/tui/src/ui_consts.rs b/crates/tui/src/ui_consts.rs index 0b52a6f1..567e37d0 100644 --- a/crates/tui/src/ui_consts.rs +++ b/crates/tui/src/ui_consts.rs @@ -6,12 +6,14 @@ /// Semantics: /// - Chat composer reserves this many columns for the left border + padding. /// - Status indicator lines begin with this many spaces for alignment. -/// - User history lines account for this many columns (e.g., "▌ ") when wrapping. +/// - User history lines account for this many columns (e.g., "❯ ") when wrapping. pub(crate) const LIVE_PREFIX_COLS: u16 = 2; pub(crate) const FOOTER_INDENT_COLS: usize = LIVE_PREFIX_COLS as usize; /// Warm amber used for the "Thought" heading and related accents. pub(crate) const REASONING_ACCENT_COLOR: ratatui::style::Color = ratatui::style::Color::Rgb(210, 150, 60); +/// Orange used for soft alert history lines (`■ …`). +pub(crate) const ALERT_COLOR: ratatui::style::Color = ratatui::style::Color::Rgb(245, 142, 53); /// Green used for completed, idle, and done indicators. pub(crate) const COMPLETED_COLOR: ratatui::style::Color = ratatui::style::Color::Rgb(120, 220, 160); diff --git a/crates/tui/src/worker.rs b/crates/tui/src/worker.rs index e3214994..bac3e00a 100644 --- a/crates/tui/src/worker.rs +++ b/crates/tui/src/worker.rs @@ -79,7 +79,6 @@ use devo_server::TurnExecutionMode; use devo_server::TurnInterruptParams; use devo_server::TurnStartParams; use devo_server::TurnStartResult; -use devo_server::TurnSteerParams; use crate::app_command::GoalObjectiveMode; use crate::app_command::InputHistoryDirection; @@ -215,6 +214,8 @@ pub(crate) struct QueryWorkerConfig { pub(crate) reasoning_effort_selection: Option, /// Permission preset to apply to the server session when it exists. pub(crate) permission_preset: PermissionPreset, + /// Default collaboration mode for newly prepared sessions. + pub(crate) default_collaboration_mode: CollaborationMode, /// Initial OS sandbox profile from project config (or permission-implied). pub(crate) initial_sandbox_profile: Option, /// Agent client capabilities to advertise to the server session. @@ -243,6 +244,11 @@ enum OperationCommand { SetModel { model: String, model_binding_id: Option, + persist_scope: crate::app_command::PersistScope, + }, + SetCollaborationMode { + collaboration_mode: CollaborationMode, + persist_scope: crate::app_command::PersistScope, }, /// TODO: Same with model, should bind at session metadata. /// Update the reasoning effort selection used for future turns. @@ -322,8 +328,12 @@ enum OperationCommand { SwitchSession(SessionId), /// Rename the current active session. RenameSession(String), - /// Delete the current active session and prepare a fresh local session. - DeleteSession, + /// Delete a session. `None` deletes the current active session and starts a + /// fresh local session; `Some(id)` deletes that session id (and only resets + /// local state when it is the active one). + DeleteSession { + session_id: Option, + }, /// Roll back the active session using the server-selected user-turn cut mode. RollbackUserTurn { user_turn_index: u32, @@ -333,11 +343,24 @@ enum OperationCommand { ForkAtUserTurn(u32), /// Interrupt the active turn when one is running. InterruptTurn, - /// Steer text into the currently active turn. - SteerTurn { + /// Push input onto the canonical session queue (busy path). + QueuePush { input: Vec, + }, + /// Promote a queued entry into the active turn as a steer. + QueueSteer { + queue_item_id: String, expected_turn_id: TurnId, }, + /// Remove a queued entry (edit-from-queue). + QueueRemove { + queue_item_id: String, + }, + /// Replace a queued entry's content in place, preserving its position. + QueueUpdate { + queue_item_id: String, + input: Vec, + }, /// Ask a side question in a one-turn forked agent. RunBtwQuestion { question: String, @@ -357,6 +380,10 @@ enum OperationCommand { }, UpdatePermissions { preset: devo_protocol::PermissionPreset, + persist_scope: crate::app_command::PersistScope, + }, + UpdateEffectiveContextWindow { + effective_context_window: u64, }, UpdateSandboxProfile { profile: String, @@ -488,11 +515,25 @@ impl QueryWorkerHandle { &self, model: String, model_binding_id: Option, + ) -> Result<()> { + self.set_model_selection_with_scope( + model, + model_binding_id, + crate::app_command::PersistScope::Session, + ) + } + + pub(crate) fn set_model_selection_with_scope( + &self, + model: String, + model_binding_id: Option, + persist_scope: crate::app_command::PersistScope, ) -> Result<()> { self.command_tx .send(OperationCommand::SetModel { model, model_binding_id, + persist_scope, }) .map_err(|_| anyhow::anyhow!("interactive worker is no longer running")) } @@ -682,10 +723,10 @@ impl QueryWorkerHandle { .map_err(|_| anyhow::anyhow!("interactive worker is no longer running")) } - /// Deletes the current active session and prepares a fresh local session. - pub(crate) fn delete_session(&self) -> Result<()> { + /// Deletes a session. When `session_id` is `None`, deletes the active session. + pub(crate) fn delete_session(&self, session_id: Option) -> Result<()> { self.command_tx - .send(OperationCommand::DeleteSession) + .send(OperationCommand::DeleteSession { session_id }) .map_err(|_| anyhow::anyhow!("interactive worker is no longer running")) } @@ -720,20 +761,44 @@ impl QueryWorkerHandle { .map_err(|_| anyhow::anyhow!("interactive worker is no longer running")) } - /// Steer input into the currently active turn. - pub(crate) fn submit_steer( + /// Push input onto the session queue while a turn is active. + pub(crate) fn queue_push(&self, input: Vec) -> Result<()> { + self.command_tx + .send(OperationCommand::QueuePush { input }) + .map_err(|_| anyhow::anyhow!("interactive worker is no longer running")) + } + + /// Promote a queued item into the active turn as a steer. + pub(crate) fn queue_steer( &self, - input: Vec, + queue_item_id: String, expected_turn_id: TurnId, ) -> Result<()> { self.command_tx - .send(OperationCommand::SteerTurn { - input, + .send(OperationCommand::QueueSteer { + queue_item_id, expected_turn_id, }) .map_err(|_| anyhow::anyhow!("interactive worker is no longer running")) } + /// Remove a queued item so it can be edited in the composer. + pub(crate) fn queue_remove(&self, queue_item_id: String) -> Result<()> { + self.command_tx + .send(OperationCommand::QueueRemove { queue_item_id }) + .map_err(|_| anyhow::anyhow!("interactive worker is no longer running")) + } + + /// Replace a queued item's content in place, preserving its position. + pub(crate) fn queue_update(&self, queue_item_id: String, input: Vec) -> Result<()> { + self.command_tx + .send(OperationCommand::QueueUpdate { + queue_item_id, + input, + }) + .map_err(|_| anyhow::anyhow!("interactive worker is no longer running")) + } + /// Ask a quick side question without interrupting the active turn. pub(crate) fn run_btw_question(&self, question: String) -> Result<()> { self.command_tx @@ -777,9 +842,40 @@ impl QueryWorkerHandle { .map_err(|_| anyhow::anyhow!("interactive worker is no longer running")) } - pub(crate) fn update_permissions(&self, preset: devo_protocol::PermissionPreset) -> Result<()> { + pub(crate) fn update_permissions( + &self, + preset: devo_protocol::PermissionPreset, + persist_scope: crate::app_command::PersistScope, + ) -> Result<()> { self.command_tx - .send(OperationCommand::UpdatePermissions { preset }) + .send(OperationCommand::UpdatePermissions { + preset, + persist_scope, + }) + .map_err(|_| anyhow::anyhow!("interactive worker is no longer running")) + } + + pub(crate) fn set_collaboration_mode( + &self, + collaboration_mode: CollaborationMode, + persist_scope: crate::app_command::PersistScope, + ) -> Result<()> { + self.command_tx + .send(OperationCommand::SetCollaborationMode { + collaboration_mode, + persist_scope, + }) + .map_err(|_| anyhow::anyhow!("interactive worker is no longer running")) + } + + pub(crate) fn update_effective_context_window( + &self, + effective_context_window: u64, + ) -> Result<()> { + self.command_tx + .send(OperationCommand::UpdateEffectiveContextWindow { + effective_context_window, + }) .map_err(|_| anyhow::anyhow!("interactive worker is no longer running")) } @@ -866,12 +962,17 @@ async fn run_worker_inner( // calls, then turns server notifications back into lightweight UI events. let mut client = spawn_client(&config.cwd, config.server_log_level.clone()).await?; let _ = client.initialize(&config.client_capabilities).await?; + let mut default_model = config.model.clone(); + let mut default_model_binding_id = config.model_binding_id.clone(); + let default_reasoning_effort_selection = config.reasoning_effort_selection.clone(); + let mut default_permission_preset = config.permission_preset; + let mut default_collaboration_mode = config.default_collaboration_mode; let mut session_id: Option = None; let mut session_cwd = config.cwd.clone(); - let mut model = config.model; - let mut model_binding_id = config.model_binding_id; - let mut reasoning_effort_selection = config.reasoning_effort_selection; - let mut permission_preset = config.permission_preset; + let mut model = default_model.clone(); + let mut model_binding_id = default_model_binding_id.clone(); + let mut reasoning_effort_selection = default_reasoning_effort_selection.clone(); + let mut session_permission_preset: Option = None; let initial_sandbox_profile = config.initial_sandbox_profile.clone(); let mut active_turn_id: Option = None; let mut turn_count = 0usize; @@ -890,6 +991,10 @@ async fn run_worker_inner( let mut active_reference_search_id: Option = None; let mut active_shell_process_ids: HashSet = HashSet::new(); let mut next_shell_process_index = 1_u64; + // Session the worker currently holds a canonical event subscription for; + // `subscription/create` accumulates server-side, so subscribe at most once + // per activated session. + let mut subscribed_session_id: Option = None; if let Some(initial_session_id) = config.initial_session_id { match client @@ -905,13 +1010,21 @@ async fn run_worker_inner( let active_agent_label = active_agent_label_from_session(&resumed.session); let (last_query_total, last_query_input) = last_query_tokens_from_resume(&resumed.session); + model = resumed + .session + .model + .clone() + .unwrap_or_else(|| default_model.clone()); + model_binding_id = resumed.session.model_binding_id.clone(); + reasoning_effort_selection = resumed.session.reasoning_effort_selection.clone(); + session_permission_preset = resumed.session.permission_preset; let _ = event_tx.send(WorkerEvent::SessionSwitched { session_id: initial_session_id.to_string(), - cwd: resumed.session.cwd, - title: resumed.session.title, + cwd: resumed.session.cwd.clone(), + title: resumed.session.title.clone(), model: resumed.session.model.clone(), - model_binding_id: resumed.session.model_binding_id.clone(), - reasoning_effort_selection: resumed.session.reasoning_effort_selection.clone(), + model_binding_id: model_binding_id.clone(), + reasoning_effort_selection: reasoning_effort_selection.clone(), reasoning_effort: resumed.session.reasoning_effort, active_agent_label, total_input_tokens: resumed.session.total_input_tokens, @@ -926,10 +1039,19 @@ async fn run_worker_inner( loaded_item_count: resumed.loaded_item_count, pending_texts: resumed.pending_texts, collaboration_mode: resumed.session.collaboration_mode, + permission_preset: resumed.session.permission_preset, + effective_context_window: resumed.session.effective_context_window, }); - model = resumed.session.model.clone().unwrap_or(model); - model_binding_id = resumed.session.model_binding_id.clone(); - reasoning_effort_selection = resumed.session.reasoning_effort_selection.clone(); + if let Some(occupancy) = resumed.session.last_context_occupancy.clone() { + let _ = event_tx.send(WorkerEvent::ContextUsageUpdated { occupancy }); + } + ensure_session_subscription( + &mut client, + initial_session_id, + &mut subscribed_session_id, + event_tx, + ) + .await; total_input_tokens = resumed.session.total_input_tokens; total_output_tokens = resumed.session.total_output_tokens; total_tokens = resumed.session.total_tokens; @@ -971,7 +1093,9 @@ async fn run_worker_inner( &mut model_binding_id, &mut reasoning_effort_selection, &mut session_id, - permission_preset, + &mut subscribed_session_id, + session_permission_preset + .unwrap_or(default_permission_preset), initial_sandbox_profile.as_deref(), event_tx, ) @@ -1054,9 +1178,14 @@ async fn run_worker_inner( Some(OperationCommand::SetModel { model: next_model, model_binding_id: next_model_binding_id, + persist_scope, }) => { model = next_model; model_binding_id = next_model_binding_id; + if persist_scope == crate::app_command::PersistScope::Default { + default_model = model.clone(); + default_model_binding_id = model_binding_id.clone(); + } input_history_cursor = None; if let Some(active_session_id) = session_id { let _ = client @@ -1065,6 +1194,26 @@ async fn run_worker_inner( model: Some(model.clone()), model_binding_id: model_binding_id.clone(), reasoning_effort_selection: reasoning_effort_selection.clone(), + collaboration_mode: None, + }) + .await; + } + } + Some(OperationCommand::SetCollaborationMode { + collaboration_mode, + persist_scope, + }) => { + if persist_scope == crate::app_command::PersistScope::Default { + default_collaboration_mode = collaboration_mode; + } + if let Some(active_session_id) = session_id { + let _ = client + .session_metadata_update(devo_server::SessionMetadataUpdateParams { + session_id: active_session_id, + model: Some(model.clone()), + model_binding_id: model_binding_id.clone(), + reasoning_effort_selection: reasoning_effort_selection.clone(), + collaboration_mode: Some(collaboration_mode), }) .await; } @@ -1078,6 +1227,7 @@ async fn run_worker_inner( model: Some(model.clone()), model_binding_id: model_binding_id.clone(), reasoning_effort_selection: reasoning_effort_selection.clone(), + collaboration_mode: None, }) .await; } @@ -1216,6 +1366,7 @@ async fn run_worker_inner( .await?; client.initialize(&config.client_capabilities).await?; session_id = None; + subscribed_session_id = None; child_agent_sessions.clear(); btw_agent_sessions.clear(); active_turn_id = None; @@ -1383,13 +1534,7 @@ async fn run_worker_inner( .await { Ok(result) => { - model = result - .session - .model - .clone() - .unwrap_or(model); - model_binding_id = result.session.model_binding_id.clone(); - reasoning_effort_selection = result.session.reasoning_effort_selection.clone(); + handle_turn_start_result(result, &mut active_turn_id); let _ = event_tx.send(WorkerEvent::SessionCompactionStarted); } Err(error) => { @@ -1466,7 +1611,9 @@ async fn run_worker_inner( &mut model_binding_id, &mut reasoning_effort_selection, &mut session_id, - permission_preset, + &mut subscribed_session_id, + session_permission_preset + .unwrap_or(default_permission_preset), initial_sandbox_profile.as_deref(), event_tx, ) @@ -1669,6 +1816,7 @@ async fn run_worker_inner( } active_turn_id = None; session_id = None; + subscribed_session_id = None; active_reference_search_id = None; session_cwd = config.cwd.clone(); input_history_cursor = None; @@ -1680,12 +1828,19 @@ async fn run_worker_inner( last_query_total_tokens = 0; last_query_input_tokens = 0; has_authoritative_usage_totals = true; + model = default_model.clone(); + model_binding_id = default_model_binding_id.clone(); + reasoning_effort_selection = + default_reasoning_effort_selection.clone(); + session_permission_preset = None; let _ = event_tx.send(WorkerEvent::NewSessionPrepared { cwd: session_cwd.clone(), model: model.clone(), model_binding_id: model_binding_id.clone(), reasoning_effort_selection: reasoning_effort_selection.clone(), reasoning_effort: None, + permission_preset: default_permission_preset, + collaboration_mode: default_collaboration_mode, active_agent_label: None, last_query_total_tokens, last_query_input_tokens, @@ -1721,9 +1876,11 @@ async fn run_worker_inner( Ok(result) => { active_turn_id = None; session_id = Some(next_session_id); + subscribed_session_id = None; child_agent_sessions.clear(); btw_agent_sessions.clear(); session_cwd = result.session.cwd.clone(); + session_permission_preset = result.session.permission_preset; input_history_cursor = None; let active_agent_label = active_agent_label_from_session(&result.session); @@ -1750,8 +1907,20 @@ async fn run_worker_inner( rich_history_items: result.history_items.clone(), loaded_item_count: result.loaded_item_count, pending_texts: result.pending_texts, - collaboration_mode: result.session.collaboration_mode, + collaboration_mode: result.session.collaboration_mode, + permission_preset: result.session.permission_preset, + effective_context_window: result.session.effective_context_window, }); + if let Some(occupancy) = result.session.last_context_occupancy.clone() { + let _ = event_tx.send(WorkerEvent::ContextUsageUpdated { occupancy }); + } + ensure_session_subscription( + &mut client, + next_session_id, + &mut subscribed_session_id, + event_tx, + ) + .await; model = result .session .model @@ -1831,8 +2000,11 @@ async fn run_worker_inner( } } } - Some(OperationCommand::DeleteSession) => { - let Some(active_session_id) = session_id else { + Some(OperationCommand::DeleteSession { + session_id: requested_session_id, + }) => { + let target_session_id = requested_session_id.or(session_id); + let Some(target_session_id) = target_session_id else { let _ = event_tx.send(WorkerEvent::TurnFailed { message: "no active session exists yet; send a prompt or switch to a saved session first".to_string(), hint: None, @@ -1846,57 +2018,70 @@ async fn run_worker_inner( }); continue; }; - match pause_active_goal_before_session_leave( - &mut client, - active_session_id, - active_turn_id, - ) - .await - { - Ok(()) => {} - Err(error) => { - emit_goal_leave_failure(event_tx, error); - continue; + let deleting_active = session_id == Some(target_session_id); + if deleting_active { + match pause_active_goal_before_session_leave( + &mut client, + target_session_id, + active_turn_id, + ) + .await + { + Ok(()) => {} + Err(error) => { + emit_goal_leave_failure(event_tx, error); + continue; + } } } match client .session_delete(AcpDeleteSessionParams { - session_id: active_session_id, + session_id: target_session_id, meta: None, }) .await { Ok(_) => { let _ = event_tx.send(WorkerEvent::SessionDeleted { - session_id: active_session_id.to_string(), + session_id: target_session_id.to_string(), }); - active_turn_id = None; - session_id = None; - active_reference_search_id = None; - session_cwd = config.cwd.clone(); - input_history_cursor = None; - turn_count = 0; - total_input_tokens = 0; - total_output_tokens = 0; - total_tokens = 0; - total_cache_read_tokens = 0; - last_query_total_tokens = 0; - last_query_input_tokens = 0; - has_authoritative_usage_totals = true; - let _ = event_tx.send(WorkerEvent::NewSessionPrepared { - cwd: session_cwd.clone(), - model: model.clone(), - model_binding_id: model_binding_id.clone(), - reasoning_effort_selection: reasoning_effort_selection.clone(), - reasoning_effort: None, - active_agent_label: None, - last_query_total_tokens, - last_query_input_tokens, - total_cache_read_tokens, - }); - let _ = - emit_skills_list(&mut client, &session_cwd, event_tx, false) - .await; + if deleting_active { + active_turn_id = None; + session_id = None; + subscribed_session_id = None; + active_reference_search_id = None; + session_cwd = config.cwd.clone(); + input_history_cursor = None; + turn_count = 0; + total_input_tokens = 0; + total_output_tokens = 0; + total_tokens = 0; + total_cache_read_tokens = 0; + last_query_total_tokens = 0; + last_query_input_tokens = 0; + has_authoritative_usage_totals = true; + model = default_model.clone(); + model_binding_id = default_model_binding_id.clone(); + reasoning_effort_selection = + default_reasoning_effort_selection.clone(); + session_permission_preset = None; + let _ = event_tx.send(WorkerEvent::NewSessionPrepared { + cwd: session_cwd.clone(), + model: model.clone(), + model_binding_id: model_binding_id.clone(), + reasoning_effort_selection: reasoning_effort_selection.clone(), + reasoning_effort: None, + permission_preset: default_permission_preset, + collaboration_mode: default_collaboration_mode, + active_agent_label: None, + last_query_total_tokens, + last_query_input_tokens, + total_cache_read_tokens, + }); + let _ = + emit_skills_list(&mut client, &session_cwd, event_tx, false) + .await; + } } Err(error) => { let _ = event_tx.send(WorkerEvent::TurnFailed { @@ -1977,8 +2162,20 @@ async fn run_worker_inner( rich_history_items: result.history_items.clone(), loaded_item_count: result.loaded_item_count, pending_texts: result.pending_texts, - collaboration_mode: result.session.collaboration_mode, + collaboration_mode: result.session.collaboration_mode, + permission_preset: result.session.permission_preset, + effective_context_window: result.session.effective_context_window, }); + if let Some(occupancy) = result.session.last_context_occupancy.clone() { + let _ = event_tx.send(WorkerEvent::ContextUsageUpdated { occupancy }); + } + ensure_session_subscription( + &mut client, + active_session_id, + &mut subscribed_session_id, + event_tx, + ) + .await; model = result.session.model.clone().unwrap_or(model); model_binding_id = result.session.model_binding_id.clone(); reasoning_effort_selection = result.session.reasoning_effort_selection.clone(); @@ -2053,6 +2250,7 @@ async fn run_worker_inner( Ok(resumed) => { active_turn_id = None; session_id = Some(next_session_id); + subscribed_session_id = None; child_agent_sessions.clear(); btw_agent_sessions.clear(); session_cwd = resumed.session.cwd.clone(); @@ -2082,7 +2280,19 @@ async fn run_worker_inner( loaded_item_count: resumed.loaded_item_count, pending_texts: resumed.pending_texts, collaboration_mode: resumed.session.collaboration_mode, + permission_preset: resumed.session.permission_preset, + effective_context_window: resumed.session.effective_context_window, }); + if let Some(occupancy) = resumed.session.last_context_occupancy.clone() { + let _ = event_tx.send(WorkerEvent::ContextUsageUpdated { occupancy }); + } + ensure_session_subscription( + &mut client, + next_session_id, + &mut subscribed_session_id, + event_tx, + ) + .await; model = resumed.session.model.clone().unwrap_or(model); model_binding_id = resumed.session.model_binding_id.clone(); reasoning_effort_selection = resumed.session.reasoning_effort_selection.clone(); @@ -2125,7 +2335,8 @@ async fn run_worker_inner( } } Some(OperationCommand::InterruptTurn) => { - if let (Some(turn_id), Some(active_session_id)) = (active_turn_id, session_id) + if let (Some(turn_id), Some(active_session_id)) = + (active_turn_id, session_id) && let Err(error) = client .turn_interrupt(TurnInterruptParams { session_id: active_session_id, @@ -2133,11 +2344,11 @@ async fn run_worker_inner( reason: Some("user requested interrupt".to_string()), }) .await - { + { let _ = event_tx.send(WorkerEvent::InterruptFailed { message: error.to_string(), }); - } + } } Some(OperationCommand::RunBtwQuestion { question }) => { let Some(active_session_id) = session_id else { @@ -2168,24 +2379,132 @@ async fn run_worker_inner( } } } - Some(OperationCommand::SteerTurn { - input, + Some(OperationCommand::QueuePush { input }) => { + let Some(active_session_id) = session_id else { + let _ = event_tx.send(WorkerEvent::TurnFailed { + message: "no active session for queue push".to_string(), + hint: None, + turn_count, + total_input_tokens, + total_output_tokens, + total_tokens, + total_cache_read_tokens, + prompt_token_estimate: total_input_tokens, + last_query_input_tokens, + }); + continue; + }; + let canonical_session = + canonical_session_id(active_session_id); + let push_result = client + .session_queue_push( + devo_protocol::canonical::rpc_turn::SessionQueuePushParams { + session_id: canonical_session.clone(), + input: crate::queue_ops::user_input_from_input_items( + &input, + ), + client_user_message_id: None, + idempotency_key: format!( + "tui-queue-{}", + SessionId::new() + ), + }, + ) + .await; + match push_result { + Ok( + devo_protocol::canonical::rpc_turn::SessionQueuePushResult::Queued { + entry, + }, + ) => { + let _ = emit_queue_snapshot( + &mut client, + &canonical_session, + devo_protocol::canonical::queue::QueueChange::Added, + entry.queue_item_id, + /*started_turn_id*/ None, + event_tx, + ) + .await; + } + Ok( + devo_protocol::canonical::rpc_turn::SessionQueuePushResult::Started { + turn, + }, + ) => { + let started_turn_id = + TurnId::try_from(turn.id.as_str()).ok(); + if let Some(turn_id) = started_turn_id { + active_turn_id = Some(turn_id); + } + let _ = emit_queue_snapshot( + &mut client, + &canonical_session, + devo_protocol::canonical::queue::QueueChange::Drained, + devo_protocol::canonical::ids::QueueItemId::from_string( + String::new(), + ), + started_turn_id, + event_tx, + ) + .await; + } + Err(error) => { + let _ = event_tx.send(WorkerEvent::TurnFailed { + message: error.to_string(), + hint: None, + turn_count, + total_input_tokens, + total_output_tokens, + total_tokens, + total_cache_read_tokens, + prompt_token_estimate: total_input_tokens, + last_query_input_tokens, + }); + } + } + } + Some(OperationCommand::QueueSteer { + queue_item_id, expected_turn_id, }) => { - if let Some(active_session_id) = session_id { - match client - .turn_steer(TurnSteerParams { - session_id: active_session_id, - expected_turn_id, - input, - }) - .await - { - Ok(result) => { - let _ = event_tx.send(WorkerEvent::SteerAccepted { - turn_id: result.turn_id, - }); - } + let Some(active_session_id) = session_id else { + continue; + }; + let canonical_session = + canonical_session_id(active_session_id); + match client + .session_queue_steer( + devo_protocol::canonical::rpc_turn::SessionQueueSteerParams { + session_id: canonical_session.clone(), + queue_item_id: + devo_protocol::canonical::ids::QueueItemId::from_string( + queue_item_id, + ), + expected_turn_id: + devo_protocol::canonical::ids::TurnId::from_string( + expected_turn_id.to_string(), + ), + }, + ) + .await + { + Ok(_) => { + let _ = event_tx.send(WorkerEvent::SteerAccepted { + turn_id: expected_turn_id, + }); + let _ = emit_queue_snapshot( + &mut client, + &canonical_session, + devo_protocol::canonical::queue::QueueChange::Promoted, + devo_protocol::canonical::ids::QueueItemId::from_string( + String::new(), + ), + Some(expected_turn_id), + event_tx, + ) + .await; + } Err(error) => { let _ = event_tx.send(WorkerEvent::TurnFailed { message: error.to_string(), @@ -2200,6 +2519,104 @@ async fn run_worker_inner( }); } } + } + Some(OperationCommand::QueueRemove { queue_item_id }) => { + let Some(active_session_id) = session_id else { + continue; + }; + let canonical_session = + canonical_session_id(active_session_id); + let removed_id = + devo_protocol::canonical::ids::QueueItemId::from_string( + queue_item_id.clone(), + ); + match client + .session_queue_remove( + devo_protocol::canonical::rpc_turn::SessionQueueRemoveParams { + session_id: canonical_session.clone(), + queue_item_id: removed_id.clone(), + }, + ) + .await + { + Ok(_) => { + let _ = emit_queue_snapshot( + &mut client, + &canonical_session, + devo_protocol::canonical::queue::QueueChange::Removed, + removed_id, + /*started_turn_id*/ None, + event_tx, + ) + .await; + } + Err(error) => { + let _ = event_tx.send(WorkerEvent::TurnFailed { + message: error.to_string(), + hint: None, + turn_count, + total_input_tokens, + total_output_tokens, + total_tokens, + total_cache_read_tokens, + prompt_token_estimate: total_input_tokens, + last_query_input_tokens, + }); + } + } + } + Some(OperationCommand::QueueUpdate { + queue_item_id, + input, + }) => { + let Some(active_session_id) = session_id else { + continue; + }; + let canonical_session = + canonical_session_id(active_session_id); + let updated_id = + devo_protocol::canonical::ids::QueueItemId::from_string( + queue_item_id.clone(), + ); + match client + .session_queue_update( + devo_protocol::canonical::rpc_turn::SessionQueueUpdateParams { + session_id: canonical_session.clone(), + queue_item_id: updated_id.clone(), + input: Some( + crate::queue_ops::user_input_from_input_items( + &input, + ), + ), + position: None, + }, + ) + .await + { + Ok(_) => { + let _ = emit_queue_snapshot( + &mut client, + &canonical_session, + devo_protocol::canonical::queue::QueueChange::Updated, + updated_id, + /*started_turn_id*/ None, + event_tx, + ) + .await; + } + Err(error) => { + let _ = event_tx.send(WorkerEvent::TurnFailed { + message: error.to_string(), + hint: None, + turn_count, + total_input_tokens, + total_output_tokens, + total_tokens, + total_cache_read_tokens, + prompt_token_estimate: total_input_tokens, + last_query_input_tokens, + }); + } } } Some(OperationCommand::ApprovalRespond { @@ -2260,8 +2677,15 @@ async fn run_worker_inner( }); } } - Some(OperationCommand::UpdatePermissions { preset }) => { - permission_preset = preset; + Some(OperationCommand::UpdatePermissions { preset, persist_scope }) => { + match persist_scope { + crate::app_command::PersistScope::Default => { + default_permission_preset = preset; + } + crate::app_command::PersistScope::Session => { + session_permission_preset = Some(preset); + } + } let Some(active_session_id) = session_id else { continue; }; @@ -2281,6 +2705,40 @@ async fn run_worker_inner( }); } } + Some(OperationCommand::UpdateEffectiveContextWindow { + effective_context_window, + }) => { + let Some(active_session_id) = session_id else { + let _ = event_tx.send( + WorkerEvent::EffectiveContextWindowUpdated { + effective_context_window, + }, + ); + continue; + }; + match client + .session_compaction_update( + devo_server::SessionCompactionUpdateParams { + session_id: active_session_id, + effective_context_window, + }, + ) + .await + { + Ok(result) => { + let _ = event_tx.send(WorkerEvent::EffectiveContextWindowUpdated { + effective_context_window: result.effective_context_window, + }); + } + Err(error) => { + let _ = event_tx.send(WorkerEvent::InterruptFailed { + message: format!( + "Failed to update compaction threshold: {error}" + ), + }); + } + } + } Some(OperationCommand::UpdateSandboxProfile { profile }) => { let Some(active_session_id) = session_id else { continue; @@ -2440,6 +2898,13 @@ async fn run_worker_inner( } continue; } + if method == "queue/updated" + && let Ok(queue_event) = + parse_canonical_queue_updated(¶ms) + { + let _ = event_tx.send(queue_event); + continue; + } let event: ServerEvent = serde_json::from_value(params) .with_context(|| format!("failed to decode server event for method {method}"))?; if handle_btw_agent_event( @@ -2722,6 +3187,17 @@ async fn run_worker_inner( }); } } + "context/usageUpdated" => { + if let ServerEvent::ContextUsageUpdated(payload) = event + && session_id.is_some_and(|id| id == payload.session_id) + { + last_query_total_tokens = + payload.occupancy.total_tokens as usize; + let _ = event_tx.send(WorkerEvent::ContextUsageUpdated { + occupancy: payload.occupancy, + }); + } + } "turn/failed" => { if let ServerEvent::TurnFailed(TurnFailedPayload { turn, error, .. }) = event { active_turn_id = None; @@ -2818,14 +3294,6 @@ async fn run_worker_inner( }); } } - "inputQueue/updated" => { - if let ServerEvent::InputQueueUpdated(payload) = event { - let _ = event_tx.send(WorkerEvent::InputQueueUpdated { - pending_count: payload.pending_count, - pending_texts: payload.pending_texts, - }); - } - } "search/updated" => { if let ServerEvent::ReferenceSearchUpdated(snapshot) = event { let _ = @@ -2874,6 +3342,20 @@ async fn run_worker_inner( }); } } + "session/effective_context_window/updated" => { + if let ServerEvent::SessionEffectiveContextWindowUpdated( + payload, + ) = event + && session_id == Some(payload.session_id) + { + let _ = event_tx.send( + WorkerEvent::EffectiveContextWindowUpdated { + effective_context_window: payload + .effective_context_window, + }, + ); + } + } "session/compaction/started" => { if let ServerEvent::SessionCompactionStarted(_) = event { let _ = event_tx.send(WorkerEvent::SessionCompactionStarted); @@ -2886,16 +3368,20 @@ async fn run_worker_inner( total_tokens = payload.session.total_tokens; let (compacted_last_query_total, compacted_last_query_input) = last_query_tokens_from_resume(&payload.session); - last_query_total_tokens = if payload.session.prompt_token_estimate > 0 { - payload.session.prompt_token_estimate - } else { - compacted_last_query_total - }; - last_query_input_tokens = if payload.session.prompt_token_estimate > 0 { - payload.session.prompt_token_estimate - } else { - compacted_last_query_input - }; + last_query_total_tokens = payload + .session + .last_context_occupancy + .as_ref() + .map(|occupancy| occupancy.total_tokens as usize) + .filter(|tokens| *tokens > 0) + .unwrap_or(compacted_last_query_total); + last_query_input_tokens = payload + .session + .last_context_occupancy + .as_ref() + .map(|occupancy| occupancy.total_tokens as usize) + .filter(|tokens| *tokens > 0) + .unwrap_or(compacted_last_query_input); let _ = event_tx.send(WorkerEvent::SessionCompacted { total_input_tokens, total_output_tokens, @@ -3024,7 +3510,9 @@ async fn ensure_session_started( /// follow-up. When no session is active yet, [`ensure_session_started`] creates one on the /// server; the returned metadata is merged into the worker's current model, model binding, and /// reasoning-effort selection. For a newly created session, this also notifies the UI via -/// [`WorkerEvent::SessionActivated`] and applies the configured permission preset. +/// [`WorkerEvent::SessionActivated`] and applies the configured permission preset. The +/// canonical session event subscription is ensured on every call; it is a no-op once the +/// active session is already subscribed. #[allow(clippy::too_many_arguments)] async fn prepare_session_for_command( client: &mut StdioServerClient, @@ -3033,6 +3521,7 @@ async fn prepare_session_for_command( model_binding_id: &mut Option, reasoning_effort_selection: &mut Option, session_id: &mut Option, + subscribed_session_id: &mut Option, permission_preset: PermissionPreset, initial_sandbox_profile: Option<&str>, event_tx: &mpsc::UnboundedSender, @@ -3065,17 +3554,129 @@ async fn prepare_session_for_command( .await?; } } + ensure_session_subscription(client, active_session_id, subscribed_session_id, event_tx).await; Ok(active_session_id) } /// Records the active turn returned by `turn/start`. /// /// When the server queues input (`TurnStartResult::Queued`), queue state for the UI is -/// delivered asynchronously via `inputQueue/updated` notifications. +/// delivered asynchronously via canonical `queue/updated` notifications. fn handle_turn_start_result(result: TurnStartResult, active_turn_id: &mut Option) { *active_turn_id = Some(result.active_turn_id()); } +fn canonical_session_id(session_id: SessionId) -> devo_protocol::canonical::ids::SessionId { + devo_protocol::canonical::ids::SessionId::from_string(session_id.to_string()) +} + +fn parse_canonical_queue_updated(params: &serde_json::Value) -> Result { + #[derive(serde::Deserialize)] + #[serde(rename_all = "camelCase")] + struct QueueUpdatedParams { + change: devo_protocol::canonical::queue::QueueChange, + queue_item_id: devo_protocol::canonical::ids::QueueItemId, + #[serde(default)] + started_turn_id: Option, + queue: Vec, + } + let parsed: QueueUpdatedParams = + serde_json::from_value(params.clone()).context("decode queue/updated params")?; + Ok(WorkerEvent::QueueUpdated { + change: parsed.change, + queue_item_id: parsed.queue_item_id, + started_turn_id: parsed + .started_turn_id + .as_ref() + .and_then(|id| TurnId::try_from(id.as_str()).ok()), + entries: parsed.queue, + }) +} + +async fn emit_queue_snapshot( + client: &mut StdioServerClient, + session_id: &devo_protocol::canonical::ids::SessionId, + change: devo_protocol::canonical::queue::QueueChange, + queue_item_id: devo_protocol::canonical::ids::QueueItemId, + started_turn_id: Option, + event_tx: &mpsc::UnboundedSender, +) -> Result<()> { + let listed = client + .session_queue_list(devo_protocol::canonical::rpc_turn::SessionQueueListParams { + session_id: session_id.clone(), + }) + .await + .context("session/queue/list after queue mutation")?; + let _ = event_tx.send(WorkerEvent::QueueUpdated { + change, + queue_item_id, + started_turn_id, + entries: listed.entries, + }); + Ok(()) +} + +/// Subscribes to canonical session events at most once per activated session. +/// +/// Server-side `subscription/create` accumulates entries, so repeat calls for +/// the same session would leak subscriptions. Callers reset +/// `subscribed_session_id` when the active session changes so the next call +/// re-subscribes. Failures are logged and left non-fatal so prompt submission +/// is never blocked; the tracked id is only recorded on success, so a later +/// call retries. +async fn ensure_session_subscription( + client: &mut StdioServerClient, + session_id: SessionId, + subscribed_session_id: &mut Option, + event_tx: &mpsc::UnboundedSender, +) { + if *subscribed_session_id == Some(session_id) { + return; + } + match subscribe_session_events(client, session_id, event_tx).await { + Ok(()) => *subscribed_session_id = Some(session_id), + Err(error) => { + tracing::warn!(?session_id, %error, "failed to subscribe to session events"); + } + } +} + +async fn subscribe_session_events( + client: &mut StdioServerClient, + session_id: SessionId, + event_tx: &mpsc::UnboundedSender, +) -> Result<()> { + let canonical = canonical_session_id(session_id); + let created = client + .subscription_create(devo_protocol::canonical::event::SubscriptionCreateParams { + selectors: vec![devo_protocol::canonical::event::StreamSelector::Session { + session_id: canonical.clone(), + }], + include_snapshot: true, + after: Vec::new(), + }) + .await + .context("subscription/create for session queue")?; + for snapshot in created.snapshots { + if let devo_protocol::canonical::event::SnapshotData::Session { queue, .. } = snapshot.data + { + let queue_item_id = queue + .first() + .map(|entry| entry.queue_item_id.clone()) + .unwrap_or_else(|| { + devo_protocol::canonical::ids::QueueItemId::from_string(String::new()) + }); + let _ = event_tx.send(WorkerEvent::QueueUpdated { + change: devo_protocol::canonical::queue::QueueChange::Added, + queue_item_id, + started_turn_id: None, + entries: queue, + }); + } + } + Ok(()) +} + async fn pause_active_goal_before_session_leave( client: &mut StdioServerClient, session_id: SessionId, @@ -3915,6 +4516,7 @@ fn project_history_items(items: &[SessionHistoryItem]) -> Vec { SessionHistoryItemKind::CommandExecution => TranscriptItemKind::ToolResult, SessionHistoryItemKind::Error => TranscriptItemKind::Error, SessionHistoryItemKind::TurnSummary => TranscriptItemKind::TurnSummary, + SessionHistoryItemKind::ContextCompaction => TranscriptItemKind::System, }; let mut transcript_item = match item.kind { SessionHistoryItemKind::ToolCall => TranscriptItem::tool_call(item.title.clone()), @@ -3935,6 +4537,14 @@ fn project_history_items(items: &[SessionHistoryItem]) -> Vec { // TurnSummary uses title for model name, duration_ms for duration in seconds TranscriptItem::new(kind, item.title.clone(), item.body.clone()) } + SessionHistoryItemKind::ContextCompaction => { + let title = if item.title.is_empty() { + "Context compacted".to_string() + } else { + item.title.clone() + }; + TranscriptItem::new(kind, title, String::new()) + } SessionHistoryItemKind::User | SessionHistoryItemKind::Assistant | SessionHistoryItemKind::Reasoning => { @@ -6166,8 +6776,11 @@ mod tests { prompt_token_estimate: 0, last_query_usage: None, last_query_total_tokens: 0, + last_context_occupancy: None, status: SessionRuntimeStatus::Idle, collaboration_mode: Default::default(), + effective_context_window: None, + permission_preset: None, } } @@ -6622,8 +7235,11 @@ mod tests { prompt_token_estimate: 0, last_query_usage: None, last_query_total_tokens: 0, + last_context_occupancy: None, status: SessionRuntimeStatus::Idle, collaboration_mode: Default::default(), + effective_context_window: None, + permission_preset: None, }; let entry = SessionListEntry { session_id: summary.session_id, @@ -6667,8 +7283,11 @@ mod tests { prompt_token_estimate: 0, last_query_usage: None, last_query_total_tokens: 0, + last_context_occupancy: None, status: SessionRuntimeStatus::Idle, collaboration_mode: Default::default(), + effective_context_window: None, + permission_preset: None, }; let entry = SessionListEntry { session_id: summary.session_id, diff --git a/crates/tui/src/worker_queue_compaction_tests.rs b/crates/tui/src/worker_queue_compaction_tests.rs index 7b0c744b..f0c15ee4 100644 --- a/crates/tui/src/worker_queue_compaction_tests.rs +++ b/crates/tui/src/worker_queue_compaction_tests.rs @@ -35,6 +35,8 @@ fn widget_with_model() -> ChatWidget { initial_reasoning_effort_selection: None, initial_permission_preset: devo_protocol::PermissionPreset::Default, initial_sandbox_profile: Some("workspace".to_string()), + initial_compaction_token_limit: None, + initial_default_collaboration_mode: devo_protocol::CollaborationMode::Build, initial_user_message: None, enhanced_keys_supported: true, is_first_run: false, @@ -62,7 +64,7 @@ fn scrollback_plain_lines(lines: &[ScrollbackLine]) -> Vec { } #[test] -fn input_queue_update_growth_adds_pending_cell_from_server_snapshot() { +fn queue_updated_drain_promotes_pending_into_history() { let mut widget = widget_with_model(); widget.handle_app_event(AppEvent::ClearTranscript); widget.handle_worker_event(WorkerEvent::TurnStarted { @@ -73,19 +75,34 @@ fn input_queue_update_growth_adds_pending_cell_from_server_snapshot() { turn_id: TurnId::new(), }); - widget.handle_worker_event(WorkerEvent::InputQueueUpdated { - pending_count: 1, - pending_texts: vec!["remote queued".to_string()], + let queue_item_id = devo_protocol::canonical::ids::QueueItemId::from_string("qit_test".into()); + widget.handle_worker_event(WorkerEvent::QueueUpdated { + change: devo_protocol::canonical::queue::QueueChange::Added, + queue_item_id: queue_item_id.clone(), + started_turn_id: None, + entries: vec![devo_protocol::canonical::queue::QueueEntry { + queue_item_id: queue_item_id.clone(), + position: 1, + input: vec![devo_protocol::canonical::item::UserInput::Text { + text: "remote queued".to_string(), + }], + preview: "remote queued".to_string(), + enqueued_at: chrono::Utc::now(), + }], }); - widget.handle_worker_event(WorkerEvent::InputQueueUpdated { - pending_count: 0, - pending_texts: Vec::new(), + assert!(widget.bottom_pane_has_pending_for_test()); + + widget.handle_worker_event(WorkerEvent::QueueUpdated { + change: devo_protocol::canonical::queue::QueueChange::Drained, + queue_item_id, + started_turn_id: Some(TurnId::new()), + entries: Vec::new(), }); let history = scrollback_plain_lines(&widget.drain_scrollback_lines(100)).join("\n"); assert!( history.contains("remote queued"), - "expected queued server snapshot to be promoted into history:\n{history}" + "expected drained queue entry to be promoted into history:\n{history}" ); } diff --git a/docs/configuration.md b/docs/configuration.md index f2bf8303..51e08373 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -199,14 +199,19 @@ Top-level keys in `DEVO_HOME/config.toml` also store a few UI preferences: ```toml theme = "aurora" collapse_reasoning = true +compaction_token_limit = 250000 ``` -- `theme` selects the TUI color theme (also set via `/theme`). +- `theme` selects the TUI color theme (also set via Settings › Appearance). - `collapse_reasoning` controls reasoning display (also set via `/show-reasoning`): - `true` (default): while streaming, show only the latest 3 lines; when finished, keep short reasoning in full and collapse longer reasoning to a one-line `Thought · …` summary (full text remains available in Ctrl+T). - `false`: show full reasoning while streaming and after it finishes. +- `compaction_token_limit` is the global absolute auto-compaction threshold in tokens + (also set via Settings › Compaction threshold). When set, every session clamps this + value to the active model's `context_window`. When unset, sessions use the model + effective context window. ### Migrating from `models.json` diff --git a/docs/configuration.zh-Hant.md b/docs/configuration.zh-Hant.md index 719bbc23..ca190a84 100644 --- a/docs/configuration.zh-Hant.md +++ b/docs/configuration.zh-Hant.md @@ -183,13 +183,17 @@ effective_context_window_percent = 90 ```toml theme = "aurora" collapse_reasoning = true +compaction_token_limit = 250000 ``` -- `theme` 選擇 TUI 配色主題(也可透過 `/theme` 設定)。 +- `theme` 選擇 TUI 配色主題(也可透過 Settings › Appearance 設定)。 - `collapse_reasoning` 控制推理顯示(也可透過 `/show-reasoning` 設定): - `true`(預設):串流輸出時只顯示最新 3 行;結束後短推理完整保留,較長推理摺疊為 一行 `Thought · …` 摘要(完整文字仍可在 Ctrl+T 檢視)。 - `false`:串流輸出與結束後都顯示完整推理。 +- `compaction_token_limit` 為全域自動壓縮絕對 token 閾值(也可透過 Settings › + Compaction threshold 設定)。設定後,每個 session 會將此值 clamp 到當前模型的 + `context_window`;未設定時沿用模型有效上下文窗口。 ### 從 `models.json` 遷移 diff --git a/specs/L2/agent/L2-DES-AGENT-002-interrupt-resume-control.md b/specs/L2/agent/L2-DES-AGENT-002-interrupt-resume-control.md index b7224acd..98a71355 100644 --- a/specs/L2/agent/L2-DES-AGENT-002-interrupt-resume-control.md +++ b/specs/L2/agent/L2-DES-AGENT-002-interrupt-resume-control.md @@ -61,9 +61,17 @@ An interrupt request may target: - The entire active turn. - A tracked background process associated with the session or turn. - A waiting approval or question prompt. +- Manual session compaction started via `session/compact` (client `/compact`), + which is an active `ManualCompaction` turn. The server should resolve the target into a runtime cancellation token, tool supervisor command, provider stream cancellation, or waiting-state transition. If the target is no longer active, the server should return an idempotent success or a structured stale-state error. +Manual `/compact` is canceled with `turn/interrupt` or ACP `session/cancel` +(which routes to interrupt when a turn is active). Cancellation dual-emits +`session/compaction/failed` with a canceled message plus the normal interrupted +turn lifecycle; turn-scoped automatic compaction remains under the parent turn's +cancel token / `turn/interrupt`. + ## Interrupt Flow Conceptual interrupt flow: diff --git a/specs/L2/app/L2-DES-APP-005-config-toml-schema.md b/specs/L2/app/L2-DES-APP-005-config-toml-schema.md index 765b3c65..5c471fde 100644 --- a/specs/L2/app/L2-DES-APP-005-config-toml-schema.md +++ b/specs/L2/app/L2-DES-APP-005-config-toml-schema.md @@ -240,6 +240,13 @@ Fields: - `theme`: optional UI theme id. - `permission_policy`: optional default permission posture. +Top-level preference keys (outside `[defaults]`) that the TUI/runtime also honor: + +- `theme`: optional UI theme id (also accepted historically under `[defaults]`). +- `collapse_reasoning`: optional boolean controlling reasoning display collapsing. +- `compaction_token_limit`: optional absolute auto-compaction token threshold applied + globally to all sessions (clamped per model `context_window`). + Rules: - `model_binding` must reference an enabled effective model binding. diff --git a/specs/L2/llm/L2-DES-LLM-003-model-usage-observability.md b/specs/L2/llm/L2-DES-LLM-003-model-usage-observability.md index d4fce386..31757b5f 100644 --- a/specs/L2/llm/L2-DES-LLM-003-model-usage-observability.md +++ b/specs/L2/llm/L2-DES-LLM-003-model-usage-observability.md @@ -161,6 +161,10 @@ Durable usage records should not store prompt content, response content, credent The durable session JSONL file remains the source of truth for usage summaries that affect session inspection. Trace-mode stream records are diagnostic artifacts and should be stored under observability retention policy rather than treated as transcript content. +### Per-turn context occupancy snapshots + +Category occupancy (`ContextOccupancy`) for `/context` and related projections should be stored on each completed `TurnRecord` and on `CompactionSnapshotLine` when compaction runs. Resume/replay takes the latest turn or compaction occupancy as the session tip; fork and rollback cuts must read the cut-turn (or an applicable compaction snapshot whose `turn_id` is still kept), not the source session tip. Session-level `session_stats.last_context_occupancy` may cache the tip for fast hydrate, but rollout remains authoritative for history cuts. Forked sessions reset cumulative token totals to a new ledger while preserving cut-point occupancy and last-query usage. + ## Server-Client Usage Projection Clients should receive safe usage projections through the server-client protocol. @@ -234,6 +238,42 @@ Adapter responsibilities: Provider adapters should not calculate monetary cost unless a later requirement defines provider pricing inputs and billing rules. +## Context Occupancy Breakdown + +Clients can read category-level context-window occupancy through +`context/usage/read` and the `context/usageUpdated` event. + +Occupancy snapshot fields: + +| Field | Meaning | +|---|---| +| `totalTokens` | Current occupied tokens for the effective context window. | +| `contextWindowTokens` | Effective context window (`context_window * percent / 100`). | +| `categories[]` | Ordered category shares with `id`, `tokens`, and `shareBps`. | + +Category ids: + +| Id | Contents | +|---|---| +| `base` | System prompt (including mode introductions); AGENTS.md / workspace instructions; environment, language, collaboration/context-change metadata; related instruction updates. | +| `skills` | Available-skills listing and skill injection / skill-tool content. | +| `toolsBuiltin` | Non-MCP tool schemas plus hosted tools. | +| `toolsMcp` | MCP tool schemas (`mcp__*` names). | +| `conversation` | Remaining messages: dialogue, tool calls/results, compaction summary, and other non-base/skills fragments. | + +Update rules: + +- After a completed turn query, scale the latest assembled-request byte estimates + so category tokens sum exactly to the latest provider query display total + (`total_tokens` when reported, otherwise `input_tokens + output_tokens`). +- After successful compaction, refresh immediately: + `conversation` becomes the post-compact prompt-message estimate (summary plus + preserved suffix); `base` / `skills` / `toolsBuiltin` / `toolsMcp` keep the + previous occupancy buckets; `totalTokens` is their sum. +- Do not expose separate `source` / `measured` fields; turn occupancy is + anchored to the latest provider query, and post-compact occupancy is the + refresh formula above until the next query completes. + ## Diagnostic Examples Example usage projection: diff --git a/specs/L2/tui/L2-DES-TUI-009-settings-hub.md b/specs/L2/tui/L2-DES-TUI-009-settings-hub.md new file mode 100644 index 00000000..985c72b4 --- /dev/null +++ b/specs/L2/tui/L2-DES-TUI-009-settings-hub.md @@ -0,0 +1,131 @@ +--- +artifact_id: L2-DES-TUI-009 +revision: 2 +status: Draft +active_baseline: no +supersedes: +superseded_by: +owner: Assistant +last_updated: 2026-08-01 +--- + +# L2-DES-TUI-009 — Settings Hub + +## Purpose + +Define the TUI Settings Hub opened by `/settings`: a tabbed overview that deep-links into existing configuration pickers and hosts the compaction-threshold editor. + +## Source Requirements + +- `L1-REQ-TUI-006` requires discoverable commands for product workflows. +- `L1-REQ-TUI-004` requires visible current execution and session state. +- `L2-DES-TUI-003` defines composer and bottom-pane popup ownership. +- `L2-DES-LLM-003` defines context pressure and compaction threshold observability. + +## Hub Pattern + +`/settings` is a hub, not a full reimplementation of every picker. + +- Overview rows show the current value. +- Enter on Model, Theme, Permissions, or Show reasoning opens the existing picker on the bottom-pane view stack. +- Esc from a nested picker returns to the hub when the hub opened that picker. +- Existing slash commands (`/model`, `/theme`, `/permissions`, `/show-reasoning`) remain first-class aliases. + +## Tabs + +| Tab | Contents | +|-----|----------| +| Session | Model, Permissions, Mode (BUILD/PLAN), Compaction threshold | +| Appearance | Theme, Show reasoning | +| Agent | Placeholder: Coming soon (personality / agent style later) | + +No Context tab and no filter bar in the first milestone. + +## Chrome And Keys + +```text +Settings + +● Session ○ Appearance ○ Agent +──────────────────────────────────────── + + Model deepseek-v4-flash + Permissions default + Mode BUILD + + Compaction threshold 190K + +↑↓ navigate Enter open Tab switch tab Esc close +``` + +Rules: + +- Title, tab row, then one separator under the tabs. No separator above the footer. +- While the hub owns focus: `Tab` / `Shift+Tab` switch tabs; `↑↓` move rows; `Enter` opens or applies; `Esc` closes the hub. +- `replaces_composer` is true for the hub and nested settings editors. +- Configuration changes remain unavailable during an active task, matching other config slash commands. + +## Mode Row + +The Mode row shows the current TUI `InputMode` label (`BUILD` or `PLAN`). Enter cycles via the existing local composer mode cycle. Shell mode is not offered from Settings. No session-mode RPC is introduced for this row. + +## Compaction Threshold + +Enter on Compaction threshold opens an absolute-token list of round product +presets (`100K`…`1M`, including `250K`). The list is not built from the raw +model `context_window`; the header still shows the model window for context, +and apply clamps to that window on the server. + +```text +Settings › Compaction + +● Session ○ Appearance ○ Agent +──────────────────────────────────────── + + Model deepseek-v4-flash + Window 1M + +> 250K (recommended) + 300K + … + 1M (current) + +↑↓ navigate Enter apply Esc back +``` + +- `(recommended)` marks the product recommendation (`250K`). +- `(current)` marks the effective session limit (matched by value or display label). +- Near-million values render as `1M` (for example `996147` → `1M`). +- Scroll overflow uses `↑ more` / `↓ more`, matching the model picker. +- Both annotations may appear on the same row. +- Apply calls `session/compaction/update` with `effectiveContextWindow` and must hot-update the session without process restart. +- Values above the model `context_window` are clamped on apply (not rejected). +- In-flight turns keep their frozen budget; the next compaction decision uses the new limit. +- First milestone is session-scoped persistence via `SessionSettings.effective_context_window` (resume restores it). No `config.toml` write. + +Resolution order: + +```text +resolved = min( + session.effective_context_window ?? model.effective_context_window(), + model.context_window +) +``` + +`resolved` drives both the context occupancy window and the automatic-compaction boundary. +## Traceability + +| Relationship | Target ID | Target Revision | Target Path | Rationale | +|---|---|---:|---|---| +| refines | L1-REQ-TUI-006 | 1 | specs/L1/L1-REQ-TUI-006-command-discovery-control.md | Hub is a discoverable configuration command surface. | +| related-to | L1-REQ-TUI-004 | 1 | specs/L1/L1-REQ-TUI-004-state-visibility.md | Hub exposes current session configuration values. | +| related-to | L2-DES-TUI-003 | 1 | specs/L2/tui/L2-DES-TUI-003-composer-and-input-modes.md | Hub is a bottom-pane view that replaces the composer while open. | +| related-to | L2-DES-LLM-003 | 1 | specs/L2/llm/L2-DES-LLM-003-model-usage-observability.md | Compaction threshold editing ties to context pressure observability. | +| related-to | L2-DES-TUI-CMD-013 | 1 | specs/L2/tui/slash-commands/L2-DES-TUI-CMD-013-settings.md | Slash command contract for `/settings`. | + +## Revision Notes + +| Revision | Date | Author | Change Type | Notes | +|---:|---|---|---|---| +| 1 | 2026-08-01 | Assistant | Initial | Settings Hub IA, tabs, compaction list, hot-update contract. | +| 2 | 2026-08-01 | Assistant | Update | Persist session override as `effective_context_window`; clamp to model `context_window`. | diff --git a/specs/L2/tui/slash-commands/L2-DES-TUI-CMD-002-model.md b/specs/L2/tui/slash-commands/L2-DES-TUI-CMD-002-model.md index b4b32924..2ec8724a 100644 --- a/specs/L2/tui/slash-commands/L2-DES-TUI-CMD-002-model.md +++ b/specs/L2/tui/slash-commands/L2-DES-TUI-CMD-002-model.md @@ -1,214 +1,81 @@ --- artifact_id: L2-DES-TUI-CMD-002 -revision: 1 +revision: 2 status: Draft active_baseline: no -supersedes: +supersedes: 1 superseded_by: owner: Assistant -last_updated: 2026-05-26 +last_updated: 2026-08-01 --- # L2-DES-TUI-CMD-002 — Slash Command: /model ## Purpose -Define the TUI behavior for `/model`, the post-onboarding command for changing the active session model, provider binding, invocation method, and reasoning effort where applicable. +Define the TUI behavior for `/model`, the post-onboarding command for changing the active session model, provider binding, and reasoning effort where applicable. ## Command Contract - Command: `/model` - Description: `choose the active model` -- Parameters: none in the first milestone. -- Mutability: session metadata; model-provider configuration only when adding or repairing configuration; default-selection configuration only where the application configuration lifecycle requires it. +- Parameters: optional model or binding name for direct selection without opening the picker. +- Mutability: session metadata for existing bindings; adding new providers or bindings is out of band via `devo onboard` in the current implementation. - Active-turn availability: blocked while a turn is generating, running tools, or waiting on active execution. ## Design Requirement -`/model` should first show configured model-provider bindings from effective configuration. These bindings may have been created by onboarding or defined directly in configuration files. Each row should use the binding display name as the primary label and may show provider and slug details as muted secondary text. The last row in the configured binding list is always `Add model...`. +`/model` without arguments opens a single below-composer picker that **replaces the composer input area** and combines: -Selecting an existing configured binding does not immediately apply the final session model when the model supports reasoning. `/model` groups model and provider together as the configured binding step, then treats reasoning effort as a distinct follow-up step. If the selected model does not support reasoning, the configured binding can be applied immediately. If the user selects `Add model...`, `/model` enters the same add-model setup sequence as onboarding: +1. A vertically scrollable list of configured model-provider bindings. +2. A horizontal reasoning-effort strip for the focused binding when that model exposes effort options. -1. Select configured model-provider binding, or choose `Add model...`. -2. If adding a model, select supported model slug. -3. If adding a model, select an existing provider or add a provider. -4. When adding a provider, enter provider name. -5. When adding a provider, enter base URL. -6. When adding a provider, enter API key. -7. Enter the model name expected by the selected provider. -8. Accept or edit the model display name shown in client interfaces. -9. Select invocation method when a new binding requires it. -10. Select reasoning effort when the selected model supports reasoning. -11. Apply the resulting model-provider binding and reasoning effort to future turns in the current session, persisting newly created provider or binding records before treating them as configured. +Up/Down (and j/k) move the model focus. Left/Right cycle reasoning effort for the focused model. Enter applies the focused model and the selected effort in one step. Esc dismisses without changing the session. -The interaction surface differs from onboarding. Onboarding may show a longer inline setup stack because it is the initial setup experience. `/model` is a focused slash-command workflow and should show only the current step directly below the composer, using the slash-command popup visual grammar from `L2-DES-TUI-003`. The slash-command surface occupies the same bottom region as the bottom status line, so while `/model` is visible the normal bottom status line is hidden. When the user confirms or submits a step, that step disappears and the next step replaces it in the same below-composer command area. Previously completed steps are retained in command state but are not rendered as visible inline history. +The model list uses the shared popup row budget (`MAX_POPUP_ROWS`, currently 8): when there are more bindings than the budget, only a scroll window is rendered and the focused row is kept visible. -## UI Flow - -`/model` opens a transient command surface below the composer. The first surface is a configured model-provider binding list, not the supported-model slug selector. The configured binding list may show binding display name and provider together, but it must not conflate reasoning effort into the binding-selection row. Discrete choices use the same searchable popup pattern as onboarding and the row layout rules from `L2-DES-TUI-003`: two-character left padding, active row primary foreground, inactive command/name text in normal foreground, and secondary details in muted foreground. Free-text values use a single active input prompt below the composer. - -```text -┃ /model - -> ● DeepSeek V4 Pro OpenRouter - GPT 5.5 OpenAI - Claude Sonnet 5 Anthropic - Add model... -``` - -Selecting an existing configured binding removes the binding list and shows the reasoning effort step when the selected model supports reasoning: - -```text -┃ /model - - Reasoning Effort - Hint: Choose the reasoning effort for gpt-5.5 through OpenAI. - - medium - > ● high - xhigh - - Enter: select and apply - Esc: back -``` - -Selecting `Add model...` removes the configured binding list and shows the first add-model step: - -```text -┃ /model - - Select Model Slug - Hint: Choose the model capability profile the session should use. - - Search: gpt - - > openai/gpt-5.5 - openai/gpt-5.4 - anthropic/claude-opus - local/qwen3-coder - - Enter: select and continue - Esc: back -``` - -After selecting a model slug in the add-model flow, the model selector is removed and the provider step appears: +`Add model...` and the multi-step onboarding-style add-model wizard described in revision 1 are **not** part of the current `/model` surface; new bindings are created via `devo onboard`. -```text -┃ /model - - Select Provider - Hint: Choose a provider or add one. - - Search: open - - > OpenAI - OpenRouter - Add provider... - - Enter: select and continue - Esc: back -``` - -When a free-text step is active, only that step is shown: - -```text -┃ /model - - Model Name - Hint: Enter the model name this provider expects. - - openai/gpt-5.5 - - Enter: continue - Esc: back -``` - -After model name entry, the display-name step lets the user accept the suggested label or edit it: +## UI Flow ```text ┃ /model - Display Name - Hint: Enter the name clients should show for this model. + › DeepSeek V4 Pro ‹ OpenRouter + GPT 5.5 OpenAI + Claude Sonnet 5 Anthropic + … - GPT 5.5 + ‹ Off Low [Medium] High Max › - Enter: continue - Esc: back + ↑↓ model ←→ effort Enter confirm Esc cancel ``` -When an invocation method is required for a new binding, the command surface replaces the previous step with the invocation selector: - -```text -┃ /model - - Invocation Method - Hint: Choose the API protocol used to call this model. - - Search: openai - - > OpenAI Responses - OpenAI Chat Completions - Anthropic Messages - - Enter: select and continue - Esc: back -``` +- The picker replaces the composer input area while open; the normal bottom status line is not shown. +- Each model row is a two-column layout: model name (plus optional current-session `‹` right after the name) padded to a shared column width, then a muted provider hint starting in a common second column. +- `›` on the left marks keyboard focus. Focused labels use accent + bold + underline for stronger contrast. +- When the binding list exceeds the visible row budget, dim `…` markers indicate more items above and/or below the window. +- When the focused model has no reasoning options, the effort strip is omitted and Left/Right are no-ops. +- When effort options do not fit the available width, the strip shows a sliding window centered on the selected option with `‹` / `›` overflow markers. +- Changing the focused model recomputes available effort options. If the previously selected effort value is still supported, it is preserved; otherwise the picker falls back to that model's session-resolved default (or the first option). -The below-composer command surface must not render the onboarding-style inline history stack: +Direct selection: ```text -Model: openai/gpt-5.5 -| -* provider name: -| ... -| -* base url: -| ... +/model gpt-5-codex ``` -That stacked rail view belongs to onboarding. `/model` uses one active step at a time below the composer. +applies a matching saved binding or catalog model without opening the picker, resolving reasoning effort from the current session selection when the target model supports it. ## Step Behavior -- The configured model-provider binding list is the first `/model` step. -- The configured binding list must be populated from effective configuration, including bindings created by onboarding and bindings defined directly in configuration files. -- The configured binding list must show configured model-provider bindings and an `Add model...` row at the bottom. It must use the configured binding display name as the primary row label. It may show provider identity, model slug, or provider-specific model name as secondary details, but it must not include reasoning effort. -- In configured binding and reasoning-effort lists, `>` marks the row currently focused by keyboard navigation and `●` marks the current active session selection. The same row may show both markers. -- Add-model substeps that have no current enabled value may show `>` without `●`. -- While any `/model` command surface is visible, the normal bottom status line must not be rendered because the command surface occupies that same area below the composer. -- Pressing Enter on the focused configured binding marked by `>` records the selected model-provider binding in command-local state. -- Reasoning effort selection is required after configured binding selection when the selected model supports reasoning, even when the binding already has a default or last-used reasoning effort. -- If the selected configured binding's model does not support reasoning, the binding may be applied immediately after selection. -- The final selection is applied only after the configured binding and any required reasoning effort step have completed. -- Selecting an existing configured binding after the first user message updates the current session selection only; it must not rewrite provider records, binding records, or default-selection fields. -- Selecting an existing configured binding before the first user message may persist the default selected binding and reasoning effort according to application configuration rules, but it must not duplicate or rewrite unchanged provider and binding records. -- Pressing Enter on `Add model...` removes the configured binding list and starts the add-model flow at supported model slug selection. -- The model slug selector must support search or filtering by slug text. -- Pressing Enter on the focused model slug marked by `>` confirms the slug, removes the model selector, and shows the provider step. -- The provider step must let the user choose an existing provider or add a provider. -- If the user chooses to add a provider, `/model` prompts for provider name, base URL, and API key as separate current-step views below the composer. -- API key entry must use hidden or masked input by default. -- After provider selection or creation, `/model` prompts for the provider-specific model name. -- After model name entry, `/model` prompts for the model display name used in client interfaces. The value should be prefilled from the supported model definition's display name. -- Invocation method selection appears after display name entry and uses the same searchable selection pattern as onboarding. -- If the selected model supports reasoning, reasoning effort selection appears after invocation method selection and uses the same searchable selection pattern as onboarding. -- If the selected model does not support reasoning, the reasoning effort step is skipped. -- Pressing Esc returns to the previous step when one exists; otherwise it cancels `/model` and clears the below-composer command surface. -- Completed step values are stored in command-local state so back navigation and final application remain correct, but completed steps are not shown as inline history below the composer. -- The command may show a concise final confirmation or success status after applying the selection, but it must not expand the full completed-step history. - -## State And Error Behavior - -- The TUI should use `model/list` to populate configured model-provider bindings for the first `/model` screen and supported model choices for the `Add model...` flow. -- The final selection should use `model/select`. -- New or modified provider and model-provider binding data should follow the same validation and persistence expectations as onboarding and should be persisted before the command applies a newly created binding. -- Persistence errors for newly created or modified configuration must keep `/model` in a recoverable command state rather than silently falling back to a session-only binding. -- The command must show credential status but must not display plaintext API keys in routine lists. +- Opening `/model` with an empty argument shows the combined picker in place of the composer input area and hides the normal bottom status line while the picker is visible. +- The binding list is populated from saved model bindings in the session (effective configuration). +- Enter on the focused row applies the model (and effort when present) immediately via turn-context override. +- Esc or Ctrl+C dismisses the picker with no session change. +- Selecting an existing configured binding updates the current session selection only; it must not rewrite provider records, binding records, or default-selection fields after the first user message. - If invoked during active work, the TUI shows a concise blocked message such as `Cannot change model while generating`. - The selected model and reasoning effort affect the next turn, not an already-running invocation. -- Validation failures keep the user on the current below-composer step with a concise error near that step. -- If persistence fails after valid setup input, `/model` reports the target configuration scope and leaves the command in a recoverable state. ## Traceability @@ -219,9 +86,7 @@ That stacked rail view belongs to onboarding. `/model` uses one active step at a | related-to | L1-REQ-APP-010 | 1 | specs/L1/L1-REQ-APP-010-configuration.md | Defines when model selection changes are persisted as defaults versus session state. | | related-to | L2-DES-MODEL-001 | 2 | specs/L2/model/L2-DES-MODEL-001-model-provider-binding.md | Defines supported models, user providers, and model-provider bindings. | | related-to | L2-DES-APP-002 | 2 | specs/L2/app/L2-DES-APP-002-configuration-precedence.md | Defines configuration write scope, persistence target behavior, and distinction between session selection and durable records. | -| related-to | L2-DES-TUI-001 | 1 | specs/L2/tui/L2-DES-TUI-001-onboarding-ui-flow.md | Reuses the onboarding model setup sequence while using a transient below-composer command surface instead of an inline history stack. | | related-to | L2-DES-TUI-003 | 1 | specs/L2/tui/L2-DES-TUI-003-composer-and-input-modes.md | Uses shared slash-command discovery, popup, and invocation behavior. | -| specified-by | L3-BEH-TUI-004 | 2 | specs/L3/tui/L3-BEH-TUI-004-slash-commands.md | L3 defines consolidated slash command parsing, routing, and model command behavior. | ## Revision Notes @@ -236,3 +101,4 @@ That stacked rail view belongs to onboarding. `/model` uses one active step at a | 1 | 2026-05-25 | Human | Refinement | Clarified that existing binding selection is session state after the first user message, while newly created provider or binding records require configuration persistence. | | 1 | 2026-05-26 | Human | Refinement | Added binding display names to configured model rows and to the add-model flow. | | 1 | 2026-05-26 | Human | Refinement | Updated `/model` choice-list marker semantics so `>` marks focus and `●` marks the active model or reasoning value. | +| 2 | 2026-08-01 | Assistant | Refinement | Combined model list and horizontal reasoning-effort strip into one picker; capped list height with scroll; deferred Add model... to onboard. | diff --git a/specs/L2/tui/slash-commands/L2-DES-TUI-CMD-013-settings.md b/specs/L2/tui/slash-commands/L2-DES-TUI-CMD-013-settings.md new file mode 100644 index 00000000..1ae09ce1 --- /dev/null +++ b/specs/L2/tui/slash-commands/L2-DES-TUI-CMD-013-settings.md @@ -0,0 +1,58 @@ +--- +artifact_id: L2-DES-TUI-CMD-013 +revision: 2 +status: Draft +active_baseline: no +supersedes: +superseded_by: +owner: Assistant +last_updated: 2026-08-01 +--- + +# L2-DES-TUI-CMD-013 — Slash Command: /settings + +## Purpose + +Define the TUI behavior for `/settings`, which opens the Settings Hub panel. + +## Command Contract + +- Command: `/settings` +- Description: `open session and appearance settings` +- Parameters: none in the first milestone. +- Mutability: may mutate session configuration through nested editors (model, permissions, theme, reasoning view, compaction threshold, local input mode). +- Active-turn availability: unavailable during an active task (same class as `/model`, `/theme`, `/permissions`). + +## UI Flow + +`/settings` opens the Settings Hub bottom-pane view defined by `L2-DES-TUI-009`. + +Rules: + +- The hub replaces the composer while open. +- Nested pickers stack above the hub; Esc returns to the hub, then closes the hub. +- Compaction threshold apply uses `session/compaction/update` (`effectiveContextWindow`) and must take effect without restarting the process. + +## State And Error Behavior + +- The hub displays server-confirmed or TUI-local current values; it must not invent compaction limits. +- Values above the model context window are clamped on apply. +- Failed updates (other errors) keep the previous threshold visible and show the rejection reason. +- `/settings` must not create a model-visible transcript turn. + +## Traceability + +| Relationship | Target ID | Target Revision | Target Path | Rationale | +|---|---|---:|---|---| +| refines | L1-REQ-TUI-006 | 1 | specs/L1/L1-REQ-TUI-006-command-discovery-control.md | Defines command-specific behavior for a discoverable TUI command. | +| related-to | L2-DES-TUI-009 | 1 | specs/L2/tui/L2-DES-TUI-009-settings-hub.md | Hub layout and nested editor behavior. | +| related-to | L2-DES-TUI-CMD-002 | 1 | specs/L2/tui/slash-commands/L2-DES-TUI-CMD-002-model.md | Deep-links to the existing model picker. | +| related-to | L2-DES-TUI-CMD-001 | 1 | specs/L2/tui/slash-commands/L2-DES-TUI-CMD-001-theme.md | Deep-links to the existing theme picker. | +| related-to | L2-DES-TUI-CMD-007 | 1 | specs/L2/tui/slash-commands/L2-DES-TUI-CMD-007-permissions.md | Deep-links to the existing permissions picker. | + +## Revision Notes + +| Revision | Date | Author | Change Type | Notes | +|---:|---|---|---|---| +| 1 | 2026-08-01 | Assistant | Initial | Initial `/settings` command design. | +| 2 | 2026-08-01 | Assistant | Update | Compaction apply persists `effectiveContextWindow` with clamp semantics. | diff --git a/specs/traceability/l1_to_l2.md b/specs/traceability/l1_to_l2.md index d48f69b4..82fd8514 100644 --- a/specs/traceability/l1_to_l2.md +++ b/specs/traceability/l1_to_l2.md @@ -174,6 +174,9 @@ | L1-REQ-TUI-006 | specs/L1/L1-REQ-TUI-006-command-discovery-control.md | L2-DES-TUI-CMD-010 | specs/L2/tui/slash-commands/L2-DES-TUI-CMD-010-goal.md | refined-by | Defines the `/goal` command behavior. | | L1-REQ-TUI-006 | specs/L1/L1-REQ-TUI-006-command-discovery-control.md | L2-DES-TUI-CMD-011 | specs/L2/tui/slash-commands/L2-DES-TUI-CMD-011-btw.md | refined-by | Defines the `/btw` command behavior. | | L1-REQ-TUI-006 | specs/L1/L1-REQ-TUI-006-command-discovery-control.md | L2-DES-TUI-CMD-012 | specs/L2/tui/slash-commands/L2-DES-TUI-CMD-012-exit.md | refined-by | Defines the `/exit` command behavior. | +| L1-REQ-TUI-006 | specs/L1/L1-REQ-TUI-006-command-discovery-control.md | L2-DES-TUI-CMD-013 | specs/L2/tui/slash-commands/L2-DES-TUI-CMD-013-settings.md | refined-by | Defines the `/settings` command behavior. | +| L1-REQ-TUI-006 | specs/L1/L1-REQ-TUI-006-command-discovery-control.md | L2-DES-TUI-009 | specs/L2/tui/L2-DES-TUI-009-settings-hub.md | refined-by | Defines the Settings Hub panel, tabs, deep-links, and compaction threshold editor. | +| L1-REQ-TUI-004 | specs/L1/L1-REQ-TUI-004-state-visibility.md | L2-DES-TUI-009 | specs/L2/tui/L2-DES-TUI-009-settings-hub.md | related-to | The Settings Hub exposes current session configuration values. | | L1-REQ-TUI-007 | specs/L1/L1-REQ-TUI-007-responsive-layout-readability.md | L2-DES-TUI-002 | specs/L2/tui/L2-DES-TUI-002-modern-tui-shell-layout.md | refined-by | The modern TUI shell layout refines responsive priorities, narrow layout, non-overlap rules, and graceful degradation. | | L1-REQ-TUI-007 | specs/L1/L1-REQ-TUI-007-responsive-layout-readability.md | L2-DES-TUI-008 | specs/L2/tui/L2-DES-TUI-008-style.md | refined-by | The TUI style system refines readable spacing, symbol use, token contrast, fallback behavior, and terminal-compatible visual treatment. | | L1-REQ-TUI-007 | specs/L1/L1-REQ-TUI-007-responsive-layout-readability.md | L2-DES-TUI-003 | specs/L2/tui/L2-DES-TUI-003-composer-and-input-modes.md | related-to | Composer and bottom status line layout affect responsiveness. |