diff --git a/crates/core/src/query.rs b/crates/core/src/query.rs index 3b4ce17..f7807c9 100644 --- a/crates/core/src/query.rs +++ b/crates/core/src/query.rs @@ -1,3 +1,4 @@ +use std::collections::HashSet; use std::sync::Arc; use std::time::Duration; @@ -610,6 +611,7 @@ pub async fn query( let mut assistant_text = String::new(); let mut reasoning_text = String::new(); let mut tool_uses: Vec<(String, String, serde_json::Value, String, bool)> = Vec::new(); + let mut emitted_tool_use_starts: HashSet = HashSet::new(); let mut final_response = None; let mut stop_reason = None; @@ -631,6 +633,13 @@ pub async fn query( Ok(StreamEvent::ToolCallStart { id, name, input, .. }) => { + if emitted_tool_use_starts.insert(id.clone()) { + emit(QueryEvent::ToolUseStart { + id: id.clone(), + name: name.clone(), + input: input.clone(), + }); + } tool_uses.push((id, name, input, String::new(), false)); } Ok(StreamEvent::ToolCallInputDelta { partial_json, .. }) => { @@ -784,11 +793,13 @@ pub async fn query( } else { initial_input }; - emit(QueryEvent::ToolUseStart { - id: id.clone(), - name: name.clone(), - input: input.clone(), - }); + if emitted_tool_use_starts.insert(id.clone()) { + emit(QueryEvent::ToolUseStart { + id: id.clone(), + name: name.clone(), + input: input.clone(), + }); + } assistant_content.push(ContentBlock::ToolUse { id: id.clone(), name: name.clone(), @@ -977,6 +988,7 @@ mod tests { use devo_protocol::Usage; use devo_provider::ModelProviderSDK; use devo_safety::PermissionMode; + use devo_tools::ToolPreparationFeedback; use devo_tools::ToolRegistry; use devo_tools::ToolRuntime; use devo_tools::errors::ToolExecutionError; @@ -1462,6 +1474,7 @@ mod tests { execution_mode: ToolExecutionMode::Mutating, capability_tags: vec![], supports_parallel: false, + preparation_feedback: ToolPreparationFeedback::None, }); let registry = Arc::new(builder.build()); let deny_checker = PermissionChecker::new(|request| { @@ -1957,6 +1970,7 @@ mod tests { execution_mode: ToolExecutionMode::Mutating, capability_tags: vec![], supports_parallel: false, + preparation_feedback: ToolPreparationFeedback::None, }); let registry = Arc::new(builder.build()); let runtime = ToolRuntime::new_without_permissions(Arc::clone(®istry)); @@ -2010,6 +2024,7 @@ mod tests { execution_mode: ToolExecutionMode::ReadOnly, capability_tags: vec![], supports_parallel: false, + preparation_feedback: ToolPreparationFeedback::None, }); let registry = Arc::new(builder.build()); let runtime = ToolRuntime::new_without_permissions(Arc::clone(®istry)); @@ -2067,6 +2082,7 @@ mod tests { execution_mode: ToolExecutionMode::Mutating, capability_tags: vec![], supports_parallel: false, + preparation_feedback: ToolPreparationFeedback::None, }); let registry = Arc::new(builder.build()); let runtime = ToolRuntime::new_without_permissions(Arc::clone(®istry)); @@ -2144,6 +2160,7 @@ mod tests { execution_mode: ToolExecutionMode::ReadOnly, capability_tags: vec![], supports_parallel: true, + preparation_feedback: ToolPreparationFeedback::None, }); let registry = Arc::new(builder.build()); let runtime = ToolRuntime::new_without_permissions(Arc::clone(®istry)); diff --git a/crates/protocol/src/event.rs b/crates/protocol/src/event.rs index 81e3e5a..a192b49 100644 --- a/crates/protocol/src/event.rs +++ b/crates/protocol/src/event.rs @@ -61,6 +61,8 @@ pub struct CommandExecutionPayload { #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct FileChangePayload { pub tool_call_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tool_name: Option, pub changes: Vec<(std::path::PathBuf, FileChange)>, pub is_error: bool, } diff --git a/crates/server/src/projection.rs b/crates/server/src/projection.rs index 72decfe..d929c49 100644 --- a/crates/server/src/projection.rs +++ b/crates/server/src/projection.rs @@ -324,10 +324,18 @@ fn parse_edited_history_metadata(output: &serde_json::Value) -> Option devo_protocol::protocol::FileChange::Add { - content: "\n".repeat(additions as usize), + content: file + .get("content") + .and_then(serde_json::Value::as_str) + .map(ToOwned::to_owned) + .unwrap_or_else(|| "\n".repeat(additions as usize)), }, "delete" => devo_protocol::protocol::FileChange::Delete { - content: "\n".repeat(deletions as usize), + content: file + .get("content") + .and_then(serde_json::Value::as_str) + .map(ToOwned::to_owned) + .unwrap_or_else(|| "\n".repeat(deletions as usize)), }, "update" | "move" => devo_protocol::protocol::FileChange::Update { unified_diff: file diff --git a/crates/server/src/runtime/turn_exec.rs b/crates/server/src/runtime/turn_exec.rs index f3e0814..896b06f 100644 --- a/crates/server/src/runtime/turn_exec.rs +++ b/crates/server/src/runtime/turn_exec.rs @@ -3,12 +3,13 @@ use std::sync::Arc; use super::*; use crate::{FileChangePayload, TurnPlanStepPayload, TurnPlanUpdatedPayload}; +use devo_tools::tool_spec::ToolPreparationFeedback; use devo_utils::git_op::extract_paths_from_patch; use tokio::sync::mpsc; struct PendingToolCall { - item_id: ItemId, - item_seq: u64, + item_id: Option, + item_seq: Option, input: serde_json::Value, is_command_execution: bool, command: String, @@ -185,7 +186,7 @@ fn command_execution_item_id_for_progress( pending_tool_calls .get(tool_use_id) .filter(|pending| pending.is_command_execution) - .map(|pending| pending.item_id) + .and_then(|pending| pending.item_id) } impl ServerRuntime { @@ -377,7 +378,13 @@ impl ServerRuntime { } let is_command_execution = is_unified_exec_tool(&name); let command = command_display_from_input(&name, &input); - let item_kind = if is_file_change_tool(&name) { + let preparation_feedback = + runtime.deps.registry.preparation_feedback(&name); + let item_kind = if preparation_feedback + == ToolPreparationFeedback::LiveOnly + { + ItemKind::ToolCall + } else if is_file_change_tool(&name) { ItemKind::FileChange } else if is_command_execution { ItemKind::CommandExecution @@ -386,9 +393,22 @@ impl ServerRuntime { } else { ItemKind::ToolCall }; - let started_payload = if is_file_change_tool(&name) { + let started_payload = if preparation_feedback + == ToolPreparationFeedback::LiveOnly + { + serde_json::to_value(ToolCallPayload { + tool_call_id: id.clone(), + tool_name: name.clone(), + parameters: input.clone(), + command_actions: command_actions_from_tool_input( + &name, &command, &input, + ), + }) + .expect("serialize tool call payload") + } else if is_file_change_tool(&name) { serde_json::to_value(FileChangePayload { tool_call_id: id.clone(), + tool_name: Some(name.clone()), changes: Vec::new(), is_error: false, }) @@ -422,14 +442,21 @@ impl ServerRuntime { }) .expect("serialize tool call payload") }; - let (item_id, item_seq) = runtime - .start_item( - session_id, - turn_for_events.turn_id, - item_kind, - started_payload, - ) - .await; + let (item_id, item_seq) = if preparation_feedback + == ToolPreparationFeedback::LiveOnly + { + let (item_id, item_seq) = runtime + .start_item( + session_id, + turn_for_events.turn_id, + item_kind, + started_payload, + ) + .await; + (Some(item_id), Some(item_seq)) + } else { + (None, None) + }; pending_tool_calls.insert( id, PendingToolCall { @@ -451,7 +478,102 @@ impl ServerRuntime { let tool_name = tool_names_by_id.get(&tool_use_id).cloned(); // First complete the pending ToolCall item so its item/completed // arrives before the ToolResult item/completed. - if let Some(pending) = pending_tool_calls.remove(&tool_use_id) { + if let Some(mut pending) = pending_tool_calls.remove(&tool_use_id) { + if pending.item_id.is_none() || pending.item_seq.is_none() { + let started_payload = if let Some(tool_name) = tool_name.clone() { + let item_kind = if runtime + .deps + .registry + .preparation_feedback(&tool_name) + == ToolPreparationFeedback::LiveOnly + { + ItemKind::ToolCall + } else if is_file_change_tool(&tool_name) { + ItemKind::FileChange + } else if pending.is_command_execution { + ItemKind::CommandExecution + } else if is_plan_tool(&tool_name) { + ItemKind::Plan + } else { + ItemKind::ToolCall + }; + let payload = if runtime + .deps + .registry + .preparation_feedback(&tool_name) + == ToolPreparationFeedback::LiveOnly + { + serde_json::to_value(ToolCallPayload { + tool_call_id: tool_use_id.clone(), + tool_name: tool_name.clone(), + parameters: pending.input.clone(), + command_actions: command_actions_from_tool_input( + &tool_name, + &pending.command, + &pending.input, + ), + }) + .expect("serialize tool call payload") + } else if is_file_change_tool(&tool_name) { + serde_json::to_value(FileChangePayload { + tool_call_id: tool_use_id.clone(), + tool_name: Some(tool_name.clone()), + changes: Vec::new(), + is_error: false, + }) + .expect("serialize file change payload") + } else if pending.is_command_execution { + serde_json::to_value(CommandExecutionPayload { + tool_call_id: tool_use_id.clone(), + tool_name: tool_name.clone(), + command: pending.command.clone(), + source: devo_protocol::protocol::ExecCommandSource::Agent, + command_actions: command_actions_from_tool_input( + &tool_name, + &pending.command, + &pending.input, + ), + output: None, + is_error: false, + }) + .expect("serialize command execution payload") + } else if is_plan_tool(&tool_name) { + serde_json::json!({ + "title": "Plan", + "text": "" + }) + } else { + serde_json::to_value(ToolCallPayload { + tool_call_id: tool_use_id.clone(), + tool_name: tool_name.clone(), + parameters: pending.input.clone(), + command_actions: command_actions_from_tool_input( + &tool_name, + &pending.command, + &pending.input, + ), + }) + .expect("serialize tool call payload") + }; + let (item_id, item_seq) = runtime + .start_item( + session_id, + turn_for_events.turn_id, + item_kind.clone(), + payload, + ) + .await; + pending.item_id = Some(item_id); + pending.item_seq = Some(item_seq); + item_kind + } else { + ItemKind::ToolCall + }; + let _ = started_payload; + } + + let pending_item_id = pending.item_id.expect("pending item id"); + let pending_item_seq = pending.item_seq.expect("pending item seq"); if let Some(tool_name) = tool_name.clone() && is_plan_tool(&tool_name) { @@ -479,8 +601,8 @@ impl ServerRuntime { .complete_item( session_id, turn_for_events.turn_id, - pending.item_id, - pending.item_seq, + pending_item_id, + pending_item_seq, ItemKind::Plan, TurnItem::Plan(TextItem { text: output_json.to_string(), @@ -552,11 +674,19 @@ impl ServerRuntime { .unwrap_or(0); let change = match kind { "add" => devo_protocol::protocol::FileChange::Add { - content: "\n".repeat(additions as usize), + content: file + .get("content") + .and_then(serde_json::Value::as_str) + .map(ToOwned::to_owned) + .unwrap_or_else(|| "\n".repeat(additions as usize)), }, "delete" => { devo_protocol::protocol::FileChange::Delete { - content: "\n".repeat(deletions as usize), + content: file + .get("content") + .and_then(serde_json::Value::as_str) + .map(ToOwned::to_owned) + .unwrap_or_else(|| "\n".repeat(deletions as usize)), } } "update" | "move" => { @@ -609,8 +739,8 @@ impl ServerRuntime { .complete_item( session_id, turn_for_events.turn_id, - pending.item_id, - pending.item_seq, + pending_item_id, + pending_item_seq, ItemKind::FileChange, TurnItem::ToolResult(ToolResultItem { tool_call_id: tool_use_id.clone(), @@ -621,6 +751,7 @@ impl ServerRuntime { }), serde_json::to_value(FileChangePayload { tool_call_id: tool_use_id.clone(), + tool_name: Some(tool_name.clone()), changes, is_error, }) @@ -661,8 +792,8 @@ impl ServerRuntime { .complete_item( session_id, turn_for_events.turn_id, - pending.item_id, - pending.item_seq, + pending_item_id, + pending_item_seq, ItemKind::CommandExecution, TurnItem::CommandExecution(CommandExecutionItem { tool_call_id: tool_use_id.clone(), @@ -692,8 +823,8 @@ impl ServerRuntime { .complete_item( session_id, turn_for_events.turn_id, - pending.item_id, - pending.item_seq, + pending_item_id, + pending_item_seq, ItemKind::ToolCall, TurnItem::ToolCall(ToolCallItem { tool_call_id: tool_use_id.clone(), @@ -1366,8 +1497,8 @@ mod tests { pending_tool_calls.insert( "exec".to_string(), PendingToolCall { - item_id: command_item_id, - item_seq: 1, + item_id: Some(command_item_id), + item_seq: Some(1), input: serde_json::json!({}), is_command_execution: true, command: "cargo test".to_string(), @@ -1376,8 +1507,8 @@ mod tests { pending_tool_calls.insert( "read".to_string(), PendingToolCall { - item_id: tool_item_id, - item_seq: 2, + item_id: Some(tool_item_id), + item_seq: Some(2), input: serde_json::json!({}), is_command_execution: false, command: String::new(), diff --git a/crates/server/tests/skills_integration.rs b/crates/server/tests/skills_integration.rs index 5bdaca9..c3712e2 100644 --- a/crates/server/tests/skills_integration.rs +++ b/crates/server/tests/skills_integration.rs @@ -299,6 +299,7 @@ fn auto_review_registry(calls: Arc) -> Arc Result<( execution_mode: ToolExecutionMode::ReadOnly, capability_tags: vec![], supports_parallel: true, + preparation_feedback: devo_tools::ToolPreparationFeedback::None, }); let registry = Arc::new(builder.build()); let provider = Arc::new(SteerCapturingProvider::default()); diff --git a/crates/tools/src/apply_patch.rs b/crates/tools/src/apply_patch.rs index 84e9b05..ac39d0e 100644 --- a/crates/tools/src/apply_patch.rs +++ b/crates/tools/src/apply_patch.rs @@ -146,6 +146,10 @@ pub(crate) async fn exec_apply_patch( "relativePath": relative_path, "kind": kind_name, "type": kind_name, + "content": match change.kind { + PatchKind::Add | PatchKind::Move | PatchKind::Update => new_content, + PatchKind::Delete => old_content, + }, "diff": diff, "patch": diff, "additions": additions, diff --git a/crates/tools/src/handlers/file_write.rs b/crates/tools/src/handlers/file_write.rs index da3b534..22a85e5 100644 --- a/crates/tools/src/handlers/file_write.rs +++ b/crates/tools/src/handlers/file_write.rs @@ -87,6 +87,7 @@ fn build_write_metadata( "files": [{ "path": path.display().to_string(), "kind": "add", + "content": content, "additions": content.lines().count(), "deletions": 0 }] diff --git a/crates/tools/src/registry.rs b/crates/tools/src/registry.rs index f0e2b14..f3afb5c 100644 --- a/crates/tools/src/registry.rs +++ b/crates/tools/src/registry.rs @@ -6,7 +6,7 @@ use devo_protocol::ToolDefinition; use crate::errors::ToolDispatchError; use crate::invocation::{ToolInvocation, ToolOutput}; use crate::tool_handler::ToolHandler; -use crate::tool_spec::{ToolExecutionMode, ToolSpec}; +use crate::tool_spec::{ToolExecutionMode, ToolPreparationFeedback, ToolSpec}; use crate::unified_exec::store::ProcessStore; #[derive(Clone)] @@ -44,6 +44,12 @@ impl ToolRegistry { self.spec(name).is_some_and(|s| s.supports_parallel) } + pub fn preparation_feedback(&self, name: &str) -> ToolPreparationFeedback { + self.spec(name) + .map(|spec| spec.preparation_feedback) + .unwrap_or(ToolPreparationFeedback::None) + } + pub async fn dispatch( &self, name: &str, @@ -304,6 +310,7 @@ mod tests { execution_mode: ToolExecutionMode::ReadOnly, capability_tags: vec![], supports_parallel: true, + preparation_feedback: ToolPreparationFeedback::None, }); let registry = builder.build(); assert!(registry.get("echo").is_some()); @@ -322,6 +329,7 @@ mod tests { execution_mode: ToolExecutionMode::ReadOnly, capability_tags: vec![], supports_parallel: true, + preparation_feedback: ToolPreparationFeedback::None, }); let registry = builder.build(); let defs = registry.tool_definitions(); @@ -341,6 +349,7 @@ mod tests { execution_mode: ToolExecutionMode::Mutating, capability_tags: vec![], supports_parallel: true, + preparation_feedback: ToolPreparationFeedback::None, }); let registry = builder.build(); @@ -360,6 +369,7 @@ mod tests { execution_mode: ToolExecutionMode::Mutating, capability_tags: vec![], supports_parallel: true, + preparation_feedback: ToolPreparationFeedback::None, }); let registry = builder.build(); @@ -417,6 +427,7 @@ mod tests { execution_mode: ToolExecutionMode::ReadOnly, capability_tags: vec![], supports_parallel: true, + preparation_feedback: ToolPreparationFeedback::None, }); let registry = builder.build(); assert!(registry.supports_parallel("read")); @@ -442,6 +453,7 @@ mod tests { execution_mode: ToolExecutionMode::ReadOnly, capability_tags: vec![], supports_parallel: true, + preparation_feedback: ToolPreparationFeedback::None, }); builder.register_handler("write", Arc::new(EchoHandler)); builder.push_spec(ToolSpec { @@ -452,6 +464,7 @@ mod tests { execution_mode: ToolExecutionMode::Mutating, capability_tags: vec![], supports_parallel: false, + preparation_feedback: ToolPreparationFeedback::None, }); let registry = builder.build(); assert!(registry.is_read_only("read")); @@ -471,6 +484,7 @@ mod tests { execution_mode: ToolExecutionMode::Mutating, capability_tags: vec![], supports_parallel: false, + preparation_feedback: ToolPreparationFeedback::None, }); let registry = builder.build(); let spec = registry.spec("tool"); @@ -497,6 +511,7 @@ mod tests { execution_mode: ToolExecutionMode::ReadOnly, capability_tags: vec![], supports_parallel: true, + preparation_feedback: ToolPreparationFeedback::None, }); let registry = builder.build(); let invocation = ToolInvocation { diff --git a/crates/tools/src/registry_plan.rs b/crates/tools/src/registry_plan.rs index b51263b..4f8e17a 100644 --- a/crates/tools/src/registry_plan.rs +++ b/crates/tools/src/registry_plan.rs @@ -2,7 +2,9 @@ use std::collections::BTreeMap; use crate::handler_kind::ToolHandlerKind; use crate::json_schema::JsonSchema; -use crate::tool_spec::{ToolCapabilityTag, ToolExecutionMode, ToolOutputMode, ToolSpec}; +use crate::tool_spec::{ + ToolCapabilityTag, ToolExecutionMode, ToolOutputMode, ToolPreparationFeedback, ToolSpec, +}; const BASH_DESCRIPTION: &str = include_str!("bash.txt"); const READ_DESCRIPTION: &str = include_str!("read.txt"); @@ -503,6 +505,7 @@ pub fn build_tool_registry_plan(config: &ToolPlanConfig) -> ToolRegistryPlan { execution_mode: ToolExecutionMode::Mutating, capability_tags: vec![ToolCapabilityTag::ExecuteProcess], supports_parallel: false, + preparation_feedback: ToolPreparationFeedback::None, }, ToolHandlerKind::ShellCommand, ); @@ -516,6 +519,7 @@ pub fn build_tool_registry_plan(config: &ToolPlanConfig) -> ToolRegistryPlan { execution_mode: ToolExecutionMode::Mutating, capability_tags: vec![ToolCapabilityTag::ExecuteProcess], supports_parallel: false, + preparation_feedback: ToolPreparationFeedback::None, }, ToolHandlerKind::Bash, ); @@ -530,6 +534,7 @@ pub fn build_tool_registry_plan(config: &ToolPlanConfig) -> ToolRegistryPlan { execution_mode: ToolExecutionMode::ReadOnly, capability_tags: vec![ToolCapabilityTag::ReadFiles], supports_parallel: true, + preparation_feedback: ToolPreparationFeedback::None, }, ToolHandlerKind::Read, ); @@ -543,6 +548,7 @@ pub fn build_tool_registry_plan(config: &ToolPlanConfig) -> ToolRegistryPlan { execution_mode: ToolExecutionMode::Mutating, capability_tags: vec![ToolCapabilityTag::WriteFiles], supports_parallel: false, + preparation_feedback: ToolPreparationFeedback::LiveOnly, }, ToolHandlerKind::Write, ); @@ -557,6 +563,7 @@ pub fn build_tool_registry_plan(config: &ToolPlanConfig) -> ToolRegistryPlan { execution_mode: ToolExecutionMode::ReadOnly, capability_tags: vec![ToolCapabilityTag::SearchWorkspace], supports_parallel: true, + preparation_feedback: ToolPreparationFeedback::None, }, ToolHandlerKind::Glob, ); @@ -570,6 +577,7 @@ pub fn build_tool_registry_plan(config: &ToolPlanConfig) -> ToolRegistryPlan { execution_mode: ToolExecutionMode::ReadOnly, capability_tags: vec![ToolCapabilityTag::SearchWorkspace], supports_parallel: true, + preparation_feedback: ToolPreparationFeedback::None, }, ToolHandlerKind::Grep, ); @@ -583,6 +591,7 @@ pub fn build_tool_registry_plan(config: &ToolPlanConfig) -> ToolRegistryPlan { execution_mode: ToolExecutionMode::Mutating, capability_tags: vec![ToolCapabilityTag::WriteFiles], supports_parallel: false, + preparation_feedback: ToolPreparationFeedback::LiveOnly, }, ToolHandlerKind::ApplyPatch, ); @@ -596,6 +605,7 @@ pub fn build_tool_registry_plan(config: &ToolPlanConfig) -> ToolRegistryPlan { execution_mode: ToolExecutionMode::Mutating, capability_tags: vec![], supports_parallel: false, + preparation_feedback: ToolPreparationFeedback::None, }, ToolHandlerKind::Plan, ); @@ -611,6 +621,7 @@ pub fn build_tool_registry_plan(config: &ToolPlanConfig) -> ToolRegistryPlan { execution_mode: ToolExecutionMode::ReadOnly, capability_tags: vec![], supports_parallel: true, + preparation_feedback: ToolPreparationFeedback::None, }, ToolHandlerKind::Question, ); @@ -625,6 +636,7 @@ pub fn build_tool_registry_plan(config: &ToolPlanConfig) -> ToolRegistryPlan { execution_mode: ToolExecutionMode::Mutating, capability_tags: vec![], supports_parallel: false, + preparation_feedback: ToolPreparationFeedback::None, }, ToolHandlerKind::Task, ); @@ -640,6 +652,7 @@ pub fn build_tool_registry_plan(config: &ToolPlanConfig) -> ToolRegistryPlan { execution_mode: ToolExecutionMode::ReadOnly, capability_tags: vec![ToolCapabilityTag::NetworkAccess], supports_parallel: true, + preparation_feedback: ToolPreparationFeedback::None, }, ToolHandlerKind::WebFetch, ); @@ -653,6 +666,7 @@ pub fn build_tool_registry_plan(config: &ToolPlanConfig) -> ToolRegistryPlan { execution_mode: ToolExecutionMode::ReadOnly, capability_tags: vec![ToolCapabilityTag::NetworkAccess], supports_parallel: true, + preparation_feedback: ToolPreparationFeedback::None, }, ToolHandlerKind::WebSearch, ); @@ -666,6 +680,7 @@ pub fn build_tool_registry_plan(config: &ToolPlanConfig) -> ToolRegistryPlan { execution_mode: ToolExecutionMode::ReadOnly, capability_tags: vec![], supports_parallel: true, + preparation_feedback: ToolPreparationFeedback::None, }, ToolHandlerKind::Skill, ); @@ -681,6 +696,7 @@ pub fn build_tool_registry_plan(config: &ToolPlanConfig) -> ToolRegistryPlan { execution_mode: ToolExecutionMode::ReadOnly, capability_tags: vec![ToolCapabilityTag::SearchWorkspace], supports_parallel: true, + preparation_feedback: ToolPreparationFeedback::None, }, ToolHandlerKind::Lsp, ); @@ -695,6 +711,7 @@ pub fn build_tool_registry_plan(config: &ToolPlanConfig) -> ToolRegistryPlan { execution_mode: ToolExecutionMode::ReadOnly, capability_tags: vec![], supports_parallel: true, + preparation_feedback: ToolPreparationFeedback::None, }, ToolHandlerKind::Invalid, ); @@ -711,6 +728,7 @@ pub fn build_tool_registry_plan(config: &ToolPlanConfig) -> ToolRegistryPlan { execution_mode: ToolExecutionMode::Mutating, capability_tags: vec![ToolCapabilityTag::ExecuteProcess], supports_parallel: true, + preparation_feedback: ToolPreparationFeedback::None, }, ToolHandlerKind::ExecCommand, ); @@ -725,6 +743,7 @@ pub fn build_tool_registry_plan(config: &ToolPlanConfig) -> ToolRegistryPlan { execution_mode: ToolExecutionMode::Mutating, capability_tags: vec![ToolCapabilityTag::ExecuteProcess], supports_parallel: false, + preparation_feedback: ToolPreparationFeedback::None, }, ToolHandlerKind::WriteStdin, ); diff --git a/crates/tools/src/router.rs b/crates/tools/src/router.rs index f4d5e3f..a62cd45 100644 --- a/crates/tools/src/router.rs +++ b/crates/tools/src/router.rs @@ -545,7 +545,9 @@ mod tests { use crate::json_schema::JsonSchema; use crate::registry::ToolRegistryBuilder; use crate::tool_handler::ToolHandler; - use crate::tool_spec::{ToolExecutionMode, ToolOutputMode, ToolSpec}; + use crate::tool_spec::{ + ToolExecutionMode, ToolOutputMode, ToolPreparationFeedback, ToolSpec, + }; use async_trait::async_trait; use pretty_assertions::assert_eq; @@ -622,6 +624,7 @@ mod tests { execution_mode: ToolExecutionMode::ReadOnly, capability_tags: vec![], supports_parallel: true, + preparation_feedback: ToolPreparationFeedback::None, }); builder.register_handler("write_tool", Arc::new(WriteTool)); builder.push_spec(ToolSpec { @@ -632,6 +635,7 @@ mod tests { execution_mode: ToolExecutionMode::Mutating, capability_tags: vec![ToolCapabilityTag::WriteFiles], supports_parallel: false, + preparation_feedback: ToolPreparationFeedback::None, }); builder.register_handler("delayed_read_tool", Arc::new(DelayedReadTool)); builder.push_spec(ToolSpec { @@ -642,6 +646,7 @@ mod tests { execution_mode: ToolExecutionMode::ReadOnly, capability_tags: vec![], supports_parallel: true, + preparation_feedback: ToolPreparationFeedback::None, }); Arc::new(builder.build()) } @@ -1043,6 +1048,7 @@ mod tests { execution_mode: ToolExecutionMode::Mutating, capability_tags: vec![], supports_parallel: false, + preparation_feedback: ToolPreparationFeedback::None, }); Arc::new(builder.build()) } diff --git a/crates/tools/src/tool_spec.rs b/crates/tools/src/tool_spec.rs index 5fedd86..61b0b75 100644 --- a/crates/tools/src/tool_spec.rs +++ b/crates/tools/src/tool_spec.rs @@ -12,6 +12,7 @@ impl ToolSpec { execution_mode: ToolExecutionMode::Mutating, capability_tags: vec![], supports_parallel: false, + preparation_feedback: ToolPreparationFeedback::None, } } } @@ -29,6 +30,7 @@ mod tests { assert_eq!(spec.execution_mode, ToolExecutionMode::Mutating); assert!(spec.capability_tags.is_empty()); assert!(!spec.supports_parallel); + assert_eq!(spec.preparation_feedback, ToolPreparationFeedback::None); } #[test] @@ -85,6 +87,12 @@ pub enum ToolCapabilityTag { ReadImages, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum ToolPreparationFeedback { + None, + LiveOnly, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ToolSpec { pub name: String, @@ -94,4 +102,5 @@ pub struct ToolSpec { pub execution_mode: ToolExecutionMode, pub capability_tags: Vec, pub supports_parallel: bool, + pub preparation_feedback: ToolPreparationFeedback, } diff --git a/crates/tui/AGENTS.md b/crates/tui/AGENTS.md new file mode 100644 index 0000000..99de8c2 --- /dev/null +++ b/crates/tui/AGENTS.md @@ -0,0 +1,236 @@ +# AGENTS.md + +This file supplements the root [AGENTS.md](/AGENTS.md). When guidance conflicts, the rules here take precedence for code under `crates/tui/`. + +## Architecture + +The TUI crate follows a three-channel event loop driven by `host.rs`: + +``` +crossterm input --> TuiEvent --> host.rs --> ChatWidget.dispatch_tui_event() +user actions --> AppEvent --> host.rs --> ChatWidget.dispatch_app_event() or worker +server responses --> WorkerEvent -> host.rs --> ChatWidget.apply_worker_event() +``` + +`AppCommand` values flow from the chat widget or bottom pane, through the event sender, back into `host.rs`, where they are translated into worker RPC calls. + +## Modules + +### Size Guidelines + +- Keep modules under 500 lines excluding tests. Move new functionality into a submodule or sibling module once a file passes ~600 lines. +- `chatwidget.rs` (~4200 lines) and `history_cell.rs` (~1600 lines) are known exceptions due to deeply coupled variant rendering. Do not add significant new code to these files; prefer extracting to new modules under `chatwidget/` or as standalone files. +- `worker.rs` (~2900 lines) is the server child-process bridge and accumulates protocol translation logic. New protocol features should add well-bounded private methods rather than standalone modules unless the new code exceeds ~400 lines. + +### Adding a New Module + +1. Add `mod my_module;` to `lib.rs` with the appropriate visibility. +2. If the module re-exports items used elsewhere, prefer `pub(crate) use` re-exports from the module rather than exposing internal structure. +3. Every new module MUST have a `//!` module-level doc comment explaining its role and how it fits into the larger architecture. +4. If the module is a submodule of an existing directory (e.g., `bottom_pane/`, `tui/`), declare it in the parent `mod.rs` instead of `lib.rs`. + +### Module Visibility + +- Default to `pub(crate)` for all types, functions, and modules. +- `pub` is reserved for the public API surface: `lib.rs` exports and the types in `app.rs` (`AppExit`, `InteractiveTuiConfig`, `InitialTuiSession`). +- Use `pub(super)` for items shared within a parent module (e.g., submodules of `chatwidget/` that share internal types). + +## Event & Command Patterns + +### Adding a New AppEvent + +1. Add the variant to the `AppEvent` enum in `app_event.rs`. +2. Handle the variant in `ChatWidget::dispatch_app_event()`. +3. If `host.rs` needs to react (e.g., overlay lifecycle, session switching), add a match arm in the host event loop. +4. Events that carry structured data should use named fields in the enum variant, not free-form strings. + +### Adding a New AppCommand + +1. Add the variant to the `AppCommand` enum in `app_command.rs`. +2. Handle translation to worker RPC in `host.rs`. +3. If the command originates from a keybinding or UI action, dispatch it via `AppEventSender::send(AppEvent::Command(AppCommand::MyVariant { ... }))`. + +### Channel Patterns + +- `AppEventSender` wraps a tokio `mpsc` sender and provides a `send()` method. Use `try_send` for bounded channels and `send` for unbounded ones. Do not call the underlying channel directly. +- `FrameRequester` wraps a tokio `watch` channel for redraw scheduling. Call `request_frame()` when a widget changes state that should be visible to the user, and the frame loop collapses rapid requests automatically. +- Channel capacities: use 1024 for `AppEvent` channels unless there is a documented reason to deviate. + +## Widget Rendering + +### Renderable Trait + +Any widget that appears in the conversation transcript or the transcript overlay should implement `crate::render::renderable::Renderable`: + +```rust +trait Renderable { + fn render(&self, area: Rect, buf: &mut Buffer); + fn desired_height(&self, width: u16) -> u16; + fn cursor_pos(&self, _area: Rect) -> Option<(u16, u16)> { None } +} +``` + +- `render`: draws into a ratatui `Buffer`. Use the `RectExt` extension methods from `crate::render` for area subdivision. +- `desired_height`: returns the number of viewport rows this widget occupies at the given terminal width. Implementations MUST account for line wrapping. The default `HistoryCell::desired_height()` delegates to `Paragraph::line_count(Wrap { trim: false })`; cell types that don't wrap (e.g., `StartupHeaderCell`) may override with a fixed count. +- `cursor_pos`: return the (x, y) cursor position relative to `area` when the widget owns keyboard input. Return `None` otherwise. + +### HistoryCell Trait + +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). +- `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. + +### Active Cell Pattern + +During streaming, `ChatWidget` holds an `Option` as the active cell. This cell is mutated in place with each token delta. When streaming completes (the turn finishes), the active cell is committed into the transcript `VecDeque`. All mutations to the active cell MUST bump the active-cell revision counter so the transcript overlay cache is invalidated. + +### Width Changes + +When the terminal resizes: + +1. `CustomTerminal` detects the resize via `autoresize()`. +2. `ChatWidget` calls `reflow_transcript()` which re-renders every committed `HistoryCell` at the new width and recomputes scroll positions. +3. Streaming `StreamCore` instances re-render their retained raw markdown source at the new width and rebuild pending queues. Already-emitted lines are preserved. + +## Streaming Pipeline + +The streaming pipeline transforms LLM token deltas into visible output with backpressure control: + +``` +Token delta --> MarkdownStreamCollector --> StreamCore --> StreamState queue --> commit_tick --> ChatWidget transcript +``` + +### Key Invariants + +- `MarkdownStreamCollector` only commits source at newline boundaries. The trailing incomplete line stays in a buffer for the next delta. +- `StreamCore` tracks three offsets: `emitted_len`, `enqueued_len`, and `rendered_lines.len()`. These MUST remain in order: `emitted <= enqueued <= rendered`. +- `StreamState` records arrival timestamps for enqueued entries so backpressure can reason about queue age without peeking into line content. +- `commit_tick.rs` polls the queue on a timer and drains using `AdaptiveChunkingPolicy`, which balances responsiveness (don't wait too long) against rendering cost (don't redraw on every byte). + +### Adding a New Stream Type + +Follow the pattern in `ChatWidget` where assistant output and plan streams coexist: + +1. Create a `StreamController` that owns a `StreamCore` and exposes `push_delta`, `tick`, and `finalize` methods. +2. Store the controller in `ChatWidget` alongside the active cell. +3. On `tick`, drain emitted lines and mutate the active cell's content. + +## BottomPane & Popup Pattern + +The `BottomPane` owns a stack of popups drawn above the composer: + +### Adding a New Popup + +1. Create the popup widget in `bottom_pane/` (e.g., `command_popup.rs`). +2. Add an enum variant to `BottomPane::popup_state` (or introduce a new state mechanism if needed). +3. Implement rendering: the popup receives a `Rect` and draws into a `Buffer`. Use `popup_consts.rs` for sizing constants (`POPUP_HEIGHT`, `POPUP_WIDTH_PERCENT`). +4. Implement key handling: `BottomPane::dispatch_key_event()` routes to the active popup. The popup returns an `InputResult` indicating the action taken. +5. If the popup shows a scrollable list, reuse `list_selection_view::ListSelectionView` rather than building a custom list. + +### Focus Management + +- `BottomPane` tracks `has_input_focus` and delegates to the active surface (composer or current popup). +- Popups that compete for focus (e.g., autocomplete vs. slash commands) use `PopupFocus` ordering in the compositor. +- When a popup closes, focus returns to the composer. The popup's result (model selection, theme choice, etc.) is surfaced through `InputResult`. + +## Text & Markdown Rendering + +### Wrapping + +- For general text that may contain URLs or file paths, use `adaptive_wrap_line` / `adaptive_wrap_lines` from `wrapping.rs`. These detect URL-like tokens and prevent splitting them across lines. +- For code blocks and plain prose where URLs are impossible, use `word_wrap_line` / `word_wrap_lines` for performance. +- Never call `textwrap` directly; route through `wrapping.rs` so the wrapping behavior is consistent. + +### File Links + +- `markdown_render.rs` treats local file paths differently from web links. Local file-link text comes from the destination path, not the label, so transcripts show the actual file target. +- Paths are shortened relative to the session working directory when possible. Always pass `cwd` to `append_markdown` so streamed and non-streamed rendering produce the same relative-path text. + +### Syntax Highlighting + +- Use `crate::render::highlight::highlight_code_to_lines` for syntax highlighting in code blocks and diffs. +- Highlighting uses `syntect`/`two-face` under the hood. Language detection is extension-based; ensure new file types are registered in the highlight module. + +## Testing + +### Test File Conventions + +- Place unit tests in dedicated `_tests.rs` files alongside the module they test (e.g., `chatwidget_tests.rs`, `custom_terminal_clear_tests.rs`, `markdown_render_tests.rs`). +- For smaller test suites (<50 lines), inline `#[cfg(test)] mod tests { ... }` in the module file is acceptable. +- Do not add test functions to production code files outside of `#[cfg(test)]` blocks. + +### Test Harnesses + +- Widget tests typically create a channel pair and construct the widget with an `AppEventSender` backed by the test channel: + ```rust + let (app_event_tx, app_event_rx) = mpsc::unbounded_channel(); + let widget = ChatWidget::new_with_app_event(ChatWidgetInit { + app_event_tx: AppEventSender::new(app_event_tx), + // ... + }); + ``` +- Use `FrameRequester::test_dummy()` for redraw scheduling in tests; do not depend on real frame-timing behavior. +- When testing streaming behavior, construct `StreamCore` / `StreamState` directly rather than going through the full TUI loop. +- Use `pretty_assertions::assert_eq` for all assertions. Deep-equality checks on entire objects are preferred over individual field comparisons. + +### Platform-Specific Tests + +- Use `#[cfg(unix)]` and `#[cfg(windows)]` to define platform-specific test cases when behavior differs. +- Tests that involve terminal modes (raw mode, alternate screen) should be `#[cfg(unix)]` unless Windows behavior is explicitly covered. + +## Terminal Lifecycle + +### Entering/Exiting Raw Mode + +- Terminal mode entry and restoration are managed by `tui.rs`. Raw mode is entered once at startup and restored exactly once at exit via `TerminalRestoreGuard` in `host.rs`. +- The restore happens after the TUI area is cleared and before the shell prints the next prompt, preventing Terminal.app prompt drift. +- For temporary restoration during external interactive programs (Ctrl+Z, spawned subprocesses), use `tui.rs` suspend/resume pairs. Do not call crossterm raw-mode functions directly. + +### Frame Scheduling + +- `FrameRateLimiter` caps draw frequency to avoid wasting terminal work on intermediate states. Configurable in `tui.rs` initialization. +- `FrameRequester` collapses rapid redraw requests: multiple `request_frame()` calls between draws result in a single render. +- When a widget changes state that should push a frame, call `FrameRequester::request_frame()`. Do not call terminal draw methods directly. + +### Overlays (Alternate Screen) + +- The Ctrl+T transcript pager and other full-screen overlays use the terminal alternate screen. `pager_overlay.rs` owns the rendering and scroll state; `host_overlay.rs` owns the enter/exit lifecycle. +- Overlays receive their own key events; `host.rs` dispatches to the overlay rather than to `ChatWidget` when an overlay is active. +- Adding a new overlay: follow the `Overlay` enum + `OverlayState` pattern in `pager_overlay.rs` and `host_overlay.rs`. + +## Dependencies + +- **ratatui**: The immediate-mode TUI library. Widgets implement `ratatui::widgets::Widget`. Use `ratatui::text::Line` and `ratatui::text::Span` for styled text. +- **crossterm**: Terminal backend. Only use crossterm types in `tui.rs`, `host.rs`, and input-handling code. Higher-level widgets should not import crossterm directly unless handling key events. +- **pulldown-cmark**: Markdown parser. Used by `markdown_render.rs`. Do not use other markdown libraries. +- **syntect / two-face**: Syntax highlighting. Used by `render/highlight.rs`. Do not add other highlighting dependencies. +- **tokio**: Async runtime. The event loop in `host.rs` is a `tokio::select!` over three streams. Worker communication uses tokio `mpsc` channels and `tokio::process`. +- **textwrap**: Line wrapping with URL-aware heuristics. Always accessed through `wrapping.rs`, never directly. +- **unicode-width / unicode-segmentation**: Width calculations and grapheme cluster iteration. Use these when measuring or splitting user-visible text. + +## Style & Naming + +### Types + +- Enum variants use PascalCase, not SCREAMING_CASE (e.g., `CompletionReason::NewTurn`, not `COMPLETION_REASON_NEW_TURN`). +- Parameter structs use a `Params` suffix (e.g., `BottomPaneParams`, `OutputLinesParams`, `SelectionViewParams`). +- Initialization structs use an `Init` suffix (e.g., `ChatWidgetInit`). + +### Channel Names + +- Sender ends: `_tx` suffix (e.g., `app_event_tx`). +- Receiver ends: `_rx` suffix (e.g., `app_event_rx`). + +### Import Style + +- Use crate-qualified paths for sibling modules (e.g., `crate::chatwidget::ChatWidget`, not `super::ChatWidget`). +- Group imports in this order: std library, external crates, devo crates, crate-local modules. Separate each group with a blank line. + +## Commits & Documentation + +- When adding or changing public API types in `app.rs` or `lib.rs`, ensure the `docs/` directory at the repository root is updated if applicable. +- Module-level `//!` doc comments should describe what the module does and how it fits into the broader architecture, not how it works internally. Internal details go on individual types and functions. +- Internal iterator patterns and streaming states should include brief comments explaining the invariants (e.g., the `emitted <= enqueued <= rendered` ordering). diff --git a/crates/tui/README.md b/crates/tui/README.md new file mode 100644 index 0000000..b065a28 --- /dev/null +++ b/crates/tui/README.md @@ -0,0 +1,498 @@ +# devo-tui + +The interactive terminal UI crate for Devo. It provides a Ratatui-based TUI with a chat-like conversation surface, a rich composer, streaming markdown rendering, tool execution display, syntax-highlighted diffs, and full-screen pager overlays. + +## Architecture Overview + +The crate follows a layered architecture where each layer has clear boundaries: + +``` + Terminal Backend (crossterm) + | + Tui (tui.rs) -- event stream, frame scheduling, raw mode + | + Host (host.rs) -- tokio event loop, channel bridging + / | \ + TuiEvent AppEvent WorkerEvent + | | | + Tui input events ChatWidget + BottomPane Worker (worker.rs) + (key/mouse) (UI rendering) (server child process) +``` + +The outer shell is `host.rs`, which owns the main `tokio` event loop. It bridges three concurrent concerns: + +1. **Terminal input** arrives as `TuiEvent` from `tui.rs` and is dispatched to the chat widget or overlay. +2. **User actions** produce `AppEvent` or `AppCommand` values that flow either to the chat widget (for local UI updates) or to the worker (for server RPC). +3. **Server responses** arrive as `WorkerEvent` from the worker and are fed into the chat widget to update the transcript. + +## Terminal UI Fundamentals + +This crate is built on a few core terminal UI concepts. If you are comfortable programming but new to terminal UIs, the main mental model is: + +> A TUI is an event-driven renderer over a terminal buffer. + +Unlike a GUI framework, there is no persistent window tree owned by the OS. The application repeatedly: + +1. Reads terminal events, such as key presses, mouse input, resize events, focus changes, and paste payloads. +2. Updates application state. +3. Requests a redraw. +4. Renders the entire visible UI for the current terminal size into a frame buffer. +5. Flushes the frame to the terminal backend. + +The terminal is therefore closer to a canvas than to a DOM. Widgets are mostly pure rendering logic over state. + +### Ratatui + +`ratatui` is the terminal UI framework used by this crate. It provides the rendering abstraction: + +- `Terminal`: owns the backend and drives frame rendering. +- `Frame`: the per-draw rendering context. +- `Rect`: rectangular areas in terminal cell coordinates. +- `Buffer`: the off-screen cell buffer that eventually gets flushed to the terminal. +- `Widget`: renderable UI components. +- `Layout`: utilities for splitting terminal space into regions. +- `Line`, `Span`, `Text`: styled text primitives. +- `Style`, `Color`, `Modifier`: styling primitives. + +A typical Ratatui render pass looks like this conceptually: + +```rust +terminal.draw(|frame| { + let area = frame.area(); + + let chunks = Layout::vertical([ + Constraint::Min(1), + Constraint::Length(3), + ]) + .split(area); + + transcript.render(chunks[0], frame.buffer_mut()); + composer.render(chunks[1], frame.buffer_mut()); +})?; +``` + +The important point is that Ratatui does not usually own your state. Your application owns state, and Ratatui renders a view of that state into the current frame. + +In this crate, `ChatWidget`, `BottomPane`, history cells, overlays, diff views, and execution cells all follow that model: state lives in application structs, and rendering converts that state into terminal cells. + +### Crossterm + +`crossterm` is the terminal backend used underneath Ratatui. It handles low-level terminal operations such as: + +* entering and leaving the alternate screen, +* enabling and disabling raw mode, +* reading keyboard and mouse events, +* enabling bracketed paste, +* enabling focus reporting, +* controlling cursor visibility and position, +* writing terminal commands to stdout. + +In this crate, `tui.rs` owns most of that terminal substrate. It is responsible for putting the terminal into the correct mode when the app starts and restoring it on exit, suspend, panic, or overlay transition. + +The most important crossterm concepts are: + +#### Raw mode + +In normal terminal mode, the shell or terminal driver processes input before the application sees it. For example, Enter may be line-buffered, Ctrl+C may interrupt the process, and backspace may be handled by the terminal. + +In raw mode, the application receives key events directly. This is required for interactive editors, TUIs, custom shortcuts, and real-time input handling. + +Because raw mode changes terminal behavior globally for the process, it must be restored reliably. A broken exit path can leave the user’s terminal in a bad state. + +#### Alternate screen + +The alternate screen is a separate terminal screen buffer. Full-screen terminal applications usually draw into the alternate screen so the user’s shell scrollback is restored when the app exits. + +This crate uses the alternate screen both for the main TUI and for full-screen overlays such as the transcript pager. + +#### Bracketed paste + +Bracketed paste lets the terminal distinguish typed input from pasted input. Without it, a multi-line paste looks like a rapid sequence of key presses. With bracketed paste, the application can detect paste boundaries and handle large pasted content more safely. + +This matters for the composer because pasted prompts, code blocks, logs, and multi-line content should not be interpreted as accidental command sequences. + +### The historical `tui` crate + +You may see the term `tui` in older Rust terminal UI material. Historically, `tui-rs` was a popular Rust TUI library. It is now effectively succeeded by `ratatui`, a maintained fork. + +In this crate: + +* `ratatui` is the external UI framework. +* `tui.rs` is this crate’s local terminal wrapper module. +* `Tui` is this crate’s abstraction around terminal mode, rendering, frame scheduling, and event streaming. + +So when reading this codebase, distinguish between: + +```text +ratatui external rendering framework +crossterm external terminal backend +tui.rs local module that integrates terminal setup, events, and rendering +Tui local runtime wrapper around the terminal +``` + +### Events + +The TUI is event-driven. Nothing happens just because a widget exists. The host loop must receive an event, update state, and schedule a frame. + +This crate separates events by responsibility: + +```text +TuiEvent terminal-originated input: keys, mouse, resize, paste, focus +AppEvent UI/application events: redraw, submit input, open popup, exit +AppCommand commands that should be sent to the worker/server +WorkerEvent server-originated events: streamed text, tool calls, results, status +``` + +This separation is important because not every event has the same destination. + +For example: + +* A key press may be handled locally by the composer. +* Pressing Enter may become an `AppCommand::UserTurn`. +* A streamed assistant token arrives as a `WorkerEvent`. +* A popup selection may update UI state without touching the worker. +* A terminal resize requires re-rendering but not a server call. + +The host loop is the bridge that decides where each event goes. + +### Rendering versus state mutation + +A common TUI design rule is: + +> Event handlers mutate state; render functions display state. + +Render code should generally avoid changing application state. This keeps redraws deterministic and makes it easier to handle resize events, animation ticks, snapshot tests, and frame coalescing. + +For example, when assistant text streams in: + +1. The worker emits a `WorkerEvent::AssistantTextDelta`. +2. `ChatWidget` mutates the active streaming cell. +3. The streaming controller decides what rendered lines are ready. +4. A frame is scheduled. +5. The next render pass draws the visible transcript from current state. + +The render pass itself should not be responsible for consuming server events or advancing application logic beyond narrowly scoped animation/render bookkeeping. + +### Frame scheduling + +Terminals are relatively expensive to redraw compared with mutating in-memory state. A burst of events should not necessarily cause an equal burst of terminal draws. + +This crate uses frame scheduling and rate limiting: + +* `FrameRequester` lets code request a redraw without drawing immediately. +* Rapid requests are collapsed into fewer actual frames. +* `FrameRateLimiter` caps draw frequency. +* Streaming output is chunked so the UI remains responsive while avoiding excessive rendering work. + +This is especially important during model streaming, command execution, large diffs, and transcript overlays. + +### Terminal coordinates and layout + +Terminal UIs render in a grid of cells, not pixels. A `Rect` has: + +```text +x, y, width, height +``` + +All layout decisions eventually become cell rectangles. + +Text width is also not always equal to byte length or character count. Unicode, emoji, CJK characters, combining marks, and ANSI escape sequences all complicate visual width. This crate has dedicated wrapping and truncation utilities to keep terminal layout stable. + +When working on rendering code, prefer existing helpers such as: + +* `live_wrap.rs` +* `wrapping.rs` +* `line_truncation.rs` +* `text_formatting.rs` +* `render/line_utils.rs` + +rather than using raw string length. + +### Input routing + +The bottom pane, popups, overlays, and main transcript do not all receive input at the same time. Input is routed to the active surface. + +Typical priority: + +1. If an overlay is active, it receives input. +2. Else if a popup is active, the popup receives input. +3. Else the composer or chat widget receives input. +4. Some global keybindings may be intercepted by the host or top-level widget. + +This prevents, for example, a key press intended for a popup from also editing the composer. + +### Overlays + +Full-screen overlays are temporary UI modes such as the transcript pager or static text viewer. An overlay usually: + +1. Takes over the alternate screen. +2. Receives input instead of the main chat widget. +3. Renders a separate full-screen view. +4. Exits on Escape or another command. +5. Restores the previous UI surface. + +The main application state still exists while the overlay is active. For the transcript overlay, the overlay can sync from the live `ChatWidget` so streamed output continues to appear in live-tail mode. + +### Streaming text + +LLM output arrives incrementally, often token by token. Rendering every token directly can produce unstable markdown and excessive redraws. + +This crate uses a streaming pipeline that buffers markdown and commits displayable content in controlled chunks: + +```text +raw token deltas + ↓ +MarkdownStreamCollector + ↓ +StreamCore + ↓ +AdaptiveChunkingPolicy + ↓ +HistoryCell lines + ↓ +ChatWidget transcript +``` + +The key idea is that streamed source text and rendered visible lines are related but not identical. Markdown may need to wait for newline boundaries or retained source re-rendering when terminal width changes. + +### Worker bridge + +The TUI is not the LLM server. The worker layer bridges between the UI and `devo-server`. + +The UI emits commands such as “start a user turn,” “interrupt,” “approve this tool call,” or “switch session.” The worker translates those into server RPC calls. Server events are translated back into `WorkerEvent`s that the UI can render. + +This keeps the terminal UI focused on interaction and display, while the worker owns server process communication and protocol translation. + +### Practical rule of thumb + +When changing this crate, first identify which layer you are modifying: + +```text +Terminal behavior tui.rs, tui/event_stream.rs, job control +Event routing host.rs, app_event.rs, app_command.rs +Main chat state chatwidget.rs +Input/composer UI bottom_pane/* +Rendering primitive history_cell.rs, render/*, markdown* +Server bridge worker.rs, events.rs +Overlay behavior pager_overlay.rs, host_overlay.rs +``` + +Most bugs become easier to reason about once the layer is clear. + +``` + +One typo to fix in your intended wording: use **“fundamental knowledge”**, not “foundmental knowledge,” and **“familiar”**, not “familar.” + +## Module Map + +### Entry Points + +| Module | Purpose | +|--------|---------| +| `lib.rs` | Public API: `run_interactive_tui`, `AppExit`, `InteractiveTuiConfig`, `InitialTuiSession`, `SavedModelEntry` | +| `host.rs` | Main event loop. Bridges `Tui`, `ChatWidget`, and `QueryWorkerHandle`. Owns terminal mode enter/restore, overlay lifecycle, and session-switch orchestration. | +| `app.rs` | Startup/exit types that the CLI layer uses to configure and receive results from the TUI. | + +### Terminal Substrate: `tui.rs` and Submodules + +| Module | Purpose | +|--------|---------| +| `tui.rs` | `Tui` wrapper: enters raw mode, alternate screen, bracketed paste, focus reporting, keyboard enhancement. Owns the main render loop. | +| `tui/event_stream.rs` | Converts crossterm `Event` into `TuiEvent`. Handles stdin pause/resume for external interactive programs. | +| `tui/frame_requester.rs` | Lightweight redraw scheduling handle. Collapses rapid requests into single draws. | +| `tui/frame_rate_limiter.rs` | Caps draw frequency so animations feel responsive without wasting terminal work. | +| `tui/job_control.rs` | Unix job control: Ctrl+Z suspend and resume with terminal mode restoration. | + +### Core UI Widget + +[`chatwidget.rs`](/Users/tsiao/Desktop/devo/crates/tui/src/chatwidget.rs) (4200+ lines) is the central widget. It owns: + +- The visible transcript (a `VecDeque` of `HistoryCell` entries). +- The active streaming cell (an `Option` that mutates in place during streaming). +- The `BottomPane` (composer + footer + popup stack). +- Session state: model selection, thinking mode, permission preset, scroll position. +- `StreamController` instances for assistant output and plan streaming. +- Input dispatch: from `TuiEvent` through keybinding maps to `AppEvent`/`AppCommand` emission. + +Submodules under `chatwidget/`: + +| Module | Purpose | +|--------|---------| +| `status_surfaces.rs` | Status-line widgets shown in the footer. | +| `session_header.rs` | Session title and metadata display. | +| `realtime.rs` | Real-time timestamp and token-count display. | +| `plugins.rs` | Plugin connector rendering. | + +### Bottom Pane (Input Surface) — `bottom_pane/` + +The bottom pane is the user-facing input area. It contains: + +| Module | Purpose | +|--------|---------| +| `mod.rs` | `BottomPane` orchestrator. Assembles composer, footer, and popup stack. Routes key events to the active surface (composer or popup). | +| `chat_composer.rs` | Multiline text input with mention autocomplete, paste handling, and input history. | +| `bottom_pane_view.rs` | Layout: splits the bottom area into composer + footer rows. | +| `textarea.rs` | Custom textarea with cursor positioning and selection support. | +| `footer.rs` | Status footer rendering: mode indicators, session info. | +| `unified_exec_footer.rs` | Footer variant for active command execution. | +| `chat_composer_history.rs` | Persistent input history with up/down navigation. | +| `slash_commands.rs` | Slash-command registration and dispatch. | +| `command_popup.rs` | Popup list for slash commands and other actions. | +| `list_selection_view.rs` | Generic scrollable selection list used by popups. | +| `approval_overlay.rs` | Permission-approval dialog for tool calls. | +| `pending_thread_approvals.rs` | Tracks approval requests that await user response. | +| `pending_input_preview.rs` | Preview of user input before submission. | +| `paste_burst.rs` | Multi-line paste detection and debouncing. | +| `theme_picker.rs` | Theme selection popup. | +| `skill_popup.rs` | Skill selection popup. | +| `file_search_popup.rs` | File-search popup for mention autocomplete. | +| `onboarding_view.rs` | First-run model/provider configuration. | +| `scroll_state.rs` | Scroll position tracking for the composer. | +| `selection_popup_common.rs` | Shared popup behavior. | +| `popup_consts.rs` | Popup sizing constants. | +| `prompt_args.rs` | Prompt argument parsing. | +| `status_line_setup.rs` | Status-line layout constants. | +| `title_setup.rs` | Title-line layout constants. | +| `custom_prompt_view.rs` | Custom prompt display. | + +### Conversation Cells + +| 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. | + +### Execution Display — `exec_cell/` + +| Module | Purpose | +|--------|---------| +| `model.rs` | `ExecCell` data model: command line, output state, exit status. | +| `render.rs` | Renders shell command output, truncated tool output previews, and active exec indicators. | +| `spinner.rs` | Animated spinner widget for in-progress operations. | + +### Streaming Pipeline — `streaming/` + +Streaming transforms streamed LLM token deltas into visible `HistoryCell` entries: + +``` +Token delta → MarkdownStreamCollector → StreamCore → queued HistoryCell → ChatWidget transcript +``` + +| Module | Purpose | +|--------|---------| +| `mod.rs` | `StreamState`: newline-gated markdown collection with a FIFO queue of committed render lines. Records arrival timestamps for backpressure decisions. | +| `controller.rs` | `StreamCore`: converts newline-complete markdown source into rendered `Line`s, manages enqueue/emit progress, handles width-change re-rendering from retained source. | +| `chunking.rs` | `AdaptiveChunkingPolicy`: computes optimal drain sizes from queue depth and line age, balancing responsiveness against rendering cost. | +| `commit_tick.rs` | Binds adaptive chunking decisions to controller drain operations. Runs on a timer to emit accumulated lines at a steady pace. | + +### Markdown Rendering + +| Module | Purpose | +|--------|---------| +| `markdown_stream.rs` | `MarkdownStreamCollector`: buffers raw markdown source and commits only at newline boundaries. Exposes `committed_source_len` to track progress. | +| `markdown.rs` | `append_markdown`: renders markdown source into ratatui `Line`s, resolving local file-link paths relative to the session working directory. | +| `markdown_render.rs` | Full markdown-to-`Text` renderer using `pulldown-cmark`. Handles headings, lists, code blocks (with syntax highlighting via `syntect`), inline code, bold/italic emphasis, links, blockquotes, and citations. | + +### Rendering Utilities — `render/` + +| Module | Purpose | +|--------|---------| +| `mod.rs` | `Insets` and `RectExt` for area manipulation. | +| `renderable.rs` | `Renderable` trait with `render`, `desired_height`, and `cursor_pos`. Used by history cells, overlays, and exec cells for uniform rendering. | +| `line_utils.rs` | Line prefixing, concatenation, and owned-line helpers. | +| `highlight.rs` | Syntax highlighting via `syntect`/`two-face`. | + +### Diff Rendering + +[`diff_render.rs`](/Users/tsiao/Desktop/devo/crates/tui/src/diff_render.rs) renders unified diffs for `FileChange` entries. Features: +- Line numbers and gutter signs (`+`/`-`/` `) +- Syntax highlighting per hunk (preserving parser state across consecutive lines) +- Theme-aware backgrounds (dark terminal: muted tints; light terminal: GitHub pastels) +- Color-level fallback (truecolor → 256 → 16 color) +- Hard-wrap for long lines with style preservation across split points + +### Pager Overlay + +Full-screen overlays rendered in the terminal alternate screen: + +| Module | Purpose | +|--------|---------| +| `pager_overlay.rs` | `TranscriptOverlay`: scrollable full-screen transcript viewer with search, Vim-style navigation, and live-tail mode. `StaticOverlay`: general-purpose full-screen text display. | +| `host_overlay.rs` | `OverlayState`: manages overlay enter/exit. Syncs transcript overlay content from the live `ChatWidget`, schedules frames for live-tail with animated cells. | + +### Worker (Server Bridge) + +[`worker.rs`](/Users/tsiao/Desktop/devo/crates/tui/src/worker.rs) (2900+ lines) spawns the `devo-server` as a child process over stdio and bridges TUI commands with server RPC: + +1. Starts a server process via `StdioServerClient`. +2. Receives `AppCommand` from the host and translates into server RPC calls (session start/resume/list, turn start/interrupt/steer, approval respond, permission update, etc.). +3. Listens for `ServerEvent` from the server and converts them into `WorkerEvent` for the host. +4. Handles session lifecycle: ensures a session exists, starts turns, processes streamed item events. + +### Supporting Modules + +| Module | Purpose | +|--------|---------| +| `events.rs` | `WorkerEvent` enum and supporting types (`TranscriptItem`, `TranscriptItemKind`, `PlanStep`, `PlanStepStatus`, `SessionListEntry`, `SavedModelEntry`). Defines the data vocabulary between worker and UI. | +| `app_event.rs` | `AppEvent` enum: UI-level events (redraw, exit, submit input, open popups, status updates). | +| `app_command.rs` | `AppCommand` enum: commands sent to the worker (user turn, steer, approval, session switch, rollback, fork). | +| `app_event_sender.rs` | Channel sender wrapper for `AppEvent` with deduplication. | +| `theme.rs` | `Theme` color palette and `ThemeSet` with builtin themes (devo, dark, light, aurora). | +| `terminal_palette.rs` | Terminal color capability detection. | +| `style.rs` | Reusable style definitions. | +| `color.rs` | Color conversion and manipulation. | +| `text_formatting.rs` | Text truncation and formatting utilities. | +| `live_wrap.rs` | Incremental text wrapping into visual rows respecting Unicode width. | +| `wrapping.rs` | Adaptive wrapping with runtime width changes. | +| `line_truncation.rs` | Line truncation with Unicode-aware width. | +| `custom_terminal.rs` | Modified `ratatui::Terminal` that handles OSC escape sequences during diffing, supports synchronized updates, and provides efficient clear. | +| `key_hint.rs` | Keybinding hint display. | +| `shimmer.rs` | Animated shimmer/placeholder effect for loading states. | +| `status_indicator_widget.rs` | Animated status indicator for processing states. | +| `startup_header.rs` | Animated startup header with logo and version info. | +| `slash_command.rs` | Slash command definitions (`/model`, `/theme`, `/thinking`, `/status`, etc.). | +| `exec_command.rs` | Shell command execution helpers. | +| `get_git_diff.rs` | Extracts git diffs for display. | +| `clipboard_copy.rs` / `clipboard_paste.rs` | System clipboard integration via `arboard`. | +| `onboarding.rs` | Persists onboarding configuration (model, provider, API key) to `config.toml`. | +| `ui_consts.rs` | Shared UI constants (prefix columns, layout widths). | +| `version.rs` | Version tracking and display. | +| `test_backend.rs` | Test backend for snapshot testing. | +| `insert_history.rs` | History insertion utilities. | +| `ansi_escape.rs` | (Re-exported from devo-utils) ANSI escape sequence handling. | + +## Data Flow: A Turn Lifecycle + +A typical user turn flows through these stages: + +1. **User types input** in the `ChatComposer` and presses Enter. +2. `BottomPane` emits an `InputResult::Submit`, which `ChatWidget` converts into an `AppCommand::UserTurn`. +3. The `AppCommand` is sent via `app_event_tx` to the host's `AppEvent::Command` handler. +4. The host forwards the command through `QueryWorkerHandle::send_command`. +5. **Worker** receives the command, translates it into `TurnStartParams`, and calls the server RPC. +6. **Server** begins streaming response items. The worker receives `ItemEventPayload` and converts each into `WorkerEvent`s (e.g., `AssistantTextDelta`, `ReasoningTextDelta`, `ToolCall`, `ToolResult`). +7. **Worker events** flow back through `worker_event_rx` to the host, which feeds them to `ChatWidget::handle_worker_event`. +8. **ChatWidget** updates the active streaming `HistoryCell` in place (appending text, setting tool state). The streaming pipeline (`StreamCore` → `AdaptiveChunkingPolicy` → `commit_tick`) manages when partial lines become visible. +9. **Turn completion** (`TurnFinished`) finalizes the active cell into committed history. `CommitTickScope` drains remaining queued lines. +10. On every state change, `FrameRequester::schedule_frame()` triggers a terminal redraw. + +## Overlay Lifecycle + +Overlays (Ctrl+T transcript, static pages) enter and exit the alternate screen: + +1. User presses Ctrl+T → host calls `OverlayState::open_transcript(tui, chat_widget)`. +2. `Tui::enter_alt_screen()` switches to alternate screen. +3. While overlay is active, `TuiEvent`s are routed through `OverlayState::handle_tui_event` instead of `ChatWidget`. +4. The transcript overlay syncs with `ChatWidget`'s live transcript cache (including the active cell tail) on each draw. +5. User presses Escape → overlay marks itself done → `OverlayState` calls `Tui::leave_alt_screen()` and schedules a normal frame. + +## Key Dependencies + +- **ratatui**: Terminal UI framework (buffer, layout, widgets, styling) +- **crossterm**: Terminal backend (raw mode, events, alternate screen) +- **tokio**: Async runtime for the event loop and worker communication +- **pulldown-cmark**: Markdown parsing +- **syntect / two-face**: Syntax highlighting +- **diffy**: Diff computation +- **arboard**: Clipboard access +- **devo-server**: Spawned child process that handles LLM communication diff --git a/crates/tui/src/chatwidget.rs b/crates/tui/src/chatwidget.rs index 5222f4e..f64facf 100644 --- a/crates/tui/src/chatwidget.rs +++ b/crates/tui/src/chatwidget.rs @@ -220,6 +220,7 @@ struct ActiveToolCall { title: String, lines: Vec>, exec_like: bool, + start_time: Option, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -999,10 +1000,18 @@ impl ChatWidget { .unwrap_or(0); let change = match kind { "add" => devo_protocol::protocol::FileChange::Add { - content: "\n".repeat(additions as usize), + content: file + .get("content") + .and_then(serde_json::Value::as_str) + .map(ToOwned::to_owned) + .unwrap_or_else(|| "\n".repeat(additions as usize)), }, "delete" => devo_protocol::protocol::FileChange::Delete { - content: "\n".repeat(deletions as usize), + content: file + .get("content") + .and_then(serde_json::Value::as_str) + .map(ToOwned::to_owned) + .unwrap_or_else(|| "\n".repeat(deletions as usize)), }, "update" | "move" => devo_protocol::protocol::FileChange::Update { unified_diff: diff.clone(), @@ -1632,6 +1641,7 @@ impl ChatWidget { WorkerEvent::ToolCall { tool_use_id, summary, + preparing, parsed_commands, } => { let command = crate::exec_command::split_command_string(&summary); @@ -1643,7 +1653,7 @@ impl ChatWidget { devo_protocol::parse_command::ParsedCommand::Unknown { .. } ) }); - if exec_like { + if exec_like && !preparing { if let Some(cell) = self .active_cell .as_mut() @@ -1664,6 +1674,7 @@ impl ChatWidget { title: summary, lines: Vec::new(), exec_like: true, + start_time: None, }, ); self.active_cell_revision = self.active_cell_revision.wrapping_add(1); @@ -1688,6 +1699,7 @@ impl ChatWidget { title: summary, lines: Vec::new(), exec_like: true, + start_time: None, }, ); self.active_cell_revision = self.active_cell_revision.wrapping_add(1); @@ -1696,24 +1708,50 @@ impl ChatWidget { return; } - let title = summary; + let title = if preparing + && (summary.starts_with("write ") + || summary.starts_with("write:") + || summary == "apply_patch") + { + if summary == "apply_patch" { + "Preparing apply_patch...".to_string() + } else { + "Preparing write...".to_string() + } + } else { + summary + }; let tool_call = ActiveToolCall { tool_use_id: tool_use_id.clone(), title: title.clone(), lines: vec![Self::running_tool_line(&title)], exec_like: false, + start_time: None, }; - self.active_tool_calls - .insert(tool_use_id.clone(), tool_call.clone()); + if preparing { + self.pending_tool_calls.push(ActiveToolCall { + start_time: Some(Instant::now()), + ..tool_call + }); + } else { + self.active_tool_calls + .insert( + tool_use_id.clone(), + ActiveToolCall { + start_time: None, + ..tool_call.clone() + }, + ); + self.add_history_entry_without_redraw(Box::new( + history_cell::AgentMessageCell::new_with_prefix( + tool_call.lines, + self.dot_prefix(DotStatus::Pending), + " ", + false, + ), + )); + } self.active_cell_revision = self.active_cell_revision.wrapping_add(1); - self.add_history_entry_without_redraw(Box::new( - history_cell::AgentMessageCell::new_with_prefix( - tool_call.lines, - self.dot_prefix(DotStatus::Pending), - " ", - false, - ), - )); self.frame_requester.schedule_frame(); self.set_status_message("Tool started"); } @@ -1765,6 +1803,7 @@ impl ChatWidget { title, lines: Vec::new(), exec_like: false, + start_time: None, }); if resolved_title.exec_like { @@ -1852,6 +1891,7 @@ impl ChatWidget { self.set_status_message("Plan updated"); } WorkerEvent::PatchApplied { changes } => { + self.pending_tool_calls.clear(); self.add_to_history(history_cell::new_patch_event(changes, &self.session.cwd)); self.set_status_message("Patch applied"); } @@ -3527,10 +3567,19 @@ impl ChatWidget { } // Pending tool calls are shown with a pending (cyan) dot until their results arrive. for pending in &self.pending_tool_calls { + let pending_lines = if let Some(start_time) = pending.start_time { + vec![Line::from(vec![ + crate::exec_cell::spinner(Some(start_time), true), + " ".into(), + Span::styled(pending.title.clone(), Self::tool_text_style()), + ])] + } else { + pending.lines.clone() + }; Self::extend_lines_with_separator( &mut lines, history_cell::AgentMessageCell::new_with_prefix( - pending.lines.clone(), + pending_lines, Self::pending_dot_prefix(), " ", false, @@ -3567,10 +3616,19 @@ impl ChatWidget { ); } for pending in &self.pending_tool_calls { + let pending_lines = if let Some(start_time) = pending.start_time { + vec![Line::from(vec![ + crate::exec_cell::spinner(Some(start_time), true), + " ".into(), + Span::styled(pending.title.clone(), Self::tool_text_style()), + ])] + } else { + pending.lines.clone() + }; Self::extend_lines_with_separator( &mut lines, history_cell::AgentMessageCell::new_with_prefix( - pending.lines.clone(), + pending_lines, Self::pending_dot_prefix(), " ", false, diff --git a/crates/tui/src/chatwidget_tests.rs b/crates/tui/src/chatwidget_tests.rs index 32ac107..95f1344 100644 --- a/crates/tui/src/chatwidget_tests.rs +++ b/crates/tui/src/chatwidget_tests.rs @@ -2373,6 +2373,7 @@ fn tool_call_start_and_finish_are_both_visible_in_history() { widget.handle_worker_event(crate::events::WorkerEvent::ToolCall { tool_use_id: "tool-1".to_string(), summary: "powershell -NoProfile -Command Get-Date".to_string(), + preparing: false, parsed_commands: None, }); @@ -2401,6 +2402,177 @@ fn tool_call_start_and_finish_are_both_visible_in_history() { ); } +#[test] +fn preparing_write_tool_call_is_visible_before_result() { + 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); + + widget.handle_worker_event(crate::events::WorkerEvent::ToolCall { + tool_use_id: "tool-1".to_string(), + summary: "write src/lib.rs".to_string(), + preparing: true, + parsed_commands: None, + }); + + let display = rendered_rows(&widget, 80, 12).join("\n"); + assert!( + display.contains("Preparing write..."), + "expected preparing write row:\n{display}" + ); +} + +#[test] +fn non_preparing_tool_call_keeps_existing_summary() { + 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); + + widget.handle_worker_event(crate::events::WorkerEvent::ToolCall { + tool_use_id: "tool-1".to_string(), + summary: "grep 'plan' in crates".to_string(), + preparing: false, + parsed_commands: None, + }); + + let display = rendered_rows(&widget, 80, 12).join("\n"); + assert!( + display.contains("Exploring") || display.contains("Search plan"), + "expected normal tool summary:\n{display}" + ); + assert!( + !display.contains("Preparing grep"), + "grep should not use preparing state:\n{display}" + ); +} + +#[test] +fn preparing_write_disappears_after_patch_applied() { + 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); + + widget.handle_worker_event(crate::events::WorkerEvent::ToolCall { + tool_use_id: "tool-1".to_string(), + summary: "write src/lib.rs".to_string(), + preparing: true, + parsed_commands: None, + }); + let before = rendered_rows(&widget, 80, 12).join("\n"); + assert!(before.contains("Preparing write..."), "expected preparing state before result:\n{before}"); + + let mut changes = std::collections::HashMap::new(); + changes.insert( + PathBuf::from("src/lib.rs"), + devo_protocol::protocol::FileChange::Add { + content: "pub fn demo() {}\n".to_string(), + }, + ); + widget.handle_worker_event(crate::events::WorkerEvent::PatchApplied { changes }); + + let after = rendered_rows(&widget, 80, 16).join("\n"); + assert!(!after.contains("Preparing write..."), "preparing state should disappear after patch applied:\n{after}"); + let history = scrollback_plain_lines(&widget.drain_scrollback_lines(100)).join("\n"); + assert!(history.contains("Added src/lib.rs") || history.contains("Edited src/lib.rs") || history.contains("Added 1 file")); +} + +#[test] +fn preparing_apply_patch_tool_call_is_visible_before_result() { + 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); + + widget.handle_worker_event(crate::events::WorkerEvent::ToolCall { + tool_use_id: "tool-1".to_string(), + summary: "apply_patch".to_string(), + preparing: true, + parsed_commands: None, + }); + + let display = rendered_rows(&widget, 80, 12).join("\n"); + assert!( + display.contains("Preparing apply_patch..."), + "expected preparing apply_patch row:\n{display}" + ); +} + +#[test] +fn preparing_apply_patch_disappears_after_patch_applied() { + 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); + + widget.handle_worker_event(crate::events::WorkerEvent::ToolCall { + tool_use_id: "tool-1".to_string(), + summary: "apply_patch".to_string(), + preparing: true, + parsed_commands: None, + }); + let before = rendered_rows(&widget, 80, 12).join("\n"); + assert!( + before.contains("Preparing apply_patch..."), + "expected preparing state before result:\n{before}" + ); + + let mut changes = std::collections::HashMap::new(); + changes.insert( + PathBuf::from("src/lib.rs"), + devo_protocol::protocol::FileChange::Add { + content: "pub fn demo() {}\n".to_string(), + }, + ); + widget.handle_worker_event(crate::events::WorkerEvent::PatchApplied { changes }); + + let after = rendered_rows(&widget, 80, 16).join("\n"); + assert!( + !after.contains("Preparing apply_patch..."), + "preparing state should disappear after patch applied:\n{after}" + ); +} + +#[test] +fn preparing_tool_row_animates_with_pre_draw_tick() { + 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); + + widget.handle_worker_event(crate::events::WorkerEvent::ToolCall { + tool_use_id: "tool-1".to_string(), + summary: "write src/lib.rs".to_string(), + preparing: true, + parsed_commands: None, + }); + let before = rendered_rows(&widget, 80, 12).join("\n"); + std::thread::sleep(std::time::Duration::from_millis(80)); + widget.pre_draw_tick(); + let after = rendered_rows(&widget, 80, 12).join("\n"); + assert_ne!(before, after, "expected preparing row to animate across ticks"); +} + #[test] fn reasoning_text_commits_to_history_when_turn_finishes() { let cwd = std::env::current_dir().expect("current directory is available"); @@ -3469,6 +3641,7 @@ fn transcript_overlay_lines_include_full_completed_tool_output() { widget.handle_worker_event(crate::events::WorkerEvent::ToolCall { tool_use_id: "tool-1".to_string(), summary: "bash".to_string(), + preparing: false, parsed_commands: None, }); widget.handle_worker_event(crate::events::WorkerEvent::ToolResult { @@ -3526,6 +3699,7 @@ fn transcript_overlay_lines_include_running_tool_output_delta() { widget.handle_worker_event(crate::events::WorkerEvent::ToolCall { tool_use_id: "tool-1".to_string(), summary: "bash".to_string(), + preparing: false, parsed_commands: None, }); widget.handle_worker_event(crate::events::WorkerEvent::ToolOutputDelta { @@ -3563,6 +3737,7 @@ fn read_tool_call_renders_as_explored_group_in_viewport() { widget.handle_worker_event(crate::events::WorkerEvent::ToolCall { tool_use_id: "tool-1".to_string(), summary: "cat foo.txt".to_string(), + preparing: false, parsed_commands: None, }); widget.handle_worker_event(crate::events::WorkerEvent::ToolResult { @@ -3608,6 +3783,7 @@ fn glob_tool_call_renders_as_explored_group_in_viewport() { widget.handle_worker_event(crate::events::WorkerEvent::ToolCall { tool_use_id: "tool-1".to_string(), summary: "glob **/Cargo.toml in crates".to_string(), + preparing: false, parsed_commands: Some(vec![ devo_protocol::parse_command::ParsedCommand::ListFiles { cmd: "glob **/Cargo.toml in crates".to_string(), @@ -3654,6 +3830,7 @@ fn grep_tool_call_renders_as_explored_group_in_viewport() { widget.handle_worker_event(crate::events::WorkerEvent::ToolCall { tool_use_id: "tool-1".to_string(), summary: "grep 'rebuild_restored_session' in crates/tui/src".to_string(), + preparing: false, parsed_commands: Some(vec![devo_protocol::parse_command::ParsedCommand::Search { cmd: "grep 'rebuild_restored_session' in crates/tui/src".to_string(), query: Some("rebuild_restored_session".to_string()), @@ -3699,6 +3876,7 @@ fn merged_explored_group_becomes_explored_after_all_results_arrive() { widget.handle_worker_event(crate::events::WorkerEvent::ToolCall { tool_use_id: "tool-1".to_string(), summary: "grep 'plan' in crates".to_string(), + preparing: false, parsed_commands: Some(vec![devo_protocol::parse_command::ParsedCommand::Search { cmd: "grep 'plan' in crates".to_string(), query: Some("plan".to_string()), @@ -3708,6 +3886,7 @@ fn merged_explored_group_becomes_explored_after_all_results_arrive() { widget.handle_worker_event(crate::events::WorkerEvent::ToolCall { tool_use_id: "tool-2".to_string(), summary: "glob **/plan.rs in crates".to_string(), + preparing: false, parsed_commands: Some(vec![ devo_protocol::parse_command::ParsedCommand::ListFiles { cmd: "glob **/plan.rs in crates".to_string(), @@ -3765,6 +3944,7 @@ fn live_viewport_shows_explored_group_while_active() { widget.handle_worker_event(crate::events::WorkerEvent::ToolCall { tool_use_id: "tool-1".to_string(), summary: "grep 'plan' in crates".to_string(), + preparing: false, parsed_commands: Some(vec![devo_protocol::parse_command::ParsedCommand::Search { cmd: "grep 'plan' in crates".to_string(), query: Some("plan".to_string()), @@ -3774,6 +3954,7 @@ fn live_viewport_shows_explored_group_while_active() { widget.handle_worker_event(crate::events::WorkerEvent::ToolCall { tool_use_id: "tool-2".to_string(), summary: "glob **/plan.rs in crates".to_string(), + preparing: false, parsed_commands: Some(vec![ devo_protocol::parse_command::ParsedCommand::ListFiles { cmd: "glob **/plan.rs in crates".to_string(), @@ -3820,6 +4001,7 @@ fn reasoning_start_closes_current_explored_group() { widget.handle_worker_event(crate::events::WorkerEvent::ToolCall { tool_use_id: "tool-1".to_string(), summary: "grep 'plan' in crates".to_string(), + preparing: false, parsed_commands: Some(vec![devo_protocol::parse_command::ParsedCommand::Search { cmd: "grep 'plan' in crates".to_string(), query: Some("plan".to_string()), @@ -3833,6 +4015,7 @@ fn reasoning_start_closes_current_explored_group() { widget.handle_worker_event(crate::events::WorkerEvent::ToolCall { tool_use_id: "tool-2".to_string(), summary: "glob **/plan.rs in crates".to_string(), + preparing: false, parsed_commands: Some(vec![ devo_protocol::parse_command::ParsedCommand::ListFiles { cmd: "glob **/plan.rs in crates".to_string(), @@ -3872,6 +4055,7 @@ fn assistant_text_start_closes_current_explored_group() { widget.handle_worker_event(crate::events::WorkerEvent::ToolCall { tool_use_id: "tool-1".to_string(), summary: "grep 'plan' in crates".to_string(), + preparing: false, parsed_commands: Some(vec![devo_protocol::parse_command::ParsedCommand::Search { cmd: "grep 'plan' in crates".to_string(), query: Some("plan".to_string()), @@ -3885,6 +4069,7 @@ fn assistant_text_start_closes_current_explored_group() { widget.handle_worker_event(crate::events::WorkerEvent::ToolCall { tool_use_id: "tool-2".to_string(), summary: "glob **/plan.rs in crates".to_string(), + preparing: false, parsed_commands: Some(vec![ devo_protocol::parse_command::ParsedCommand::ListFiles { cmd: "glob **/plan.rs in crates".to_string(), @@ -3924,6 +4109,7 @@ fn merged_explored_group_stays_completed_when_tool_results_arrive_after_tool_cal widget.handle_worker_event(crate::events::WorkerEvent::ToolCall { tool_use_id: "tool-1".to_string(), summary: "grep 'plan' in crates".to_string(), + preparing: false, parsed_commands: Some(vec![devo_protocol::parse_command::ParsedCommand::Search { cmd: "grep 'plan' in crates".to_string(), query: Some("plan".to_string()), @@ -3933,6 +4119,7 @@ fn merged_explored_group_stays_completed_when_tool_results_arrive_after_tool_cal widget.handle_worker_event(crate::events::WorkerEvent::ToolCall { tool_use_id: "tool-2".to_string(), summary: "glob **/plan.rs in crates".to_string(), + preparing: false, parsed_commands: Some(vec![ devo_protocol::parse_command::ParsedCommand::ListFiles { cmd: "glob **/plan.rs in crates".to_string(), @@ -4004,6 +4191,7 @@ fn explored_group_in_history_can_finish_late_completions() { widget.handle_worker_event(crate::events::WorkerEvent::ToolCall { tool_use_id: "tool-1".to_string(), summary: "grep 'plan' in crates".to_string(), + preparing: false, parsed_commands: Some(vec![devo_protocol::parse_command::ParsedCommand::Search { cmd: "grep 'plan' in crates".to_string(), query: Some("plan".to_string()), @@ -4013,6 +4201,7 @@ fn explored_group_in_history_can_finish_late_completions() { widget.handle_worker_event(crate::events::WorkerEvent::ToolCall { tool_use_id: "tool-2".to_string(), summary: "glob **/plan.rs in crates".to_string(), + preparing: false, parsed_commands: Some(vec![ devo_protocol::parse_command::ParsedCommand::ListFiles { cmd: "glob **/plan.rs in crates".to_string(), @@ -4031,6 +4220,7 @@ fn explored_group_in_history_can_finish_late_completions() { widget.handle_worker_event(crate::events::WorkerEvent::ToolCall { tool_use_id: "tool-3".to_string(), summary: "write src/main.rs".to_string(), + preparing: false, parsed_commands: None, }); @@ -4098,6 +4288,7 @@ async fn successful_write_tool_result_triggers_diff_event() { widget.handle_worker_event(crate::events::WorkerEvent::ToolCall { tool_use_id: "tool-1".to_string(), summary: "write src/main.rs".to_string(), + preparing: false, parsed_commands: None, }); widget.handle_worker_event(crate::events::WorkerEvent::ToolResult { @@ -4152,6 +4343,30 @@ fn patch_applied_event_renders_edited_block() { assert!(blob.contains("▌ Edited") || blob.contains("▌ Added")); } +#[test] +fn added_file_patch_applied_event_renders_added_content_lines() { + 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(".")); + + let mut changes = std::collections::HashMap::new(); + changes.insert( + PathBuf::from("quicksort.rs"), + devo_protocol::protocol::FileChange::Add { + content: "pub fn quicksort() {\n println!(\"hi\");\n}\n".to_string(), + }, + ); + widget.handle_worker_event(crate::events::WorkerEvent::PatchApplied { changes }); + + let blob = scrollback_plain_lines(&widget.drain_scrollback_lines(100)).join("\n"); + assert!(blob.contains("Added quicksort.rs") || blob.contains("Edited quicksort.rs") || blob.contains("Added 1 file")); + assert!(blob.contains("pub fn quicksort()"), "expected added file content to render:\n{blob}"); + assert!(blob.contains("println!(\"hi\");"), "expected added file body to render:\n{blob}"); +} + #[test] fn apply_patch_style_full_git_diff_reports_non_zero_counts() { let model = Model { @@ -4325,6 +4540,54 @@ fn session_switch_without_rich_edited_metadata_degrades_to_tool_result_path() { ); } +#[test] +fn session_switch_restores_added_file_content_in_edited_block() { + 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); + + let mut changes = std::collections::HashMap::new(); + changes.insert( + PathBuf::from("quicksort.rs"), + devo_protocol::protocol::FileChange::Add { + content: "pub fn quicksort() {\n println!(\"hi\");\n}\n".to_string(), + }, + ); + widget.handle_worker_event(crate::events::WorkerEvent::SessionSwitched { + session_id: "session-1".to_string(), + cwd: std::env::current_dir().expect("current directory is available"), + title: None, + model: Some("test-model".to_string()), + thinking: None, + reasoning_effort: None, + total_input_tokens: 0, + total_output_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: Some("call-1".to_string()), + kind: devo_protocol::SessionHistoryItemKind::ToolResult, + title: "write".to_string(), + body: String::new(), + metadata: Some(devo_protocol::SessionHistoryMetadata::Edited { changes }), + duration_ms: None, + }], + loaded_item_count: 1, + pending_texts: Vec::new(), + }); + + let blob = scrollback_plain_lines(&widget.drain_scrollback_lines(100)).join("\n"); + assert!(blob.contains("pub fn quicksort()"), "expected restored added file content:\n{blob}"); + assert!(blob.contains("println!(\"hi\");"), "expected restored added file body:\n{blob}"); +} + #[test] fn session_switch_without_rich_edited_metadata_still_restores_edited_block() { let cwd = std::env::current_dir().expect("current directory is available"); diff --git a/crates/tui/src/events.rs b/crates/tui/src/events.rs index 3dd478e..923e3e2 100644 --- a/crates/tui/src/events.rs +++ b/crates/tui/src/events.rs @@ -105,6 +105,8 @@ pub(crate) enum WorkerEvent { tool_use_id: String, /// Human-readable summary line for the tool execution. summary: String, + /// Whether this early tool signal should render as a live-only preparing state. + preparing: bool, /// Optional parsed command semantics for command-like and exploration-like tools. parsed_commands: Option>, }, diff --git a/crates/tui/src/worker.rs b/crates/tui/src/worker.rs index ab574a9..a6517ae 100644 --- a/crates/tui/src/worker.rs +++ b/crates/tui/src/worker.rs @@ -1255,6 +1255,7 @@ async fn run_worker_inner( let _ = event_tx.send(WorkerEvent::ToolCall { tool_use_id: payload.tool_call_id, summary: payload.command, + preparing: false, parsed_commands: Some(payload.command_actions), }); } @@ -1269,6 +1270,7 @@ async fn run_worker_inner( let _ = event_tx.send(WorkerEvent::ToolCall { tool_use_id: payload.tool_call_id.clone(), summary, + preparing: payload.tool_name == "write", parsed_commands: Some(payload.command_actions), }); } @@ -2137,10 +2139,18 @@ fn patch_event_from_tool_result(payload: &ToolResultPayload) -> Option devo_protocol::protocol::FileChange::Add { - content: "\n".repeat(additions as usize), + content: file + .get("content") + .and_then(serde_json::Value::as_str) + .map(ToOwned::to_owned) + .unwrap_or_else(|| "\n".repeat(additions as usize)), }, "delete" => devo_protocol::protocol::FileChange::Delete { - content: "\n".repeat(deletions as usize), + content: file + .get("content") + .and_then(serde_json::Value::as_str) + .map(ToOwned::to_owned) + .unwrap_or_else(|| "\n".repeat(deletions as usize)), }, "update" | "move" => devo_protocol::protocol::FileChange::Update { unified_diff: file @@ -2731,11 +2741,13 @@ mod tests { WorkerEvent::ToolCall { tool_use_id: payload.tool_call_id.clone(), summary: payload.command.clone(), + preparing: false, parsed_commands: Some(payload.command_actions.clone()), }, WorkerEvent::ToolCall { tool_use_id: payload.tool_call_id, summary: payload.command, + preparing: false, parsed_commands: Some(payload.command_actions), } );